summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlex Legler <alex@a3li.li>2015-08-15 13:32:14 +0200
committerAlex Legler <alex@a3li.li>2015-08-15 13:32:14 +0200
commitd0cbe50515ded38cebcacb2c5690114caf610687 (patch)
tree9720ef5feb52b7233ec55d4b3f67a63bb2600ac1
parentAdd Thanks (diff)
downloadextensions-d0cbe50515ded38cebcacb2c5690114caf610687.tar.gz
extensions-d0cbe50515ded38cebcacb2c5690114caf610687.tar.bz2
extensions-d0cbe50515ded38cebcacb2c5690114caf610687.zip
Add Flow
-rw-r--r--Flow/.arcconfig4
-rw-r--r--Flow/.csslintrc11
-rw-r--r--Flow/.gitattributes1
-rw-r--r--Flow/.gitignore13
-rw-r--r--Flow/.gitreview6
-rw-r--r--Flow/.jscsrc13
-rw-r--r--Flow/.jshintignore3
-rw-r--r--Flow/.jshintrc32
-rw-r--r--Flow/.rubocop.yml33
-rw-r--r--Flow/.rubocop_todo.yml42
-rw-r--r--Flow/COPYING339
-rw-r--r--Flow/Flow.alias.php88
-rw-r--r--Flow/Flow.namespaces.php115
-rw-r--r--Flow/Flow.php353
-rw-r--r--Flow/FlowActions.php836
-rw-r--r--Flow/Gemfile9
-rw-r--r--Flow/Gemfile.lock107
-rw-r--r--Flow/Gruntfile.js51
-rw-r--r--Flow/Hooks.php1331
-rw-r--r--Flow/Makefile128
-rw-r--r--Flow/Resources.php548
-rw-r--r--Flow/autoload.php362
-rw-r--r--Flow/composer.json10
-rw-r--r--Flow/composer.lock121
-rw-r--r--Flow/container-test.php16
-rw-r--r--Flow/container.php1203
-rw-r--r--Flow/db_patches/patch-88bit_uuids.sql20
-rw-r--r--Flow/db_patches/patch-88bit_uuids.sqlite.sql20
-rw-r--r--Flow/db_patches/patch-add-linkstables.sql32
-rw-r--r--Flow/db_patches/patch-add-revision-content-length.sql3
-rw-r--r--Flow/db_patches/patch-add-wiki.sql16
-rw-r--r--Flow/db_patches/patch-add_workflow_type.sql6
-rw-r--r--Flow/db_patches/patch-add_workflow_type.sqlite.sql10
-rw-r--r--Flow/db_patches/patch-censor_to_suppress.sql12
-rw-r--r--Flow/db_patches/patch-default_null_workflow_user.sql4
-rw-r--r--Flow/db_patches/patch-default_null_workflow_user.sqlite.sql30
-rw-r--r--Flow/db_patches/patch-drop_definition.sql3
-rw-r--r--Flow/db_patches/patch-drop_workflow_user.sql4
-rw-r--r--Flow/db_patches/patch-flow_tree_idx_fix.sql4
-rw-r--r--Flow/db_patches/patch-increase_width_wiki_fields.sql11
-rw-r--r--Flow/db_patches/patch-moderation_reason.sql3
-rw-r--r--Flow/db_patches/patch-rc_source.sql4
-rw-r--r--Flow/db_patches/patch-remove_unique_ref_indices.sql15
-rw-r--r--Flow/db_patches/patch-remove_usernames.sql12
-rw-r--r--Flow/db_patches/patch-remove_usernames_2.sql8
-rw-r--r--Flow/db_patches/patch-rev_change_type.sql2
-rw-r--r--Flow/db_patches/patch-rev_change_type.sqlite.sql50
-rw-r--r--Flow/db_patches/patch-rev_change_type_update.sql14
-rw-r--r--Flow/db_patches/patch-rev_type_id.sql4
-rw-r--r--Flow/db_patches/patch-revision_last_editor.sql7
-rw-r--r--Flow/db_patches/patch-revision_user_idx.sql2
-rw-r--r--Flow/db_patches/patch-revision_user_ip.sql3
-rw-r--r--Flow/db_patches/patch-subscription_user_id.sql3
-rw-r--r--Flow/db_patches/patch-summary2header.sql9
-rw-r--r--Flow/db_patches/patch-summary2header.sqlite.sql21
-rw-r--r--Flow/db_patches/patch-topic_list_topic_id_idx.sql1
-rw-r--r--Flow/db_patches/patch-tree_orig_create_time.sql2
-rw-r--r--Flow/db_patches/patch-workflow_lookup_idx.sql1
-rw-r--r--Flow/defines.php5
-rw-r--r--Flow/flow.sql171
-rw-r--r--Flow/handlebars/compiled/flow_block_board-history.handlebars.php142
-rw-r--r--Flow/handlebars/compiled/flow_block_header.handlebars.php52
-rw-r--r--Flow/handlebars/compiled/flow_block_header_diff_view.handlebars.php36
-rw-r--r--Flow/handlebars/compiled/flow_block_header_edit.handlebars.php67
-rw-r--r--Flow/handlebars/compiled/flow_block_header_single_view.handlebars.php38
-rw-r--r--Flow/handlebars/compiled/flow_block_header_undo_edit.handlebars.php71
-rw-r--r--Flow/handlebars/compiled/flow_block_loop.handlebars.php28
-rw-r--r--Flow/handlebars/compiled/flow_block_topic.handlebars.php249
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_diff_view.handlebars.php36
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_edit_title.handlebars.php58
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_history.handlebars.php144
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_lock.handlebars.php76
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_moderate_post.handlebars.php308
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_moderate_topic.handlebars.php308
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_single_view.handlebars.php39
-rw-r--r--Flow/handlebars/compiled/flow_block_topic_undo_edit.handlebars.php73
-rw-r--r--Flow/handlebars/compiled/flow_block_topiclist.handlebars.php368
-rw-r--r--Flow/handlebars/compiled/flow_block_topiclist_newtopic.handlebars.php90
-rw-r--r--Flow/handlebars/compiled/flow_block_topicsummary_diff_view.handlebars.php36
-rw-r--r--Flow/handlebars/compiled/flow_block_topicsummary_edit.handlebars.php75
-rw-r--r--Flow/handlebars/compiled/flow_block_topicsummary_single_view.handlebars.php37
-rw-r--r--Flow/handlebars/compiled/flow_block_topicsummary_undo_edit.handlebars.php72
-rw-r--r--Flow/handlebars/compiled/flow_post.handlebars.php282
-rw-r--r--Flow/handlebars/compiled/flow_revision_diff_header.handlebars.php33
-rw-r--r--Flow/handlebars/compiled/flow_tooltip.handlebars.php29
-rw-r--r--Flow/handlebars/compiled/timestamp.handlebars.php33
-rw-r--r--Flow/handlebars/flow_anon_warning.partial.handlebars27
-rw-r--r--Flow/handlebars/flow_block_board-history.handlebars11
-rw-r--r--Flow/handlebars/flow_block_header.handlebars4
-rw-r--r--Flow/handlebars/flow_block_header_diff_view.handlebars21
-rw-r--r--Flow/handlebars/flow_block_header_edit.handlebars37
-rw-r--r--Flow/handlebars/flow_block_header_single_view.handlebars19
-rw-r--r--Flow/handlebars/flow_block_header_undo_edit.handlebars48
-rw-r--r--Flow/handlebars/flow_block_loop.handlebars3
-rw-r--r--Flow/handlebars/flow_block_topic.handlebars8
-rw-r--r--Flow/handlebars/flow_block_topic_diff_view.handlebars24
-rw-r--r--Flow/handlebars/flow_block_topic_edit_title.handlebars9
-rw-r--r--Flow/handlebars/flow_block_topic_history.handlebars13
-rw-r--r--Flow/handlebars/flow_block_topic_lock.handlebars2
-rw-r--r--Flow/handlebars/flow_block_topic_moderate_post.handlebars9
-rw-r--r--Flow/handlebars/flow_block_topic_moderate_topic.handlebars9
-rw-r--r--Flow/handlebars/flow_block_topic_single_view.handlebars24
-rw-r--r--Flow/handlebars/flow_block_topic_undo_edit.handlebars50
-rw-r--r--Flow/handlebars/flow_block_topiclist.handlebars21
-rw-r--r--Flow/handlebars/flow_block_topiclist_newtopic.handlebars3
-rw-r--r--Flow/handlebars/flow_block_topicsummary_diff_view.handlebars22
-rw-r--r--Flow/handlebars/flow_block_topicsummary_edit.handlebars45
-rw-r--r--Flow/handlebars/flow_block_topicsummary_single_view.handlebars22
-rw-r--r--Flow/handlebars/flow_block_topicsummary_undo_edit.handlebars49
-rw-r--r--Flow/handlebars/flow_board_navigation.partial.handlebars75
-rw-r--r--Flow/handlebars/flow_board_toc_loop.partial.handlebars23
-rw-r--r--Flow/handlebars/flow_edit_post.partial.handlebars37
-rw-r--r--Flow/handlebars/flow_edit_post_ajax.partial.handlebars3
-rw-r--r--Flow/handlebars/flow_edit_topic_title.partial.handlebars27
-rw-r--r--Flow/handlebars/flow_editor_switcher.partial.handlebars18
-rw-r--r--Flow/handlebars/flow_errors.partial.handlebars11
-rw-r--r--Flow/handlebars/flow_form_buttons.partial.handlebars10
-rw-r--r--Flow/handlebars/flow_header_detail.partial.handlebars21
-rw-r--r--Flow/handlebars/flow_history_line.partial.handlebars44
-rw-r--r--Flow/handlebars/flow_load_more.partial.handlebars23
-rw-r--r--Flow/handlebars/flow_moderate_post.partial.handlebars32
-rw-r--r--Flow/handlebars/flow_moderate_post_confirmation.partial.handlebars42
-rw-r--r--Flow/handlebars/flow_moderate_topic.partial.handlebars32
-rw-r--r--Flow/handlebars/flow_moderate_topic_confirmation.partial.handlebars45
-rw-r--r--Flow/handlebars/flow_moderation_actions_list.partial.handlebars272
-rw-r--r--Flow/handlebars/flow_newtopic_form.partial.handlebars50
l---------Flow/handlebars/flow_post.handlebars1
-rw-r--r--Flow/handlebars/flow_post.partial.handlebars33
-rw-r--r--Flow/handlebars/flow_post_actions.partial.handlebars7
-rw-r--r--Flow/handlebars/flow_post_author.partial.handlebars39
-rw-r--r--Flow/handlebars/flow_post_inner.partial.handlebars31
-rw-r--r--Flow/handlebars/flow_post_meta_actions.partial.handlebars66
-rw-r--r--Flow/handlebars/flow_post_moderation_state.partial.handlebars7
-rw-r--r--Flow/handlebars/flow_post_replies.partial.handlebars13
-rw-r--r--Flow/handlebars/flow_preview.partial.handlebars12
-rw-r--r--Flow/handlebars/flow_preview_warning.partial.handlebars6
-rw-r--r--Flow/handlebars/flow_reply_form.partial.handlebars63
-rw-r--r--Flow/handlebars/flow_revision_diff_header.handlebars9
-rw-r--r--Flow/handlebars/flow_subscribed.partial.handlebars7
-rw-r--r--Flow/handlebars/flow_tooltip.handlebars4
-rw-r--r--Flow/handlebars/flow_tooltip_subscribed.partial.handlebars6
-rw-r--r--Flow/handlebars/flow_topic.partial.handlebars39
-rw-r--r--Flow/handlebars/flow_topic_moderation_flag.partial.handlebars4
-rw-r--r--Flow/handlebars/flow_topic_titlebar.partial.handlebars16
-rw-r--r--Flow/handlebars/flow_topic_titlebar_content.partial.handlebars27
-rw-r--r--Flow/handlebars/flow_topic_titlebar_lock.partial.handlebars50
-rw-r--r--Flow/handlebars/flow_topic_titlebar_summary.partial.handlebars9
-rw-r--r--Flow/handlebars/flow_topic_titlebar_watch.partial.handlebars17
-rw-r--r--Flow/handlebars/flow_topiclist_loop.partial.handlebars6
-rw-r--r--Flow/handlebars/form_element.partial.handlebars24
-rw-r--r--Flow/handlebars/timestamp.handlebars13
-rw-r--r--Flow/hooks.txt16
-rw-r--r--Flow/i18n/ace.json19
-rw-r--r--Flow/i18n/ar.json16
-rw-r--r--Flow/i18n/as.json10
-rw-r--r--Flow/i18n/ast.json214
-rw-r--r--Flow/i18n/az.json10
-rw-r--r--Flow/i18n/bcc.json10
-rw-r--r--Flow/i18n/bcl.json43
-rw-r--r--Flow/i18n/be.json8
-rw-r--r--Flow/i18n/bg.json20
-rw-r--r--Flow/i18n/bn.json37
-rw-r--r--Flow/i18n/bo.json37
-rw-r--r--Flow/i18n/br.json115
-rw-r--r--Flow/i18n/bs.json22
-rw-r--r--Flow/i18n/bxr.json27
-rw-r--r--Flow/i18n/ca.json254
-rw-r--r--Flow/i18n/ce.json84
-rw-r--r--Flow/i18n/cs.json21
-rw-r--r--Flow/i18n/de.json482
-rw-r--r--Flow/i18n/el.json25
-rw-r--r--Flow/i18n/en-gb.json16
-rw-r--r--Flow/i18n/en.json549
-rw-r--r--Flow/i18n/eo.json26
-rw-r--r--Flow/i18n/es-formal.json13
-rw-r--r--Flow/i18n/es.json369
-rw-r--r--Flow/i18n/et.json199
-rw-r--r--Flow/i18n/eu.json34
-rw-r--r--Flow/i18n/fa.json257
-rw-r--r--Flow/i18n/fi.json135
-rw-r--r--Flow/i18n/fr.json515
-rw-r--r--Flow/i18n/fy.json16
-rw-r--r--Flow/i18n/gl.json124
-rw-r--r--Flow/i18n/gu.json12
-rw-r--r--Flow/i18n/he.json516
-rw-r--r--Flow/i18n/hi.json20
-rw-r--r--Flow/i18n/hr.json8
-rw-r--r--Flow/i18n/hu.json9
-rw-r--r--Flow/i18n/hy.json12
-rw-r--r--Flow/i18n/ia.json101
-rw-r--r--Flow/i18n/id.json14
-rw-r--r--Flow/i18n/it.json359
-rw-r--r--Flow/i18n/ja.json342
-rw-r--r--Flow/i18n/jam.json10
-rw-r--r--Flow/i18n/jbo.json17
-rw-r--r--Flow/i18n/ka.json14
-rw-r--r--Flow/i18n/km.json8
-rw-r--r--Flow/i18n/kn.json8
-rw-r--r--Flow/i18n/ko.json305
-rw-r--r--Flow/i18n/ksh.json123
-rw-r--r--Flow/i18n/ku-latn.json9
-rw-r--r--Flow/i18n/lb.json215
-rw-r--r--Flow/i18n/lki.json8
-rw-r--r--Flow/i18n/lrc.json8
-rw-r--r--Flow/i18n/lt.json21
-rw-r--r--Flow/i18n/lv.json29
-rw-r--r--Flow/i18n/lzh.json17
-rw-r--r--Flow/i18n/mg.json8
-rw-r--r--Flow/i18n/mk.json512
-rw-r--r--Flow/i18n/ml.json210
-rw-r--r--Flow/i18n/mn.json14
-rw-r--r--Flow/i18n/mr.json26
-rw-r--r--Flow/i18n/ms.json53
-rw-r--r--Flow/i18n/mt.json9
-rw-r--r--Flow/i18n/my.json8
-rw-r--r--Flow/i18n/myv.json8
-rw-r--r--Flow/i18n/nap.json22
-rw-r--r--Flow/i18n/nb.json323
-rw-r--r--Flow/i18n/nds-nl.json8
-rw-r--r--Flow/i18n/ne.json18
-rw-r--r--Flow/i18n/nl.json340
-rw-r--r--Flow/i18n/oc.json122
-rw-r--r--Flow/i18n/om.json11
-rw-r--r--Flow/i18n/pa.json37
-rw-r--r--Flow/i18n/pam.json12
-rw-r--r--Flow/i18n/pl.json139
-rw-r--r--Flow/i18n/ps.json20
-rw-r--r--Flow/i18n/pt-br.json188
-rw-r--r--Flow/i18n/pt.json484
-rw-r--r--Flow/i18n/qqq.json552
-rw-r--r--Flow/i18n/qu.json9
-rw-r--r--Flow/i18n/ro.json41
-rw-r--r--Flow/i18n/roa-tara.json88
-rw-r--r--Flow/i18n/ru.json526
-rw-r--r--Flow/i18n/sc.json9
-rw-r--r--Flow/i18n/scn.json8
-rw-r--r--Flow/i18n/sco.json84
-rw-r--r--Flow/i18n/si.json16
-rw-r--r--Flow/i18n/sl.json29
-rw-r--r--Flow/i18n/sq.json9
-rw-r--r--Flow/i18n/sr-ec.json34
-rw-r--r--Flow/i18n/sr-el.json14
-rw-r--r--Flow/i18n/sv.json469
-rw-r--r--Flow/i18n/ta.json14
-rw-r--r--Flow/i18n/te.json84
-rw-r--r--Flow/i18n/tl.json17
-rw-r--r--Flow/i18n/tr.json30
-rw-r--r--Flow/i18n/tyv.json7
-rw-r--r--Flow/i18n/ug-arab.json11
-rw-r--r--Flow/i18n/uk.json383
-rw-r--r--Flow/i18n/uz.json8
-rw-r--r--Flow/i18n/vi.json370
-rw-r--r--Flow/i18n/vo.json9
-rw-r--r--Flow/i18n/yi.json57
-rw-r--r--Flow/i18n/zh-hans.json527
-rw-r--r--Flow/i18n/zh-hant.json399
-rw-r--r--Flow/includes/Actions/Action.php147
-rw-r--r--Flow/includes/Actions/EditAction.php31
-rw-r--r--Flow/includes/Actions/PurgeAction.php197
-rw-r--r--Flow/includes/Actions/ViewAction.php48
-rw-r--r--Flow/includes/Api/ApiFlow.php240
-rw-r--r--Flow/includes/Api/ApiFlowBase.php169
-rw-r--r--Flow/includes/Api/ApiFlowBaseGet.php89
-rw-r--r--Flow/includes/Api/ApiFlowBasePost.php120
-rw-r--r--Flow/includes/Api/ApiFlowEditHeader.php76
-rw-r--r--Flow/includes/Api/ApiFlowEditPost.php77
-rw-r--r--Flow/includes/Api/ApiFlowEditTitle.php71
-rw-r--r--Flow/includes/Api/ApiFlowEditTopicSummary.php74
-rw-r--r--Flow/includes/Api/ApiFlowLockTopic.php80
-rw-r--r--Flow/includes/Api/ApiFlowModeratePost.php72
-rw-r--r--Flow/includes/Api/ApiFlowModerateTopic.php68
-rw-r--r--Flow/includes/Api/ApiFlowNewTopic.php77
-rw-r--r--Flow/includes/Api/ApiFlowReply.php76
-rw-r--r--Flow/includes/Api/ApiFlowUndoEditHeader.php44
-rw-r--r--Flow/includes/Api/ApiFlowUndoEditPost.php43
-rw-r--r--Flow/includes/Api/ApiFlowUndoEditTopicSummary.php43
-rw-r--r--Flow/includes/Api/ApiFlowViewHeader.php71
-rw-r--r--Flow/includes/Api/ApiFlowViewPost.php74
-rw-r--r--Flow/includes/Api/ApiFlowViewTopic.php48
-rw-r--r--Flow/includes/Api/ApiFlowViewTopicList.php114
-rw-r--r--Flow/includes/Api/ApiFlowViewTopicSummary.php71
-rw-r--r--Flow/includes/Api/ApiParsoidUtilsFlow.php93
-rw-r--r--Flow/includes/Api/ApiQueryPropFlowInfo.php70
-rw-r--r--Flow/includes/Block/Block.php370
-rw-r--r--Flow/includes/Block/BoardHistory.php84
-rw-r--r--Flow/includes/Block/Header.php329
-rw-r--r--Flow/includes/Block/Topic.php973
-rw-r--r--Flow/includes/Block/TopicList.php435
-rw-r--r--Flow/includes/Block/TopicSummary.php378
-rw-r--r--Flow/includes/BlockFactory.php75
-rw-r--r--Flow/includes/Collection/AbstractCollection.php240
-rw-r--r--Flow/includes/Collection/CollectionCache.php80
-rw-r--r--Flow/includes/Collection/HeaderCollection.php13
-rw-r--r--Flow/includes/Collection/LocalCacheAbstractCollection.php170
-rw-r--r--Flow/includes/Collection/PostCollection.php41
-rw-r--r--Flow/includes/Collection/PostSummaryCollection.php36
-rw-r--r--Flow/includes/Container.php50
-rw-r--r--Flow/includes/Content/BoardContent.php225
-rw-r--r--Flow/includes/Content/BoardContentHandler.php147
-rw-r--r--Flow/includes/Content/Content.php69
-rw-r--r--Flow/includes/Data/BagOStuff/BufferedBagOStuff.php415
-rw-r--r--Flow/includes/Data/BagOStuff/LocalBufferedBagOStuff.php51
-rw-r--r--Flow/includes/Data/BufferedCache.php129
-rw-r--r--Flow/includes/Data/Compactor.php30
-rw-r--r--Flow/includes/Data/Compactor/FeatureCompactor.php87
-rw-r--r--Flow/includes/Data/Compactor/ShallowCompactor.php105
-rw-r--r--Flow/includes/Data/Index.php94
-rw-r--r--Flow/includes/Data/Index/BoardHistoryIndex.php145
-rw-r--r--Flow/includes/Data/Index/FeatureIndex.php680
-rw-r--r--Flow/includes/Data/Index/TopKIndex.php170
-rw-r--r--Flow/includes/Data/Index/TopicHistoryIndex.php134
-rw-r--r--Flow/includes/Data/Index/UniqueFeatureIndex.php39
-rw-r--r--Flow/includes/Data/LifecycleHandler.php14
-rw-r--r--Flow/includes/Data/Listener/DeferredInsertLifecycleHandler.php66
-rw-r--r--Flow/includes/Data/Listener/EditCountListener.php44
-rw-r--r--Flow/includes/Data/Listener/ModerationLoggingListener.php82
-rw-r--r--Flow/includes/Data/Listener/NotificationListener.php99
-rw-r--r--Flow/includes/Data/Listener/OccupationListener.php100
-rw-r--r--Flow/includes/Data/Listener/RecentChangesListener.php169
-rw-r--r--Flow/includes/Data/Listener/ReferenceRecorder.php303
-rw-r--r--Flow/includes/Data/Listener/UrlGenerationListener.php45
-rw-r--r--Flow/includes/Data/Listener/UserNameListener.php65
-rw-r--r--Flow/includes/Data/Listener/WatchTopicListener.php137
-rw-r--r--Flow/includes/Data/Listener/WorkflowTopicListListener.php81
-rw-r--r--Flow/includes/Data/ManagerGroup.php155
-rw-r--r--Flow/includes/Data/Mapper/BasicObjectMapper.php53
-rw-r--r--Flow/includes/Data/Mapper/CachingObjectMapper.php131
-rw-r--r--Flow/includes/Data/ObjectLocator.php330
-rw-r--r--Flow/includes/Data/ObjectManager.php380
-rw-r--r--Flow/includes/Data/ObjectMapper.php52
-rw-r--r--Flow/includes/Data/ObjectStorage.php73
-rw-r--r--Flow/includes/Data/Pager/HistoryPager.php122
-rw-r--r--Flow/includes/Data/Pager/Pager.php234
-rw-r--r--Flow/includes/Data/Pager/PagerPage.php55
-rw-r--r--Flow/includes/Data/Storage/BasicDbStorage.php209
-rw-r--r--Flow/includes/Data/Storage/BoardHistoryStorage.php156
-rw-r--r--Flow/includes/Data/Storage/DbStorage.php229
-rw-r--r--Flow/includes/Data/Storage/HeaderRevisionStorage.php12
-rw-r--r--Flow/includes/Data/Storage/PostRevisionStorage.php114
-rw-r--r--Flow/includes/Data/Storage/PostSummaryRevisionStorage.php12
-rw-r--r--Flow/includes/Data/Storage/RevisionStorage.php512
-rw-r--r--Flow/includes/Data/Storage/TopicHistoryStorage.php83
-rw-r--r--Flow/includes/Data/Storage/TopicListLastUpdatedStorage.php44
-rw-r--r--Flow/includes/Data/Storage/TopicListStorage.php28
-rw-r--r--Flow/includes/Data/Utils/Merger.php97
-rw-r--r--Flow/includes/Data/Utils/MultiDimArray.php101
-rw-r--r--Flow/includes/Data/Utils/RawSql.php24
-rw-r--r--Flow/includes/Data/Utils/RecentChangeFactory.php15
-rw-r--r--Flow/includes/Data/Utils/ResultDuplicator.php114
-rw-r--r--Flow/includes/Data/Utils/SortArrayByKeys.php37
-rw-r--r--Flow/includes/Data/Utils/UserMerger.php156
-rw-r--r--Flow/includes/DbFactory.php98
-rw-r--r--Flow/includes/Exception/CatchableFatalErrorException.php13
-rw-r--r--Flow/includes/Exception/ExceptionHandling.php355
-rw-r--r--Flow/includes/FlowActions.php98
-rw-r--r--Flow/includes/Formatter/AbstractFormatter.php316
-rw-r--r--Flow/includes/Formatter/AbstractQuery.php405
-rw-r--r--Flow/includes/Formatter/BaseTopicListFormatter.php50
-rw-r--r--Flow/includes/Formatter/BoardHistoryQuery.php55
-rw-r--r--Flow/includes/Formatter/CategoryViewerFormatter.php39
-rw-r--r--Flow/includes/Formatter/CategoryViewerQuery.php92
-rw-r--r--Flow/includes/Formatter/CheckUserFormatter.php53
-rw-r--r--Flow/includes/Formatter/CheckUserQuery.php143
-rw-r--r--Flow/includes/Formatter/Contributions.php114
-rw-r--r--Flow/includes/Formatter/ContributionsQuery.php336
-rw-r--r--Flow/includes/Formatter/FeedItemFormatter.php76
-rw-r--r--Flow/includes/Formatter/IRCLineUrlFormatter.php119
-rw-r--r--Flow/includes/Formatter/PostHistoryQuery.php49
-rw-r--r--Flow/includes/Formatter/PostSummaryQuery.php25
-rw-r--r--Flow/includes/Formatter/RecentChanges.php201
-rw-r--r--Flow/includes/Formatter/RecentChangesQuery.php218
-rw-r--r--Flow/includes/Formatter/RevisionDiffViewFormatter.php80
-rw-r--r--Flow/includes/Formatter/RevisionFormatter.php978
-rw-r--r--Flow/includes/Formatter/RevisionUndoViewFormatter.php71
-rw-r--r--Flow/includes/Formatter/RevisionViewFormatter.php131
-rw-r--r--Flow/includes/Formatter/RevisionViewQuery.php200
-rw-r--r--Flow/includes/Formatter/SinglePostQuery.php42
-rw-r--r--Flow/includes/Formatter/TocTopicListFormatter.php71
-rw-r--r--Flow/includes/Formatter/TopicFormatter.php118
-rw-r--r--Flow/includes/Formatter/TopicHistoryQuery.php64
-rw-r--r--Flow/includes/Formatter/TopicListFormatter.php148
-rw-r--r--Flow/includes/Formatter/TopicListQuery.php239
-rw-r--r--Flow/includes/Formatter/TopicRow.php23
-rw-r--r--Flow/includes/Import/Converter.php337
-rw-r--r--Flow/includes/Import/Exception.php17
-rw-r--r--Flow/includes/Import/IConversionStrategy.php82
-rw-r--r--Flow/includes/Import/ImportSource.php84
-rw-r--r--Flow/includes/Import/ImportSourceStore.php89
-rw-r--r--Flow/includes/Import/Importer.php903
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/CachedData.php178
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/ConversionStrategy.php133
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/Exception.php13
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/Iterators.php251
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/Objects.php464
-rw-r--r--Flow/includes/Import/LiquidThreadsApi/Source.php445
-rw-r--r--Flow/includes/Import/Plain/ImportHeader.php31
-rw-r--r--Flow/includes/Import/Plain/ObjectRevision.php45
-rw-r--r--Flow/includes/Import/Postprocessor/LqtRedirector.php84
-rw-r--r--Flow/includes/Import/Postprocessor/PostprocessingException.php8
-rw-r--r--Flow/includes/Import/Postprocessor/Postprocessor.php51
-rw-r--r--Flow/includes/Import/Postprocessor/ProcessorGroup.php44
-rw-r--r--Flow/includes/Import/Postprocessor/SpecialLogTopic.php58
-rw-r--r--Flow/includes/Import/Wikitext/ConversionStrategy.php123
-rw-r--r--Flow/includes/Import/Wikitext/ImportSource.php88
-rw-r--r--Flow/includes/LinksTableUpdater.php137
-rw-r--r--Flow/includes/Log/ActionFormatter.php153
-rw-r--r--Flow/includes/Log/LqtImportFormatter.php45
-rw-r--r--Flow/includes/Log/ModerationLogger.php107
-rw-r--r--Flow/includes/Log/Query.php43
-rw-r--r--Flow/includes/Model/AbstractRevision.php716
-rw-r--r--Flow/includes/Model/AbstractSummary.php38
-rw-r--r--Flow/includes/Model/Anchor.php188
-rw-r--r--Flow/includes/Model/Header.php82
-rw-r--r--Flow/includes/Model/PostRevision.php389
-rw-r--r--Flow/includes/Model/PostSummary.php45
-rw-r--r--Flow/includes/Model/Reference.php151
-rw-r--r--Flow/includes/Model/TopicListEntry.php98
-rw-r--r--Flow/includes/Model/URLReference.php75
-rw-r--r--Flow/includes/Model/UUID.php463
-rw-r--r--Flow/includes/Model/UserTuple.php110
-rw-r--r--Flow/includes/Model/WikiReference.php95
-rw-r--r--Flow/includes/Model/Workflow.php296
-rw-r--r--Flow/includes/Notifications/Controller.php520
-rw-r--r--Flow/includes/Notifications/Formatter.php248
-rw-r--r--Flow/includes/Notifications/Notifications.php106
-rw-r--r--Flow/includes/Notifications/UserLocator.php50
-rw-r--r--Flow/includes/Parsoid/ContentFixer.php100
-rw-r--r--Flow/includes/Parsoid/Extractor.php27
-rw-r--r--Flow/includes/Parsoid/Extractor/CategoryExtractor.php52
-rw-r--r--Flow/includes/Parsoid/Extractor/ExtLinkExtractor.php31
-rw-r--r--Flow/includes/Parsoid/Extractor/ImageExtractor.php40
-rw-r--r--Flow/includes/Parsoid/Extractor/PlaceholderExtractor.php56
-rw-r--r--Flow/includes/Parsoid/Extractor/TransclusionExtractor.php53
-rw-r--r--Flow/includes/Parsoid/Extractor/WikiLinkExtractor.php35
-rw-r--r--Flow/includes/Parsoid/Fixer.php20
-rw-r--r--Flow/includes/Parsoid/Fixer/BadImageRemover.php86
-rw-r--r--Flow/includes/Parsoid/Fixer/BaseHrefFixer.php62
-rw-r--r--Flow/includes/Parsoid/Fixer/WikiLinkFixer.php90
-rw-r--r--Flow/includes/Parsoid/ReferenceExtractor.php96
-rw-r--r--Flow/includes/Parsoid/ReferenceFactory.php81
-rw-r--r--Flow/includes/Parsoid/Utils.php313
-rw-r--r--Flow/includes/RecoverableErrorHandler.php28
-rw-r--r--Flow/includes/ReferenceClarifier.php139
-rw-r--r--Flow/includes/Repository/MultiGetList.php104
-rw-r--r--Flow/includes/Repository/RootPostLoader.php237
-rw-r--r--Flow/includes/Repository/TitleRepository.php15
-rw-r--r--Flow/includes/Repository/TreeRepository.php474
-rw-r--r--Flow/includes/Repository/UserName/OneStepUserNameQuery.php53
-rw-r--r--Flow/includes/Repository/UserName/TwoStepUserNameQuery.php74
-rw-r--r--Flow/includes/Repository/UserName/UserNameQuery.php20
-rw-r--r--Flow/includes/Repository/UserNameBatch.php147
-rw-r--r--Flow/includes/RevisionActionPermissions.php215
-rw-r--r--Flow/includes/SpamFilter/AbuseFilter.php133
-rw-r--r--Flow/includes/SpamFilter/ConfirmEdit.php60
-rw-r--r--Flow/includes/SpamFilter/ContentLengthFilter.php37
-rw-r--r--Flow/includes/SpamFilter/Controller.php57
-rw-r--r--Flow/includes/SpamFilter/SpamBlacklist.php61
-rw-r--r--Flow/includes/SpamFilter/SpamFilter.php24
-rw-r--r--Flow/includes/SpamFilter/SpamRegex.php50
-rw-r--r--Flow/includes/Specials/SpecialEnableFlow.php123
-rw-r--r--Flow/includes/Specials/SpecialFlow.php195
-rw-r--r--Flow/includes/SubmissionHandler.php154
-rw-r--r--Flow/includes/TalkpageManager.php271
-rw-r--r--Flow/includes/TemplateHelper.php805
-rw-r--r--Flow/includes/Templating.php203
-rw-r--r--Flow/includes/UrlGenerator.php834
-rw-r--r--Flow/includes/Utils/NamespaceIterator.php58
-rw-r--r--Flow/includes/Utils/PagesWithPropertyIterator.php83
-rw-r--r--Flow/includes/View.php291
-rw-r--r--Flow/includes/WatchedTopicItems.php95
-rw-r--r--Flow/includes/WorkflowLoader.php68
-rw-r--r--Flow/includes/WorkflowLoaderFactory.php158
-rw-r--r--Flow/maintenance/FlowAddMissingModerationLogs.php107
-rw-r--r--Flow/maintenance/FlowFixEditCount.php129
-rw-r--r--Flow/maintenance/FlowFixLog.php189
-rw-r--r--Flow/maintenance/FlowFixUserIp.php163
-rw-r--r--Flow/maintenance/FlowPopulateLinksTables.php115
-rw-r--r--Flow/maintenance/FlowSetUserIp.php183
-rw-r--r--Flow/maintenance/FlowUpdateRecentChanges.php175
-rw-r--r--Flow/maintenance/FlowUpdateRevisionContentLength.php154
-rw-r--r--Flow/maintenance/FlowUpdateRevisionTypeId.php105
-rw-r--r--Flow/maintenance/FlowUpdateUserWiki.php253
-rw-r--r--Flow/maintenance/MaintenanceDebugLogger.php63
-rw-r--r--Flow/maintenance/benchUuidTimestampConversion.php131
-rw-r--r--Flow/maintenance/compileLightncandy.php66
-rw-r--r--Flow/maintenance/convertLqt.php72
-rw-r--r--Flow/maintenance/convertLqtPage.php104
-rw-r--r--Flow/maintenance/convertNamespaceFromWikitext.php79
-rw-r--r--Flow/maintenance/convertToText.php179
-rw-r--r--Flow/modules/contributions/base.js31
-rw-r--r--Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js41
-rw-r--r--Flow/modules/editor/editors/ext.flow.editors.none.js165
-rw-r--r--Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js208
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js22
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js12
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js65
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less49
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js55
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js58
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg83
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg69
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js372
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less9
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js40
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js29
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js170
-rw-r--r--Flow/modules/editor/ext.flow.editor.js272
-rw-r--r--Flow/modules/editor/ext.flow.parsoid.js46
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-api-events.js920
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-interactive-events.js213
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-load-events.js42
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-misc.js127
-rw-r--r--Flow/modules/engine/components/board/base/flow-boardandhistory-base.js190
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-loadmore.js664
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-navigation.js282
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-preview.js237
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-switcheditor.js52
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-toc.js354
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-visualeditor.js37
-rw-r--r--Flow/modules/engine/components/board/flow-board.js196
-rw-r--r--Flow/modules/engine/components/board/flow-boardhistory.js59
-rw-r--r--Flow/modules/engine/components/common/flow-component-engines.js39
-rw-r--r--Flow/modules/engine/components/common/flow-component-events.js917
-rw-r--r--Flow/modules/engine/components/common/flow-component-menus.js142
-rw-r--r--Flow/modules/engine/components/flow-component.js251
-rw-r--r--Flow/modules/engine/components/flow-registry.js165
-rw-r--r--Flow/modules/engine/misc/flow-api.js411
-rw-r--r--Flow/modules/engine/misc/flow-baseconvert.js70
-rw-r--r--Flow/modules/engine/misc/flow-eventlog.js45
-rw-r--r--Flow/modules/engine/misc/flow-handlebars.js581
-rw-r--r--Flow/modules/engine/misc/jquery.conditionalScroll.js51
-rw-r--r--Flow/modules/engine/misc/jquery.findWithParent.js45
-rw-r--r--Flow/modules/engine/misc/mw-ui.enhance.js451
-rw-r--r--Flow/modules/engine/misc/mw-ui.modal.js410
-rw-r--r--Flow/modules/flow-initialize.js14
-rw-r--r--Flow/modules/handlebars.js25
-rw-r--r--Flow/modules/messagePoster/ext.flow.messagePoster.js57
-rw-r--r--Flow/modules/notification/icon/Talk-ltr.pngbin0 -> 298 bytes
-rw-r--r--Flow/modules/notification/icon/Talk-rtl.pngbin0 -> 448 bytes
-rw-r--r--Flow/modules/styles/board/content-preview.less24
-rw-r--r--Flow/modules/styles/board/editor-switcher.less54
-rw-r--r--Flow/modules/styles/board/form-actions.less54
-rw-r--r--Flow/modules/styles/board/header.less27
-rw-r--r--Flow/modules/styles/board/menu.less154
-rw-r--r--Flow/modules/styles/board/moderated.less35
-rw-r--r--Flow/modules/styles/board/navigation.less139
-rw-r--r--Flow/modules/styles/board/replycount.less30
-rw-r--r--Flow/modules/styles/board/terms-of-use.less22
-rw-r--r--Flow/modules/styles/board/timestamps.less59
-rw-r--r--Flow/modules/styles/board/topic/meta.less9
-rw-r--r--Flow/modules/styles/board/topic/post.less190
-rw-r--r--Flow/modules/styles/board/topic/summary.less18
-rw-r--r--Flow/modules/styles/board/topic/titlebar.less71
-rw-r--r--Flow/modules/styles/board/topic/watchlist.less69
-rw-r--r--Flow/modules/styles/common.less124
-rw-r--r--Flow/modules/styles/errors.less11
-rw-r--r--Flow/modules/styles/flow.less/flow.colors.less4
-rw-r--r--Flow/modules/styles/flow.less/flow.helpers.less16
-rw-r--r--Flow/modules/styles/flow.less/flow.variables.less5
-rw-r--r--Flow/modules/styles/history/history-line.less3
-rw-r--r--Flow/modules/styles/js.less104
-rw-r--r--Flow/modules/styles/mediawiki.ui/forms.less243
-rw-r--r--Flow/modules/styles/mediawiki.ui/modal.less84
-rw-r--r--Flow/modules/styles/mediawiki.ui/text.less10
-rw-r--r--Flow/modules/styles/mediawiki.ui/tooltips.less212
-rw-r--r--Flow/modules/styles/minerva/common.less4
-rw-r--r--Flow/modules/vendor/Storer.js1355
-rw-r--r--Flow/modules/vendor/handlebars.js3079
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.eotbin0 -> 15946 bytes
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.svg291
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.ttfbin0 -> 15628 bytes
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.woffbin0 -> 11168 bytes
-rw-r--r--Flow/modules/wikiglyph/flow-override.less7
-rw-r--r--Flow/modules/wikiglyph/wikiglyphs.css359
-rw-r--r--Flow/package.json18
-rw-r--r--Flow/scripts/.htaccess1
-rwxr-xr-xFlow/scripts/analyze-phpstorm.sh61
-rw-r--r--Flow/scripts/analyze-phpstorm.xml21
-rw-r--r--Flow/scripts/gen-autoload.php22
-rwxr-xr-xFlow/scripts/generatecss.php29
-rw-r--r--Flow/scripts/hooks-shared.sh36
-rwxr-xr-xFlow/scripts/pre-commit61
-rwxr-xr-xFlow/scripts/pre-review27
-rwxr-xr-xFlow/scripts/qunit.sh14
-rwxr-xr-xFlow/scripts/remotecheck.sh15
-rw-r--r--Flow/tests/browser/README.md1
-rw-r--r--Flow/tests/browser/features/action_menu_permalink.feature31
-rw-r--r--Flow/tests/browser/features/anon_interface.feature9
-rw-r--r--Flow/tests/browser/features/edit_existing.feature22
-rw-r--r--Flow/tests/browser/features/flow_in_recent_changes.feature18
-rw-r--r--Flow/tests/browser/features/flow_logged_in.feature33
-rw-r--r--Flow/tests/browser/features/flow_no_javascript.feature21
-rw-r--r--Flow/tests/browser/features/lock_unlock_topics.feature48
-rw-r--r--Flow/tests/browser/features/moderation.feature44
-rw-r--r--Flow/tests/browser/features/new_topic.feature15
-rw-r--r--Flow/tests/browser/features/post_links.feature12
-rw-r--r--Flow/tests/browser/features/reply.feature30
-rw-r--r--Flow/tests/browser/features/reply_moderation.feature17
-rw-r--r--Flow/tests/browser/features/sorting_topics.feature19
-rw-r--r--Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb41
-rw-r--r--Flow/tests/browser/features/step_definitions/edit_existing_steps.rb47
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb11
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb65
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_steps.rb167
-rw-r--r--Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb75
-rw-r--r--Flow/tests/browser/features/step_definitions/moderation_steps.rb47
-rw-r--r--Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb24
-rw-r--r--Flow/tests/browser/features/step_definitions/reply_steps.rb65
-rw-r--r--Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb34
-rw-r--r--Flow/tests/browser/features/step_definitions/thank_steps.rb37
-rw-r--r--Flow/tests/browser/features/step_definitions/watch_steps.rb64
-rw-r--r--Flow/tests/browser/features/support/env.rb10
-rw-r--r--Flow/tests/browser/features/support/hooks.rb3
-rw-r--r--Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb7
-rw-r--r--Flow/tests/browser/features/support/pages/flow_page.rb239
-rw-r--r--Flow/tests/browser/features/support/pages/new_flow_page.rb7
-rw-r--r--Flow/tests/browser/features/support/pages/recent_changes_page.rb8
-rw-r--r--Flow/tests/browser/features/support/pages/user_page.rb9
-rw-r--r--Flow/tests/browser/features/thank.feature23
-rw-r--r--Flow/tests/browser/features/watch.feature36
-rw-r--r--Flow/tests/externals/phantomjs-qunit-runner.js127
-rw-r--r--Flow/tests/phpunit/Block/TopicListTest.php58
-rw-r--r--Flow/tests/phpunit/BlockFactoryTest.php69
-rw-r--r--Flow/tests/phpunit/Collection/PostCollectionTest.php125
-rw-r--r--Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php290
-rw-r--r--Flow/tests/phpunit/ContainerTest.php44
-rw-r--r--Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php273
-rw-r--r--Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php31
-rw-r--r--Flow/tests/phpunit/Data/BufferedCacheTest.php33
-rw-r--r--Flow/tests/phpunit/Data/CachingObjectMapperTest.php35
-rw-r--r--Flow/tests/phpunit/Data/Index/FeatureIndexTest.php104
-rw-r--r--Flow/tests/phpunit/Data/IndexTest.php111
-rw-r--r--Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php78
-rw-r--r--Flow/tests/phpunit/Data/ManagerGroupTest.php66
-rw-r--r--Flow/tests/phpunit/Data/NothingTest.php66
-rw-r--r--Flow/tests/phpunit/Data/ObjectLocatorTest.php24
-rw-r--r--Flow/tests/phpunit/Data/Pager/PagerTest.php513
-rw-r--r--Flow/tests/phpunit/Data/RevisionStorageTest.php138
-rw-r--r--Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php56
-rw-r--r--Flow/tests/phpunit/Data/UserNameBatchTest.php89
-rw-r--r--Flow/tests/phpunit/Data/UserNameListenerTest.php51
-rw-r--r--Flow/tests/phpunit/FlowActionsTest.php22
-rw-r--r--Flow/tests/phpunit/FlowTestCase.php29
-rw-r--r--Flow/tests/phpunit/Formatter/FormatterTest.php150
-rw-r--r--Flow/tests/phpunit/Formatter/RevisionFormatterTest.php165
-rw-r--r--Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php121
-rw-r--r--Flow/tests/phpunit/HookTest.php206
-rw-r--r--Flow/tests/phpunit/Import/ConverterTest.php101
-rw-r--r--Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php33
-rw-r--r--Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php157
-rw-r--r--Flow/tests/phpunit/Import/PageImportStateTest.php116
-rw-r--r--Flow/tests/phpunit/Import/TalkpageImportOperationTest.php164
-rw-r--r--Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php96
-rw-r--r--Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php50
-rw-r--r--Flow/tests/phpunit/LinksTableTest.php473
-rw-r--r--Flow/tests/phpunit/Mock/MockImportHeader.php34
-rw-r--r--Flow/tests/phpunit/Mock/MockImportPost.php50
-rw-r--r--Flow/tests/phpunit/Mock/MockImportRevision.php52
-rw-r--r--Flow/tests/phpunit/Mock/MockImportSource.php42
-rw-r--r--Flow/tests/phpunit/Mock/MockImportSummary.php30
-rw-r--r--Flow/tests/phpunit/Mock/MockImportTopic.php52
-rw-r--r--Flow/tests/phpunit/Model/PostRevisionTest.php53
-rw-r--r--Flow/tests/phpunit/Model/UUIDTest.php161
-rw-r--r--Flow/tests/phpunit/Model/UserTupleTest.php49
-rw-r--r--Flow/tests/phpunit/Notifications/NotifiedUsersTest.php142
-rw-r--r--Flow/tests/phpunit/PagerTest.php105
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php61
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php36
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php93
-rw-r--r--Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php180
-rw-r--r--Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php32
-rw-r--r--Flow/tests/phpunit/Parsoid/UtilsTest.php113
-rw-r--r--Flow/tests/phpunit/PermissionsTest.php373
-rw-r--r--Flow/tests/phpunit/PostRevisionTestCase.php234
-rw-r--r--Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php83
-rw-r--r--Flow/tests/phpunit/Repository/TreeRepositoryTest.php90
-rw-r--r--Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php140
-rw-r--r--Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php36
-rw-r--r--Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php58
-rw-r--r--Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php99
-rw-r--r--Flow/tests/phpunit/SpamFilter/SpamRegexTest.php58
-rw-r--r--Flow/tests/phpunit/TemplateHelperTest.php48
-rw-r--r--Flow/tests/phpunit/TemplatingTest.php73
-rw-r--r--Flow/tests/phpunit/UrlGeneratorTest.php114
-rw-r--r--Flow/tests/phpunit/WatchedTopicItemsTest.php83
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditPostTest.php49
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditTitleTest.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowLockTopicTest.php78
-rw-r--r--Flow/tests/phpunit/api/ApiFlowModeratePostTest.php54
-rw-r--r--Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php71
-rw-r--r--Flow/tests/phpunit/api/ApiFlowReplyTest.php45
-rw-r--r--Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php79
-rw-r--r--Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php255
-rw-r--r--Flow/tests/phpunit/api/ApiTestCase.php95
-rw-r--r--Flow/tests/phpunit/api/ApiWatchTopicTest.php54
-rw-r--r--Flow/tests/phpunit/bootstrap.php19
-rw-r--r--Flow/tests/phpunit/flow.suite.xml29
-rw-r--r--Flow/tests/qunit/engine/components/board/test_flow-board.js178
-rw-r--r--Flow/tests/qunit/engine/misc/test_flow-handlebars.js153
-rw-r--r--Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js128
-rw-r--r--Flow/vendor/Pimple/Container.php281
-rw-r--r--Flow/vendor/Pimple/ServiceProviderInterface.php46
-rw-r--r--Flow/version4
705 files changed, 83522 insertions, 0 deletions
diff --git a/Flow/.arcconfig b/Flow/.arcconfig
new file mode 100644
index 00000000..531ffdf9
--- /dev/null
+++ b/Flow/.arcconfig
@@ -0,0 +1,4 @@
+{
+ "project.name": "MediaWiki-extensions-Flow",
+ "phabricator.uri": "https://phabricator.wikimedia.org"
+}
diff --git a/Flow/.csslintrc b/Flow/.csslintrc
new file mode 100644
index 00000000..e777c7f3
--- /dev/null
+++ b/Flow/.csslintrc
@@ -0,0 +1,11 @@
+{
+ "adjoining-classes": false,
+ "box-model": false,
+ "box-sizing": false,
+ "fallback-colors": false,
+ "important": false,
+ "outline-none": false,
+ "qualified-headings": false,
+ "universal-selector": false,
+ "unqualified-attributes": false
+}
diff --git a/Flow/.gitattributes b/Flow/.gitattributes
new file mode 100644
index 00000000..f0d6e86a
--- /dev/null
+++ b/Flow/.gitattributes
@@ -0,0 +1 @@
+handlebars/compiled/* -diff -whitespace
diff --git a/Flow/.gitignore b/Flow/.gitignore
new file mode 100644
index 00000000..9319e8be
--- /dev/null
+++ b/Flow/.gitignore
@@ -0,0 +1,13 @@
+.svn
+*~
+*.kate-swp
+.*.swp
+scripts/hhvm-wrapper.phar
+scripts/remotes
+node_modules/
+tests/browser/.gem
+tests/browser/screenshots
+vendor/composer/
+vendor/symfony/
+vendor/autoload.php
+\#*#
diff --git a/Flow/.gitreview b/Flow/.gitreview
new file mode 100644
index 00000000..8c23f526
--- /dev/null
+++ b/Flow/.gitreview
@@ -0,0 +1,6 @@
+[gerrit]
+host=gerrit.wikimedia.org
+port=29418
+project=mediawiki/extensions/Flow.git
+defaultbranch=master
+defaultrebase=0
diff --git a/Flow/.jscsrc b/Flow/.jscsrc
new file mode 100644
index 00000000..bf354819
--- /dev/null
+++ b/Flow/.jscsrc
@@ -0,0 +1,13 @@
+{
+ "preset": "wikimedia",
+
+ "requireSpaceAfterKeywords": null,
+ "requireSpacesInsideArrayBrackets": null,
+ "disallowQuotedKeysInObjects": null,
+ "disallowDanglingUnderscores": null,
+ "disallowSpaceAfterObjectKeys": null,
+ "disallowMultipleLineBreaks": null,
+ "requireCamelCaseOrUpperCaseIdentifiers": null,
+ "validateQuoteMarks": null,
+ "validateIndentation": null
+}
diff --git a/Flow/.jshintignore b/Flow/.jshintignore
new file mode 100644
index 00000000..db20c5e2
--- /dev/null
+++ b/Flow/.jshintignore
@@ -0,0 +1,3 @@
+modules/jquery.scroll.js
+modules/vendor/handlebars-v2.0.0-alpha.2.js
+modules/vendor/Storer.js
diff --git a/Flow/.jshintrc b/Flow/.jshintrc
new file mode 100644
index 00000000..85b889e8
--- /dev/null
+++ b/Flow/.jshintrc
@@ -0,0 +1,32 @@
+{
+ "globals": {
+ "console": true,
+ "jQuery": true,
+ "JsDiff": true,
+ "Hogan": true,
+ "QUnit": true,
+ "mw": true,
+ "mediaWiki": true,
+ "strictEqual": true,
+ "deepEqual": true,
+ "ok": true,
+ "sinon": true,
+ "ve": true,
+ "Handlebars": true,
+ "initStorer": true,
+ "OO": true,
+ "moment": true
+ },
+ "browser": true, // document, navigator, etc.
+ "curly": true, // requres curly braces around loops and conditionals
+ "devel": true, // console, alert, etc.
+ "eqeqeq": true, // prohibits == and !=
+ "es3": false, // needed to catch foo.new object keys, but disabled because of "static" keyword
+ "forin": false, // make for-in loops require hasOwnProperty check
+ "onevar": true, // only one var declaration per function
+ "supernew": true, // suppress warnings about "weird" object constructions
+ "trailing": true, // disallow trailing whitespace
+ "undef" : true, // prohibits the use of undefined variables
+ "unused": "vars" // complain about unused variables but not arguments
+ // "white": true // enforce Crockford rules
+}
diff --git a/Flow/.rubocop.yml b/Flow/.rubocop.yml
new file mode 100644
index 00000000..6fc09c79
--- /dev/null
+++ b/Flow/.rubocop.yml
@@ -0,0 +1,33 @@
+inherit_from: .rubocop_todo.yml
+
+# See discussion of rules at
+# https://www.mediawiki.org/wiki/Manual:Coding_conventions/Ruby
+
+Metrics/AbcSize:
+ Enabled: false
+
+Metrics/ClassLength:
+ Enabled: false
+
+Metrics/CyclomaticComplexity:
+ Enabled: false
+
+Metrics/MethodLength:
+ Enabled: false
+
+Metrics/ParameterLists:
+ Enabled: false
+
+Metrics/PerceivedComplexity:
+ Enabled: false
+
+Style/Alias:
+ Enabled: false
+
+# rubocop fix lifted from CirrusSearch/.rubocop.yml
+Style/LeadingCommentSpace:
+ Exclude:
+ - Gemfile # RVM doesn't recognise spaces after the #s
+
+Style/SignalException:
+ Enabled: false
diff --git a/Flow/.rubocop_todo.yml b/Flow/.rubocop_todo.yml
new file mode 100644
index 00000000..8e5ff3f0
--- /dev/null
+++ b/Flow/.rubocop_todo.yml
@@ -0,0 +1,42 @@
+# This configuration was generated by `rubocop --auto-gen-config`
+# on 2014-11-07 11:17:24 -0700 using RuboCop version 0.27.0.
+# The point is for the user to remove these configuration records
+# one by one as the offenses are removed from the code base.
+# Note that changes in the inspected code, or installation of new
+# versions of RuboCop, may require this file to be generated again.
+
+# Offense count: 66
+# Configuration parameters: AllowURI, URISchemes.
+Metrics/LineLength:
+ Max: 224
+
+# Offense count: 1
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+Style/BracesAroundHashParameters:
+ Enabled: false
+
+# Offense count: 1
+# Configuration parameters: Keywords.
+Style/CommentAnnotation:
+ Enabled: false
+
+# Offense count: 5
+Style/Documentation:
+ Enabled: false
+
+# Offense count: 1
+# Configuration parameters: AllowedVariables.
+Style/GlobalVars:
+ Enabled: false
+
+# Offense count: 111
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, SupportedStyles.
+Style/StringLiterals:
+ Enabled: false
+
+# Offense count: 1
+# Cop supports --auto-correct.
+Style/WhileUntilDo:
+ Enabled: false
diff --git a/Flow/COPYING b/Flow/COPYING
new file mode 100644
index 00000000..d159169d
--- /dev/null
+++ b/Flow/COPYING
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/Flow/Flow.alias.php b/Flow/Flow.alias.php
new file mode 100644
index 00000000..0521e2a2
--- /dev/null
+++ b/Flow/Flow.alias.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Aliases for Special:Flow
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+// @codingStandardsIgnoreFile
+
+$specialPageAliases = array();
+
+/** English (English) */
+$specialPageAliases['en'] = array(
+ 'Flow' => array( 'Flow' ),
+ 'EnableFlow' => array( 'EnableFlow' ),
+);
+
+/** Arabic (العربية) */
+$specialPageAliases['ar'] = array(
+ 'Flow' => array( 'سريان' ),
+);
+
+/** Egyptian Spoken Arabic (مصرى) */
+$specialPageAliases['arz'] = array(
+ 'Flow' => array( 'سريان' ),
+);
+
+/** German (Deutsch) */
+$specialPageAliases['de'] = array(
+ 'EnableFlow' => array( 'Flow_aktivieren' ),
+);
+
+/** Zazaki (Zazaki) */
+$specialPageAliases['diq'] = array(
+ 'Flow' => array( 'Rodayış' ),
+);
+
+/** Hebrew (עברית) */
+$specialPageAliases['he'] = array(
+ 'Flow' => array( 'זרימה' ),
+ 'EnableFlow' => array( 'הפעלת_זרימה' ),
+);
+
+/** Korean (한국어) */
+$specialPageAliases['ko'] = array(
+ 'Flow' => array( '플로우' ),
+);
+
+/** لوری (لوری) */
+$specialPageAliases['lrc'] = array(
+ 'Flow' => array( 'جریان' ),
+);
+
+/** Macedonian (македонски) */
+$specialPageAliases['mk'] = array(
+ 'Flow' => array( 'Тек' ),
+ 'EnableFlow' => array( 'ОвозможиТек' ),
+);
+
+/** Malayalam (മലയാളം) */
+$specialPageAliases['ml'] = array(
+ 'Flow' => array( 'പ്രവാഹം' ),
+);
+
+/** Portuguese (português) */
+$specialPageAliases['pt'] = array(
+ 'Flow' => array( 'Fluência', 'Fluencia' ),
+);
+
+/** Brazilian Portuguese (português do Brasil) */
+$specialPageAliases['pt-br'] = array(
+ 'EnableFlow' => array( 'Habilitar_Flow' ),
+);
+
+/** Swedish (svenska) */
+$specialPageAliases['sv'] = array(
+ 'Flow' => array( 'Flöde' ),
+);
+
+/** Simplified Chinese (中文(简体)‎) */
+$specialPageAliases['zh-hans'] = array(
+ 'EnableFlow' => array( '启用Flow' ),
+);
+
+/** Traditional Chinese (中文(繁體)‎) */
+$specialPageAliases['zh-hant'] = array(
+ 'Flow' => array( '流動量' ),
+ 'EnableFlow' => array( '啟用流動量' ),
+); \ No newline at end of file
diff --git a/Flow/Flow.namespaces.php b/Flow/Flow.namespaces.php
new file mode 100644
index 00000000..816cc9c0
--- /dev/null
+++ b/Flow/Flow.namespaces.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Translations of the namespaces introduced by Flow.
+ *
+ * @file
+ */
+
+require_once __DIR__ . '/defines.php';
+
+$namespaceNames = array();
+
+/** Asturian */
+$namespaceNames['ast'] = array(
+ NS_TOPIC => 'Asuntu',
+);
+
+/** German */
+$namespaceNames['de'] = array(
+ NS_TOPIC => 'Thema',
+);
+
+/** English */
+$namespaceNames['en'] = array(
+ NS_TOPIC => 'Topic',
+);
+
+/** Spanish */
+$namespaceNames['es'] = array(
+ NS_TOPIC => 'Tema',
+);
+
+/** Persian */
+$namespaceNames['fa'] = array(
+ NS_TOPIC => 'موضوع',
+);
+
+/** Finnish */
+$namespaceNames['fi'] = array(
+ NS_TOPIC => 'Aihe',
+);
+
+/** French */
+$namespaceNames['fr'] = array(
+ NS_TOPIC => 'Sujet',
+);
+
+/** Hebrew */
+$namespaceNames['he'] = array(
+ NS_TOPIC => 'נושא',
+);
+
+/** Italian */
+$namespaceNames['it'] = array(
+ NS_TOPIC => 'Argomento',
+);
+
+/** Luxembourgish */
+$namespaceNames['lb'] = array(
+ NS_TOPIC => 'Thema',
+);
+
+/** Latvian */
+$namespaceNames['lv'] = array(
+ NS_TOPIC => 'Tēma',
+);
+
+/** Macedonian */
+$namespaceNames['mk'] = array(
+ NS_TOPIC => 'Тема',
+);
+
+/** Dutch */
+$namespaceNames['nl'] = array(
+ NS_TOPIC => 'Onderwerp',
+);
+
+/** Occitan */
+$namespaceNames['oc'] = array(
+ NS_TOPIC => 'Subjècte',
+);
+
+/** Polish */
+$namespaceNames['pl'] = array(
+ NS_TOPIC => 'Temat',
+);
+
+/** Portuguese */
+$namespaceNames['pt'] = array(
+ NS_TOPIC => 'Tópico',
+);
+
+/** Russian */
+$namespaceNames['ru'] = array(
+ NS_TOPIC => 'Тема',
+);
+
+/** Slovenian */
+$namespaceNames['sl'] = array(
+ NS_TOPIC => 'Tema',
+);
+
+/** Swedish */
+$namespaceNames['sv'] = array(
+ NS_TOPIC => 'Ämne',
+);
+
+/** Ukrainian */
+$namespaceNames['uk'] = array(
+ NS_TOPIC => 'Тема',
+);
+
+/** Yiddish */
+$namespaceNames['yi'] = array(
+ NS_TOPIC => 'טעמע',
+);
diff --git a/Flow/Flow.php b/Flow/Flow.php
new file mode 100644
index 00000000..48ea2ab4
--- /dev/null
+++ b/Flow/Flow.php
@@ -0,0 +1,353 @@
+<?php
+/**
+ * MediaWiki Extension: Flow
+ *
+ * Flow, a discussion system for MediaWiki
+ * Copyright (C) 2013-2015 Flow contributors
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * ---
+ * Older parts of Flow are also available under the terms:
+ *
+ * ---
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * This program is distributed WITHOUT ANY WARRANTY.
+ * ---
+ *
+ * Third-party libraries are under their own licenses. See vendor and modules/vendor.
+ */
+
+/**
+ *
+ * @file
+ * @ingroup Extensions
+ */
+
+// Extension credits that will show up on Special:Version
+$wgExtensionCredits['other'][] = array(
+ 'path' => __FILE__,
+ 'name' => 'Flow',
+ 'url' => 'https://www.mediawiki.org/wiki/Extension:Flow',
+ 'author' => array(
+ // Alphabetical by last name
+ 'Erik Bernhardson',
+ 'Matthew Flaschen',
+ 'Andrew Garrett',
+ 'Shahyar Ghobadpour',
+ 'Pau Giner',
+ 'Chris McMahon',
+ 'Kunal Mehta',
+ 'Matthias Mullie',
+ 'S Page',
+ 'Jon Robson',
+ 'Benny Situ',
+ ),
+ 'descriptionmsg' => 'flow-desc',
+ 'license-name' => 'GPL-2.0+', // Appears with link to COPYING on Special:Version
+);
+
+require_once __DIR__ . '/defines.php';
+
+define( 'CONTENT_MODEL_FLOW_BOARD', 'flow-board' );
+$wgNamespacesWithSubpages[NS_TOPIC] = false;
+$wgNamespaceContentModels[NS_TOPIC] = CONTENT_MODEL_FLOW_BOARD;
+
+$dir = __DIR__ . '/';
+require $dir . 'Resources.php';
+
+$wgMessagesDirs['Flow'] = __DIR__ . '/i18n';
+$wgExtensionMessagesFiles['FlowNamespaces'] = $dir . '/Flow.namespaces.php';
+
+// This file is autogenerated by scripts/gen-autoload.php
+require __DIR__ . '/autoload.php';
+
+$wgAPIModules['flow-parsoid-utils'] = 'Flow\Api\ApiParsoidUtilsFlow';
+$wgAPIModules['flow'] = 'Flow\Api\ApiFlow';
+$wgAPIPropModules['flowinfo'] = 'Flow\Api\ApiQueryPropFlowInfo';
+
+// Special:Flow
+$wgExtensionMessagesFiles['FlowAlias'] = $dir . 'Flow.alias.php';
+$wgSpecialPages['Flow'] = 'Flow\Specials\SpecialFlow';
+$wgSpecialPages['EnableFlow'] = 'Flow\Specials\SpecialEnableFlow';
+$wgSpecialPageGroups['Flow'] = 'redirects';
+$wgSpecialPageGroups['EnableFlow'] = 'wiki';
+
+// Housekeeping hooks
+$wgHooks['LoadExtensionSchemaUpdates'][] = 'FlowHooks::getSchemaUpdates';
+$wgHooks['GetPreferences'][] = 'FlowHooks::onGetPreferences';
+$wgHooks['UnitTestsList'][] = 'FlowHooks::getUnitTests';
+$wgHooks['OldChangesListRecentChangesLine'][] = 'FlowHooks::onOldChangesListRecentChangesLine';
+$wgHooks['ChangesListInsertArticleLink'][] = 'FlowHooks::onChangesListInsertArticleLink';
+$wgHooks['ChangesListInitRows'][] = 'FlowHooks::onChangesListInitRows';
+$wgHooks['EnhancedChangesList::getLogText'][] = 'FlowHooks::onGetLogText';
+$wgHooks['SkinTemplateNavigation::Universal'][] = 'FlowHooks::onSkinTemplateNavigation';
+$wgHooks['Article::MissingArticleConditions'][] = 'FlowHooks::onMissingArticleConditions';
+$wgHooks['SpecialWatchlistGetNonRevisionTypes'][] = 'FlowHooks::onSpecialWatchlistGetNonRevisionTypes';
+$wgHooks['UserGetReservedNames'][] = 'FlowHooks::onUserGetReservedNames';
+$wgHooks['ResourceLoaderGetConfigVars'][] = 'FlowHooks::onResourceLoaderGetConfigVars';
+$wgHooks['ContribsPager::reallyDoQuery'][] = 'FlowHooks::onContributionsQuery';
+$wgHooks['DeletedContribsPager::reallyDoQuery'][] = 'FlowHooks::onDeletedContributionsQuery';
+$wgHooks['ContributionsLineEnding'][] = 'FlowHooks::onContributionsLineEnding';
+$wgHooks['DeletedContributionsLineEnding'][] = 'FlowHooks::onDeletedContributionsLineEnding';
+$wgHooks['ApiFeedContributions::feedItem'][] = 'FlowHooks::onContributionsFeedItem';
+$wgHooks['AbuseFilter-computeVariable'][] = 'FlowHooks::onAbuseFilterComputeVariable';
+$wgHooks['AbortEmailNotification'][] = 'FlowHooks::onAbortEmailNotification';
+$wgHooks['InfoAction'][] = 'FlowHooks::onInfoAction';
+$wgHooks['SpecialCheckUserGetLinksFromRow'][] = 'FlowHooks::onSpecialCheckUserGetLinksFromRow';
+$wgHooks['CheckUserInsertForRecentChange'][] = 'FlowHooks::onCheckUserInsertForRecentChange';
+$wgHooks['SkinMinervaDefaultModules'][] = 'FlowHooks::onSkinMinervaDefaultModules';
+$wgHooks['IRCLineURL'][] = 'FlowHooks::onIRCLineURL';
+$wgHooks['FlowAddModules'][] = 'Flow\Parsoid\Utils::onFlowAddModules';
+$wgHooks['WhatLinksHereProps'][] = 'FlowHooks::onWhatLinksHereProps';
+$wgHooks['ResourceLoaderTestModules'][] = 'FlowHooks::onResourceLoaderTestModules';
+$wgHooks['ContentHandlerDefaultModelFor'][] = 'Flow\Content\Content::onGetDefaultModel';
+$wgHooks['ShowMissingArticle'][] = 'Flow\Content\Content::onShowMissingArticle';
+$wgHooks['ArticleAfterFetchContentObject'][] = 'Flow\Content\Content::onFetchContentObject';
+$wgHooks['MessageCache::get'][] = 'FlowHooks::onMessageCacheGet';
+$wgHooks['WatchArticle'][] = 'FlowHooks::onWatchArticle';
+$wgHooks['UnwatchArticle'][] = 'FlowHooks::onWatchArticle';
+$wgHooks['CanonicalNamespaces'][] = 'FlowHooks::onCanonicalNamespaces';
+$wgHooks['MovePageIsValidMove'][] = 'FlowHooks::onMovePageIsValidMove';
+$wgHooks['AbortMove'][] = 'FlowHooks::onAbortMove';
+$wgHooks['TitleSquidURLs'][] = 'FlowHooks::onTitleSquidURLs';
+$wgHooks['WatchlistEditorBuildRemoveLine'][] = 'FlowHooks::onWatchlistEditorBuildRemoveLine';
+$wgHooks['WatchlistEditorBeforeFormRender'][] = 'FlowHooks::onWatchlistEditorBeforeFormRender';
+$wgHooks['NamespaceIsMovable'][] = 'FlowHooks::onNamespaceIsMovable';
+$wgHooks['CategoryViewer::doCategoryQuery'][] = 'FlowHooks::onCategoryViewerDoCategoryQuery';
+$wgHooks['CategoryViewer::generateLink'][] = 'FlowHooks::onCategoryViewerGenerateLink';
+$wgHooks['ArticleConfirmDelete'][] = 'FlowHooks::onArticleConfirmDelete';
+$wgHooks['ArticleDelete'][] = 'FlowHooks::onArticleDelete';
+
+// Extension:UserMerge support
+$wgHooks['UserMergeAccountFields'][] = 'FlowHooks::onUserMergeAccountFields';
+$wgHooks['MergeAccountFromTo'][] = 'FlowHooks::onMergeAccountFromTo';
+
+// Special case: Flow is the successor to LiquidThreads and any Flow boards should automatically
+// not be LiquidThreads talk pages.
+$wgHooks['LiquidThreadsIsLqtPage'][] = 'FlowHooks::onIsLiquidThreadsPage';
+
+// Extension initialization
+$wgExtensionFunctions[] = 'FlowHooks::initFlowExtension';
+
+// Flow Content Type
+$wgContentHandlers['flow-board'] = 'Flow\Content\BoardContentHandler';
+
+// User permissions
+// Added to $wgFlowGroupPermissions instead of $wgGroupPermissions immediately,
+// to easily fetch Flow-specific permissions in tests/PermissionsTest.php.
+// If you wish to make local permission changes, add them to $wgGroupPermissions
+// directly - tests will fail otherwise, since they'll be based on a different
+// permissions config than what's assumed to test.
+$wgFlowGroupPermissions = array();
+$wgFlowGroupPermissions['*']['flow-hide'] = true;
+$wgFlowGroupPermissions['user']['flow-lock'] = true;
+$wgFlowGroupPermissions['sysop']['flow-lock'] = true;
+$wgFlowGroupPermissions['sysop']['flow-delete'] = true;
+$wgFlowGroupPermissions['sysop']['flow-edit-post'] = true;
+$wgFlowGroupPermissions['oversight']['flow-suppress'] = true;
+$wgFlowGroupPermissions['flow-bot']['flow-create-board'] = true;
+$wgGroupPermissions = array_merge_recursive( $wgGroupPermissions, $wgFlowGroupPermissions );
+
+$wgAvailableRights[] = 'flow-hide';
+$wgAvailableRights[] = 'flow-lock';
+$wgAvailableRights[] = 'flow-delete';
+$wgAvailableRights[] = 'flow-suppress';
+$wgAvailableRights[] = 'flow-edit-post';
+$wgAvailableRights[] = 'flow-create-board';
+
+// Register Flow import paths
+$wgResourceLoaderLESSImportPaths = array_merge( $wgResourceLoaderLESSImportPaths, array(
+ $dir . "modules/styles/flow.less/",
+) );
+
+// Configuration
+
+// URL for more information about the Flow notification system
+$wgFlowHelpPage = '//www.mediawiki.org/wiki/Special:MyLanguage/Help:Extension:Flow';
+
+// $wgFlowCluster will define what external DB server should be used.
+// If set to false, the current database (wfGetDB) will be used to read/write
+// data from/to. If Flow data is supposed to be stored on an external database,
+// set the value of this variable to the $wgExternalServers key representing
+// that external connection.
+$wgFlowCluster = false;
+
+// Database to use for Flow metadata. Set to false to use the wiki db. Any number of wikis can
+// and should share the same Flow database.
+$wgFlowDefaultWikiDb = false;
+
+// Used for content storage. False to store content in flow db. Otherwise a cluster or
+// list of clusters to use with ExternalStore. Provided clusters must exist in
+// $wgExternalStores. Multiple clusters required for HA, so inserts can continue
+// if one of the masters is down for maint or any other reason.
+// ex:
+// $wgFlowExternalStore = array( 'DB://cluster24', 'DB://cluster25' );
+$wgFlowExternalStore = false;
+
+// By default, Flow will store content in HTML. However, this requires having Parsoid up
+// and running, as it'll be necessary to convert HTML to wikitext for the basic editor.
+// (n.b. to use VisualEditor, you'll definitely need Parsoid, so if you do support VE,
+// might as well set this to HTML right away)
+// If $wgFlowParsoidURL is null, $wgFlowContentFormat will be forced to wikitext.
+//
+// The 'wikitext' format is likely to be deprecated in the future.
+$wgFlowContentFormat = 'html'; // possible values: html|wikitext XXX bug 70148 with wikitext
+
+// Flow Parsoid config
+// Please note that this configuration is separate from VE's Parsoid config:
+// you'll have to fill out these variables too if you want to use Parsoid.
+$wgFlowParsoidURL = null; // also see $wgVisualEditorParsoidURL
+$wgFlowParsoidPrefix = null; // also see $wgVisualEditorParsoidPrefix
+$wgFlowParsoidTimeout = null; // also see $wgVisualEditorParsoidTimeout
+// Forward users' Cookie: headers to Parsoid. Required for private wikis (login required to read).
+// If the wiki is not private (i.e. $wgGroupPermissions['*']['read'] is true) this configuration
+// variable will be ignored.
+//
+// This feature requires a non-locking session store. The default session store will not work and
+// will cause deadlocks when trying to use this feature. If you experience deadlock issues, enable
+// $wgSessionsInObjectCache.
+//
+// WARNING: ONLY enable this on private wikis and ONLY IF you understand the SECURITY IMPLICATIONS
+// of sending Cookie headers to Parsoid over HTTP. For security reasons, it is strongly recommended
+// that $wgVisualEditorParsoidURL be pointed to localhost if this setting is enabled.
+$wgFlowParsoidForwardCookies = false;
+
+// Flow Configuration for EventLogging
+$wgFlowConfig = array(
+ 'version' => '0.1.0',
+);
+
+// When visiting the flow for an article but not specifying what type of workflow should be viewed,
+// use this workflow
+$wgFlowDefaultWorkflow = 'discussion';
+
+// Limits for paging
+$wgFlowDefaultLimit = 10;
+$wgFlowMaxLimit = 100;
+
+// Echo notification subscription preference
+$wgDefaultUserOptions['echo-subscriptions-web-flow-discussion'] = true;
+$wgDefaultUserOptions['echo-subscriptions-email-flow-discussion'] = false;
+
+// Default sort order of a topiclist view. See TopicListBlock::getFindOptions()
+// for more information.
+$wgDefaultUserOptions['flow-topiclist-sortby'] = 'newest';
+
+// Default editor to use in Flow
+$wgDefaultUserOptions['flow-editor'] = 'none';
+
+// Maximum number of users that can be mentioned in one comment
+$wgFlowMaxMentionCount = 100;
+
+// Pages to occupy is an array of normalised page names, e.g. array( 'User talk:Zomg' ).
+$wgFlowOccupyPages = array();
+
+// Namespaces to occupy is an array of NS_* constants, e.g. array( NS_USER_TALK ).
+$wgFlowOccupyNamespaces = array();
+
+// Max threading depth
+$wgFlowMaxThreadingDepth = 8;
+
+// A list of editors to use, in priority order
+$wgFlowEditorList = array( 'none' ); // EXPERIMENTAL prepend 'visualeditor'
+
+// Action details config file
+require $dir . 'FlowActions.php';
+
+// Register activity log formatter hooks
+foreach( $wgFlowActions as $action => $options ) {
+ if ( is_string( $options ) ) {
+ continue;
+ }
+ if ( isset( $options['log_type'] ) ) {
+ $log = $options['log_type'];
+
+ // Some actions are more complex closures - to be added manually.
+ if ( is_string( $log ) ) {
+ $wgLogActionsHandlers["$log/flow-$action"] = 'Flow\Log\ActionFormatter';
+ }
+ }
+}
+// Manually add that more complex actions
+$wgLogActionsHandlers['delete/flow-restore-post'] = 'Flow\Log\ActionFormatter';
+$wgLogActionsHandlers['suppress/flow-restore-post'] = 'Flow\Log\ActionFormatter';
+$wgLogActionsHandlers['delete/flow-restore-topic'] = 'Flow\Log\ActionFormatter';
+$wgLogActionsHandlers['suppress/flow-restore-topic'] = 'Flow\Log\ActionFormatter';
+$wgLogActionsHandlers['import/lqt-to-flow-topic'] = 'Flow\Log\LqtImportFormatter';
+
+// Register URL actions
+foreach( $wgFlowActions as $action => $options ) {
+ if ( is_array( $options ) && isset( $options['handler-class'] ) ) {
+ $wgActions[$action] = true;
+ }
+}
+
+// Set this to false to disable all memcache usage. Do not just turn the cache
+// back on, it will be out of sync with the database. There is not yet an official
+// process for re-sync'ing the cache yet, currently the per-index versions would
+// need to incremented(ask the flow team).
+//
+// This will reduce, but not necessarily kill, performance. The queries issued
+// will be the queries necessary to fill the cache rather than only the queries
+// needed to answer the request. A bit of a refactor in ObjectManager::findMulti
+// to allow query without indexes, along with adjusting container.php to only
+// include the indexes when this is true, would get most of the way towards making
+// this a reasonably performant option.
+$wgFlowUseMemcache = true;
+
+// The default length of time to cache flow data in memcache. This value can be tuned
+// in conjunction with measurements of cache hit/miss ratios to achieve the desired
+// tradeoff between memory usage, db queries, and response time. The initial default
+// of 3 days means Flow will attempt to keep in memcache all data models requested in
+// the last 3 days.
+$wgFlowCacheTime = 60 * 60 * 24 * 3;
+// A version string appended to cache keys. Bump this if cache format or logic changes.
+// Flow can be a cross-wiki database accessed by wikis running different versions of the
+// Flow code; WMF sometimes overrides this globally in wmf-config/CommonSettings.php
+$wgFlowCacheVersion = '4.5';
+
+// Custom group name for AbuseFilter
+// Acceptable values:
+// * a specific value for flow-specific filters
+// * 'default' to use core filters; make sure they are compatible with both core
+// and Flow (e.g. Flow has no 'summary' variable to test on)
+// * false to not use AbuseFilter
+$wgFlowAbuseFilterGroup = 'flow';
+
+// AbuseFilter emergency disable values for Flow
+$wgFlowAbuseFilterEmergencyDisableThreshold = 0.10;
+$wgFlowAbuseFilterEmergencyDisableCount = 50;
+$wgFlowAbuseFilterEmergencyDisableAge = 86400; // One day.
+
+// Actions that must pass through to MediaWiki on flow enabled pages
+$wgFlowCoreActionWhitelist = array( 'info', 'protect', 'unprotect', 'unwatch', 'watch', 'history', 'wikilove' );
+
+// When set to true Flow will compile templates into their intermediate forms
+// on every run. When set to false Flow will use the versions already written
+// to disk. Production should always have this set to false.
+$wgFlowServerCompileTemplates = false;
+
+// Enable/disable event logging
+$wgFlowEventLogging = false;
diff --git a/Flow/FlowActions.php b/Flow/FlowActions.php
new file mode 100644
index 00000000..3e5e5bb2
--- /dev/null
+++ b/Flow/FlowActions.php
@@ -0,0 +1,836 @@
+<?php
+
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\Header;
+use Flow\RevisionActionPermissions;
+use Flow\Log\ModerationLogger;
+use Flow\Data\Listener\RecentChangesListener;
+
+/**
+ * Flow actions: key => value map with key being the action name.
+ * The value consists of an array of these below keys (and appropriate values):
+ * * performs-writes: Must be boolean true for any action that writes to the wiki.
+ * actions with this set will additionally require the core 'edit' permission.
+ * * log_type: the Special:Log filter to save actions to; false means 'not logged'.
+ * * rc_insert: whether or not to insert the write action into RC table.
+ * * permissions: array of permissions, where each key is the existing post
+ * state and the value is the right required to execute the action. A blank
+ * value means anyone can take the action. However, an omitted key means
+ * no one can perform the action described by that key.
+ * * links: the set of read links to generate and return in API responses
+ * * actions: the set of write links to generate and return in API responses
+ * * history: all history-related information:
+ * * i18n-message: the i18n message key for this change type
+ * * i18n-params: array of i18n parameters for the provided message (see
+ * AbstractFormatter::processParam phpdoc for more details)
+ * * class: classname to be added to the list-item for this changetype
+ * * bundle: array with, again, all of the above information if multiple types
+ * should be bundled (then the bundle i18n & class will be used to generate
+ * the list-item; clicking on it will reveal the individual history entries)
+ * * watch: Used by the WatchTopicListener to auto-subscribe users to topics. Only
+ * value value currently is immediate.
+ * * immediate: watchlist the title in the current process
+ * * rc_title: Either 'article' or 'owner' to select between Workflow::getArticleTitle
+ * or Workflow::getOwnerTitle being used as the related recentchanges entry on insert
+ * * editcount: True to increment user's edit count for this action
+ * * modules: Modules to insert with RL to html page for this action instead of the defaults
+ * * moduleStyles: Style modules to insert with RL to html page for this action instead of the defaults
+ */
+$wgFlowActions = array(
+ 'create-header' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ Header::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'board-history', 'workflow', 'header-revision' ),
+ 'actions' => array( 'edit-header' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-create-header',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ ),
+ 'class' => 'flow-history-create-header',
+ ),
+ 'editcount' => true,
+ ),
+
+ 'edit-header' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ Header::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'board-history', 'diff-header', 'workflow', 'header-revision' ),
+ 'actions' => array( 'edit-header', 'undo-edit-header' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-header',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ ),
+ 'class' => 'flow-history-edit-header',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'editcount' => true,
+ ),
+
+ // @todo this is almost copy/paste from edit-header except the handler-class. find
+ // a way to share.
+ 'undo-edit-header' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ Header::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'board-history', 'diff-header', 'workflow', 'header-revision' ),
+ 'actions' => array( 'edit-header', 'undo-edit-header' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-header',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ ),
+ 'class' => 'flow-history-edit-header',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'editcount' => true,
+ // theis modules/moduleStyles is repeated in all the undo-* actions. Find a way to share.
+ 'modules' => array( 'ext.flow.undo' ),
+ 'moduleStyles' => array(
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.input',
+ 'ext.flow.styles.base',
+ 'ext.flow.board.styles',
+ 'ext.flow.board.topic.styles',
+ ),
+ ),
+
+ 'create-topic-summary' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostSummary::MODERATED_NONE => '',
+ PostSummary::MODERATED_LOCKED => array( 'flow-lock', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_HIDDEN => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_SUPPRESSED => array( 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'topic-history', 'watch-topic', 'unwatch-topic', 'summary-revision' ),
+ 'actions' => array( 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-create-topic-summary',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-of-summary',
+ ),
+ 'class' => 'flow-history-create-topic-summary',
+ ),
+ 'editcount' => true,
+ ),
+
+ 'edit-topic-summary' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostSummary::MODERATED_NONE => '',
+ PostSummary::MODERATED_LOCKED => array( 'flow-lock', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_HIDDEN => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_SUPPRESSED => array( 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'topic-history', 'diff-post-summary', 'watch-topic', 'unwatch-topic', 'summary-revision' ),
+ 'actions' => array( 'edit-topic-summary', 'lock-topic', 'restore-topic', 'undo-edit-topic-summary' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-topic-summary',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-of-summary',
+ ),
+ 'class' => 'flow-history-edit-topic-summary',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'editcount' => true,
+ ),
+
+ // @todo this is almost copy/paste from edit-topic-summary except the handler class. find a
+ // way to share
+ 'undo-edit-topic-summary' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostSummary::MODERATED_NONE => '',
+ PostSummary::MODERATED_LOCKED => array( 'flow-lock', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_HIDDEN => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostSummary::MODERATED_SUPPRESSED => array( 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'topic-history', 'diff-post-summary', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'edit-topic-summary', 'lock-topic', 'restore-topic', 'undo-edit-topic-summary' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-topic-summary',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-of-summary',
+ ),
+ 'class' => 'flow-history-edit-topic-summary',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'editcount' => true,
+ 'modules' => array( 'ext.flow.undo' ),
+ 'moduleStyles' => array(
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.input',
+ 'ext.flow.styles.base',
+ 'ext.flow.board.styles',
+ 'ext.flow.board.topic.styles',
+ ),
+ ),
+
+ 'edit-title' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'topic', 'topic-history', 'diff-post', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'lock-topic', 'hide-topic', 'delete-topic', 'suppress-topic', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-title',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'workflow-url',
+ 'wikitext',
+ 'prev-wikitext',
+ ),
+ 'class' => 'flow-history-edit-title',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'watch' => array(
+ 'immediate' => array( 'Flow\\Data\\Listener\\ImmediateWatchTopicListener', 'getCurrentUser' ),
+ ),
+ 'editcount' => true,
+ ),
+
+ // Normal posts are the 'reply' type.
+ 'new-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'rc_title' => 'owner',
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'topic-history', 'topic', 'post', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'hide-topic', 'delete-topic', 'suppress-topic', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-new-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'workflow-url',
+ 'wikitext',
+ ),
+ 'class' => 'flow-history-new-post',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'watch' => array(
+ 'immediate' => array( 'Flow\\Data\\Listener\\ImmediateWatchTopicListener', 'getCurrentUser' ),
+ ),
+ 'editcount' => true,
+ ),
+
+ 'edit-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ // no permissions needed for own posts
+ PostRevision::MODERATED_NONE => function( PostRevision $post, RevisionActionPermissions $permissions ) {
+ return $post->isCreator( $permissions->getUser() ) ? '' : 'flow-edit-post';
+ }
+ ),
+ 'root-permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'post-history', 'topic-history', 'topic', 'post', 'diff-post', 'post-revision' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post', 'undo-edit-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-url',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-edit-post',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'watch' => array(
+ 'immediate' => array( 'Flow\\Data\\Listener\\ImmediateWatchTopicListener', 'getCurrentUser' ),
+ ),
+ 'editcount' => true,
+ ),
+
+ // @todo this is almost (but not quite) copy/paste from 'edit-post'. find a way to share?
+ 'undo-edit-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => false, // maybe?
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'root-permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'post-history', 'topic-history', 'topic', 'post', 'diff-post', 'post-revision' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post', 'undo-edit-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-edit-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-url',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-edit-post',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'watch' => array(
+ 'immediate' => array( 'Flow\\Data\\Listener\\ImmediateWatchTopicListener', 'getCurrentUser' ),
+ ),
+ 'editcount' => true,
+ 'modules' => array( 'ext.flow.undo' ),
+ 'moduleStyles' => array(
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.input',
+ 'ext.flow.styles.base',
+ 'ext.flow.board.styles',
+ 'ext.flow.board.topic.styles',
+ ),
+ ),
+
+ 'hide-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ // Permissions required to perform action. The key is the moderation state
+ // of the post to perform the action against. The value is a string or array
+ // of user rights that can allow this action.
+ PostRevision::MODERATED_NONE => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ ),
+ 'root-permissions' => array(
+ // Can only hide within an unmoderated or hidden topic. This doesn't check for a specific
+ // permissions because thats already done above in 'permissions', this just ensures the
+ // topic is in an appropriate state.
+ PostRevision::MODERATED_NONE => '',
+ PostRevision::MODERATED_HIDDEN => '',
+ ),
+ 'links' => array( 'topic', 'post', 'post-history', 'topic-history', 'post-revision' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-hid-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'post-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-hide-post',
+ ),
+ ),
+
+ 'hide-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'post', 'topic-history', 'post-history', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'restore-topic', 'hide-topic', 'delete-topic', 'suppress-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-hid-topic',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'workflow-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-hide-topic',
+ ),
+ ),
+
+ 'delete-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => 'delete',
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_HIDDEN => array( 'flow-delete', 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'post', 'post-history', 'topic-history', 'post-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-deleted-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'post-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-delete-post',
+ ),
+ ),
+
+ 'delete-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => 'delete',
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_HIDDEN => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_LOCKED => array( 'flow-delete', 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'topic-history', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'hide-topic', 'delete-topic', 'suppress-topic', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-deleted-topic',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'workflow-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-delete-topic',
+ ),
+ ),
+
+ 'suppress-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => 'suppress',
+ 'rc_insert' => false,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => 'flow-suppress',
+ PostRevision::MODERATED_HIDDEN => 'flow-suppress',
+ PostRevision::MODERATED_DELETED => 'flow-suppress',
+ ),
+ 'links' => array( 'topic', 'post', 'topic-history', 'post-revision' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-suppressed-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'post-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-suppress-post',
+ ),
+ ),
+
+ 'suppress-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => 'suppress',
+ 'rc_insert' => false,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => 'flow-suppress',
+ PostRevision::MODERATED_HIDDEN => 'flow-suppress',
+ PostRevision::MODERATED_DELETED => 'flow-suppress',
+ PostRevision::MODERATED_LOCKED => 'flow-suppress',
+ ),
+ 'links' => array( 'topic', 'topic-history', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'hide-topic', 'delete-topic', 'suppress-topic', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-suppressed-topic',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'workflow-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-suppress-topic',
+ ),
+ ),
+
+ 'lock-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => 'lock',
+ 'rc_insert' => true,
+ 'permissions' => array(
+ // Only non-moderated topic can be locked
+ PostRevision::MODERATED_NONE => array( 'flow-lock', 'flow-delete', 'flow-suppress' ),
+ ),
+ 'links' => array( 'topic', 'topic-history', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'edit-topic-summary', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-locked-topic',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'workflow-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => 'flow-history-locked-topic',
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+
+ 'restore-post' => array(
+ 'performs-writes' => true,
+ 'log_type' => function( PostRevision $revision, ModerationLogger $logger ) {
+ $post = $revision->getCollection();
+ $previousRevision = $post->getPrevRevision( $revision );
+ if ( $previousRevision ) {
+ // Kind of log depends on the previous change type:
+ // * if post was deleted, restore should go to deletion log
+ // * if post was suppressed, restore should go to suppression log
+ global $wgFlowActions;
+ return $wgFlowActions[$previousRevision->getModerationState() . '-post']['log_type'];
+ }
+
+ return '';
+ },
+ 'rc_insert' => function( PostRevision $revision, RecentChangesListener $recentChanges ) {
+ $post = $revision->getCollection();
+ $previousRevision = $post->getPrevRevision( $revision );
+ if ( $previousRevision ) {
+ // * if post was hidden/deleted, restore can go to RC
+ // * if post was suppressed, restore can not go to RC
+ global $wgFlowActions;
+ return $wgFlowActions[$previousRevision->getModerationState() . '-post']['rc_insert'];
+ }
+
+ return true;
+ },
+ 'permissions' => array(
+ PostRevision::MODERATED_HIDDEN => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_SUPPRESSED => 'flow-suppress',
+ ),
+ 'links' => array( 'topic', 'post', 'post-history', 'post-revision' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'restore-post', 'hide-post', 'delete-post', 'suppress-post' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-restored-post',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'post-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => function( PostRevision $revision ) {
+ $previous = $revision->getCollection()->getPrevRevision( $revision );
+ $state = $previous->getModerationState();
+ return "flow-history-un$state-post";
+ }
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+
+ 'restore-topic' => array(
+ 'performs-writes' => true,
+ 'log_type' => function( PostRevision $revision, ModerationLogger $logger ) {
+ $post = $revision->getCollection();
+ $previousRevision = $post->getPrevRevision( $revision );
+ if ( $previousRevision ) {
+ // Kind of log depends on the previous change type:
+ // * if topic was deleted, restore should go to deletion log
+ // * if topic was suppressed, restore should go to suppression log
+ global $wgFlowActions;
+ return $wgFlowActions[$previousRevision->getModerationState() . '-topic']['log_type'];
+ }
+
+ return '';
+ },
+ 'rc_insert' => function( PostRevision $revision, RecentChangesListener $recentChanges ) {
+ $post = $revision->getCollection();
+ $previousRevision = $post->getPrevRevision( $revision );
+ if ( $previousRevision ) {
+ // * if topic was hidden/deleted, restore can go to RC
+ // * if topic was suppressed, restore can not go to RC
+ global $wgFlowActions;
+ return $wgFlowActions[$previousRevision->getModerationState() . '-topic']['rc_insert'];
+ }
+
+ return true;
+ },
+ 'permissions' => array(
+ PostRevision::MODERATED_LOCKED => array( 'flow-lock', 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_HIDDEN => array( 'flow-hide', 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_SUPPRESSED => 'flow-suppress',
+ ),
+ 'links' => array( 'topic', 'topic-history', 'topic-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-title', 'hide-topic', 'delete-topic', 'suppress-topic', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-restored-topic',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'creator-text',
+ 'workflow-url',
+ 'moderated-reason',
+ 'topic-of-post',
+ ),
+ 'class' => function( PostRevision $revision ) {
+ $previous = $revision->getCollection()->getPrevRevision( $revision );
+ $state = $previous->getModerationState();
+ return "flow-history-un$state-topic";
+ }
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+
+ 'view' => array(
+ 'performs-writes' => false,
+ 'log_type' => false, // don't log views
+ 'rc_insert' => false, // won't even be called, actually; only for writes
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ // Everyone has permission to see this, but hidden comments are only visible (collapsed) on permalinks directly to them.
+ PostRevision::MODERATED_HIDDEN => '',
+ PostRevision::MODERATED_LOCKED => '',
+ PostRevision::MODERATED_DELETED => array( 'flow-delete', 'flow-suppress' ),
+ PostRevision::MODERATED_SUPPRESSED => 'flow-suppress',
+ ),
+ 'links' => array(), // @todo
+ 'actions' => array(), // view is not a recorded change type, no actions will be requested
+ 'history' => array(), // views don't generate history
+ 'handler-class' => 'Flow\Actions\ViewAction',
+ ),
+
+ 'reply' => array(
+ 'performs-writes' => true,
+ 'log_type' => false,
+ 'rc_insert' => true,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'root-permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ ),
+ 'links' => array( 'topic-history', 'topic', 'post', 'post-revision', 'watch-topic', 'unwatch-topic' ),
+ 'actions' => array( 'reply', 'thank', 'edit-post', 'hide-post', 'delete-post', 'suppress-post', 'edit-topic-summary', 'lock-topic', 'restore-topic' ),
+ 'history' => array(
+ 'i18n-message' => 'flow-rev-message-reply',
+ 'i18n-params' => array(
+ 'user-links',
+ 'user-text',
+ 'post-url',
+ 'topic-of-post',
+ 'summary',
+ ),
+ 'class' => 'flow-history-reply',
+ 'bundle' => array(
+ 'i18n-message' => 'flow-rev-message-reply-bundle',
+ 'i18n-params' => array(
+ 'bundle-count'
+ ),
+ 'class' => 'flow-history-bundle',
+ ),
+ ),
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ 'watch' => array(
+ 'immediate' => array( 'Flow\\Data\\Listener\\ImmediateWatchTopicListener', 'getCurrentUser' ),
+ ),
+ 'editcount' => true,
+ ),
+
+ 'history' => array(
+ 'performs-writes' => false,
+ 'log_type' => false,
+ 'rc_insert' => false, // won't even be called, actually; only for writes
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => function( AbstractRevision $revision, RevisionActionPermissions $permissions ) {
+ static $previousCollectionId;
+
+ /*
+ * To check permissions, both the current revision (revision-
+ * specific moderation state)& the last revision (global
+ * collection moderation state) will always be checked.
+ * This one has special checks to make sure "restore" actions
+ * are hidden when the user has no permissions to see the
+ * moderation state they were restored from.
+ * We don't want that test to happen; otherwise, when a post
+ * has just been restored in the most recent revisions, that
+ * would result in none of the previous revisions being
+ * available (because a user would need permissions for the the
+ * state the last revision was restored from)
+ */
+ $collection = $revision->getCollection();
+ if ( $previousCollectionId && $collection->getId()->equals( $previousCollectionId ) ) {
+ // doublecheck that this run is indeed against the most
+ // recent revision, to get the global collection state
+ try {
+ /** @var Flow\Collection\CollectionCache $cache */
+ $cache = \Flow\Container::get( 'collection.cache' );
+ $lastRevision = $cache->getLastRevisionFor( $revision );
+ if ( $revision->getRevisionId()->equals( $lastRevision->getRevisionId() ) ) {
+ $previousCollectionId = null;
+ return '';
+ }
+ } catch ( Exception $e ) {
+ // nothing to do here; if fetching last revision failed,
+ // we're just not testing any stored revision; that's ok
+ }
+ }
+ $previousCollectionId = $collection->getId();
+
+ /*
+ * If a revision was the result of a restore-action, we have
+ * to look at the previous revision what the original moderation
+ * status was; permissions for the restore-actions visibility
+ * is the same as the moderation (e.g. if user can't see
+ * suppress actions, he can't see restores from suppress.
+ */
+ if ( strpos( $revision->getChangeType(), 'restore-' ) === 0 ) {
+ $previous = $collection->getPrevRevision( $revision );
+
+ if ( $previous === null || $previous->getModerationState() === AbstractRevision::MODERATED_NONE ) {
+ return '';
+ }
+
+ return $permissions->getPermission( $previous, 'history' );
+ }
+
+ return '';
+ },
+ PostRevision::MODERATED_HIDDEN => '',
+ PostRevision::MODERATED_LOCKED => '',
+ PostRevision::MODERATED_DELETED => '',
+ PostRevision::MODERATED_SUPPRESSED => 'flow-suppress',
+ ),
+ 'history' => array(), // views don't generate history
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+
+ // Pseudo-action to determine when to show thank links,
+ // currently no limitation. if you can see revision you
+ // can thank.
+ 'thank' => array(
+ 'performs-writes' => false,
+ 'permissions' => array(
+ PostRevision::MODERATED_NONE => '',
+ PostRevision::MODERATED_HIDDEN => '',
+ PostRevision::MODERATED_LOCKED => '',
+ PostRevision::MODERATED_DELETED => '',
+ PostRevision::MODERATED_SUPPRESSED => '',
+ ),
+ ),
+
+ // Actions not tied to a particular revision change_type
+ // or just move these to a different file
+ 'compare-header-revisions' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'view-header' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'compare-post-revisions' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ // @todo - This is a very bad action name, consolidate with view-post action
+ 'single-view' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'view-topic-summary' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'compare-postsummary-revisions' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'moderate-topic' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'moderate-post' => array(
+ 'handler-class' => 'Flow\Actions\FlowAction',
+ ),
+ 'purge' => array(
+ 'handler-class' => 'Flow\Actions\PurgeAction',
+ ),
+
+ // log & all other formatters have same config as history
+ 'log' => 'history',
+ 'recentchanges' => 'history',
+ 'contributions' => 'history',
+ 'checkuser' => 'history',
+
+ /*
+ * Backwards compatibility; these are old values that may have made their
+ * way into the database. patch-rev_change_type_update.sql should take care
+ * of these, but just to be sure ;)
+ * Instead of having the correct config-array as value, you can just
+ * reference another action.
+ */
+ 'flow-rev-message-edit-title' => 'edit-title',
+ 'flow-edit-title' => 'edit-title',
+ 'flow-rev-message-new-post' => 'new-topic',
+ 'flow-new-post' => 'new-topic',
+ 'flow-rev-message-edit-post' => 'edit-post',
+ 'flow-edit-post' => 'edit-post',
+ 'flow-rev-message-reply' => 'reply',
+ 'flow-reply' => 'reply',
+ 'flow-rev-message-restored-post' => 'restore-post',
+ 'flow-post-restored' => 'restore-post',
+ 'flow-rev-message-hid-post' => 'hide-post',
+ 'flow-post-hidden' => 'hide-post',
+ 'flow-rev-message-deleted-post' => 'delete-post',
+ 'flow-post-deleted' => 'delete-post',
+ 'flow-rev-message-censored-post' => 'suppress-post',
+ 'flow-post-censored' => 'suppress-post',
+ 'flow-rev-message-edit-header' => 'edit-header',
+ 'flow-edit-summary' => 'edit-header',
+ 'flow-rev-message-create-header' => 'create-header',
+ 'flow-create-summary' => 'create-header',
+ 'flow-create-header' => 'create-header',
+ /*
+ * Backwards compatibility for previous suppression terminology (=censor).
+ * patch-censor_to_suppress.sql should take care of all of these occurrences.
+ */
+ 'censor-post' => 'suppress-post',
+ 'censor-topic' => 'suppress-topic',
+ /*
+ * Backwards compatibility for old (separated) history actions
+ */
+ 'post-history' => 'history',
+ 'topic-history' => 'history',
+ 'board-history' => 'history',
+
+ // The new-topic type used to be called new-post
+ 'new-post' => 'new-topic',
+
+ // BC for lock-topic, which used to be called differently
+ 'close-topic' => 'lock-topic',
+ 'close-open-topic' => 'lock-topic',
+);
diff --git a/Flow/Gemfile b/Flow/Gemfile
new file mode 100644
index 00000000..daa473d1
--- /dev/null
+++ b/Flow/Gemfile
@@ -0,0 +1,9 @@
+#ruby=ruby-2.1.1
+#ruby-gemset=Flow
+
+source "https://rubygems.org"
+
+gem "csscss"
+gem "mediawiki_api"
+gem "mediawiki_selenium"
+gem "rubocop", require: false
diff --git a/Flow/Gemfile.lock b/Flow/Gemfile.lock
new file mode 100644
index 00000000..298cb15a
--- /dev/null
+++ b/Flow/Gemfile.lock
@@ -0,0 +1,107 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ ast (2.0.0)
+ astrolabe (1.3.0)
+ parser (>= 2.2.0.pre.3, < 3.0)
+ blankslate (3.1.3)
+ builder (3.2.2)
+ childprocess (0.5.5)
+ ffi (~> 1.0, >= 1.0.11)
+ colorize (0.7.5)
+ csscss (1.3.3)
+ colorize
+ parslet (>= 1.6.1, < 2.0)
+ cucumber (1.3.19)
+ builder (>= 2.1.2)
+ diff-lcs (>= 1.1.3)
+ gherkin (~> 2.12)
+ multi_json (>= 1.7.5, < 2.0)
+ multi_test (>= 0.1.2)
+ data_magic (0.20)
+ faker (>= 1.1.2)
+ yml_reader (>= 0.4)
+ diff-lcs (1.2.5)
+ domain_name (0.5.23)
+ unf (>= 0.0.5, < 1.0.0)
+ faker (1.4.3)
+ i18n (~> 0.5)
+ faraday (0.9.1)
+ multipart-post (>= 1.2, < 3)
+ faraday-cookie_jar (0.0.6)
+ faraday (>= 0.7.4)
+ http-cookie (~> 1.0.0)
+ ffi (1.9.6)
+ gherkin (2.12.2)
+ multi_json (~> 1.3)
+ headless (1.0.2)
+ http-cookie (1.0.2)
+ domain_name (~> 0.5)
+ i18n (0.7.0)
+ json (1.8.2)
+ mediawiki_api (0.3.1)
+ faraday (~> 0.9, >= 0.9.0)
+ faraday-cookie_jar (~> 0.0, >= 0.0.6)
+ mediawiki_selenium (0.4.2)
+ cucumber (~> 1.3, >= 1.3.10)
+ headless (~> 1.0, >= 1.0.1)
+ json (~> 1.8, >= 1.8.1)
+ mediawiki_api (~> 0.2, >= 0.2.1)
+ page-object (~> 1.0)
+ rest-client (~> 1.6, >= 1.6.7)
+ rspec-expectations (~> 2.14, >= 2.14.4)
+ syntax (~> 1.2, >= 1.2.0)
+ thor (~> 0.19, >= 0.19.1)
+ mime-types (2.4.3)
+ multi_json (1.11.0)
+ multi_test (0.1.2)
+ multipart-post (2.0.0)
+ netrc (0.10.3)
+ page-object (1.0.3)
+ page_navigation (>= 0.9)
+ selenium-webdriver (>= 2.44.0)
+ watir-webdriver (>= 0.6.11)
+ page_navigation (0.9)
+ data_magic (>= 0.14)
+ parser (2.2.0.3)
+ ast (>= 1.1, < 3.0)
+ parslet (1.6.2)
+ blankslate (>= 2.0, <= 4.0)
+ powerpack (0.1.0)
+ rainbow (2.0.0)
+ rest-client (1.7.3)
+ mime-types (>= 1.16, < 3.0)
+ netrc (~> 0.7)
+ rspec-expectations (2.99.2)
+ diff-lcs (>= 1.1.3, < 2.0)
+ rubocop (0.29.1)
+ astrolabe (~> 1.3)
+ parser (>= 2.2.0.1, < 3.0)
+ powerpack (~> 0.1)
+ rainbow (>= 1.99.1, < 3.0)
+ ruby-progressbar (~> 1.4)
+ ruby-progressbar (1.7.1)
+ rubyzip (1.1.7)
+ selenium-webdriver (2.45.0)
+ childprocess (~> 0.5)
+ multi_json (~> 1.0)
+ rubyzip (~> 1.0)
+ websocket (~> 1.0)
+ syntax (1.2.0)
+ thor (0.19.1)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.6)
+ watir-webdriver (0.7.0)
+ selenium-webdriver (>= 2.45)
+ websocket (1.2.1)
+ yml_reader (0.5)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ csscss
+ mediawiki_api
+ mediawiki_selenium
+ rubocop
diff --git a/Flow/Gruntfile.js b/Flow/Gruntfile.js
new file mode 100644
index 00000000..3bba215b
--- /dev/null
+++ b/Flow/Gruntfile.js
@@ -0,0 +1,51 @@
+/*!
+ * Grunt file
+ *
+ * @package Flow
+ */
+
+/*jshint node:true */
+module.exports = function ( grunt ) {
+ grunt.loadNpmTasks( 'grunt-contrib-csslint' );
+ grunt.loadNpmTasks( 'grunt-contrib-jshint' );
+ grunt.loadNpmTasks( 'grunt-contrib-watch' );
+ grunt.loadNpmTasks( 'grunt-banana-checker' );
+ grunt.loadNpmTasks( 'grunt-jscs' );
+
+ grunt.initConfig( {
+ jshint: {
+ options: {
+ jshintrc: true
+ },
+ all: [
+ '*.js',
+ 'modules/**/*.js',
+ '!modules/vendor/*.js'
+ ]
+ },
+ jscs: {
+ src: [ '<%= jshint.all %>', '!modules/vendor/**' ]
+ },
+ csslint: {
+ options: {
+ csslintrc: '.csslintrc'
+ },
+ all: 'modules/**/*.css'
+ },
+ banana: {
+ all: 'i18n/'
+ },
+ watch: {
+ files: [
+ '.{csslintrc,jscsrc,jshintignore,jshintrc}',
+ '<%= jshint.all %>',
+ '<%= csslint.all %>'
+ ],
+ tasks: 'test'
+ }
+ } );
+
+ grunt.registerTask( 'lint', [ 'jscs', 'jshint', 'csslint', 'banana' ] );
+ grunt.registerTask( 'test', 'lint' );
+ grunt.registerTask( 'default', 'test' );
+};
diff --git a/Flow/Hooks.php b/Flow/Hooks.php
new file mode 100644
index 00000000..8151b589
--- /dev/null
+++ b/Flow/Hooks.php
@@ -0,0 +1,1331 @@
+<?php
+
+use Flow\Collection\PostCollection;
+use Flow\Container;
+use Flow\Exception\FlowException;
+use Flow\Formatter\CheckUserQuery;
+use Flow\Model\UUID;
+use Flow\NotificationController;
+use Flow\OccupationController;
+use Flow\SpamFilter\AbuseFilter;
+use Flow\TalkpageManager;
+use Flow\WorkflowLoaderFactory;
+
+class FlowHooks {
+ /**
+ * @var OccupationController Initialized during extension intialization
+ */
+ protected static $occupationController;
+
+ /**
+ * @var AbuseFilter Initialized during extension initialization
+ */
+ protected static $abuseFilter;
+
+ /**
+ * Initialized during extension initialization rather than
+ * in container so that non-flow pages don't load the container.
+ *
+ * @return OccupationController
+ */
+ public static function getOccupationController() {
+ if ( self::$occupationController === null ) {
+ global $wgFlowOccupyNamespaces,
+ $wgFlowOccupyPages;
+
+ // NS_TOPIC is always occupied
+ $namespaces = $wgFlowOccupyNamespaces;
+ $namespaces[] = NS_TOPIC;
+
+ self::$occupationController = new TalkpageManager(
+ array_unique( $namespaces ),
+ $wgFlowOccupyPages
+ );
+ }
+ return self::$occupationController;
+ }
+
+ /**
+ * Initialized during extension initialization rather than
+ * in container so that non-flow pages don't load the container.
+ *
+ * @return AbuseFilter
+ */
+ public static function getAbuseFilter() {
+ if ( self::$abuseFilter === null ) {
+ global $wgUser,
+ $wgFlowAbuseFilterGroup,
+ $wgFlowAbuseFilterEmergencyDisableThreshold,
+ $wgFlowAbuseFilterEmergencyDisableCount,
+ $wgFlowAbuseFilterEmergencyDisableAge;
+
+ self::$abuseFilter = new AbuseFilter( $wgUser, $wgFlowAbuseFilterGroup );
+ self::$abuseFilter->setup( array(
+ 'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold,
+ 'count' => $wgFlowAbuseFilterEmergencyDisableCount,
+ 'age' => $wgFlowAbuseFilterEmergencyDisableAge,
+ ) );
+ }
+ return self::$abuseFilter;
+ }
+
+ /**
+ * Initialize Flow extension with necessary data, this function is invoked
+ * from $wgExtensionFunctions
+ */
+ public static function initFlowExtension() {
+ global $wgFlowContentFormat, $wgFlowParsoidURL;
+
+ // needed to determine if a page is occupied by flow
+ self::getOccupationController();
+
+ // necessary to render flow notifications
+ if ( class_exists( 'EchoNotifier' ) ) {
+ NotificationController::setup();
+ }
+
+ // necessary to provide flow options in abuse filter on-wiki pages
+ global $wgFlowAbuseFilterGroup;
+ if ( $wgFlowAbuseFilterGroup ) {
+ self::getAbuseFilter();
+ }
+
+ if ( $wgFlowContentFormat === 'html' && $wgFlowParsoidURL === null ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Warning: $wgFlowContentFormat was set to \'html\', but you do not have Parsoid enabled. Changing $wgFlowContentFormat to \'wikitext\'' );
+ $wgFlowContentFormat = 'wikitext';
+ }
+
+ // development dependencies to simplify testing
+ if ( defined( 'MW_PHPUNIT_TEST' ) && file_exists( __DIR__ . '/vendor/autoload.php' ) ) {
+ require_once __DIR__ . '/vendor/autoload.php';
+ }
+ }
+
+ /**
+ * Reset anything that happened in self::initFlowExtension for
+ * unit tests
+ */
+ public static function resetFlowExtension() {
+ self::$abuseFilter = null;
+ self::$occupationController = null;
+ }
+
+ /**
+ * Hook: LoadExtensionSchemaUpdates
+ *
+ * @param $updater DatabaseUpdater object
+ * @return bool true in all cases
+ */
+ public static function getSchemaUpdates( DatabaseUpdater $updater ) {
+ $dir = __DIR__;
+ $baseSQLFile = "$dir/flow.sql";
+ $updater->addExtensionTable( 'flow_revision', $baseSQLFile );
+ $updater->addExtensionField( 'flow_revision', 'rev_last_edit_id', "$dir/db_patches/patch-revision_last_editor.sql" );
+ $updater->addExtensionField( 'flow_revision', 'rev_mod_reason', "$dir/db_patches/patch-moderation_reason.sql" );
+ if ( $updater->getDB()->getType() === 'sqlite' ) {
+ $updater->modifyExtensionField( 'flow_summary_revision', 'summary_workflow_id', "$dir/db_patches/patch-summary2header.sqlite.sql" );
+ $updater->modifyExtensionField( 'flow_revision', 'rev_comment', "$dir/db_patches/patch-rev_change_type.sqlite.sql" );
+ // sqlite ignores field types, this just substr's uuid's to 88 bits
+ $updater->modifyExtensionField( 'flow_workflow', 'workflow_id', "$dir/db_patches/patch-88bit_uuids.sqlite.sql" );
+ $updater->addExtensionField( 'flow_workflow', 'workflow_type', "$dir/db_patches/patch-add_workflow_type.sqlite" );
+ $updater->modifyExtensionField( 'flow_workflow', 'workflow_user_id', "$dir/db_patches/patch-default_null_workflow_user.sqlite.sql" );
+ } else {
+ // sqlite doesn't support alter table change, it also considers all types the same so
+ // this patch doesn't matter to it.
+ $updater->modifyExtensionField( 'flow_subscription', 'subscription_user_id', "$dir/db_patches/patch-subscription_user_id.sql" );
+ // renames columns, alternate patch is above for sqlite
+ $updater->modifyExtensionField( 'flow_summary_revision', 'summary_workflow_id', "$dir/db_patches/patch-summary2header.sql" );
+ // rename rev_change_type -> rev_comment, alternate patch is above for sqlite
+ $updater->modifyExtensionField( 'flow_revision', 'rev_comment', "$dir/db_patches/patch-rev_change_type.sql" );
+ // convert 128 bit uuid's into 88bit
+ $updater->modifyExtensionField( 'flow_workflow', 'workflow_id', "$dir/db_patches/patch-88bit_uuids.sql" );
+ $updater->addExtensionField( 'flow_workflow', 'workflow_type', "$dir/db_patches/patch-add_workflow_type.sql" );
+ $updater->modifyExtensionField( 'flow_workflow', 'workflow_user_id', "$dir/db_patches/patch-default_null_workflow_user.sql" );
+
+ // Doesn't need SQLite support, since SQLite doesn't care about text widths.
+ $updater->modifyExtensionField( 'flow_workflow', 'workflow_wiki', "$dir/db_patches/patch-increase_width_wiki_fields.sql" );
+ }
+
+ $updater->addExtensionIndex( 'flow_workflow', 'flow_workflow_lookup', "$dir/db_patches/patch-workflow_lookup_idx.sql" );
+ $updater->addExtensionIndex( 'flow_topic_list', 'flow_topic_list_topic_id', "$dir/db_patches/patch-topic_list_topic_id_idx.sql" );
+ $updater->modifyExtensionField( 'flow_revision', 'rev_change_type', "$dir/db_patches/patch-rev_change_type_update.sql" );
+ $updater->modifyExtensionField( 'recentchanges', 'rc_source', "$dir/db_patches/patch-rc_source.sql" );
+ $updater->modifyExtensionField( 'flow_revision', 'rev_change_type', "$dir/db_patches/patch-censor_to_suppress.sql" );
+ $updater->addExtensionField( 'flow_revision', 'rev_user_ip', "$dir/db_patches/patch-remove_usernames.sql" );
+ $updater->addExtensionField( 'flow_revision', 'rev_user_wiki', "$dir/db_patches/patch-add-wiki.sql" );
+ $updater->addExtensionIndex( 'flow_tree_revision', 'flow_tree_descendant_rev_id', "$dir/db_patches/patch-flow_tree_idx_fix.sql" );
+ $updater->dropExtensionField( 'flow_tree_revision', 'tree_orig_create_time', "$dir/db_patches/patch-tree_orig_create_time.sql" );
+ $updater->addExtensionIndex( 'flow_revision', 'flow_revision_user', "$dir/db_patches/patch-revision_user_idx.sql" );
+ $updater->modifyExtensionField( 'flow_revision', 'rev_user_ip', "$dir/db_patches/patch-revision_user_ip.sql" );
+ $updater->addExtensionField( 'flow_revision', 'rev_type_id', "$dir/db_patches/patch-rev_type_id.sql" );
+ $updater->addExtensionTable( 'flow_ext_ref', "$dir/db_patches/patch-add-linkstables.sql" );
+ $updater->dropExtensionTable( 'flow_definition', "$dir/db_patches/patch-drop_definition.sql" );
+ $updater->dropExtensionField( 'flow_workflow', 'workflow_user_ip', "$dir/db_patches/patch-drop_workflow_user.sql" );
+ $updater->addExtensionField( 'flow_revision', 'rev_content_length', "$dir/db_patches/patch-add-revision-content-length.sql" );
+ $updater->addExtensionIndex( 'flow_ext_ref', 'flow_ext_ref_idx', "$dir/db_patches/patch-remove_unique_ref_indices.sql" );
+
+ require_once __DIR__.'/maintenance/FlowUpdateRecentChanges.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateRecentChanges' );
+
+ require_once __DIR__.'/maintenance/FlowSetUserIp.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowSetUserIp' );
+
+ /*
+ * Remove old *_user_text columns once the maintenance script that
+ * moves the necessary data has been run.
+ * This duplicates what is being done in FlowSetUserIp already, but that
+ * was not always the case, so that script may have already run without
+ * having executed this.
+ */
+ if ( $updater->updateRowExists( 'FlowSetUserIp' ) ) {
+ $updater->dropExtensionField( 'flow_revision', 'rev_user_text', "$dir/db_patches/patch-remove_usernames_2.sql" );
+ }
+
+ require_once __DIR__.'/maintenance/FlowUpdateUserWiki.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateUserWiki' );
+
+ require_once __DIR__.'/maintenance/FlowUpdateRevisionTypeId.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateRevisionTypeId' );
+
+ require_once __DIR__.'/maintenance/FlowPopulateLinksTables.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowPopulateLinksTables' );
+
+ require_once __DIR__.'/maintenance/FlowUpdateRevisionContentLength.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateRevisionContentLength' );
+
+ require_once __DIR__.'/maintenance/FlowFixLog.php';
+ $updater->addPostDatabaseUpdateMaintenance( 'FlowFixLog' );
+
+ return true;
+ }
+
+ /**
+ * Hook: UnitTestsList
+ * @see http://www.mediawiki.org/wiki/Manual:Hooks/UnitTestsList
+ *
+ * @param &$files Array of unit test files
+ * @return bool true in all cases
+ */
+ static function getUnitTests( &$files ) {
+ $it = new RecursiveDirectoryIterator( __DIR__ . '/tests/phpunit' );
+ $it = new RecursiveIteratorIterator( $it );
+ foreach ( $it as $path => $file ) {
+ if ( substr( $path, -8 ) === 'Test.php' ) {
+ $files[] = $path;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Loads RecentChanges list metadata into a temporary cache for later use.
+ *
+ * @param ChangesList $changesList
+ * @param array $rows
+ */
+ public static function onChangesListInitRows( ChangesList $changesList, $rows ) {
+ if ( !( $changesList instanceof OldChangesList || $changesList instanceof EnhancedChangesList ) ) {
+ return;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ /** @var Flow\Formatter\RecentChangesQuery $query */
+ $query = Container::get( 'query.recentchanges' );
+ $query->loadMetadataBatch(
+ $rows,
+ $changesList->isWatchlist()
+ );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ }
+ restore_error_handler();
+ }
+
+ /**
+ * Updates the given Flow topic line in an enhanced changes list (grouped RecentChanges).
+ *
+ * @param ChangesList $changesList
+ * @param string $articlelink
+ * @param string $s
+ * @param RecentChange $rc
+ * @param bool $unpatrolled
+ * @param bool $isWatchlist
+ * @return bool
+ */
+ public static function onChangesListInsertArticleLink(
+ ChangesList &$changesList,
+ &$articlelink,
+ &$s,
+ &$rc,
+ $unpatrolled,
+ $isWatchlist
+ ) {
+ if ( !( $changesList instanceof EnhancedChangesList ) ) {
+ // This method is only to update EnhancedChangesList.
+ // onOldChangesListRecentChangesLine allows updating OldChangesList,
+ // and supports adding wrapper classes.
+ return true;
+ }
+ $classes = null; // avoid pass-by-reference error
+ return self::processRecentChangesLine( $changesList, $articlelink, $rc, $classes, true );
+ }
+
+ /**
+ * Updates a Flow line in the old changes list (standard RecentChanges).
+ *
+ * @param ChangesList $changesList
+ * @param string $s
+ * @param RecentChange $rc
+ * @param array $classes
+ * @return bool
+ */
+ public static function onOldChangesListRecentChangesLine(
+ ChangesList &$changesList,
+ &$s,
+ RecentChange $rc,
+ &$classes = array()
+ ) {
+ return self::processRecentChangesLine( $changesList, $s, $rc, $classes );
+ }
+
+ /**
+ * Does the actual work for onOldChangesListRecentChangesLine and
+ * onChangesListInsertArticleLink hooks. Either updates an entire
+ * line with meta info (old changes), or simply updates the link to
+ * the topic (enhanced).
+ *
+ * @param ChangesList $changesList
+ * @param string $s
+ * @param RecentChange $rc
+ * @param array|null $classes
+ * @param bool $topicOnly
+ * @return bool
+ */
+ protected static function processRecentChangesLine(
+ ChangesList &$changesList,
+ &$s,
+ RecentChange $rc,
+ &$classes = null,
+ $topicOnly = false
+ ) {
+ $source = $rc->getAttribute( 'rc_source' );
+ if ( $source === null ) {
+ $rcType = (int) $rc->getAttribute( 'rc_type' );
+ if ( $rcType !== RC_FLOW ) {
+ return true;
+ }
+ } elseif ( $source !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ /** @var Flow\Formatter\RecentChangesQuery $query */
+ $query = Container::get( 'query.recentchanges' );
+
+ $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
+ if ( $row === false ) {
+ restore_error_handler();
+ return false;
+ }
+
+ /** @var Flow\Formatter\RecentChanges $formatter */
+ $formatter = Container::get( 'formatter.recentchanges' );
+ $line = $formatter->format( $row, $changesList, $topicOnly );
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc ' . $rc->getAttribute( 'rc_id' ) . ' ' . $e );
+ MWExceptionHandler::logException( $e );
+ restore_error_handler();
+ return false;
+ }
+ restore_error_handler();
+
+ if ( $line === false ) {
+ return false;
+ }
+
+ if ( is_array( $classes ) ) {
+ // Add the flow class to <li>
+ $classes[] = 'flow-recentchanges-line';
+ }
+ // Update the line markup
+ $s = $line;
+
+ return true;
+ }
+
+ /**
+ * Alter the enhanced RC links: (n changes | history)
+ * The default diff links are incorrect!
+ *
+ * @param EnhancedChangesList $changesList
+ * @param array $links
+ * @param RecentChange[] $block
+ * @return bool
+ */
+ public static function onGetLogText( $changesList, &$links, $block ) {
+ $rc = $block[0];
+
+ // quit if non-flow
+ $source = $block[0]->getAttribute( 'rc_source' );
+ if ( $source === null ) {
+ $rcType = (int) $block[0]->getAttribute( 'rc_type' );
+ if ( $rcType !== RC_FLOW ) {
+ return true;
+ }
+ } elseif ( $source !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ /** @var Flow\Formatter\RecentChangesQuery $query */
+ $query = Container::get( 'query.recentchanges' );
+
+ $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
+ if ( $row === false ) {
+ restore_error_handler();
+ return false;
+ }
+
+ /** @var Flow\Formatter\RecentChanges $formatter */
+ $formatter = Container::get( 'formatter.recentchanges' );
+ $links = $formatter->getLogTextLinks( $row, $changesList, $block, $links );
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc logtext ' . $rc->getAttribute( 'rc_id' ) . ' ' . $e );
+ MWExceptionHandler::logException( $e );
+ restore_error_handler();
+ return false;
+ }
+ restore_error_handler();
+
+ return true;
+ }
+
+ public static function onSpecialCheckUserGetLinksFromRow( CheckUser $checkUser, $row, &$links ) {
+ if ( !$row->cuc_type == RC_FLOW ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ $replacement = null;
+ try {
+ /** @var CheckUserQuery $query */
+ $query = Container::get( 'query.checkuser' );
+ // @todo: create hook to allow batch-loading this data, instead of doing piecemeal like this
+ $query->loadMetadataBatch( array( $row ) );
+ $row = $query->getResult( $checkUser, $row );
+ if ( $row !== false ) {
+ /** @var Flow\Formatter\CheckUserFormatter $formatter */
+ $formatter = Container::get( 'formatter.checkuser' );
+ $replacement = $formatter->format( $row, $checkUser->getContext() );
+ }
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting cu ' . json_encode( $row ) . ' ' . $e );
+ MWExceptionHandler::logException( $e );
+ }
+ restore_error_handler();
+
+ if ( $replacement === null ) {
+ // some sort of failure, but this is a RC_FLOW so blank out hist/diff links
+ // which aren't correct
+ unset( $links['history'] );
+ unset( $links['diff'] );
+ } else {
+ $links = $replacement;
+ }
+
+ return true;
+ }
+
+ /**
+ * Regular talk page "Create source" and "Add topic" links are quite useless
+ * in the context of Flow boards. Let's get rid of them.
+ *
+ * @param SkinTemplate $template
+ * @param array $links
+ * @return bool
+ */
+ public static function onSkinTemplateNavigation( SkinTemplate &$template, &$links ) {
+ global $wgFlowCoreActionWhitelist,
+ $wgMFPageActions;
+
+ $title = $template->getTitle();
+
+ // if Flow is enabled on this talk page, overrule talk page red link
+ if ( self::$occupationController->isTalkpageOccupied( $title ) ) {
+ // Turn off page actions in MobileFrontend.
+ // FIXME: Find more elegant standard way of doing this.
+ $wgMFPageActions = array();
+
+ // watch star links are inside the topic itself
+ if ( $title->getNamespace() === NS_TOPIC ) {
+ unset( $links['actions']['watch'] );
+ unset( $links['actions']['unwatch'] );
+ }
+
+ // hide all views unless whitelisted
+ foreach ( $links['views'] as $action => $data ) {
+ if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
+ unset( $links['views'][$action] );
+ }
+ }
+
+ // hide all actions unless whitelisted
+ foreach ( $links['actions'] as $action => $data ) {
+ if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
+ unset( $links['actions'][$action] );
+ }
+ }
+
+ if ( isset( $links['namespaces']['topic_talk'] ) ) {
+ // hide discussion page in Topic namespace(which is already discussion)
+ unset( $links['namespaces']['topic_talk'] );
+ // hide protection (topic protection is done via moderation)
+ unset( $links['actions']['protect'] );
+ // topic pages are also not movable
+ unset( $links['actions']['move'] );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Interact with the mobile skin's default modules on Flow enabled pages
+ *
+ * @param Skin $skin
+ * @param array $modules
+ * @return bool
+ */
+ public static function onSkinMinervaDefaultModules( Skin $skin, array &$modules ) {
+ // Disable toggling on occupied talk pages in mobile
+ $title = $skin->getTitle();
+ if ( self::$occupationController->isTalkpageOccupied( $title ) ) {
+ $modules['toggling'] = array();
+ }
+ // Turn off default mobile talk overlay for these pages
+ if ( $title->canTalk() ) {
+ $talkPage = $title->getTalkPage();
+ if ( self::$occupationController->isTalkpageOccupied( $talkPage ) ) {
+ // TODO: Insert lightweight JavaScript that opens flow via ajax
+ $modules['talk'] = array();
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * When a (talk) page does not exist, one of the checks being performed is
+ * to see if the page had once existed but was removed. In doing so, the
+ * deletion & move log is checked.
+ *
+ * In theory, a Flow board could overtake a non-existing talk page. If that
+ * board is later removed, this will be run to see if a message can be
+ * displayed to inform the user if the page has been deleted/moved.
+ *
+ * Since, in Flow, we also write (topic, post, ...) deletion to the deletion
+ * log, we don't want those to appear, since they're not actually actions
+ * related to that talk page (rather: they were actions on the board)
+ *
+ * @param array &$conds Array of conditions
+ * @param array &$logTypes Array of log types
+ * @return bool
+ */
+ public static function onMissingArticleConditions( array &$conds, array $logTypes ) {
+ global $wgLogActionsHandlers;
+ /** @var Flow\FlowActions $actions */
+ $actions = Container::get( 'flow_actions' );
+
+ foreach ( $actions->getActions() as $action ) {
+ foreach ( $logTypes as $logType ) {
+ // Check if Flow actions are defined for the requested log types
+ // and make sure they're ignored.
+ if ( isset( $wgLogActionsHandlers["$logType/flow-$action"] ) ) {
+ $conds[] = "log_action != " . wfGetDB( DB_SLAVE )->addQuotes( "flow-$action" );
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Adds Flow entries to watchlists
+ *
+ * @param array &$types Type array to modify
+ * @return boolean true
+ */
+ public static function onSpecialWatchlistGetNonRevisionTypes( &$types ) {
+ $types[] = RC_FLOW;
+ return true;
+ }
+
+ /**
+ * Make sure no user can register a flow-*-usertext username, to avoid
+ * confusion with a real user when we print e.g. "Suppressed" instead of a
+ * username. Additionally reserve the username used to add a revision on
+ * taking over a page.
+ *
+ * @param array $names
+ * @return bool
+ */
+ public static function onUserGetReservedNames( &$names ) {
+ $permissions = Flow\Model\AbstractRevision::$perms;
+ foreach ( $permissions as $permission ) {
+ $names[] = "msg:flow-$permission-usertext";
+ }
+ $names[] = 'msg:flow-system-usertext';
+
+ // Reserve both the localized username and the English fallback for the
+ // taking-over revision.
+ $names[] = 'msg:flow-talk-username';
+ $names[] = 'Flow talk page manager';
+
+ return true;
+ }
+
+ // Static variables that do not vary by request
+ public static function onResourceLoaderGetConfigVars( &$vars ) {
+ global $wgFlowEditorList;
+
+ $vars['wgFlowEditorList'] = $wgFlowEditorList;
+ $vars['wgFlowMaxTopicLength'] = Flow\Model\PostRevision::MAX_TOPIC_LENGTH;
+ $vars['wgFlowMentionTemplate'] = wfMessage( 'flow-ve-mention-template' )->inContentLanguage()->plain();
+
+ return true;
+ }
+
+ /**
+ * Intercept contribution entries and format those belonging to Flow
+ *
+ * @param ContribsPager $pager Contributions object
+ * @param string &$ret The HTML line
+ * @param stdClass $row The data for this line
+ * @param array &$classes the classes to add to the surrounding <li>
+ * @return bool
+ */
+ public static function onDeletedContributionsLineEnding( $pager, &$ret, $row, &$classes ) {
+ global $wgHooks;
+ static $javascriptIncluded = false;
+
+ if ( !$row instanceof Flow\Formatter\FormatterRow ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ /** @var Flow\Formatter\Contributions $formatter */
+ $formatter = Container::get( 'formatter.contributions' );
+ $line = $formatter->format( $row, $pager );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ $line = false;
+ }
+ restore_error_handler();
+
+ if ( $line === false ) {
+ return false;
+ }
+
+ $classes[] = 'mw-flow-contribution';
+ $ret = $line;
+
+ // If we output one or more lines of contributions entries we also need to include
+ // the javascript that hooks into moderation actions.
+ // @todo not a huge fan of this static variable, what else though?
+ if ( !$javascriptIncluded ) {
+ $javascriptIncluded = true;
+ $wgHooks['SpecialPageAfterExecute'][] = function( $specialPage, $subPage ) {
+ $specialPage->getOutput()->addModules( array( 'ext.flow.contributions' ) );
+ $specialPage->getOutput()->addModuleStyles( array( 'ext.flow.contributions.styles' ) );
+ };
+ }
+
+ return true;
+ }
+
+ /**
+ * Intercept contribution entries and format those belonging to Flow
+ *
+ * @param ContribsPager $pager Contributions object
+ * @param string &$ret The HTML line
+ * @param stdClass $row The data for this line
+ * @param array &$classes the classes to add to the surrounding <li>
+ * @return bool
+ */
+ public static function onContributionsLineEnding( $pager, &$ret, $row, &$classes ) {
+ return static::onDeletedContributionsLineEnding( $pager, $ret, $row, $classes );
+ }
+
+ /**
+ * Convert flow contributions entries into FeedItem instances
+ * for ApiFeedContributions
+ *
+ * @param object $row Single row of data from ContribsPager
+ * @param IContextSource $ctx The context to creat the feed item within
+ * @param FeedItem &$feedItem Return value holder for created feed item.
+ * @return bool
+ */
+ public static function onContributionsFeedItem( $row, IContextSource $ctx, FeedItem &$feedItem = null ) {
+ if ( !$row instanceof Flow\Formatter\FormatterRow ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ /** @var Flow\Formatter\Contributions $formatter */
+ $formatter = Container::get( 'formatter.contributions.feeditem' );
+ $result = $formatter->format( $row, $ctx );
+ restore_error_handler();
+
+ if ( $result instanceof FeedItem ) {
+ $feedItem = $result;
+ return true;
+ } else {
+ // If we failed to render a flow row, cancel it. This could be
+ // either permissions or bugs.
+ return false;
+ }
+ }
+
+ /**
+ * Adds Flow contributions to the DeletedContributions special page
+ *
+ * @param $data array an array of results of all contribs queries, to be
+ * merged to form all contributions data
+ * @param ContribsPager $pager Object hooked into
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return bool
+ */
+ public static function onDeletedContributionsQuery( &$data, $pager, $offset, $limit, $descending ) {
+ global $wgFlowOccupyNamespaces, $wgFlowOccupyPages;
+
+ // Ignore when looking in a specific namespace where there is no Flow
+ if ( $pager->namespace !== '' ) {
+ // Flow enabled on entire namespace(s)
+ $namespaces = array_flip( $wgFlowOccupyNamespaces );
+
+ // Flow enabled on specific pages - get those namespaces
+ foreach ( $wgFlowOccupyPages as $page ) {
+ $title = Title::newFromText( $page );
+ $namespaces[$title->getNamespace()] = 1;
+ }
+
+ if ( !isset( $namespaces[$pager->namespace] ) ) {
+ return true;
+ }
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ // Contributions may be on pages outside the set of currently
+ // enabled pages so we must disable to occupation listener
+ /** @var Flow\Data\Listener\OccupationListener $listener */
+ $listener = Container::get( 'listener.occupation' );
+ $listener->setEnabled( false );
+ /** @var Flow\Formatter\ContributionsQuery $query */
+ $query = Container::get( 'query.contributions' );
+ $results = $query->getResults( $pager, $offset, $limit, $descending );
+ $listener->setEnabled( true );
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failed contributions query' );
+ MWExceptionHandler::logException( $e );
+ $results = false;
+ }
+ restore_error_handler();
+
+ if ( $results === false ) {
+ return false;
+ }
+
+ $data[] = $results;
+
+ return true;
+ }
+
+ /**
+ * Adds Flow contributions to the Contributions special page
+ *
+ * @param $data array an array of results of all contribs queries, to be
+ * merged to form all contributions data
+ * @param ContribsPager $pager Object hooked into
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return bool
+ */
+ public static function onContributionsQuery( &$data, $pager, $offset, $limit, $descending ) {
+ // Flow has nothing to do with the tag filter, so ignore tag searches
+ if ( $pager->tagFilter != false ) {
+ return true;
+ }
+
+ return static::onDeletedContributionsQuery( $data, $pager, $offset, $limit, $descending );
+ }
+
+ /**
+ * Adds lazy-load methods for AbstractRevision objects.
+ *
+ * @param string $method: Method to generate the variable
+ * @param AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param mixed &$result Result of the computation
+ * @return bool
+ */
+ public static function onAbuseFilterComputeVariable( $method, AbuseFilterVariableHolder $vars, $parameters, &$result ) {
+ // fetch all lazy-load methods
+ $methods = self::$abuseFilter->lazyLoadMethods();
+
+ // method isn't known here
+ if ( !isset( $methods[$method] ) ) {
+ return true;
+ }
+
+ // fetch variable result from lazy-load method
+ $result = $methods[$method]( $vars, $parameters );
+ return false;
+ }
+
+ /**
+ * Abort notifications coming from RecentChange class, Flow has its
+ * own notifications through Echo.
+ *
+ * @param User $editor
+ * @param Title $title
+ * @return bool false to abort email notification
+ */
+ public static function onAbortEmailNotification( $editor, $title ) {
+ if ( self::$occupationController->isTalkpageOccupied( $title ) ) {
+ // Since we are aborting the notification we need to manually update the watchlist
+ EmailNotification::updateWatchlistTimestamp( $editor, $title, wfTimestampNow() );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public static function onInfoAction( IContextSource $ctx, &$pageinfo ) {
+ if ( !self::$occupationController->isTalkpageOccupied( $ctx->getTitle() ) ) {
+ return true;
+ }
+
+ // All of the info in this section is wrong for Flow pages,
+ // so we'll just remove it.
+ unset( $pageinfo['header-edits'] );
+
+ // These keys are wrong on Flow pages, so we'll remove them
+ static $badMessageKeys = array( 'pageinfo-length', 'pageinfo-content-model' );
+
+ foreach ( $pageinfo['header-basic'] as $num => $val ) {
+ if ( $val[0] instanceof Message && in_array( $val[0]->getKey(), $badMessageKeys ) ) {
+ unset($pageinfo['header-basic'][$num]);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Overwrite terms of use message if the overwrite exits
+ *
+ * @param string &$key
+ * @return bool
+ */
+ public static function onMessageCacheGet( &$key ) {
+ global $wgResourceModules;
+
+ static $terms = array (
+ 'flow-terms-of-use-new-topic' => null,
+ 'flow-terms-of-use-reply' => null,
+ 'flow-terms-of-use-edit' => null,
+ 'flow-terms-of-use-summarize' => null,
+ 'flow-terms-of-use-lock-topic' => null,
+ 'flow-terms-of-use-unlock-topic' => null
+ );
+
+ if ( !array_key_exists( $key, $terms ) ) {
+ return true;
+ }
+
+ if ( $terms[$key] === null ) {
+ $message = wfMessage( "wikimedia-$key" );
+ if ( $message->exists() ) {
+ $terms[$key] = "wikimedia-$key";
+ $wgResourceModules['ext.flow.templating']['messages'][] = "wikimedia-$key";
+ } else {
+ $terms[$key] = false;
+ }
+ }
+
+ if ( $terms[$key] ) {
+ $key = $terms[$key];
+ }
+ return true;
+ }
+
+ /**
+ * @param RecentChange $rc
+ * @param array &$rcRow
+ * @return bool
+ */
+ public static function onCheckUserInsertForRecentChange( RecentChange $rc, array &$rcRow ) {
+ if ( $rc->getAttribute( 'rc_source' ) !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
+ return true;
+ }
+
+ $params = unserialize( $rc->getAttribute( 'rc_params' ) );
+ $change = $params['flow-workflow-change'];
+
+ // don't forget to increase the version number when data format changes
+ $comment = CheckUserQuery::VERSION_PREFIX;
+ $comment .= ',' . $change['action'];
+ $comment .= ',' . $change['workflow'];
+ $comment .= ',' . $change['revision'];
+ if ( isset( $change['post'] ) ) {
+ $comment .= ',' . $change['post'];
+ }
+
+ $rcRow['cuc_comment'] = $comment;
+
+ return true;
+ }
+
+ public static function onIRCLineURL( &$url, &$query, RecentChange $rc ) {
+ if ( $rc->getAttribute( 'rc_source' ) !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
+ return true;
+ }
+
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ $result = null;
+ try {
+ /** @var Flow\Formatter\IRCLineUrlFormatter $formatter */
+ $formatter = Container::get( 'formatter.irclineurl' );
+ $result = $formatter->format( $rc );
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting rc ' . $rc->getAttribute( 'rc_id' )
+ . ': ' . $e->getMessage() );
+ MWExceptionHandler::logException( $e );
+ }
+ restore_error_handler();
+
+ if ( $result !== null ) {
+ $url = $result;
+ $query = '';
+ }
+
+ return true;
+ }
+
+ public static function onWhatLinksHereProps( $row, Title $title, Title $target, &$props ) {
+ set_error_handler( new Flow\RecoverableErrorHandler, -1 );
+ try {
+ /** @var Flow\ReferenceClarifier $clarifier */
+ $clarifier = Flow\Container::get( 'reference.clarifier' );
+ $newProps = $clarifier->getWhatLinksHereProps( $row, $title, $target );
+
+ $props = array_merge( $props, $newProps );
+ } catch ( Exception $e ) {
+ wfDebugLog( 'Flow', sprintf(
+ '%s: Failed formatting WhatLinksHere for %s to %s',
+ __METHOD__,
+ $title->getFullText(),
+ $target->getFullText()
+ ) );
+ MWExceptionHandler::logException( $e );
+ }
+ restore_error_handler();
+
+ return true;
+ }
+
+ /**
+ * Add topiclist sortby to preferences.
+ *
+ * @param $user User object
+ * @param &$preferences array Preferences object
+ * @return bool
+ */
+ public static function onGetPreferences( $user, &$preferences ) {
+ $preferences['flow-topiclist-sortby'] = array(
+ 'type' => 'api',
+ );
+
+ $preferences['flow-editor'] = array(
+ 'type' => 'api'
+ );
+
+ return true;
+ }
+
+ /**
+ * ResourceLoaderTestModules hook handler
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderTestModules
+ *
+ * @param array $testModules
+ * @param ResourceLoader $resourceLoader
+ * @return bool
+ */
+ public static function onResourceLoaderTestModules( array &$testModules,
+ ResourceLoader &$resourceLoader
+ ) {
+ global $wgResourceModules;
+
+ // find test files for every RL module
+ foreach ( $wgResourceModules as $key => $module ) {
+ if ( preg_match( '/ext.flow(?:\.|$)/', $key ) && isset( $module['scripts'] ) ) {
+ $testFiles = array();
+
+ $scripts = (array) $module['scripts'];
+ foreach ( $scripts as $script ) {
+ $testFile = 'tests/qunit/' . dirname( $script ) . '/test_' . basename( $script );
+ // if a test file exists for a given JS file, add it
+ if ( file_exists( __DIR__ . '/' . $testFile ) ) {
+ $testFiles[] = $testFile;
+ }
+ }
+ // if test files exist for given module, create a corresponding test module
+ if ( count( $testFiles ) > 0 ) {
+ $module = array(
+ 'remoteExtPath' => 'Flow',
+ 'dependencies' => array( $key ),
+ 'localBasePath' => __DIR__,
+ 'scripts' => $testFiles,
+ );
+ $testModules['qunit']["$key.tests"] = $module;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Don't (un)watch a non-existing flow topic
+ *
+ * @param User $user
+ * @param WikiPage $page
+ * $param Status $status
+ */
+ public static function onWatchArticle( &$user, WikiPage &$page, &$status ) {
+ $title = $page->getTitle();
+ if ( $title->getNamespace() == NS_TOPIC ) {
+ // @todo - use !$title->exists()?
+ /** @var Flow\Data\ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+ $found = $storage->find(
+ 'PostRevision',
+ array( 'rev_type_id' => strtolower( $title->getDBkey() ) ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( !$found ) {
+ return false;
+ }
+ $post = reset( $found );
+ if ( !$post->isTopicTitle() ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Adds the topic namespace.
+ */
+ public static function onCanonicalNamespaces( &$list ) {
+ $list[NS_TOPIC] = 'Topic';
+ return true;
+ }
+
+ public static function onMovePageIsValidMove( Title $oldTitle, Title $newTitle, Status $status ) {
+ if ( self::$occupationController->isTalkpageOccupied( $oldTitle ) ) {
+ $status->fatal( 'flow-error-move' );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Moving a Flow page is not yet supported; make sure it can't be done.
+ *
+ * @param Title $oldTitle
+ * @param Title $newTitle
+ * @param User $user
+ * @param string|null $error Null coming in; assign (textual) error message when failing
+ * @param string $reason
+ * @return bool
+ */
+ public static function onAbortMove( $oldTitle, $newTitle, $user, &$error, $reason ) {
+ $status = new Status();
+ self::onMovePageIsValidMove( $oldTitle, $newTitle, $status );
+ if ( !$status->isOK() ) {
+ $error = $status->getHTML();
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param Title $title
+ * @param string[] $urls
+ * @return bool
+ */
+ public static function onTitleSquidURLs( Title $title, array &$urls ) {
+ if ( $title->getNamespace() !== NS_TOPIC ) {
+ return true;
+ }
+ try {
+ $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
+ } catch ( Flow\Exception\InvalidInputException $e ) {
+ MWExceptionHandler::logException( $e );
+ wfDebugLog( 'Flow', __METHOD__ . ': Invalid title ' . $title->getPrefixedText() );
+ return true;
+ }
+ /** @var Flow\Data\ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+ $workflow = $storage->get( 'Workflow', $uuid );
+ if ( !$workflow instanceof Flow\Model\Workflow ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Title for non-existent Workflow ' . $title->getPrefixedText() );
+ return true;
+ }
+ $urls = array_merge(
+ $urls,
+ $workflow->getOwnerTitle()->getSquidURLs()
+ );
+
+ return true;
+ }
+
+ /**
+ * @param array $tools Extra links
+ * @param Title $title
+ * @param bool $redirect Whether the page is a redirect
+ * @param Skin $skin
+ * @param string $link
+ * @return bool
+ */
+ public static function onWatchlistEditorBuildRemoveLine( &$tools, $title, $redirect, $skin, &$link = '' ) {
+ if ( $title->getNamespace() !== NS_TOPIC ) {
+ // Leave all non Flow topics alone!
+ return true;
+ }
+
+ /*
+ * Link to talk page is no applicable for Flow topics
+ * Note that key 'talk' doesn't exist prior to
+ * https://gerrit.wikimedia.org/r/#/c/156522/, so on old MW's, the link
+ * to talk page will still be present.
+ */
+ unset( $tools['talk'] );
+
+ if ( !$link ) {
+ /*
+ * https://gerrit.wikimedia.org/r/#/c/156118/ adds argument $link.
+ * Prior to that patch, it was impossible to change the link, so
+ * let's quit early if it doesn't exist.
+ */
+ return true;
+ }
+
+ try {
+ // Find the title text of this specific topic
+ $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
+ $collection = PostCollection::newFromId( $uuid );
+ $revision = $collection->getLastRevision();
+ } catch ( Exception $e ) {
+ wfWarn( __METHOD__ . ': Failed to locate revision for: ' . $title->getDBKey() );
+ return true;
+ }
+
+ // Titles are never parsed, so request as wikitext
+ $content = $revision->getContent( 'wikitext' );
+ $link = Linker::link( $title, htmlspecialchars( $content ) );
+
+ return true;
+ }
+
+ /**
+ * @param array $watchlistInfo Watchlisted pages
+ * @return bool
+ */
+ public static function onWatchlistEditorBeforeFormRender( &$watchlistInfo ) {
+ if ( !isset( $watchlistInfo[NS_TOPIC] ) ) {
+ // No topics watchlisted
+ return true;
+ }
+
+ $ids = array_keys( $watchlistInfo[NS_TOPIC] );
+
+ // build array of queries to be executed all at once
+ $queries = array();
+ foreach( $ids as $id ) {
+ try {
+ $uuid = WorkflowLoaderFactory::uuidFromTitlePair( NS_TOPIC, $id );
+ $queries[] = array( 'rev_type_id' => $uuid );
+ } catch ( Exception $e ) {
+ // invalid id
+ unset( $watchlistInfo[NS_TOPIC][$id] );
+ }
+ }
+
+ /** @var Flow\Data\ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+
+ /*
+ * Now, finally find all requested topics - this will be stored in
+ * local cache so subsequent calls (in onWatchlistEditorBuildRemoveLine)
+ * will just find these in memory, instead of doing a bunch of network
+ * requests.
+ */
+ $storage->findMulti(
+ 'PostRevision',
+ $queries,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ return true;
+ }
+
+ /**
+ * For integration with the UserMerge extension. Provides the database and
+ * sets of table/column pairs to update user id's within.
+ *
+ * @param array $updateFields
+ * @return bool
+ */
+ public static function onUserMergeAccountFields( &$updateFields ) {
+ /** @var Flow\Data\Utils\UserMerger $merger */
+ $merger = Container::get( 'user_merger' );
+ foreach ( $merger->getAccountFields() as $row ) {
+ $updateFields[] = $row;
+ }
+
+ return true;
+ }
+
+ /**
+ * Finalize the merge by purging any cached value that contained $oldUser
+ */
+ public static function onMergeAccountFromTo( User &$oldUser, User &$newUser ) {
+ /** @var Flow\Data\Utils\UserMerger $merger */
+ $merger = Container::get( 'user_merger' );
+ $merger->finalizeMerge( $oldUser->getId(), $newUser->getId() );
+
+ return true;
+ }
+
+ /**
+ * Gives precedence to Flow over LQT.
+ */
+ public static function onIsLiquidThreadsPage( Title $title, &$isLqtPage ) {
+ if ( $isLqtPage && self::$occupationController->isTalkpageOccupied( $title ) ) {
+ $isLqtPage = false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param int $namespace
+ * @param bool $movable
+ * @return bool
+ */
+ public static function onNamespaceIsMovable( $namespace, &$movable ) {
+ $movable &= $namespace !== NS_TOPIC;
+ return true;
+ }
+
+ public static function onCategoryViewerDoCategoryQuery( $type, $res ) {
+ if ( $type !== 'page' ) {
+ return true;
+ }
+
+ /** @var Flow\Formatter\CategoryViewerQuery */
+ $query = Container::get( 'query.categoryviewer' );
+ $query->loadMetadataBatch( $res );
+
+ return true;
+ }
+
+ public static function onCategoryViewerGenerateLink( $type, Title $title, $html, &$link ) {
+ if ( $type !== 'page' || $title->getNamespace() !== NS_TOPIC ) {
+ return true;
+ }
+ $uuid = UUID::create( strtolower( $title->getDBkey() ) );
+ if ( !$uuid ) {
+ return true;
+ }
+ /** @var Flow\Formatter\CategoryViewerQuery */
+ $query = Container::get( 'query.categoryviewer' );
+ $row = $query->getResult( $uuid );
+ /** @var Flow\Formatter\CategoryViewerFormatter */
+ $formatter = Container::get( 'formatter.categoryviewer' );
+ $result = $formatter->format( $row );
+ if ( $result ) {
+ $link = $result;
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets error HTML for attempted NS_TOPIC deletion using core interface
+ *
+ * @param Title $title Topic title they are attempting to delete
+ * @return string Error html
+ */
+ protected static function getTopicDeletionError( Title $title ) {
+ $error = wfMessage( 'flow-error-core-topic-deletion', $title->getFullURL() )->parse();
+ $wrappedError = Html::rawElement( 'span', array(
+ 'class' => 'plainlinks',
+ ), $error );
+ return $wrappedError;
+ }
+
+ // This should block them from wasting their time filling the form, but it won't
+ // without a core change. However, it does show the message.
+ /**
+ * Shows an error message when the user visits the deletion form if the page is in
+ * the Topic namespace.
+ *
+ * @param WikiPage $article Page the user requested to delete
+ * @param OutputPage $out Output page
+ * @param string &$reason Pre-filled reason given for deletion (note, this could
+ * be used to customize this for boards and/or topics later)
+ * @return bool False if it is a Topic; otherwise, true
+ */
+ public static function onArticleConfirmDelete( $article, $output, &$reason ) {
+ $title = $article->getTitle();
+ if ( $title->inNamespace( NS_TOPIC ) ) {
+ $output->addHTML( FlowHooks::getTopicDeletionError( $title ) );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Blocks topics from being deleted using the core deletion process, since it
+ * doesn't work.
+ *
+ * @param WikiPage &$article Page the user requested to delete
+ * @param User &$user User who requested to delete the article
+ * @param string &$reason Reason given for deletion
+ * @param string &$error Error explaining why we are not allowing the deletion
+ * @return bool False if it is a Topic (to block it); otherwise, true
+ */
+ public static function onArticleDelete( WikiPage &$article, User &$user, &$reason, &$error ) {
+ $title = $article->getTitle();
+ if ( $title->inNamespace( NS_TOPIC ) ) {
+ $error = FlowHooks::getTopicDeletionError( $title );
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/Flow/Makefile b/Flow/Makefile
new file mode 100644
index 00000000..21432a32
--- /dev/null
+++ b/Flow/Makefile
@@ -0,0 +1,128 @@
+MW_INSTALL_PATH ?= ../..
+MEDIAWIKI_LOAD_URL ?= http://localhost:8080/w/load.php
+
+# Flow files to analyze
+ANALYZE=container.php Flow.php Resources.php includes/
+
+# Extra files with some of the dependencies to reduce false positives from hhvm-wrapper
+ANALYZE_EXTRA=../../includes/GlobalFunctions.php ../../includes/Defines.php ../../includes/api/ApiBase.php \
+ ../../includes/logging/LogFormatter.php ../../includes/context/ContextSource.php \
+ ../../includes/db/DatabaseUtility.php \
+ ../Echo/formatters/BasicFormatter.php ../Echo/formatters/NotificationFormatter.php
+
+# mediawiki-vagrant default to hhvm rather than php5, which is mostly
+# fine but really slow for commands like phplint
+PHP=`command -v php5 || command -v php`
+
+###
+# Labs maintenance
+###
+ee-flow:
+ ssh ee-flow.eqiad.wmflabs 'cd /srv/mediawiki/extensions/Flow && make master'
+ee-flow-extra:
+ ssh ee-flow-extra.eqiad.wmflabs 'cd /vagrant/mediawiki/extensions/Flow && make master'
+# Used to be ee-flow-big, not so big any more
+ee-flow-extra2:
+ ssh ee-flow-extra2.eqiad.wmflabs 'cd /srv/mediawiki/extensions/Flow && make master'
+update-labs: ee-flow ee-flow-extra ee-flow-extra2
+
+###
+# Meta stuff
+###
+installhooks:
+ ln -sf ${PWD}/scripts/pre-commit .git/hooks/pre-commit
+ ln -sf ${PWD}/scripts/pre-review .git/hooks/pre-review
+
+remotes:
+ @scripts/remotecheck.sh
+
+gerrit: remotes
+ @scripts/remotes/gerrit.py --project 'mediawiki/extensions/Flow' --gtscore -1 --ignorepattern 'WIP'
+
+message: remotes
+ @python scripts/remotes/message.py
+
+messagecheck: remotes
+ @python scripts/remotes/message.py check
+
+###
+# Lints
+###
+lint: grunt phplint checkless messagecheck
+
+phplint:
+ @find ./ -type f -iname '*.php' -print0 | xargs -0 -P 12 -L 1 ${PHP} -l
+
+nodecheck:
+ @which npm > /dev/null && npm install \
+ || (echo "You need to install Node.JS and npm! See http://nodejs.org/" && false)
+
+gruntcheck: nodecheck
+ @which grunt > /dev/null || sudo npm install -g grunt-cli
+
+grunt: gruntcheck
+ @grunt test
+
+checkless:
+ @${PHP} ../../maintenance/checkLess.php
+
+csscss: gems
+ echo "Generating CSS file..."
+ php scripts/generatecss.php ${MEDIAWIKI_LOAD_URL} /tmp/foo.css
+ csscss -v /tmp/foo.css --num 2 --no-match-shorthand --ignore-properties=display,position,top,bottom,left,right
+###
+# Testing
+###
+phpunit:
+ cd ${MW_INSTALL_PATH}/tests/phpunit && ${PHP} phpunit.php --configuration ${MW_INSTALL_PATH}/extensions/Flow/tests/phpunit/flow.suite.xml --group=Flow
+
+qunit:
+ @scripts/qunit.sh
+
+vagrant-browsertests:
+ @vagrant ssh -- -X cd /vagrant/mediawiki/extensions/Flow/tests/browser '&&' MEDIAWIKI_URL=http://127.0.0.1:8080/wiki/ MEDIAWIKI_USER=Admin MEDIAWIKI_PASSWORD=vagrant bundle exec cucumber /vagrant/mediawiki/extensions/Flow/tests/browser/features/ -f pretty
+
+###
+# Static analysis
+###
+install-analyze-hhvm:
+ wget -O scripts/hhvm-wrapper.phar https://phar.phpunit.de/hhvm-wrapper.phar
+ @which hhvm >/dev/null || which ${HHVM_HOME} >/dev/null || (echo Could not locate hhvm && false)
+
+analyze-hhvm:
+ @test -f scripts/hhvm-wrapper.phar || (echo Run \`make install-analyze\` first && false)
+ php scripts/hhvm-wrapper.phar analyze ${ANALYZE} ${ANALYZE_EXTRA}
+
+analyze-phpstorm:
+ @scripts/analyze-phpstorm.sh
+
+analyze: analyze-hhvm analyze-phpstorm
+
+###
+# Compile lightncandy templates
+###
+compile-lightncandy:
+ @${PHP} maintenance/compileLightncandy.php
+
+###
+# Compile class autoloader for $wgAutoloadClasses
+###
+autoload:
+ @${PHP} scripts/gen-autoload.php
+
+###
+# Update this repository
+###
+gems:
+ bundle install
+
+master:
+ git fetch
+ @echo Here is what is new on origin/master:
+ @git log HEAD..origin/master
+ @echo Checkout and update master:
+ git checkout master && git pull --ff-only
+ @echo 'exit( ( $$wgFlowCluster === false && $$wgFlowDefaultWikiDb === false) ? 0 : 1 )' | php ../../maintenance/eval.php && echo Apply DB updates \(if any\) && php $(MW_INSTALL_PATH)/maintenance/update.php --quick | sed -n '/^[^.]/p' || echo DB updates must be applied manually.
+ @echo TODO Update Parsoid and restart it\? Other extensions\?
+ @echo Run some tests\!\!\!
+
diff --git a/Flow/Resources.php b/Flow/Resources.php
new file mode 100644
index 00000000..67637fbc
--- /dev/null
+++ b/Flow/Resources.php
@@ -0,0 +1,548 @@
+<?php
+
+$mobile = array(
+ 'targets' => array( 'desktop', 'mobile' ),
+);
+
+$flowResourceTemplate = array(
+ 'localBasePath' => $dir . 'modules',
+ 'remoteExtPath' => 'Flow/modules',
+);
+
+$wgResourceModules += array(
+ 'ext.flow.contributions' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'contributions/base.js',
+ ),
+ ),
+ 'ext.flow.contributions.styles' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/history/history-line.less',
+ ),
+ ),
+ 'ext.flow.templating' => array(
+ 'localBasePath' => __DIR__,
+ 'remoteExtPath' => 'Flow',
+ 'scripts' => array(
+ 'modules/engine/misc/flow-handlebars.js',
+ ),
+ 'dependencies' => array(
+ 'mediawiki.template.handlebars',
+ 'moment',
+ ),
+ 'templates' => array(
+ 'handlebars/flow_anon_warning.partial.handlebars',
+ 'handlebars/flow_block_board-history.handlebars',
+ 'handlebars/flow_block_header.handlebars',
+ 'handlebars/flow_block_header_diff_view.handlebars',
+ 'handlebars/flow_block_header_edit.handlebars',
+ 'handlebars/flow_block_header_single_view.handlebars',
+ 'handlebars/flow_block_loop.handlebars',
+ 'handlebars/flow_block_topic.handlebars',
+ 'handlebars/flow_block_topic_diff_view.handlebars',
+ 'handlebars/flow_block_topic_edit_title.handlebars',
+ 'handlebars/flow_block_topic_history.handlebars',
+ 'handlebars/flow_block_topic_moderate_post.handlebars',
+ 'handlebars/flow_block_topic_moderate_topic.handlebars',
+ 'handlebars/flow_block_topic_single_view.handlebars',
+ 'handlebars/flow_block_topiclist.handlebars',
+ 'handlebars/flow_block_topicsummary_diff_view.handlebars',
+ 'handlebars/flow_block_topicsummary_edit.handlebars',
+ 'handlebars/flow_block_topicsummary_single_view.handlebars',
+ 'handlebars/flow_board_navigation.partial.handlebars',
+ 'handlebars/flow_board_toc_loop.partial.handlebars',
+ 'handlebars/flow_edit_post_ajax.partial.handlebars',
+ 'handlebars/flow_edit_post.partial.handlebars',
+ 'handlebars/flow_edit_topic_title.partial.handlebars',
+ 'handlebars/flow_editor_switcher.partial.handlebars',
+ 'handlebars/flow_errors.partial.handlebars',
+ 'handlebars/flow_form_buttons.partial.handlebars',
+ 'handlebars/flow_header_detail.partial.handlebars',
+ 'handlebars/flow_load_more.partial.handlebars',
+ 'handlebars/flow_moderate_post_confirmation.partial.handlebars',
+ 'handlebars/flow_moderate_post.partial.handlebars',
+ 'handlebars/flow_moderate_topic_confirmation.partial.handlebars',
+ 'handlebars/flow_moderate_topic.partial.handlebars',
+ 'handlebars/flow_moderation_actions_list.partial.handlebars',
+ 'handlebars/flow_newtopic_form.partial.handlebars',
+ 'handlebars/flow_post_actions.partial.handlebars',
+ 'handlebars/flow_post_author.partial.handlebars',
+ 'handlebars/flow_post_inner.partial.handlebars',
+ 'handlebars/flow_post_meta_actions.partial.handlebars',
+ 'handlebars/flow_post_moderation_state.partial.handlebars',
+ 'handlebars/flow_post_replies.partial.handlebars',
+ 'handlebars/flow_post.handlebars',
+ 'handlebars/flow_preview_warning.partial.handlebars',
+ 'handlebars/flow_reply_form.partial.handlebars',
+ 'handlebars/flow_subscribed.partial.handlebars',
+ 'handlebars/flow_tooltip_subscribed.partial.handlebars',
+ 'handlebars/flow_tooltip.handlebars',
+ 'handlebars/flow_topic.partial.handlebars',
+ 'handlebars/flow_topic_titlebar_content.partial.handlebars',
+ 'handlebars/flow_topic_titlebar_lock.partial.handlebars',
+ 'handlebars/flow_topic_titlebar_summary.partial.handlebars',
+ 'handlebars/flow_topic_titlebar_watch.partial.handlebars',
+ 'handlebars/flow_topic_titlebar.partial.handlebars',
+ 'handlebars/flow_topic_moderation_flag.partial.handlebars',
+ 'handlebars/flow_topiclist_loop.partial.handlebars',
+ 'handlebars/form_element.partial.handlebars',
+ 'handlebars/timestamp.handlebars',
+ ),
+ 'messages' => array(
+ 'flow-anon-warning',
+ 'flow-cancel',
+ 'flow-edit-header-placeholder',
+ 'flow-edit-header-submit',
+ 'flow-edit-title-submit',
+ 'flow-load-more',
+ 'flow-newest-topics',
+ 'flow-newtopic-content-placeholder',
+ 'flow-newtopic-save',
+ 'flow-newtopic-start-placeholder',
+ 'flow-post-action-delete-post',
+ 'flow-post-action-undelete-post',
+ 'flow-post-action-edit-post',
+ 'flow-post-action-edit-post-submit',
+ 'flow-post-action-hide-post',
+ 'flow-post-action-unhide-post',
+ 'flow-post-action-post-history',
+ 'flow-post-action-view',
+ 'flow-post-action-suppress-post',
+ 'flow-post-action-unsuppress-post',
+ 'flow-post-action-restore-post',
+ 'flow-post-action-undo-moderation',
+ "flow-preview-return-edit-post",
+ 'flow-preview',
+ 'flow-recent-topics',
+ 'flow-reply-submit',
+ 'flow-reply-topic-title-placeholder',
+ 'flow-sorting-tooltip-newest',
+ 'flow-sorting-tooltip-recent',
+ 'flow-summarize-topic-submit',
+ 'flow-unlock-topic-submit',
+ 'flow-lock-topic-submit',
+ 'flow-toggle-small-topics',
+ 'flow-toggle-topics',
+ 'flow-toggle-topics-posts',
+ 'flow-topic-comments',
+ 'flow-topic-action-hide-topic',
+ 'flow-topic-action-lock-topic',
+ 'flow-topic-action-delete-topic',
+ 'flow-topic-action-edit-title',
+ 'flow-topic-action-hide-topic',
+ 'flow-topic-action-history',
+ 'flow-topic-action-resummarize-topic',
+ 'flow-topic-action-summarize-topic',
+ 'flow-topic-action-unlock-topic',
+ 'flow-topic-action-suppress-topic',
+ 'flow-topic-action-view',
+ 'flow-topic-action-hide-topic',
+ 'flow-topic-action-unhide-topic',
+ 'flow-topic-action-delete-topic',
+ 'flow-topic-action-undelete-topic',
+ 'flow-topic-action-suppress-topic',
+ 'flow-topic-action-unsuppress-topic',
+ 'flow-topic-action-restore-topic',
+ 'flow-topic-action-undo-moderation',
+ 'flow-hide-post-content',
+ 'flow-delete-post-content',
+ 'flow-suppress-post-content',
+ 'flow-hide-title-content',
+ 'flow-delete-title-content',
+ 'flow-suppress-title-content',
+ 'talkpagelinktext',
+ 'flow-cancel-warning',
+ // Moderation state
+ 'flow-lock-title-content',
+ 'flow-lock-post-content',
+ 'flow-hide-title-content',
+ 'flow-hide-post-content',
+ 'flow-delete-title-content',
+ 'flow-delete-post-content',
+ 'flow-suppress-title-content',
+ 'flow-suppress-post-content',
+ // Previews
+ 'flow-preview-warning',
+ 'flow-anonymous',
+ // Core messages needed
+ 'blocklink',
+ 'contribslink',
+ // Terms of use
+ 'flow-terms-of-use-new-topic',
+ 'flow-terms-of-use-reply',
+ 'flow-terms-of-use-edit',
+ 'flow-terms-of-use-summarize',
+ 'flow-terms-of-use-lock-topic',
+ 'flow-terms-of-use-unlock-topic',
+ 'flow-no-more-fwd',
+ // Tooltip
+ 'flow-topic-notification-subscribe-title',
+ 'flow-topic-notification-subscribe-description',
+ 'flow-board-notification-subscribe-title',
+ 'flow-board-notification-subscribe-description',
+ // Moderation
+ 'flow-moderation-title-unhide-post',
+ 'flow-moderation-title-undelete-post',
+ 'flow-moderation-title-unsuppress-post',
+ 'flow-moderation-title-unhide-topic',
+ 'flow-moderation-title-undelete-topic',
+ 'flow-moderation-title-unsuppress-topic',
+ 'flow-moderation-title-hide-post',
+ 'flow-moderation-title-delete-post',
+ 'flow-moderation-title-suppress-post',
+ 'flow-moderation-title-hide-topic',
+ 'flow-moderation-title-delete-topic',
+ 'flow-moderation-title-suppress-topic',
+ 'flow-moderation-placeholder-unhide-post',
+ 'flow-moderation-placeholder-undelete-post',
+ 'flow-moderation-placeholder-unsuppress-post',
+ 'flow-moderation-placeholder-unlock-topic',
+ 'flow-moderation-placeholder-unhide-topic',
+ 'flow-moderation-placeholder-undelete-topic',
+ 'flow-moderation-placeholder-unsuppress-topic',
+ 'flow-moderation-placeholder-hide-post',
+ 'flow-moderation-placeholder-delete-post',
+ 'flow-moderation-placeholder-suppress-post',
+ 'flow-moderation-placeholder-lock-topic',
+ 'flow-moderation-placeholder-hide-topic',
+ 'flow-moderation-placeholder-delete-topic',
+ 'flow-moderation-placeholder-suppress-topic',
+ 'flow-moderation-confirm-unhide-post',
+ 'flow-moderation-confirm-undelete-post',
+ 'flow-moderation-confirm-unsuppress-post',
+ 'flow-moderation-confirm-unlock-topic',
+ 'flow-moderation-confirm-unhide-topic',
+ 'flow-moderation-confirm-undelete-topic',
+ 'flow-moderation-confirm-unsuppress-topic',
+ 'flow-moderation-confirm-hide-post',
+ 'flow-moderation-confirm-delete-post',
+ 'flow-moderation-confirm-suppress-post',
+ 'flow-moderation-confirm-lock-topic',
+ 'flow-moderation-confirm-hide-topic',
+ 'flow-moderation-confirm-delete-topic',
+ 'flow-moderation-confirm-suppress-topic',
+ 'flow-moderation-confirmation-hide-topic',
+ 'flow-moderation-confirmation-delete-topic',
+ 'flow-moderation-confirmation-suppress-topic',
+ 'flow-topic-moderated-reason-prefix',
+ // Undo actions
+ 'flow-post-undo-hide',
+ 'flow-post-undo-delete',
+ 'flow-post-undo-suppress',
+ 'flow-topic-undo-hide',
+ 'flow-topic-undo-delete',
+ 'flow-topic-undo-suppress',
+ // Timestamps
+ 'flow-edited',
+ 'flow-edited-by',
+ // Board header
+ "flow-board-header-browse-topics-link",
+ // editor switching
+ "flow-wikitext-editor-help",
+ "flow-wikitext-editor-help-and-preview",
+ "flow-wikitext-editor-help-uses-wikitext",
+ "flow-wikitext-editor-help-preview-the-result",
+ ),
+ ) + $mobile,
+ // @todo: upstream to mediawiki ui
+ 'ext.flow.mediawiki.ui.modal' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/mediawiki.ui/modal.less',
+ ),
+ ) + $mobile,
+ // @todo: upstream to mediawiki ui
+ 'ext.flow.mediawiki.ui.text' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/mediawiki.ui/text.less',
+ ),
+ ) + $mobile,
+ // @todo: upstream to mediawiki ui
+ 'ext.flow.mediawiki.ui.form' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/mediawiki.ui/forms.less',
+ ),
+ ) + $mobile,
+ // @todo: upstream to mediawiki ui
+ 'ext.flow.mediawiki.ui.tooltips' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/mediawiki.ui/tooltips.less',
+ ),
+ ) + $mobile,
+ 'ext.flow.icons.styles' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'wikiglyph/wikiglyphs.css',
+ 'wikiglyph/flow-override.less',
+ ),
+ ) + $mobile,
+ 'ext.flow.styles.base' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/common.less',
+ 'styles/errors.less',
+ 'styles/history/history-line.less',
+ ),
+ ) + $mobile,
+ 'ext.flow.board.styles' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/board/header.less',
+ 'styles/board/menu.less',
+ 'styles/board/navigation.less',
+ 'styles/board/moderated.less',
+ 'styles/board/timestamps.less',
+ 'styles/board/replycount.less',
+ 'styles/js.less',
+ 'styles/board/content-preview.less',
+ 'styles/board/form-actions.less',
+ 'styles/board/terms-of-use.less',
+ 'styles/board/editor-switcher.less',
+ ),
+ ) + $mobile,
+ 'ext.flow.board.topic.styles' => $flowResourceTemplate + array(
+ 'styles' => array(
+ 'styles/board/topic/titlebar.less',
+ 'styles/board/topic/meta.less',
+ 'styles/board/topic/post.less',
+ 'styles/board/topic/summary.less',
+ 'styles/board/topic/watchlist.less',
+ ),
+ ) + $mobile,
+ // MediaWiki Handlebars provider. Should not have anything Flow-specific
+ 'mediawiki.template.handlebars' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'vendor/handlebars.js',
+ 'handlebars.js',
+ ),
+ 'dependencies' => array(
+ 'mediawiki.template',
+ ),
+ ) + $mobile,
+ 'ext.flow.components' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'engine/components/flow-registry.js',
+ 'engine/components/flow-component.js',
+ // FlowApi
+ 'engine/misc/flow-api.js',
+ // FlowEventLog
+ 'engine/misc/flow-eventlog.js',
+ // FlowComponent must come before actual components
+ 'engine/components/common/flow-component-engines.js',
+ 'engine/components/common/flow-component-events.js',
+
+ // Component: BoardAndHistoryBase
+ // Base class for both FlowBoardComponent and FlowBoardHistoryComponent
+ // Implements common methods between them, such as topic namespace checking
+ 'engine/components/board/base/flow-boardandhistory-base.js',
+
+ // Component: FlowBoardComponent
+ 'engine/components/board/flow-board.js',
+ // Also needed for event log
+ 'engine/components/board/base/flow-board-misc.js',
+ ),
+ 'dependencies' => array(
+ 'oojs',
+ 'ext.flow.templating', // prototype-based for progressiveEnhancement
+ 'ext.flow.jquery.findWithParent',
+ 'ext.flow.vendor.storer',
+ 'mediawiki.Title',
+ 'mediawiki.user',
+ 'mediawiki.Uri',
+ ),
+ ) + $mobile,
+ 'ext.flow' => $flowResourceTemplate + array(
+ 'scripts' => array( // Component order is important
+ // MW UI
+ 'engine/misc/mw-ui.enhance.js',
+ 'engine/misc/mw-ui.modal.js',
+
+ // Feature: flow-menu
+ 'engine/components/common/flow-component-menus.js',
+
+ 'engine/components/board/base/flow-board-api-events.js',
+ 'engine/components/board/base/flow-board-interactive-events.js',
+ 'engine/components/board/base/flow-board-load-events.js',
+ // Feature: Load More
+ 'engine/components/board/features/flow-board-loadmore.js',
+ // Feature: Board Navigation Header
+ 'engine/components/board/features/flow-board-navigation.js',
+ // Feature: Table of Contents
+ 'engine/components/board/features/flow-board-toc.js',
+ // Feature: VisualEditor
+ 'engine/components/board/features/flow-board-visualeditor.js',
+ // Feature: Switch between editors
+ 'engine/components/board/features/flow-board-switcheditor.js',
+
+ // Component: FlowBoardHistoryComponent
+ 'engine/components/board/flow-boardhistory.js',
+ // this must be last (of everything loaded. otherwise a components
+ // can be initialized before all the mixins are loaded. Can we mixin
+ // after initialization?)
+ 'flow-initialize.js',
+ ),
+ 'dependencies' => array(
+ 'ext.flow.components',
+ 'ext.flow.editor',
+ 'ext.flow.preview',
+ 'jquery.throttle-debounce',
+ 'mediawiki.jqueryMsg',
+ 'ext.flow.jquery.conditionalScroll',
+ 'mediawiki.api',
+ 'mediawiki.util',
+ 'mediawiki.api.options', // required by switch-editor feature
+ ),
+ 'messages' => array(
+ 'flow-error-external',
+ 'flow-error-http',
+ 'flow-error-fetch-after-lock',
+ 'mw-ui-unsubmitted-confirm',
+ 'flow-reply-link',
+ )
+ ) + $mobile,
+ 'ext.flow.vendor.storer' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'vendor/Storer.js',
+ ),
+ ) + $mobile,
+ 'ext.flow.preview' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'engine/components/board/features/flow-board-preview.js',
+ // wfBaseConvert ported to js
+ 'engine/misc/flow-baseconvert.js',
+ ),
+ 'dependencies' => array(
+ 'ext.flow.components',
+ ),
+ ) + $mobile,
+ 'ext.flow.undo' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ // this must be last (of everything loaded. otherwise a components
+ // can be initialized before all the mixins are loaded. Can we mixin
+ // after initialization?)
+ 'flow-initialize.js',
+ ),
+ // minimal subset for the undo pages
+ 'dependencies' => array(
+ 'ext.flow.components',
+ 'ext.flow.preview',
+ ),
+ ) + $mobile,
+ 'ext.flow.editor' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'editor/editors/ext.flow.editors.AbstractEditor.js',
+ 'editor/ext.flow.editor.js',
+ ),
+ 'dependencies' => array(
+ 'oojs',
+ 'mediawiki.user',
+ 'ext.flow.parsoid',
+ // specific editor (ext.flow.editors.*) dependencies (if any) will be loaded via JS
+ ),
+ ) + $mobile,
+ 'ext.flow.editors.none' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'editor/editors/ext.flow.editors.none.js',
+ ),
+ 'messages' => array(
+ 'flow-wikitext-switch-editor-tooltip',
+ ),
+ ) + $mobile,
+
+ // Basically this is just all the Flow-specific VE stuff, except ext.flow.editors.visualeditor.js,
+ // That needs to register itself even if the browser doesn't support VE (so we can tell
+ // the editor dispatcher that). But we want to reduce what we load if the browser can't actually
+ // use VE.
+ 'ext.flow.visualEditor' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'editor/editors/visualeditor/mw.flow.ve.Target.js',
+ 'editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js',
+ 'editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js',
+ // MentionInspectorTool must be after MentionInspector and before MentionContextItem.
+ 'editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js',
+ 'editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js',
+ 'editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js',
+ 'editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js',
+ 'editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js',
+ 'editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js',
+ ),
+ 'styles' => array(
+ 'editor/editors/visualeditor/mw.flow.ve.Target.less',
+ 'editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less',
+ ),
+ 'dependencies' => array(
+ 'ext.visualEditor.core',
+ 'ext.visualEditor.core.desktop',
+ 'ext.visualEditor.data',
+ 'ext.visualEditor.icons',
+ // See comment at bottom of mw.flow.ve.Target.js.
+ 'ext.visualEditor.mediawiki',
+ 'ext.visualEditor.mwlink',
+ 'ext.visualEditor.mwtransclusion',
+ 'ext.visualEditor.standalone',
+ 'site',
+ 'user',
+ 'mediawiki.api',
+ 'ext.flow.editors.none', // needed to figure out if that editor is supported, for switch button
+ ),
+ 'messages' => array(
+ 'flow-ve-mention-context-item-label',
+ 'flow-ve-mention-inspector-title',
+ 'flow-ve-mention-inspector-remove-label',
+ 'flow-ve-mention-inspector-invalid-user',
+ 'flow-ve-mention-tool-title',
+ 'flow-ve-switch-editor-tool-title',
+ ),
+ ),
+
+ 'ext.flow.editors.visualeditor' => $flowResourceTemplate + array(
+ 'scripts' => 'editor/editors/visualeditor/ext.flow.editors.visualeditor.js',
+ 'dependencies' => array(
+ 'jquery.spinner',
+ // ve dependencies will be loaded via JS
+ ),
+ ),
+
+ 'ext.flow.parsoid' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'editor/ext.flow.parsoid.js',
+ ),
+ ) + $mobile,
+ // This integrates with core mediawiki.messagePoster, and the module name
+ // must be exactly this.
+ 'mediawiki.messagePoster.flow-board' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'messagePoster/ext.flow.messagePoster.js',
+ ),
+ 'dependencies' => array(
+ 'oojs',
+ 'mediawiki.api',
+ 'mediawiki.messagePoster',
+ ),
+ ) + $mobile,
+ 'ext.flow.jquery.conditionalScroll' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'engine/misc/jquery.conditionalScroll.js',
+ ),
+ ) + $mobile,
+ 'ext.flow.jquery.findWithParent' => $flowResourceTemplate + array(
+ 'scripts' => array(
+ 'engine/misc/jquery.findWithParent.js',
+ ),
+ ) + $mobile,
+);
+
+$wgHooks['ResourceLoaderRegisterModules'][] = function ( ResourceLoader &$resourceLoader ) {
+ global $wgFlowEventLogging, $wgResourceModules;
+
+ // Only if EventLogging in Flow is enabled & EventLogging exists
+ if ( $wgFlowEventLogging && class_exists( 'ResourceLoaderSchemaModule' ) ) {
+ $resourceLoader->register( 'schema.FlowReplies', array(
+ 'class' => 'ResourceLoaderSchemaModule',
+ 'schema' => 'FlowReplies',
+ // See https://meta.wikimedia.org/wiki/Schema:FlowReplies, below title
+ 'revision' => 10561344,
+ ) );
+
+ // Add as dependency to Flow JS
+ $wgResourceModules['ext.flow']['dependencies'][] = 'schema.FlowReplies';
+ }
+
+ return true;
+};
diff --git a/Flow/autoload.php b/Flow/autoload.php
new file mode 100644
index 00000000..1be28356
--- /dev/null
+++ b/Flow/autoload.php
@@ -0,0 +1,362 @@
+<?php
+// This file is generated by scripts/gen-autoload.php, do not adjust manually
+// @codingStandardsIgnoreFile
+global $wgAutoloadClasses;
+
+$wgAutoloadClasses += array(
+ 'FlowHooks' => __DIR__ . '/Hooks.php',
+ 'Flow\\Actions\\EditAction' => __DIR__ . '/includes/Actions/EditAction.php',
+ 'Flow\\Actions\\FlowAction' => __DIR__ . '/includes/Actions/Action.php',
+ 'Flow\\Actions\\PurgeAction' => __DIR__ . '/includes/Actions/PurgeAction.php',
+ 'Flow\\Actions\\ViewAction' => __DIR__ . '/includes/Actions/ViewAction.php',
+ 'Flow\\Api\\ApiFlow' => __DIR__ . '/includes/Api/ApiFlow.php',
+ 'Flow\\Api\\ApiFlowBase' => __DIR__ . '/includes/Api/ApiFlowBase.php',
+ 'Flow\\Api\\ApiFlowBaseGet' => __DIR__ . '/includes/Api/ApiFlowBaseGet.php',
+ 'Flow\\Api\\ApiFlowBasePost' => __DIR__ . '/includes/Api/ApiFlowBasePost.php',
+ 'Flow\\Api\\ApiFlowEditHeader' => __DIR__ . '/includes/Api/ApiFlowEditHeader.php',
+ 'Flow\\Api\\ApiFlowEditPost' => __DIR__ . '/includes/Api/ApiFlowEditPost.php',
+ 'Flow\\Api\\ApiFlowEditTitle' => __DIR__ . '/includes/Api/ApiFlowEditTitle.php',
+ 'Flow\\Api\\ApiFlowEditTopicSummary' => __DIR__ . '/includes/Api/ApiFlowEditTopicSummary.php',
+ 'Flow\\Api\\ApiFlowLockTopic' => __DIR__ . '/includes/Api/ApiFlowLockTopic.php',
+ 'Flow\\Api\\ApiFlowModeratePost' => __DIR__ . '/includes/Api/ApiFlowModeratePost.php',
+ 'Flow\\Api\\ApiFlowModerateTopic' => __DIR__ . '/includes/Api/ApiFlowModerateTopic.php',
+ 'Flow\\Api\\ApiFlowNewTopic' => __DIR__ . '/includes/Api/ApiFlowNewTopic.php',
+ 'Flow\\Api\\ApiFlowReply' => __DIR__ . '/includes/Api/ApiFlowReply.php',
+ 'Flow\\Api\\ApiFlowUndoEditHeader' => __DIR__ . '/includes/Api/ApiFlowUndoEditHeader.php',
+ 'Flow\\Api\\ApiFlowUndoEditPost' => __DIR__ . '/includes/Api/ApiFlowUndoEditPost.php',
+ 'Flow\\Api\\ApiFlowUndoEditTopicSummary' => __DIR__ . '/includes/Api/ApiFlowUndoEditTopicSummary.php',
+ 'Flow\\Api\\ApiFlowViewHeader' => __DIR__ . '/includes/Api/ApiFlowViewHeader.php',
+ 'Flow\\Api\\ApiFlowViewPost' => __DIR__ . '/includes/Api/ApiFlowViewPost.php',
+ 'Flow\\Api\\ApiFlowViewTopic' => __DIR__ . '/includes/Api/ApiFlowViewTopic.php',
+ 'Flow\\Api\\ApiFlowViewTopicList' => __DIR__ . '/includes/Api/ApiFlowViewTopicList.php',
+ 'Flow\\Api\\ApiFlowViewTopicSummary' => __DIR__ . '/includes/Api/ApiFlowViewTopicSummary.php',
+ 'Flow\\Api\\ApiParsoidUtilsFlow' => __DIR__ . '/includes/Api/ApiParsoidUtilsFlow.php',
+ 'Flow\\Api\\ApiQueryPropFlowInfo' => __DIR__ . '/includes/Api/ApiQueryPropFlowInfo.php',
+ 'Flow\\BlockFactory' => __DIR__ . '/includes/BlockFactory.php',
+ 'Flow\\Block\\AbstractBlock' => __DIR__ . '/includes/Block/Block.php',
+ 'Flow\\Block\\Block' => __DIR__ . '/includes/Block/Block.php',
+ 'Flow\\Block\\BoardHistoryBlock' => __DIR__ . '/includes/Block/BoardHistory.php',
+ 'Flow\\Block\\HeaderBlock' => __DIR__ . '/includes/Block/Header.php',
+ 'Flow\\Block\\TopicBlock' => __DIR__ . '/includes/Block/Topic.php',
+ 'Flow\\Block\\TopicListBlock' => __DIR__ . '/includes/Block/TopicList.php',
+ 'Flow\\Block\\TopicSummaryBlock' => __DIR__ . '/includes/Block/TopicSummary.php',
+ 'Flow\\Collection\\AbstractCollection' => __DIR__ . '/includes/Collection/AbstractCollection.php',
+ 'Flow\\Collection\\CollectionCache' => __DIR__ . '/includes/Collection/CollectionCache.php',
+ 'Flow\\Collection\\HeaderCollection' => __DIR__ . '/includes/Collection/HeaderCollection.php',
+ 'Flow\\Collection\\LocalCacheAbstractCollection' => __DIR__ . '/includes/Collection/LocalCacheAbstractCollection.php',
+ 'Flow\\Collection\\PostCollection' => __DIR__ . '/includes/Collection/PostCollection.php',
+ 'Flow\\Collection\\PostSummaryCollection' => __DIR__ . '/includes/Collection/PostSummaryCollection.php',
+ 'Flow\\Container' => __DIR__ . '/includes/Container.php',
+ 'Flow\\Content\\BoardContent' => __DIR__ . '/includes/Content/BoardContent.php',
+ 'Flow\\Content\\BoardContentHandler' => __DIR__ . '/includes/Content/BoardContentHandler.php',
+ 'Flow\\Content\\Content' => __DIR__ . '/includes/Content/Content.php',
+ 'Flow\\Data\\BagOStuff\\BufferedBagOStuff' => __DIR__ . '/includes/Data/BagOStuff/BufferedBagOStuff.php',
+ 'Flow\\Data\\BagOStuff\\LocalBufferedBagOStuff' => __DIR__ . '/includes/Data/BagOStuff/LocalBufferedBagOStuff.php',
+ 'Flow\\Data\\BufferedCache' => __DIR__ . '/includes/Data/BufferedCache.php',
+ 'Flow\\Data\\Compactor' => __DIR__ . '/includes/Data/Compactor.php',
+ 'Flow\\Data\\Compactor\\FeatureCompactor' => __DIR__ . '/includes/Data/Compactor/FeatureCompactor.php',
+ 'Flow\\Data\\Compactor\\ShallowCompactor' => __DIR__ . '/includes/Data/Compactor/ShallowCompactor.php',
+ 'Flow\\Data\\Index' => __DIR__ . '/includes/Data/Index.php',
+ 'Flow\\Data\\Index\\BoardHistoryIndex' => __DIR__ . '/includes/Data/Index/BoardHistoryIndex.php',
+ 'Flow\\Data\\Index\\FeatureIndex' => __DIR__ . '/includes/Data/Index/FeatureIndex.php',
+ 'Flow\\Data\\Index\\TopKIndex' => __DIR__ . '/includes/Data/Index/TopKIndex.php',
+ 'Flow\\Data\\Index\\TopicHistoryIndex' => __DIR__ . '/includes/Data/Index/TopicHistoryIndex.php',
+ 'Flow\\Data\\Index\\UniqueFeatureIndex' => __DIR__ . '/includes/Data/Index/UniqueFeatureIndex.php',
+ 'Flow\\Data\\LifecycleHandler' => __DIR__ . '/includes/Data/LifecycleHandler.php',
+ 'Flow\\Data\\Listener\\AbstractTopicInsertListener' => __DIR__ . '/includes/Data/Listener/WatchTopicListener.php',
+ 'Flow\\Data\\Listener\\DeferredInsertLifecycleHandler' => __DIR__ . '/includes/Data/Listener/DeferredInsertLifecycleHandler.php',
+ 'Flow\\Data\\Listener\\EditCountListener' => __DIR__ . '/includes/Data/Listener/EditCountListener.php',
+ 'Flow\\Data\\Listener\\ImmediateWatchTopicListener' => __DIR__ . '/includes/Data/Listener/WatchTopicListener.php',
+ 'Flow\\Data\\Listener\\ModerationLoggingListener' => __DIR__ . '/includes/Data/Listener/ModerationLoggingListener.php',
+ 'Flow\\Data\\Listener\\NotificationListener' => __DIR__ . '/includes/Data/Listener/NotificationListener.php',
+ 'Flow\\Data\\Listener\\OccupationListener' => __DIR__ . '/includes/Data/Listener/OccupationListener.php',
+ 'Flow\\Data\\Listener\\RecentChangesListener' => __DIR__ . '/includes/Data/Listener/RecentChangesListener.php',
+ 'Flow\\Data\\Listener\\ReferenceRecorder' => __DIR__ . '/includes/Data/Listener/ReferenceRecorder.php',
+ 'Flow\\Data\\Listener\\UrlGenerationListener' => __DIR__ . '/includes/Data/Listener/UrlGenerationListener.php',
+ 'Flow\\Data\\Listener\\UserNameListener' => __DIR__ . '/includes/Data/Listener/UserNameListener.php',
+ 'Flow\\Data\\Listener\\WorkflowTopicListListener' => __DIR__ . '/includes/Data/Listener/WorkflowTopicListListener.php',
+ 'Flow\\Data\\ManagerGroup' => __DIR__ . '/includes/Data/ManagerGroup.php',
+ 'Flow\\Data\\Mapper\\BasicObjectMapper' => __DIR__ . '/includes/Data/Mapper/BasicObjectMapper.php',
+ 'Flow\\Data\\Mapper\\CachingObjectMapper' => __DIR__ . '/includes/Data/Mapper/CachingObjectMapper.php',
+ 'Flow\\Data\\ObjectLocator' => __DIR__ . '/includes/Data/ObjectLocator.php',
+ 'Flow\\Data\\ObjectManager' => __DIR__ . '/includes/Data/ObjectManager.php',
+ 'Flow\\Data\\ObjectMapper' => __DIR__ . '/includes/Data/ObjectMapper.php',
+ 'Flow\\Data\\ObjectStorage' => __DIR__ . '/includes/Data/ObjectStorage.php',
+ 'Flow\\Data\\Pager\\HistoryPager' => __DIR__ . '/includes/Data/Pager/HistoryPager.php',
+ 'Flow\\Data\\Pager\\Pager' => __DIR__ . '/includes/Data/Pager/Pager.php',
+ 'Flow\\Data\\Pager\\PagerPage' => __DIR__ . '/includes/Data/Pager/PagerPage.php',
+ 'Flow\\Data\\Storage\\BasicDbStorage' => __DIR__ . '/includes/Data/Storage/BasicDbStorage.php',
+ 'Flow\\Data\\Storage\\BoardHistoryStorage' => __DIR__ . '/includes/Data/Storage/BoardHistoryStorage.php',
+ 'Flow\\Data\\Storage\\DbStorage' => __DIR__ . '/includes/Data/Storage/DbStorage.php',
+ 'Flow\\Data\\Storage\\HeaderRevisionStorage' => __DIR__ . '/includes/Data/Storage/HeaderRevisionStorage.php',
+ 'Flow\\Data\\Storage\\PostRevisionStorage' => __DIR__ . '/includes/Data/Storage/PostRevisionStorage.php',
+ 'Flow\\Data\\Storage\\PostSummaryRevisionStorage' => __DIR__ . '/includes/Data/Storage/PostSummaryRevisionStorage.php',
+ 'Flow\\Data\\Storage\\RevisionStorage' => __DIR__ . '/includes/Data/Storage/RevisionStorage.php',
+ 'Flow\\Data\\Storage\\TopicHistoryStorage' => __DIR__ . '/includes/Data/Storage/TopicHistoryStorage.php',
+ 'Flow\\Data\\Storage\\TopicListLastUpdatedStorage' => __DIR__ . '/includes/Data/Storage/TopicListLastUpdatedStorage.php',
+ 'Flow\\Data\\Storage\\TopicListStorage' => __DIR__ . '/includes/Data/Storage/TopicListStorage.php',
+ 'Flow\\Data\\Utils\\Merger' => __DIR__ . '/includes/Data/Utils/Merger.php',
+ 'Flow\\Data\\Utils\\MultiDimArray' => __DIR__ . '/includes/Data/Utils/MultiDimArray.php',
+ 'Flow\\Data\\Utils\\RawSql' => __DIR__ . '/includes/Data/Utils/RawSql.php',
+ 'Flow\\Data\\Utils\\RecentChangeFactory' => __DIR__ . '/includes/Data/Utils/RecentChangeFactory.php',
+ 'Flow\\Data\\Utils\\ResultDuplicator' => __DIR__ . '/includes/Data/Utils/ResultDuplicator.php',
+ 'Flow\\Data\\Utils\\SortArrayByKeys' => __DIR__ . '/includes/Data/Utils/SortArrayByKeys.php',
+ 'Flow\\Data\\Utils\\UserMerger' => __DIR__ . '/includes/Data/Utils/UserMerger.php',
+ 'Flow\\DbFactory' => __DIR__ . '/includes/DbFactory.php',
+ 'Flow\\Exception\\CatchableFatalErrorException' => __DIR__ . '/includes/Exception/CatchableFatalErrorException.php',
+ 'Flow\\Exception\\CrossWikiException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\DataModelException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\DataPersistenceException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\FailCommitException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\FlowException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\InvalidActionException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\InvalidDataException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\InvalidInputException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\InvalidReferenceException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\InvalidTopicUuidException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\NoIndexException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\NoParsoidException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\PermissionException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\UnknownWorkflowIdException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\WikitextException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\Exception\\WrongNumberArgumentsException' => __DIR__ . '/includes/Exception/ExceptionHandling.php',
+ 'Flow\\FlowActions' => __DIR__ . '/includes/FlowActions.php',
+ 'Flow\\Formatter\\AbstractFormatter' => __DIR__ . '/includes/Formatter/AbstractFormatter.php',
+ 'Flow\\Formatter\\AbstractQuery' => __DIR__ . '/includes/Formatter/AbstractQuery.php',
+ 'Flow\\Formatter\\BaseTopicListFormatter' => __DIR__ . '/includes/Formatter/BaseTopicListFormatter.php',
+ 'Flow\\Formatter\\BoardHistoryQuery' => __DIR__ . '/includes/Formatter/BoardHistoryQuery.php',
+ 'Flow\\Formatter\\CategoryViewerFormatter' => __DIR__ . '/includes/Formatter/CategoryViewerFormatter.php',
+ 'Flow\\Formatter\\CategoryViewerQuery' => __DIR__ . '/includes/Formatter/CategoryViewerQuery.php',
+ 'Flow\\Formatter\\CheckUserFormatter' => __DIR__ . '/includes/Formatter/CheckUserFormatter.php',
+ 'Flow\\Formatter\\CheckUserQuery' => __DIR__ . '/includes/Formatter/CheckUserQuery.php',
+ 'Flow\\Formatter\\CheckUserRow' => __DIR__ . '/includes/Formatter/CheckUserQuery.php',
+ 'Flow\\Formatter\\Contributions' => __DIR__ . '/includes/Formatter/Contributions.php',
+ 'Flow\\Formatter\\ContributionsQuery' => __DIR__ . '/includes/Formatter/ContributionsQuery.php',
+ 'Flow\\Formatter\\ContributionsRow' => __DIR__ . '/includes/Formatter/ContributionsQuery.php',
+ 'Flow\\Formatter\\DeletedContributionsRow' => __DIR__ . '/includes/Formatter/ContributionsQuery.php',
+ 'Flow\\Formatter\\FeedItemFormatter' => __DIR__ . '/includes/Formatter/FeedItemFormatter.php',
+ 'Flow\\Formatter\\FormatterRow' => __DIR__ . '/includes/Formatter/AbstractQuery.php',
+ 'Flow\\Formatter\\HeaderViewQuery' => __DIR__ . '/includes/Formatter/RevisionViewQuery.php',
+ 'Flow\\Formatter\\IRCLineUrlFormatter' => __DIR__ . '/includes/Formatter/IRCLineUrlFormatter.php',
+ 'Flow\\Formatter\\PostHistoryQuery' => __DIR__ . '/includes/Formatter/PostHistoryQuery.php',
+ 'Flow\\Formatter\\PostSummaryQuery' => __DIR__ . '/includes/Formatter/PostSummaryQuery.php',
+ 'Flow\\Formatter\\PostSummaryViewQuery' => __DIR__ . '/includes/Formatter/RevisionViewQuery.php',
+ 'Flow\\Formatter\\PostViewQuery' => __DIR__ . '/includes/Formatter/RevisionViewQuery.php',
+ 'Flow\\Formatter\\RecentChanges' => __DIR__ . '/includes/Formatter/RecentChanges.php',
+ 'Flow\\Formatter\\RecentChangesQuery' => __DIR__ . '/includes/Formatter/RecentChangesQuery.php',
+ 'Flow\\Formatter\\RecentChangesRow' => __DIR__ . '/includes/Formatter/RecentChangesQuery.php',
+ 'Flow\\Formatter\\RevisionDiffViewFormatter' => __DIR__ . '/includes/Formatter/RevisionDiffViewFormatter.php',
+ 'Flow\\Formatter\\RevisionFormatter' => __DIR__ . '/includes/Formatter/RevisionFormatter.php',
+ 'Flow\\Formatter\\RevisionUndoViewFormatter' => __DIR__ . '/includes/Formatter/RevisionUndoViewFormatter.php',
+ 'Flow\\Formatter\\RevisionViewFormatter' => __DIR__ . '/includes/Formatter/RevisionViewFormatter.php',
+ 'Flow\\Formatter\\RevisionViewQuery' => __DIR__ . '/includes/Formatter/RevisionViewQuery.php',
+ 'Flow\\Formatter\\SinglePostQuery' => __DIR__ . '/includes/Formatter/SinglePostQuery.php',
+ 'Flow\\Formatter\\TocTopicListFormatter' => __DIR__ . '/includes/Formatter/TocTopicListFormatter.php',
+ 'Flow\\Formatter\\TopicFormatter' => __DIR__ . '/includes/Formatter/TopicFormatter.php',
+ 'Flow\\Formatter\\TopicHistoryQuery' => __DIR__ . '/includes/Formatter/TopicHistoryQuery.php',
+ 'Flow\\Formatter\\TopicListFormatter' => __DIR__ . '/includes/Formatter/TopicListFormatter.php',
+ 'Flow\\Formatter\\TopicListQuery' => __DIR__ . '/includes/Formatter/TopicListQuery.php',
+ 'Flow\\Formatter\\TopicRow' => __DIR__ . '/includes/Formatter/TopicRow.php',
+ 'Flow\\Import\\Converter' => __DIR__ . '/includes/Import/Converter.php',
+ 'Flow\\Import\\FileImportSourceStore' => __DIR__ . '/includes/Import/ImportSourceStore.php',
+ 'Flow\\Import\\HistoricalUIDGenerator' => __DIR__ . '/includes/Import/Importer.php',
+ 'Flow\\Import\\IConversionStrategy' => __DIR__ . '/includes/Import/IConversionStrategy.php',
+ 'Flow\\Import\\IImportHeader' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IImportObject' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IImportPost' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IImportSource' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IImportSummary' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IImportTopic' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IObjectRevision' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\IRevisionableObject' => __DIR__ . '/includes/Import/ImportSource.php',
+ 'Flow\\Import\\ImportException' => __DIR__ . '/includes/Import/Exception.php',
+ 'Flow\\Import\\ImportSourceStore' => __DIR__ . '/includes/Import/ImportSourceStore.php',
+ 'Flow\\Import\\ImportSourceStoreException' => __DIR__ . '/includes/Import/Exception.php',
+ 'Flow\\Import\\Importer' => __DIR__ . '/includes/Import/Importer.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ApiBackend' => __DIR__ . '/includes/Import/LiquidThreadsApi/Source.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ApiNotFoundException' => __DIR__ . '/includes/Import/LiquidThreadsApi/Exception.php',
+ 'Flow\\Import\\LiquidThreadsApi\\CachedApiData' => __DIR__ . '/includes/Import/LiquidThreadsApi/CachedData.php',
+ 'Flow\\Import\\LiquidThreadsApi\\CachedData' => __DIR__ . '/includes/Import/LiquidThreadsApi/CachedData.php',
+ 'Flow\\Import\\LiquidThreadsApi\\CachedPageData' => __DIR__ . '/includes/Import/LiquidThreadsApi/CachedData.php',
+ 'Flow\\Import\\LiquidThreadsApi\\CachedThreadData' => __DIR__ . '/includes/Import/LiquidThreadsApi/CachedData.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ConversionStrategy' => __DIR__ . '/includes/Import/LiquidThreadsApi/ConversionStrategy.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportHeader' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportPost' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportRevision' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportSource' => __DIR__ . '/includes/Import/LiquidThreadsApi/Source.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportSummary' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ImportTopic' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\LocalApiBackend' => __DIR__ . '/includes/Import/LiquidThreadsApi/Source.php',
+ 'Flow\\Import\\LiquidThreadsApi\\MovedImportPost' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\MovedImportRevision' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\MovedImportTopic' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\PageRevisionedObject' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\RemoteApiBackend' => __DIR__ . '/includes/Import/LiquidThreadsApi/Source.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ReplyIterator' => __DIR__ . '/includes/Import/LiquidThreadsApi/Iterators.php',
+ 'Flow\\Import\\LiquidThreadsApi\\RevisionIterator' => __DIR__ . '/includes/Import/LiquidThreadsApi/Iterators.php',
+ 'Flow\\Import\\LiquidThreadsApi\\ScriptedImportRevision' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php',
+ 'Flow\\Import\\LiquidThreadsApi\\TopicIterator' => __DIR__ . '/includes/Import/LiquidThreadsApi/Iterators.php',
+ 'Flow\\Import\\NullImportSourceStore' => __DIR__ . '/includes/Import/ImportSourceStore.php',
+ 'Flow\\Import\\PageImportState' => __DIR__ . '/includes/Import/Importer.php',
+ 'Flow\\Import\\Plain\\ImportHeader' => __DIR__ . '/includes/Import/Plain/ImportHeader.php',
+ 'Flow\\Import\\Plain\\ObjectRevision' => __DIR__ . '/includes/Import/Plain/ObjectRevision.php',
+ 'Flow\\Import\\Postprocessor\\LqtRedirector' => __DIR__ . '/includes/Import/Postprocessor/LqtRedirector.php',
+ 'Flow\\Import\\Postprocessor\\PostprocessingException' => __DIR__ . '/includes/Import/Postprocessor/PostprocessingException.php',
+ 'Flow\\Import\\Postprocessor\\Postprocessor' => __DIR__ . '/includes/Import/Postprocessor/Postprocessor.php',
+ 'Flow\\Import\\Postprocessor\\ProcessorGroup' => __DIR__ . '/includes/Import/Postprocessor/ProcessorGroup.php',
+ 'Flow\\Import\\Postprocessor\\SpecialLogTopic' => __DIR__ . '/includes/Import/Postprocessor/SpecialLogTopic.php',
+ 'Flow\\Import\\TalkpageImportOperation' => __DIR__ . '/includes/Import/Importer.php',
+ 'Flow\\Import\\TopicImportState' => __DIR__ . '/includes/Import/Importer.php',
+ 'Flow\\Import\\Wikitext\\ConversionStrategy' => __DIR__ . '/includes/Import/Wikitext/ConversionStrategy.php',
+ 'Flow\\Import\\Wikitext\\ImportSource' => __DIR__ . '/includes/Import/Wikitext/ImportSource.php',
+ 'Flow\\LinksTableUpdater' => __DIR__ . '/includes/LinksTableUpdater.php',
+ 'Flow\\Log\\ActionFormatter' => __DIR__ . '/includes/Log/ActionFormatter.php',
+ 'Flow\\Log\\LogQuery' => __DIR__ . '/includes/Log/Query.php',
+ 'Flow\\Log\\LqtImportFormatter' => __DIR__ . '/includes/Log/LqtImportFormatter.php',
+ 'Flow\\Log\\ModerationLogger' => __DIR__ . '/includes/Log/ModerationLogger.php',
+ 'Flow\\Model\\AbstractRevision' => __DIR__ . '/includes/Model/AbstractRevision.php',
+ 'Flow\\Model\\AbstractSummary' => __DIR__ . '/includes/Model/AbstractSummary.php',
+ 'Flow\\Model\\Anchor' => __DIR__ . '/includes/Model/Anchor.php',
+ 'Flow\\Model\\Header' => __DIR__ . '/includes/Model/Header.php',
+ 'Flow\\Model\\PostRevision' => __DIR__ . '/includes/Model/PostRevision.php',
+ 'Flow\\Model\\PostSummary' => __DIR__ . '/includes/Model/PostSummary.php',
+ 'Flow\\Model\\Reference' => __DIR__ . '/includes/Model/Reference.php',
+ 'Flow\\Model\\TopicListEntry' => __DIR__ . '/includes/Model/TopicListEntry.php',
+ 'Flow\\Model\\URLReference' => __DIR__ . '/includes/Model/URLReference.php',
+ 'Flow\\Model\\UUID' => __DIR__ . '/includes/Model/UUID.php',
+ 'Flow\\Model\\UserTuple' => __DIR__ . '/includes/Model/UserTuple.php',
+ 'Flow\\Model\\WikiReference' => __DIR__ . '/includes/Model/WikiReference.php',
+ 'Flow\\Model\\Workflow' => __DIR__ . '/includes/Model/Workflow.php',
+ 'Flow\\NewTopicFormatter' => __DIR__ . '/includes/Notifications/Formatter.php',
+ 'Flow\\NotificationController' => __DIR__ . '/includes/Notifications/Controller.php',
+ 'Flow\\NotificationFormatter' => __DIR__ . '/includes/Notifications/Formatter.php',
+ 'Flow\\NotificationsUserLocator' => __DIR__ . '/includes/Notifications/UserLocator.php',
+ 'Flow\\OccupationController' => __DIR__ . '/includes/TalkpageManager.php',
+ 'Flow\\Parsoid\\ContentFixer' => __DIR__ . '/includes/Parsoid/ContentFixer.php',
+ 'Flow\\Parsoid\\Extractor' => __DIR__ . '/includes/Parsoid/Extractor.php',
+ 'Flow\\Parsoid\\Extractor\\CategoryExtractor' => __DIR__ . '/includes/Parsoid/Extractor/CategoryExtractor.php',
+ 'Flow\\Parsoid\\Extractor\\ExtLinkExtractor' => __DIR__ . '/includes/Parsoid/Extractor/ExtLinkExtractor.php',
+ 'Flow\\Parsoid\\Extractor\\ImageExtractor' => __DIR__ . '/includes/Parsoid/Extractor/ImageExtractor.php',
+ 'Flow\\Parsoid\\Extractor\\PlaceholderExtractor' => __DIR__ . '/includes/Parsoid/Extractor/PlaceholderExtractor.php',
+ 'Flow\\Parsoid\\Extractor\\TransclusionExtractor' => __DIR__ . '/includes/Parsoid/Extractor/TransclusionExtractor.php',
+ 'Flow\\Parsoid\\Extractor\\WikiLinkExtractor' => __DIR__ . '/includes/Parsoid/Extractor/WikiLinkExtractor.php',
+ 'Flow\\Parsoid\\Fixer' => __DIR__ . '/includes/Parsoid/Fixer.php',
+ 'Flow\\Parsoid\\Fixer\\BadImageRemover' => __DIR__ . '/includes/Parsoid/Fixer/BadImageRemover.php',
+ 'Flow\\Parsoid\\Fixer\\BaseHrefFixer' => __DIR__ . '/includes/Parsoid/Fixer/BaseHrefFixer.php',
+ 'Flow\\Parsoid\\Fixer\\WikiLinkFixer' => __DIR__ . '/includes/Parsoid/Fixer/WikiLinkFixer.php',
+ 'Flow\\Parsoid\\ReferenceExtractor' => __DIR__ . '/includes/Parsoid/ReferenceExtractor.php',
+ 'Flow\\Parsoid\\ReferenceFactory' => __DIR__ . '/includes/Parsoid/ReferenceFactory.php',
+ 'Flow\\Parsoid\\Utils' => __DIR__ . '/includes/Parsoid/Utils.php',
+ 'Flow\\RecoverableErrorHandler' => __DIR__ . '/includes/RecoverableErrorHandler.php',
+ 'Flow\\ReferenceClarifier' => __DIR__ . '/includes/ReferenceClarifier.php',
+ 'Flow\\Repository\\MultiGetList' => __DIR__ . '/includes/Repository/MultiGetList.php',
+ 'Flow\\Repository\\RootPostLoader' => __DIR__ . '/includes/Repository/RootPostLoader.php',
+ 'Flow\\Repository\\TitleRepository' => __DIR__ . '/includes/Repository/TitleRepository.php',
+ 'Flow\\Repository\\TreeRepository' => __DIR__ . '/includes/Repository/TreeRepository.php',
+ 'Flow\\Repository\\UserNameBatch' => __DIR__ . '/includes/Repository/UserNameBatch.php',
+ 'Flow\\Repository\\UserName\\OneStepUserNameQuery' => __DIR__ . '/includes/Repository/UserName/OneStepUserNameQuery.php',
+ 'Flow\\Repository\\UserName\\TwoStepUserNameQuery' => __DIR__ . '/includes/Repository/UserName/TwoStepUserNameQuery.php',
+ 'Flow\\Repository\\UserName\\UserNameQuery' => __DIR__ . '/includes/Repository/UserName/UserNameQuery.php',
+ 'Flow\\RevisionActionPermissions' => __DIR__ . '/includes/RevisionActionPermissions.php',
+ 'Flow\\SpamFilter\\AbuseFilter' => __DIR__ . '/includes/SpamFilter/AbuseFilter.php',
+ 'Flow\\SpamFilter\\ConfirmEdit' => __DIR__ . '/includes/SpamFilter/ConfirmEdit.php',
+ 'Flow\\SpamFilter\\ContentLengthFilter' => __DIR__ . '/includes/SpamFilter/ContentLengthFilter.php',
+ 'Flow\\SpamFilter\\Controller' => __DIR__ . '/includes/SpamFilter/Controller.php',
+ 'Flow\\SpamFilter\\SpamBlacklist' => __DIR__ . '/includes/SpamFilter/SpamBlacklist.php',
+ 'Flow\\SpamFilter\\SpamFilter' => __DIR__ . '/includes/SpamFilter/SpamFilter.php',
+ 'Flow\\SpamFilter\\SpamRegex' => __DIR__ . '/includes/SpamFilter/SpamRegex.php',
+ 'Flow\\Specials\\SpecialEnableFlow' => __DIR__ . '/includes/Specials/SpecialEnableFlow.php',
+ 'Flow\\Specials\\SpecialFlow' => __DIR__ . '/includes/Specials/SpecialFlow.php',
+ 'Flow\\SubmissionHandler' => __DIR__ . '/includes/SubmissionHandler.php',
+ 'Flow\\TalkpageManager' => __DIR__ . '/includes/TalkpageManager.php',
+ 'Flow\\TemplateHelper' => __DIR__ . '/includes/TemplateHelper.php',
+ 'Flow\\Templating' => __DIR__ . '/includes/Templating.php',
+ 'Flow\\Tests\\Api\\ApiFlowEditHeaderTest' => __DIR__ . '/tests/phpunit/api/ApiFlowEditHeaderTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowEditPostTest' => __DIR__ . '/tests/phpunit/api/ApiFlowEditPostTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowEditTitleTest' => __DIR__ . '/tests/phpunit/api/ApiFlowEditTitleTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowEditTopicSummaryTest' => __DIR__ . '/tests/phpunit/api/ApiFlowEditTopicSummary.php',
+ 'Flow\\Tests\\Api\\ApiFlowLockTopicTest' => __DIR__ . '/tests/phpunit/api/ApiFlowLockTopicTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowModeratePostTest' => __DIR__ . '/tests/phpunit/api/ApiFlowModeratePostTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowModerateTopicTest' => __DIR__ . '/tests/phpunit/api/ApiFlowModerateTopicTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowReplyTest' => __DIR__ . '/tests/phpunit/api/ApiFlowReplyTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowViewHeaderTest' => __DIR__ . '/tests/phpunit/api/ApiFlowViewHeaderTest.php',
+ 'Flow\\Tests\\Api\\ApiFlowViewTopicListTest' => __DIR__ . '/tests/phpunit/api/ApiFlowViewTopicListTest.php',
+ 'Flow\\Tests\\Api\\ApiTestCase' => __DIR__ . '/tests/phpunit/api/ApiTestCase.php',
+ 'Flow\\Tests\\Api\\ApiWatchTopicTest' => __DIR__ . '/tests/phpunit/api/ApiWatchTopicTest.php',
+ 'Flow\\Tests\\BlockFactoryTest' => __DIR__ . '/tests/phpunit/BlockFactoryTest.php',
+ 'Flow\\Tests\\Block\\TopicListTest' => __DIR__ . '/tests/phpunit/Block/TopicListTest.php',
+ 'Flow\\Tests\\BufferedBagOStuffTest' => __DIR__ . '/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php',
+ 'Flow\\Tests\\BufferedCacheTest' => __DIR__ . '/tests/phpunit/Data/BufferedCacheTest.php',
+ 'Flow\\Tests\\Collection\\PostCollectionTest' => __DIR__ . '/tests/phpunit/Collection/PostCollectionTest.php',
+ 'Flow\\Tests\\Collection\\RevisionCollectionPermissionsTest' => __DIR__ . '/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php',
+ 'Flow\\Tests\\ContainerTest' => __DIR__ . '/tests/phpunit/ContainerTest.php',
+ 'Flow\\Tests\\Data\\CachingObjectManagerTest' => __DIR__ . '/tests/phpunit/Data/CachingObjectMapperTest.php',
+ 'Flow\\Tests\\Data\\FlowNothingTest' => __DIR__ . '/tests/phpunit/Data/NothingTest.php',
+ 'Flow\\Tests\\Data\\IndexTest' => __DIR__ . '/tests/phpunit/Data/IndexTest.php',
+ 'Flow\\Tests\\Data\\Index\\FeatureIndexTest' => __DIR__ . '/tests/phpunit/Data/Index/FeatureIndexTest.php',
+ 'Flow\\Tests\\Data\\Index\\MockFeatureIndex' => __DIR__ . '/tests/phpunit/Data/Index/FeatureIndexTest.php',
+ 'Flow\\Tests\\Data\\Listener\\RecentChangesListenerTest' => __DIR__ . '/tests/phpunit/Data/Listener/RecentChangesListenerTest.php',
+ 'Flow\\Tests\\Data\\ManagerGroupTest' => __DIR__ . '/tests/phpunit/Data/ManagerGroupTest.php',
+ 'Flow\\Tests\\Data\\ObjectLocatorTest' => __DIR__ . '/tests/phpunit/Data/ObjectLocatorTest.php',
+ 'Flow\\Tests\\Data\\Pager\\PagerTest' => __DIR__ . '/tests/phpunit/Data/Pager/PagerTest.php',
+ 'Flow\\Tests\\Data\\RevisionStorageTest' => __DIR__ . '/tests/phpunit/Data/RevisionStorageTest.php',
+ 'Flow\\Tests\\Data\\Storage\\RevisionStorageTest' => __DIR__ . '/tests/phpunit/Data/Storage/RevisionStorageTest.php',
+ 'Flow\\Tests\\Data\\UserNameBatchTest' => __DIR__ . '/tests/phpunit/Data/UserNameBatchTest.php',
+ 'Flow\\Tests\\Data\\UserNameListenerTest' => __DIR__ . '/tests/phpunit/Data/UserNameListenerTest.php',
+ 'Flow\\Tests\\FlowActionsTest' => __DIR__ . '/tests/phpunit/FlowActionsTest.php',
+ 'Flow\\Tests\\FlowTestCase' => __DIR__ . '/tests/phpunit/FlowTestCase.php',
+ 'Flow\\Tests\\Formatter\\FormatterTest' => __DIR__ . '/tests/phpunit/Formatter/FormatterTest.php',
+ 'Flow\\Tests\\Formatter\\RevisionFormatterTest' => __DIR__ . '/tests/phpunit/Formatter/RevisionFormatterTest.php',
+ 'Flow\\Tests\\Handlebars\\FlowPostMetaActionsTest' => __DIR__ . '/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php',
+ 'Flow\\Tests\\HookTest' => __DIR__ . '/tests/phpunit/HookTest.php',
+ 'Flow\\Tests\\Import\\ConverterTest' => __DIR__ . '/tests/phpunit/Import/ConverterTest.php',
+ 'Flow\\Tests\\Import\\HistoricalUIDGeneratorTest' => __DIR__ . '/tests/phpunit/Import/HistoricalUIDGeneratorTest.php',
+ 'Flow\\Tests\\Import\\LiquidThreadsApi\\ConversionStrategyTest' => __DIR__ . '/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php',
+ 'Flow\\Tests\\Import\\PageImportStateTest' => __DIR__ . '/tests/phpunit/Import/PageImportStateTest.php',
+ 'Flow\\Tests\\Import\\TalkpageImportOperationTest' => __DIR__ . '/tests/phpunit/Import/TalkpageImportOperationTest.php',
+ 'Flow\\Tests\\Import\\Wikitext\\ConversionStrategyTest' => __DIR__ . '/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php',
+ 'Flow\\Tests\\Import\\Wikitext\\ImportSourceTest' => __DIR__ . '/tests/phpunit/Import/Wikitext/ImportSourceTest.php',
+ 'Flow\\Tests\\LinksTableTest' => __DIR__ . '/tests/phpunit/LinksTableTest.php',
+ 'Flow\\Tests\\LocalBufferedBagOStuffTest' => __DIR__ . '/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php',
+ 'Flow\\Tests\\Mock\\MockImportHeader' => __DIR__ . '/tests/phpunit/Mock/MockImportHeader.php',
+ 'Flow\\Tests\\Mock\\MockImportPost' => __DIR__ . '/tests/phpunit/Mock/MockImportPost.php',
+ 'Flow\\Tests\\Mock\\MockImportRevision' => __DIR__ . '/tests/phpunit/Mock/MockImportRevision.php',
+ 'Flow\\Tests\\Mock\\MockImportSource' => __DIR__ . '/tests/phpunit/Mock/MockImportSource.php',
+ 'Flow\\Tests\\Mock\\MockImportSummary' => __DIR__ . '/tests/phpunit/Mock/MockImportSummary.php',
+ 'Flow\\Tests\\Mock\\MockImportTopic' => __DIR__ . '/tests/phpunit/Mock/MockImportTopic.php',
+ 'Flow\\Tests\\Model\\PostRevisionTest' => __DIR__ . '/tests/phpunit/Model/PostRevisionTest.php',
+ 'Flow\\Tests\\Model\\UUIDTest' => __DIR__ . '/tests/phpunit/Model/UUIDTest.php',
+ 'Flow\\Tests\\Model\\UserTupleTest' => __DIR__ . '/tests/phpunit/Model/UserTupleTest.php',
+ 'Flow\\Tests\\NotifiedUsersTest' => __DIR__ . '/tests/phpunit/Notifications/NotifiedUsersTest.php',
+ 'Flow\\Tests\\PagerTest' => __DIR__ . '/tests/phpunit/PagerTest.php',
+ 'Flow\\Tests\\Parsoid\\BadImageRemoverTest' => __DIR__ . '/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php',
+ 'Flow\\Tests\\Parsoid\\BaseHrefFixerTest' => __DIR__ . '/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php',
+ 'Flow\\Tests\\Parsoid\\Fixer\\MethodReturnsConstraint' => __DIR__ . '/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php',
+ 'Flow\\Tests\\Parsoid\\Fixer\\WikiLinkFixerTest' => __DIR__ . '/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php',
+ 'Flow\\Tests\\Parsoid\\ParsoidUtilsTest' => __DIR__ . '/tests/phpunit/Parsoid/UtilsTest.php',
+ 'Flow\\Tests\\Parsoid\\ReferenceExtractorTestCase' => __DIR__ . '/tests/phpunit/Parsoid/ReferenceExtractorTest.php',
+ 'Flow\\Tests\\Parsoid\\ReferenceFactoryTest' => __DIR__ . '/tests/phpunit/Parsoid/ReferenceFactoryTest.php',
+ 'Flow\\Tests\\PermissionsTest' => __DIR__ . '/tests/phpunit/PermissionsTest.php',
+ 'Flow\\Tests\\PostRevisionTestCase' => __DIR__ . '/tests/phpunit/PostRevisionTestCase.php',
+ 'Flow\\Tests\\Repository\\TreeRepositoryTest' => __DIR__ . '/tests/phpunit/Repository/TreeRepositoryTest.php',
+ 'Flow\\Tests\\Repository\\TreeRepositorydbTest' => __DIR__ . '/tests/phpunit/Repository/TreeRepositoryDbTest.php',
+ 'Flow\\Tests\\SpamFilter\\AbuseFilterTest' => __DIR__ . '/tests/phpunit/SpamFilter/AbuseFilterTest.php',
+ 'Flow\\Tests\\SpamFilter\\ConfirmEditTest' => __DIR__ . '/tests/phpunit/SpamFilter/ConfirmEditTest.php',
+ 'Flow\\Tests\\SpamFilter\\ContentLengthFilterTest' => __DIR__ . '/tests/phpunit/SpamFilter/ContentLengthFilterTest.php',
+ 'Flow\\Tests\\SpamFilter\\SpamBlacklistTest' => __DIR__ . '/tests/phpunit/SpamFilter/SpamBlacklistTest.php',
+ 'Flow\\Tests\\SpamFilter\\SpamRegexTest' => __DIR__ . '/tests/phpunit/SpamFilter/SpamRegexTest.php',
+ 'Flow\\Tests\\TemplateHelperTest' => __DIR__ . '/tests/phpunit/TemplateHelperTest.php',
+ 'Flow\\Tests\\TemplatingTest' => __DIR__ . '/tests/phpunit/TemplatingTest.php',
+ 'Flow\\Tests\\UrlGeneratorTest' => __DIR__ . '/tests/phpunit/UrlGeneratorTest.php',
+ 'Flow\\Tests\\WatchedTopicItemTest' => __DIR__ . '/tests/phpunit/WatchedTopicItemsTest.php',
+ 'Flow\\UrlGenerator' => __DIR__ . '/includes/UrlGenerator.php',
+ 'Flow\\Utils\\NamespaceIterator' => __DIR__ . '/includes/Utils/NamespaceIterator.php',
+ 'Flow\\Utils\\PagesWithPropertyIterator' => __DIR__ . '/includes/Utils/PagesWithPropertyIterator.php',
+ 'Flow\\View' => __DIR__ . '/includes/View.php',
+ 'Flow\\WatchedTopicItems' => __DIR__ . '/includes/WatchedTopicItems.php',
+ 'Flow\\WorkflowLoader' => __DIR__ . '/includes/WorkflowLoader.php',
+ 'Flow\\WorkflowLoaderFactory' => __DIR__ . '/includes/WorkflowLoaderFactory.php',
+ 'MaintenanceDebugLogger' => __DIR__ . '/maintenance/MaintenanceDebugLogger.php',
+ 'Pimple\\Container' => __DIR__ . '/vendor/Pimple/Container.php',
+ 'Pimple\\ServiceProviderInterface' => __DIR__ . '/vendor/Pimple/ServiceProviderInterface.php',
+);
diff --git a/Flow/composer.json b/Flow/composer.json
new file mode 100644
index 00000000..de1290b5
--- /dev/null
+++ b/Flow/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "mediawiki/flow",
+ "description": "Discussion and collaboration system extension for MediaWiki",
+ "license": "GPL-2.0+",
+ "require": {},
+ "require-dev": {
+ "symfony/dom-crawler": "~2.5",
+ "symfony/css-selector": "~2.5"
+ }
+}
diff --git a/Flow/composer.lock b/Flow/composer.lock
new file mode 100644
index 00000000..02da5678
--- /dev/null
+++ b/Flow/composer.lock
@@ -0,0 +1,121 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+ "This file is @generated automatically"
+ ],
+ "hash": "430513e3e3fc840fc6d08892687cbc2d",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "symfony/css-selector",
+ "version": "v2.5.6",
+ "target-dir": "Symfony/Component/CssSelector",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/CssSelector.git",
+ "reference": "7cdf543a3f31935aae58de4e6e607d4bdeb3f5dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/CssSelector/zipball/7cdf543a3f31935aae58de4e6e607d4bdeb3f5dc",
+ "reference": "7cdf543a3f31935aae58de4e6e607d4bdeb3f5dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.5-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Symfony\\Component\\CssSelector\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ },
+ {
+ "name": "Jean-François Simon",
+ "email": "jeanfrancois.simon@sensiolabs.com"
+ },
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ }
+ ],
+ "description": "Symfony CssSelector Component",
+ "homepage": "http://symfony.com",
+ "time": "2014-10-09 16:00:03"
+ },
+ {
+ "name": "symfony/dom-crawler",
+ "version": "v2.5.6",
+ "target-dir": "Symfony/Component/DomCrawler",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/DomCrawler.git",
+ "reference": "5b45ef9b8a8e6dda47eb9b9b982f61e8b2caa21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/DomCrawler/zipball/5b45ef9b8a8e6dda47eb9b9b982f61e8b2caa21e",
+ "reference": "5b45ef9b8a8e6dda47eb9b9b982f61e8b2caa21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "symfony/css-selector": "~2.0"
+ },
+ "suggest": {
+ "symfony/css-selector": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.5-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Symfony\\Component\\DomCrawler\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ },
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ }
+ ],
+ "description": "Symfony DomCrawler Component",
+ "homepage": "http://symfony.com",
+ "time": "2014-10-01 05:50:18"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "platform": [],
+ "platform-dev": []
+}
diff --git a/Flow/container-test.php b/Flow/container-test.php
new file mode 100644
index 00000000..087627b5
--- /dev/null
+++ b/Flow/container-test.php
@@ -0,0 +1,16 @@
+<?php
+
+$container = include __DIR__ . '/container.php';
+
+// need a testcase to get at the mocking methods.
+$testCase = new Flow\Tests\FlowTestCase();
+
+$container['controller.spamblacklist'] = $testCase->getMockBuilder( 'Flow\\SpamFilter\\SpamBlacklist' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+$container['controller.spamblacklist']->expects( $testCase->any() )
+ ->method( 'validate' )
+ ->will( $testCase->returnValue( Status::newGood() ) );
+
+return $container;
diff --git a/Flow/container.php b/Flow/container.php
new file mode 100644
index 00000000..9c47b4a9
--- /dev/null
+++ b/Flow/container.php
@@ -0,0 +1,1203 @@
+<?php
+
+$c = new Flow\Container;
+
+// MediaWiki
+if ( defined( 'RUN_MAINTENANCE_IF_MAIN' ) ) {
+ $c['user'] = new User;
+} else {
+ $c['user'] = isset( $GLOBALS['wgUser'] ) ? $GLOBALS['wgUser'] : new User;
+}
+$c['output'] = $GLOBALS['wgOut'];
+$c['request'] = $GLOBALS['wgRequest'];
+$c['memcache'] = function( $c ) {
+ global $wgFlowUseMemcache, $wgMemc;
+
+ if ( $wgFlowUseMemcache ) {
+ return $wgMemc;
+ } else {
+ return new \HashBagOStuff();
+ }
+};
+$c['cache.version'] = $GLOBALS['wgFlowCacheVersion'];
+
+// Flow config
+$c['flow_actions'] = function( $c ) {
+ global $wgFlowActions;
+ return new Flow\FlowActions( $wgFlowActions );
+};
+
+// Always returns the correct database for flow storage
+$c['db.factory'] = function( $c ) {
+ global $wgFlowDefaultWikiDb, $wgFlowCluster;
+ return new Flow\DbFactory( $wgFlowDefaultWikiDb, $wgFlowCluster );
+};
+
+// Database Access Layer external from main implementation
+$c['repository.tree'] = function( $c ) {
+ global $wgFlowCacheTime;
+ return new Flow\Repository\TreeRepository(
+ $c['db.factory'],
+ $c['memcache.buffered']
+ );
+};
+
+$c['url_generator'] = function( $c ) {
+ return new Flow\UrlGenerator();
+};
+// listener is attached to storage.workflow, it
+// notifies the url generator about all loaded workflows.
+$c['listener.url_generator'] = function( $c ) {
+ return new Flow\Data\Listener\UrlGenerationListener(
+ $c['url_generator']
+ );
+};
+
+$c['watched_items'] = function( $c ) {
+ return new Flow\WatchedTopicItems(
+ $c['user'],
+ wfGetDB( DB_SLAVE, 'watchlist' )
+ );
+};
+
+$c['link_batch'] = function() {
+ return new LinkBatch;
+};
+
+$c['wiki_link_fixer'] = function( $c ) {
+ return new Flow\Parsoid\Fixer\WikiLinkFixer( $c['link_batch'] );
+};
+
+$c['bad_image_remover'] = function( $c ) {
+ return new Flow\Parsoid\Fixer\BadImageRemover( 'wfIsBadImage' );
+};
+
+$c['base_href_fixer'] = function( $c ) {
+ global $wgArticlePath;
+
+ return new Flow\Parsoid\Fixer\BaseHrefFixer( $wgArticlePath );
+};
+
+$c['content_fixer'] = function( $c ) {
+ return new Flow\Parsoid\ContentFixer(
+ $c['wiki_link_fixer'],
+ $c['bad_image_remover'],
+ $c['base_href_fixer']
+ );
+};
+
+$c['permissions'] = function( $c ) {
+ return new Flow\RevisionActionPermissions( $c['flow_actions'], $c['user'] );
+};
+
+$c['lightncandy.template_dir'] = __DIR__ . '/handlebars';
+$c['lightncandy'] = function( $c ) {
+ global $wgFlowServerCompileTemplates;
+
+ return new Flow\TemplateHelper(
+ $c['lightncandy.template_dir'],
+ $wgFlowServerCompileTemplates
+ );
+};
+
+$c['templating'] = function( $c ) {
+ return new Flow\Templating(
+ $c['repository.username'],
+ $c['url_generator'],
+ $c['output'],
+ $c['content_fixer'],
+ $c['permissions']
+ );
+};
+
+// New Storage Impl
+use Flow\Data\BufferedCache;
+use Flow\Data\Mapper\BasicObjectMapper;
+use Flow\Data\Mapper\CachingObjectMapper;
+use Flow\Data\Storage\BasicDbStorage;
+use Flow\Data\Storage\TopicListStorage;
+use Flow\Data\Storage\TopicListLastUpdatedStorage;
+use Flow\Data\Storage\PostRevisionStorage;
+use Flow\Data\Storage\HeaderRevisionStorage;
+use Flow\Data\Storage\PostSummaryRevisionStorage;
+use Flow\Data\Storage\TopicHistoryStorage;
+use Flow\Data\Index\UniqueFeatureIndex;
+use Flow\Data\Index\TopKIndex;
+use Flow\Data\Index\TopicHistoryIndex;
+use Flow\Data\Storage\BoardHistoryStorage;
+use Flow\Data\Index\BoardHistoryIndex;
+use Flow\Data\ObjectManager;
+use Flow\Data\ObjectLocator;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+
+$c['memcache.buffered'] = function( $c ) {
+ global $wgFlowCacheTime;
+
+ // This is the real buffered cached that will allow transactional-like cache
+ $bufferedCache = new Flow\Data\BagOStuff\LocalBufferedBagOStuff( $c['memcache'] );
+ // This is Flow's wrapper around it, to have a fixed cache expiry time
+ return new BufferedCache( $bufferedCache, $wgFlowCacheTime );
+};
+// Batched username loader
+$c['repository.username.query'] = function( $c ) {
+ return new Flow\Repository\UserName\TwoStepUserNameQuery(
+ $c['db.factory']
+ );
+};
+$c['repository.username'] = function( $c ) {
+ return new Flow\Repository\UserNameBatch(
+ $c['repository.username.query']
+ );
+};
+$c['collection.cache'] = function( $c ) {
+ return new Flow\Collection\CollectionCache();
+};
+// Individual workflow instances
+$c['storage.workflow.class'] = 'Flow\Model\Workflow';
+$c['storage.workflow.table'] = 'flow_workflow';
+$c['storage.workflow.primary_key'] = array( 'workflow_id' );
+$c['storage.workflow.backend'] = function( $c ) {
+ return new BasicDbStorage(
+ $c['db.factory'],
+ $c['storage.workflow.table'],
+ $c['storage.workflow.primary_key']
+ );
+};
+$c['storage.workflow.mapper'] = function( $c ) {
+ return CachingObjectMapper::model(
+ $c['storage.workflow.class'],
+ $c['storage.workflow.primary_key']
+ );
+};
+$c['storage.workflow.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.workflow.backend'],
+ 'flow_workflow:v2:pk',
+ $c['storage.workflow.primary_key']
+ );
+};
+$c['storage.workflow.indexes.title_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.workflow.backend'],
+ 'flow_workflow:title:v2:',
+ array( 'workflow_wiki', 'workflow_namespace', 'workflow_title_text', 'workflow_type' ),
+ array(
+ 'shallow' => $c['storage.workflow.indexes.primary'],
+ 'limit' => 1,
+ 'sort' => 'workflow_id'
+ )
+ );
+};
+$c['storage.workflow.indexes'] = function( $c ) {
+ return array(
+ $c['storage.workflow.indexes.primary'],
+ $c['storage.workflow.indexes.title_lookup']
+ );
+};
+$c['storage.workflow.listeners.topiclist'] = function( $c ) {
+ return new Flow\Data\Listener\WorkflowTopicListListener(
+ $c['storage.topic_list'],
+ $c['storage.topic_list.indexes.last_updated']
+ );
+};
+$c['storage.workflow.listeners'] = function( $c ) {
+ return array(
+ $c['listener.occupation'],
+ $c['listener.url_generator'],
+ $c['storage.workflow.listeners.topiclist'],
+ );
+};
+$c['storage.workflow'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.workflow.mapper'],
+ $c['storage.workflow.backend'],
+ $c['storage.workflow.indexes'],
+ $c['storage.workflow.listeners']
+ );
+};
+$c['listener.recentchanges'] = function( $c ) {
+ global $wgContLang;
+ // Recent change listeners go out to external services and
+ // as such must only be run after the transaction is commited.
+ return new Flow\Data\Listener\DeferredInsertLifecycleHandler(
+ $c['deferred_queue'],
+ new Flow\Data\Listener\RecentChangesListener(
+ $c['flow_actions'],
+ $c['repository.username'],
+ new Flow\Data\Utils\RecentChangeFactory,
+ $c['formatter.irclineurl']
+ )
+ );
+};
+$c['listener.occupation'] = function( $c ) {
+ global $wgFlowDefaultWorkflow;
+
+ return new Flow\Data\Listener\OccupationListener(
+ $c['occupation_controller'],
+ $c['deferred_queue'],
+ $wgFlowDefaultWorkflow
+ );
+};
+
+$c['storage.board_history.backend'] = function( $c ) {
+ return new BoardHistoryStorage( $c['db.factory'] );
+};
+$c['storage.board_history.indexes.primary'] = function( $c ) {
+ return new BoardHistoryIndex(
+ $c['memcache.buffered'],
+ // backend storage
+ $c['storage.board_history.backend'],
+ // key prefix
+ 'flow_revision:topic_list_history',
+ // primary key
+ array( 'topic_list_id' ),
+ // index options
+ array(
+ 'limit' => 500,
+ 'sort' => 'rev_id',
+ 'order' => 'DESC'
+ ),
+ $c['storage.topic_list']
+ );
+};
+$c['storage.board_history.mapper'] = function( $c ) {
+ return new BasicObjectMapper(
+ function( $rev ) use( $c ) {
+ if ( $rev instanceof PostRevision ) {
+ return $c['storage.post.mapper']->toStorageRow( $rev );
+ } elseif ( $rev instanceof Header ) {
+ return $c['storage.header.mapper']->toStorageRow( $rev );
+ } elseif ( $rev instanceof PostSummary ) {
+ return $c['storage.post_summary.mapper']->toStorageRow( $rev );
+ } else {
+ throw new \Flow\Exception\InvalidDataException( 'Invalid class for board history entry: ' . get_class( $rev ), 'fail-load-data' );
+ }
+ },
+ function( array $row, $obj = null ) use( $c ) {
+ if ( $row['rev_type'] === 'header' ) {
+ return $c['storage.header.mapper']->fromStorageRow( $row, $obj );
+ } elseif ( $row['rev_type'] === 'post' ) {
+ return $c['storage.post.mapper']->fromStorageRow( $row, $obj );
+ } elseif ( $row['rev_type'] === 'post-summary' ) {
+ return $c['storage.post_summary.mapper']->fromStorageRow( $row, $obj );
+ } else {
+ throw new \Flow\Exception\InvalidDataException( 'Invalid rev_type for board history entry: ' . $row['rev_type'], 'fail-load-data' );
+ }
+ }
+ );
+};
+$c['storage.board_history.indexes'] = function( $c ) {
+ return array( $c['storage.board_history.indexes.primary'] );
+};
+$c['storage.board_history'] = function( $c ) {
+ return new ObjectLocator(
+ $c['storage.board_history.mapper'],
+ $c['storage.board_history.backend'],
+ $c['storage.board_history.indexes']
+ );
+};
+
+$c['storage.header.listeners.username'] = function( $c ) {
+ return new Flow\Data\Listener\UserNameListener(
+ $c['repository.username'],
+ array(
+ 'rev_user_id' => 'rev_user_wiki',
+ 'rev_mod_user_id' => 'rev_mod_user_wiki',
+ 'rev_edit_user_id' => 'rev_edit_user_wiki'
+ )
+ );
+};
+$c['storage.header.listeners'] = function( $c ) {
+ return array(
+ $c['reference.recorder'],
+ $c['storage.board_history.indexes.primary'],
+ $c['storage.header.listeners.username'],
+ $c['listener.recentchanges'],
+ $c['listener.editcount'],
+ );
+};
+$c['storage.header.primary_key'] = array( 'rev_id' );
+$c['storage.header.mapper'] = function( $c ) {
+ return CachingObjectMapper::model( 'Flow\\Model\\Header', array( 'rev_id' ) );
+};
+$c['storage.header.backend'] = function( $c ) {
+ global $wgFlowExternalStore;
+ return new HeaderRevisionStorage(
+ $c['db.factory'],
+ $wgFlowExternalStore
+ );
+
+};
+$c['storage.header.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.header.backend'],
+ 'flow_header:v2:pk',
+ $c['storage.header.primary_key']
+ );
+};
+$c['storage.header.indexes.topic_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.header.backend'],
+ 'flow_header:workflow',
+ array( 'rev_type_id' ),
+ array(
+ 'limit' => 100,
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'shallow' => $c['storage.header.indexes.primary'],
+ 'create' => function( array $row ) {
+ return $row['rev_parent_id'] === null;
+ },
+ )
+ );
+};
+$c['storage.header.indexes'] = function( $c ) {
+ return array(
+ $c['storage.header.indexes.primary'],
+ $c['storage.header.indexes.topic_lookup']
+ );
+};
+$c['storage.header'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.header.mapper'],
+ $c['storage.header.backend'],
+ $c['storage.header.indexes'],
+ $c['storage.header.listeners']
+ );
+};
+
+$c['storage.post_summary.class'] = 'Flow\Model\PostSummary';
+$c['storage.post_summary.primary_key'] = array( 'rev_id' );
+$c['storage.post_summary.mapper'] = function( $c ) {
+ return CachingObjectMapper::model(
+ $c['storage.post_summary.class'],
+ $c['storage.post_summary.primary_key']
+ );
+};
+$c['storage.post_summary.listeners.username'] = function( $c ) {
+ return new Flow\Data\Listener\UserNameListener(
+ $c['repository.username'],
+ array(
+ 'rev_user_id' => 'rev_user_wiki',
+ 'rev_mod_user_id' => 'rev_mod_user_wiki',
+ 'rev_edit_user_id' => 'rev_edit_user_wiki'
+ )
+ );
+};
+$c['storage.post_summary.listeners'] = function( $c ) {
+ return array(
+ $c['listener.recentchanges'],
+ $c['storage.post_summary.listeners.username'],
+ $c['storage.board_history.indexes.primary'],
+ $c['listener.editcount'],
+ // topic history -- to keep a history by topic we have to know what topic every post
+ // belongs to, not just its parent. TopicHistoryIndex is a slight tweak to TopKIndex
+ // using TreeRepository for extra information and stuffing it into topic_root while indexing
+ $c['storage.topic_history.indexes.primary'],
+ $c['reference.recorder'],
+ );
+};
+$c['storage.post_summary.backend'] = function( $c ) {
+ global $wgFlowExternalStore;
+ return new PostSummaryRevisionStorage(
+ $c['db.factory'],
+ $wgFlowExternalStore
+ );
+};
+$c['storage.post_summary.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.post_summary.backend'],
+ 'flow_post_summary:v2:pk',
+ $c['storage.post_summary.primary_key']
+ );
+};
+$c['storage.post_summary.indexes.topic_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.post_summary.backend'],
+ 'flow_post_summary:workflow',
+ array( 'rev_type_id' ),
+ array(
+ 'limit' => 100,
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'shallow' => $c['storage.post_summary.indexes.primary'],
+ 'create' => function( array $row ) {
+ return $row['rev_parent_id'] === null;
+ },
+ )
+ );
+};
+$c['storage.post_summary.indexes'] = function( $c ) {
+ return array(
+ $c['storage.post_summary.indexes.primary'],
+ $c['storage.post_summary.indexes.topic_lookup']
+ );
+};
+$c['storage.post_summary'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.post_summary.mapper'],
+ $c['storage.post_summary.backend'],
+ $c['storage.post_summary.indexes'],
+ $c['storage.post_summary.listeners']
+ );
+};
+
+$c['storage.topic_list.class'] = 'Flow\Model\TopicListEntry';
+$c['storage.topic_list.table'] = 'flow_topic_list';
+$c['storage.topic_list.primary_key'] = array( 'topic_list_id', 'topic_id' );
+$c['storage.topic_list.indexes.last_updated.backend'] = function( $c ) {
+ return new TopicListLastUpdatedStorage(
+ $c['db.factory'],
+ $c['storage.topic_list.table'],
+ $c['storage.topic_list.primary_key']
+ );
+};
+$c['storage.topic_list.mapper'] = function( $c ) {
+ return CachingObjectMapper::model(
+ $c['storage.topic_list.class'],
+ $c['storage.topic_list.primary_key']
+ );
+};
+$c['storage.topic_list.backend'] = function( $c ) {
+ return new TopicListStorage(
+ // factory and table
+ $c['db.factory'],
+ $c['storage.topic_list.table'],
+ $c['storage.topic_list.primary_key']
+ );
+};
+// Lookup from topic_id to its owning board id
+$c['storage.topic_list.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.topic_list.backend'],
+ 'flow_topic_list:topic',
+ array( 'topic_id' )
+ );
+};
+// Lookup from board to contained topics
+$c['storage.topic_list.indexes.reverse_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.topic_list.backend'],
+ 'flow_topic_list:list',
+ array( 'topic_list_id' ),
+ array( 'sort' => 'topic_id' )
+ );
+};
+$c['storage.topic_list.indexes.last_updated'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.topic_list.indexes.last_updated.backend'],
+ 'flow_topic_list_last_updated:list',
+ array( 'topic_list_id' ),
+ array(
+ 'sort' => 'workflow_last_update_timestamp',
+ 'order' => 'desc'
+ )
+ );
+};
+$c['storage.topic_list.indexes'] = function( $c ) {
+ return array(
+ $c['storage.topic_list.indexes.primary'],
+ $c['storage.topic_list.indexes.reverse_lookup'],
+ $c['storage.topic_list.indexes.last_updated'],
+ );
+};
+$c['storage.topic_list'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.topic_list.mapper'],
+ $c['storage.topic_list.backend'],
+ $c['storage.topic_list.indexes']
+ );
+};
+$c['storage.post.class'] = 'Flow\Model\PostRevision';
+$c['storage.post.primary_key'] = array( 'rev_id' );
+$c['storage.post.mapper'] = function( $c ) {
+ return CachingObjectMapper::model(
+ $c['storage.post.class'],
+ $c['storage.post.primary_key']
+ );
+};
+$c['storage.post.backend'] = function( $c ) {
+ global $wgFlowExternalStore;
+ return new PostRevisionStorage(
+ $c['db.factory'],
+ $wgFlowExternalStore,
+ $c['repository.tree']
+ );
+};
+$c['storage.post.listeners.moderation_logging'] = function( $c ) {
+ return new Flow\Data\Listener\ModerationLoggingListener(
+ $c['logger.moderation']
+ );
+};
+$c['storage.post.listeners.username'] = function( $c ) {
+ return new Flow\Data\Listener\UserNameListener(
+ $c['repository.username'],
+ array(
+ 'rev_user_id' => 'rev_user_wiki',
+ 'rev_mod_user_id' => 'rev_mod_user_wiki',
+ 'rev_edit_user_id' => 'rev_edit_user_wiki',
+ 'tree_orig_user_id' => 'tree_orig_user_wiki'
+ )
+ );
+};
+$c['storage.post.listeners.watch_topic'] = function( $c ) {
+ // Auto-subscribe users to the topic after performing specific actions
+ return new Flow\Data\Listener\ImmediateWatchTopicListener(
+ $c['watched_items']
+ );
+};
+$c['storage.post.listeners.notification'] = function( $c ) {
+ // Defer notifications triggering till end of request so we could get
+ // article_id in the case of a new topic, this will need support of
+ // adding deferred update when running deferred update
+ return new Flow\Data\Listener\DeferredInsertLifecycleHandler(
+ $c['deferred_queue'],
+ new Flow\Data\Listener\NotificationListener(
+ $c['controller.notification']
+ )
+ );
+};
+$c['storage.post.listeners'] = function( $c ) {
+ return array(
+ $c['reference.recorder'],
+ $c['collection.cache'],
+ $c['storage.post.listeners.username'],
+ $c['storage.post.listeners.watch_topic'],
+ $c['storage.post.listeners.notification'],
+ $c['storage.post.listeners.moderation_logging'],
+ $c['listener.recentchanges'],
+ $c['listener.editcount'],
+ // topic history -- to keep a history by topic we have to know what topic every post
+ // belongs to, not just its parent. TopicHistoryIndex is a slight tweak to TopKIndex
+ // using TreeRepository for extra information and stuffing it into topic_root while indexing
+ $c['storage.board_history.indexes.primary'],
+ $c['storage.topic_history.indexes.primary'],
+ );
+};
+$c['storage.post.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.post.backend'],
+ 'flow_revision:v4:pk',
+ $c['storage.post.primary_key']
+ );
+};
+// Each bucket holds a list of revisions in a single post
+$c['storage.post.indexes.post_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.post.backend'],
+ 'flow_revision:descendant',
+ array( 'rev_type_id' ),
+ array(
+ 'limit' => 100,
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'shallow' => $c['storage.post.indexes.primary'],
+ 'create' => function( array $row ) {
+ // return true to create instead of merge index
+ return $row['rev_parent_id'] === null;
+ },
+ )
+ );
+};
+$c['storage.post.indexes'] = function( $c ) {
+ return array(
+ $c['storage.post.indexes.primary'],
+ $c['storage.post.indexes.post_lookup'],
+ $c['storage.topic_history.indexes.topic_lookup']
+ );
+};
+$c['storage.post'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.post.mapper'],
+ $c['storage.post.backend'],
+ $c['storage.post.indexes'],
+ $c['storage.post.listeners']
+ );
+};
+$c['storage.topic_history.primary_key'] = array( 'rev_id' );
+$c['storage.topic_history.backend'] = function( $c ) {
+ global $wgFlowExternalStore;
+ return new TopicHistoryStorage(
+ new PostRevisionStorage( $c['db.factory'], $wgFlowExternalStore, $c['repository.tree'] ),
+ new PostSummaryRevisionStorage( $c['db.factory'], $wgFlowExternalStore )
+ );
+};
+$c['storage.topic_history.indexes.primary'] = function( $c ) {
+ return new UniqueFeatureIndex(
+ $c['memcache.buffered'],
+ $c['storage.topic_history.backend'],
+ 'flow_revision:v4:pk',
+ $c['storage.topic_history.primary_key']
+ );
+};
+$c['storage.topic_history.indexes.topic_lookup'] = function( $c ) {
+ return new TopicHistoryIndex(
+ $c['memcache.buffered'],
+ $c['storage.topic_history.backend'],
+ $c['repository.tree'],
+ 'flow_revision:topic',
+ array( 'topic_root_id' ),
+ array(
+ 'limit' => 500,
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'shallow' => $c['storage.topic_history.indexes.primary'],
+ 'create' => function( array $row ) {
+ // only create new indexes for post revisions
+ if ( $row['rev_type'] !== 'post' ) {
+ return false;
+ }
+ // if the post has no parent and the revision has no parent
+ // then this is a brand new topic title
+ return $row['tree_parent_id'] === null && $row['rev_parent_id'] === null;
+ },
+ )
+ );
+};
+$c['storage.topic_history.indexes'] = function( $c ) {
+ return array(
+ $c['storage.topic_history.indexes.primary'],
+ $c['storage.topic_history.indexes.topic_lookup'],
+ );
+};
+$c['storage.topic_history.mapper'] = function( $c ) {
+ return new BasicObjectMapper(
+ function( $rev ) use( $c ) {
+ if ( $rev instanceof PostRevision ) {
+ return $c['storage.post.mapper']->toStorageRow( $rev );
+ } elseif ( $rev instanceof PostSummary ) {
+ return $c['storage.post_summary.mapper']->toStorageRow( $rev );
+ } else {
+ throw new \Flow\Exception\InvalidDataException( 'Invalid class for board history entry: ' . get_class( $rev ), 'fail-load-data' );
+ }
+ },
+ function( array $row, $obj = null ) use( $c ) {
+ if ( $row['rev_type'] === 'post' ) {
+ return $c['storage.post.mapper']->fromStorageRow( $row, $obj );
+ } elseif ( $row['rev_type'] === 'post-summary' ) {
+ return $c['storage.post_summary.mapper']->fromStorageRow( $row, $obj );
+ } else {
+ throw new \Flow\Exception\InvalidDataException( 'Invalid rev_type for board history entry: ' . $row['rev_type'], 'fail-load-data' );
+ }
+ }
+ );
+};
+$c['storage.topic_history'] = function( $c ) {
+ return new ObjectLocator(
+ $c['storage.topic_history.mapper'],
+ $c['storage.topic_history.backend'],
+ $c['storage.topic_history.indexes']
+ );
+};
+$c['storage.manager_list'] = function( $c ) {
+ return array(
+ 'Flow\\Model\\Workflow' => 'storage.workflow',
+ 'Workflow' => 'storage.workflow',
+
+ 'Flow\\Model\\PostRevision' => 'storage.post',
+ 'PostRevision' => 'storage.post',
+ 'post' => 'storage.post',
+
+ 'Flow\\Model\\PostSummary' => 'storage.post_summary',
+ 'PostSummary' => 'storage.post_summary',
+ 'post-summary' => 'storage.post_summary',
+
+ 'Flow\\Model\\TopicListEntry' => 'storage.topic_list',
+ 'TopicListEntry' => 'storage.topic_list',
+
+ 'Flow\\Model\\Header' => 'storage.header',
+ 'Header' => 'storage.header',
+ 'header' => 'storage.header',
+
+ 'BoardHistoryEntry' => 'storage.board_history',
+
+ 'TopicHistoryEntry' => 'storage.topic_history',
+
+ 'Flow\\Model\\WikiReference' => 'storage.wiki_reference',
+ 'WikiReference' => 'storage.wiki_reference',
+
+ 'Flow\\Model\\URLReference' => 'storage.url_reference',
+ 'URLReference' => 'storage.url_reference',
+ );
+};
+$c['storage'] = function( $c ) {
+ return new \Flow\Data\ManagerGroup(
+ $c,
+ $c['storage.manager_list']
+ );
+};
+$c['loader.root_post'] = function( $c ) {
+ return new \Flow\Repository\RootPostLoader(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+
+// Queue of callbacks to run by DeferredUpdates, but only
+// on successfull commit
+$c['deferred_queue'] = function( $c ) {
+ return new SplQueue;
+};
+
+$c['submission_handler'] = function( $c ) {
+ return new Flow\SubmissionHandler(
+ $c['storage'],
+ $c['db.factory'],
+ $c['memcache.buffered'],
+ $c['deferred_queue']
+ );
+};
+$c['factory.block'] = function( $c ) {
+ return new Flow\BlockFactory(
+ $c['storage'],
+ $c['loader.root_post']
+ );
+};
+$c['factory.loader.workflow'] = function( $c ) {
+ global $wgFlowDefaultWorkflow;
+
+ return new Flow\WorkflowLoaderFactory(
+ $c['storage'],
+ $c['factory.block'],
+ $c['submission_handler'],
+ $wgFlowDefaultWorkflow
+ );
+};
+// Initialized in FlowHooks to faciliate only loading the flow container
+// when flow is specifically requested to run. Extension initialization
+// must always happen before calling flow code.
+$c['occupation_controller'] = FlowHooks::getOccupationController();
+
+$c['controller.notification'] = function( $c ) {
+ global $wgContLang;
+ return new Flow\NotificationController( $wgContLang );
+};
+
+// Initialized in FlowHooks to faciliate only loading the flow container
+// when flow is specifically requested to run. Extension initialization
+// must always happen before calling flow code.
+$c['controller.abusefilter'] = FlowHooks::getAbuseFilter();
+
+$c['controller.spamregex'] = function( $c ) {
+ return new Flow\SpamFilter\SpamRegex;
+};
+
+$c['controller.spamblacklist'] = function( $c ) {
+ return new Flow\SpamFilter\SpamBlacklist;
+};
+
+$c['controller.confirmedit'] = function( $c ) {
+ return new Flow\SpamFilter\ConfirmEdit;
+};
+
+$c['controller.contentlength'] = function( $c ) {
+ return new Flow\SpamFilter\ContentLengthFilter;
+};
+
+$c['controller.spamfilter'] = function( $c ) {
+ return new Flow\SpamFilter\Controller(
+ $c['controller.spamregex'],
+ $c['controller.spamblacklist'],
+ $c['controller.abusefilter'],
+ $c['controller.confirmedit'],
+ $c['controller.contentlength']
+ );
+};
+
+$c['query.categoryviewer'] = function( $c ) {
+ return new Flow\Formatter\CategoryViewerQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+$c['formatter.categoryviewer'] = function( $c ) {
+ return new Flow\Formatter\CategoryViewerFormatter(
+ $c['permissions']
+ );
+};
+$c['query.singlepost'] = function( $c ) {
+ return new Flow\Formatter\SinglePostQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+$c['query.checkuser'] = function( $c ) {
+ return new Flow\Formatter\CheckUserQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+
+$c['formatter.irclineurl'] = function( $c ) {
+ return new Flow\Formatter\IRCLineUrlFormatter(
+ $c['permissions'],
+ $c['formatter.revision']
+ );
+};
+
+$c['formatter.checkuser'] = function( $c ) {
+ return new Flow\Formatter\CheckUserFormatter(
+ $c['permissions'],
+ $c['formatter.revision']
+ );
+};
+$c['formatter.revisionview'] = function( $c ) {
+ return new Flow\Formatter\RevisionViewFormatter(
+ $c['url_generator'],
+ $c['formatter.revision']
+ );
+};
+$c['formatter.revision.diff.view'] = function( $c ) {
+ return new Flow\Formatter\RevisionDiffViewFormatter(
+ $c['formatter.revisionview'],
+ $c['url_generator']
+ );
+};
+$c['query.topiclist'] = function( $c ) {
+ return new Flow\Formatter\TopicListQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['permissions'],
+ $c['watched_items']
+ );
+};
+$c['query.topic.history'] = function( $c ) {
+ return new Flow\Formatter\TopicHistoryQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+$c['query.post.history'] = function( $c ) {
+ return new Flow\Formatter\PostHistoryQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+$c['query.recentchanges'] = function( $c ) {
+ $query = new Flow\Formatter\RecentChangesQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['flow_actions']
+ );
+ $query->setExtendWatchlist( $c['user']->getOption( 'extendwatchlist' ) );
+
+ return $query;
+};
+$c['query.postsummary'] = function( $c ) {
+ return new Flow\Formatter\PostSummaryQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['flow_actions']
+ );
+};
+$c['query.header.view'] = function( $c ) {
+ return new Flow\Formatter\HeaderViewQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['permissions']
+ );
+};
+$c['query.post.view'] = function( $c ) {
+ return new Flow\Formatter\PostViewQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['permissions']
+ );
+};
+$c['query.postsummary.view'] = function( $c ) {
+ return new Flow\Formatter\PostSummaryViewQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['permissions']
+ );
+};
+$c['formatter.recentchanges'] = function( $c ) {
+ return new Flow\Formatter\RecentChanges(
+ $c['permissions'],
+ $c['formatter.revision']
+ );
+};
+
+$c['query.contributions'] = function( $c ) {
+ return new Flow\Formatter\ContributionsQuery(
+ $c['storage'],
+ $c['repository.tree'],
+ $c['memcache'],
+ $c['db.factory']
+ );
+};
+$c['formatter.contributions'] = function( $c ) {
+ return new Flow\Formatter\Contributions(
+ $c['permissions'],
+ $c['formatter.revision']
+ );
+};
+$c['formatter.contributions.feeditem'] = function( $c ) {
+ return new Flow\Formatter\FeedItemFormatter(
+ $c['permissions'],
+ $c['formatter.revision']
+ );
+};
+$c['query.board-history'] = function( $c ) {
+ return new Flow\Formatter\BoardHistoryQuery(
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+// The RevisionFormatter holds internal state like
+// contentType of output and if it should include history
+// properties. To prevent different code using the formatter
+// from causing problems return a new RevisionFormatter every
+// time it is requested.
+$c['formatter.revision'] = $c->factory( function( $c ) {
+ global $wgFlowMaxThreadingDepth;
+
+ return new Flow\Formatter\RevisionFormatter(
+ $c['permissions'],
+ $c['templating'],
+ $c['repository.username'],
+ $wgFlowMaxThreadingDepth
+ );
+} );
+$c['formatter.topiclist'] = function( $c ) {
+ return new Flow\Formatter\TopicListFormatter(
+ $c['url_generator'],
+ $c['formatter.revision']
+ );
+};
+$c['formatter.topiclist.toc'] = function ( $c ) {
+ return new Flow\Formatter\TocTopicListFormatter(
+ $c['templating']
+ );
+};
+$c['formatter.topic'] = function( $c ) {
+ return new Flow\Formatter\TopicFormatter(
+ $c['url_generator'],
+ $c['formatter.revision']
+ );
+};
+$c['logger.moderation'] = function( $c ) {
+ return new Flow\Log\ModerationLogger(
+ $c['flow_actions']
+ );
+};
+
+$c['storage.wiki_reference.class'] = 'Flow\Model\WikiReference';
+$c['storage.wiki_reference.table'] = 'flow_wiki_ref';
+$c['storage.wiki_reference.primary_key'] = array(
+ 'ref_src_namespace',
+ 'ref_src_title',
+ 'ref_src_object_id',
+ 'ref_type',
+ 'ref_target_namespace',
+ 'ref_target_title'
+);
+$c['storage.wiki_reference.mapper'] = function( $c ) {
+ return Flow\Data\Mapper\BasicObjectMapper::model(
+ $c['storage.wiki_reference.class']
+ );
+};
+$c['storage.wiki_reference.backend'] = function( $c ) {
+ return new BasicDbStorage(
+ $c['db.factory'],
+ $c['storage.wiki_reference.table'],
+ $c['storage.wiki_reference.primary_key']
+ );
+};
+$c['storage.wiki_reference.indexes.source_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.wiki_reference.backend'],
+ 'flow_ref:wiki:by-source',
+ array(
+ 'ref_src_namespace',
+ 'ref_src_title',
+ ),
+ array(
+ 'order' => 'ASC',
+ 'sort' => 'ref_src_object_id',
+ )
+ );
+};
+$c['storage.wiki_reference.indexes.revision_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.wiki_reference.backend'],
+ 'flow_ref:wiki:by-revision:v2',
+ array(
+ 'ref_src_object_type',
+ 'ref_src_object_id',
+ ),
+ array(
+ 'order' => 'ASC',
+ 'sort' => array( 'ref_target_namespace', 'ref_target_title' ),
+ )
+ );
+};
+$c['storage.wiki_reference.indexes'] = function( $c ) {
+ return array(
+ $c['storage.wiki_reference.indexes.source_lookup'],
+ $c['storage.wiki_reference.indexes.revision_lookup'],
+ );
+};
+$c['storage.wiki_reference'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.wiki_reference.mapper'],
+ $c['storage.wiki_reference.backend'],
+ $c['storage.wiki_reference.indexes'],
+ array()
+ );
+};
+$c['storage.url_reference.class'] = 'Flow\Model\URLReference';
+$c['storage.url_reference.table'] = 'flow_ext_ref';
+$c['storage.url_reference.primary_key'] = array(
+ 'ref_src_namespace',
+ 'ref_src_title',
+ 'ref_src_object_id',
+ 'ref_type',
+ 'ref_target'
+);
+$c['storage.url_reference.mapper'] = function( $c ) {
+ return Flow\Data\Mapper\BasicObjectMapper::model(
+ $c['storage.url_reference.class']
+ );
+};
+$c['storage.url_reference.backend'] = function( $c ) {
+ return new BasicDbStorage(
+ // factory and table
+ $c['db.factory'],
+ $c['storage.url_reference.table'],
+ $c['storage.url_reference.primary_key']
+ );
+};
+
+$c['storage.url_reference.indexes.revision_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.url_reference.backend'],
+ 'flow_ref:url:by-source',
+ array(
+ 'ref_src_namespace',
+ 'ref_src_title',
+ ),
+ array(
+ 'order' => 'ASC',
+ 'sort' => 'ref_src_object_id',
+ )
+ );
+};
+$c['storage.url_reference.indexes.source_lookup'] = function( $c ) {
+ return new TopKIndex(
+ $c['memcache.buffered'],
+ $c['storage.url_reference.backend'],
+ 'flow_ref:url:by-revision:v2',
+ array(
+ 'ref_src_object_type',
+ 'ref_src_object_id',
+ ),
+ array(
+ 'order' => 'ASC',
+ 'sort' => array( 'ref_target' ),
+ )
+ );
+};
+$c['storage.url_reference.indexes'] = function( $c ) {
+ return array(
+ $c['storage.url_reference.indexes.source_lookup'],
+ $c['storage.url_reference.indexes.revision_lookup'],
+ );
+};
+$c['storage.url_reference'] = function( $c ) {
+ return new ObjectManager(
+ $c['storage.url_reference.mapper'],
+ $c['storage.url_reference.backend'],
+ $c['storage.url_reference.indexes'],
+ array()
+ );
+};
+
+$c['reference.updater.links-tables'] = function( $c ) {
+ return new Flow\LinksTableUpdater( $c['storage'] );
+};
+
+$c['reference.clarifier'] = function( $c ) {
+ return new Flow\ReferenceClarifier( $c['storage'], $c['url_generator'] );
+};
+
+$c['reference.extractor'] = function( $c ) {
+ $default = array(
+ new Flow\Parsoid\Extractor\ImageExtractor,
+ new Flow\Parsoid\Extractor\PlaceholderExtractor,
+ new Flow\Parsoid\Extractor\WikiLinkExtractor,
+ new Flow\Parsoid\Extractor\ExtLinkExtractor,
+ new Flow\Parsoid\Extractor\TransclusionExtractor,
+ );
+ $extractors = array(
+ 'header' => $default,
+ 'post-summary' => $default,
+ 'post' => $default,
+ );
+ // In addition to the defaults header and summaries collect
+ // the related categories.
+ $extractors['header'][] = $extractors['post-summary'][] = new Flow\Parsoid\Extractor\CategoryExtractor;
+
+ return new Flow\Parsoid\ReferenceExtractor( $extractors );
+};
+
+$c['reference.recorder'] = function( $c ) {
+ return new Flow\Data\Listener\ReferenceRecorder(
+ $c['reference.extractor'],
+ $c['reference.updater.links-tables'],
+ $c['storage'],
+ $c['repository.tree']
+ );
+};
+
+$c['user_merger'] = function( $c ) {
+ return new Flow\Data\Utils\UserMerger(
+ $c['db.factory'],
+ $c['storage']
+ );
+};
+
+$c['importer'] = function( $c ) {
+ $importer = new Flow\Import\Importer(
+ $c['storage'],
+ $c['factory.loader.workflow'],
+ $c['memcache.buffered'],
+ $c['db.factory'],
+ $c['deferred_queue'],
+ $c['occupation_controller']
+ );
+
+ $importer->addPostprocessor( new Flow\Import\Postprocessor\SpecialLogTopic(
+ $c['occupation_controller']->getTalkpageManager()
+ ) );
+
+ return $importer;
+};
+
+$c['listener.editcount'] = function( $c ) {
+ return new \Flow\Data\Listener\EditCountListener( $c['flow_actions'] );
+};
+
+$c['formatter.undoedit'] = function( $c ) {
+ return new Flow\Formatter\RevisionUndoViewFormatter(
+ $c['formatter.revisionview']
+ );
+};
+
+return $c;
diff --git a/Flow/db_patches/patch-88bit_uuids.sql b/Flow/db_patches/patch-88bit_uuids.sql
new file mode 100644
index 00000000..fc40a65a
--- /dev/null
+++ b/Flow/db_patches/patch-88bit_uuids.sql
@@ -0,0 +1,20 @@
+ALTER TABLE /*_*/flow_workflow
+ CHANGE workflow_id workflow_id binary(11) not null;
+
+ALTER TABLE /*_*/flow_topic_list
+ CHANGE topic_list_id topic_list_id binary(11) not null,
+ CHANGE topic_id topic_id binary(11) default null;
+
+ALTER TABLE /*_*/flow_tree_revision
+ CHANGE tree_rev_descendant_id tree_rev_descendant_id binary(11) not null,
+ CHANGE tree_rev_id tree_rev_id binary(11) not null,
+ CHANGE tree_parent_id tree_parent_id binary(11) default null;
+
+ALTER TABLE /*_*/flow_revision
+ CHANGE rev_id rev_id binary(11) not null,
+ CHANGE rev_parent_id rev_parent_id binary(11) default null,
+ CHANGE rev_last_edit_id rev_last_edit_id binary(11) default null;
+
+ALTER TABLE /*_*/flow_tree_node
+ CHANGE tree_ancestor_id tree_ancestor_id binary(11) not null,
+ CHANGE tree_descendant_id tree_descendant_id binary(11) not null;
diff --git a/Flow/db_patches/patch-88bit_uuids.sqlite.sql b/Flow/db_patches/patch-88bit_uuids.sqlite.sql
new file mode 100644
index 00000000..a1076941
--- /dev/null
+++ b/Flow/db_patches/patch-88bit_uuids.sqlite.sql
@@ -0,0 +1,20 @@
+UPDATE /*_*/flow_topic_list
+ SET topic_list_id = substr( topic_list_id, 1, 11 ),
+ topic_id = substr( topic_id, 1, 11 );
+
+UPDATE /*_*/flow_workflow
+ SET workflow_id = substr( workflow_id, 1, 11 );
+
+UPDATE /*_*/flow_tree_revision
+ SET tree_rev_descendant_id = substr( tree_rev_descendant_id, 1, 11 ),
+ tree_rev_id = substr( tree_rev_id, 1, 11 ),
+ tree_parent_id = substr( tree_parent_id, 1, 11 );
+
+UPDATE /*_*/flow_revision
+ SET rev_id = substr( rev_id, 1, 11 ),
+ rev_parent_id = substr( rev_parent_id, 1, 11 ),
+ rev_last_edit_id = substr( rev_last_edit_id, 1, 11 );
+
+UPDATE /*_*/flow_tree_node
+ SET tree_ancestor_id = substr( tree_ancestor_id, 1, 11 ),
+ tree_descendant_id = substr( tree_descendant_id, 1, 11 );
diff --git a/Flow/db_patches/patch-add-linkstables.sql b/Flow/db_patches/patch-add-linkstables.sql
new file mode 100644
index 00000000..39564ee8
--- /dev/null
+++ b/Flow/db_patches/patch-add-linkstables.sql
@@ -0,0 +1,32 @@
+CREATE TABLE /*_*/flow_wiki_ref (
+ ref_src_object_id binary(11) not null,
+ ref_src_object_type varbinary(32) not null,
+ ref_src_workflow_id binary(11) not null,
+ ref_src_namespace int not null,
+ ref_src_title varbinary(255) not null,
+ ref_target_namespace int not null,
+ ref_target_title varbinary(255) not null,
+ ref_type varbinary(16) not null
+);
+
+CREATE UNIQUE INDEX /*i*/flow_wiki_ref_pk ON /*_*/flow_wiki_ref
+ (ref_src_namespace, ref_src_title, ref_type, ref_target_namespace, ref_target_title, ref_src_object_type, ref_src_object_id);
+
+CREATE UNIQUE INDEX /*i*/flow_wiki_ref_revision ON /*_*/flow_wiki_ref
+ (ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target_namespace, ref_target_title);
+
+CREATE TABLE /*_*/flow_ext_ref (
+ ref_src_object_id binary(11) not null,
+ ref_src_object_type varbinary(32) not null,
+ ref_src_workflow_id binary(11) not null,
+ ref_src_namespace int not null,
+ ref_src_title varbinary(255) not null,
+ ref_target varbinary(255) not null,
+ ref_type varbinary(16) not null
+);
+
+CREATE UNIQUE INDEX /*i*/flow_ext_ref_pk ON /*_*/flow_ext_ref
+ (ref_src_namespace, ref_src_title, ref_type, ref_target, ref_src_object_type, ref_src_object_id);
+
+CREATE UNIQUE INDEX /*i*/flow_ext_ref_revision ON /*_*/flow_ext_ref
+ (ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target);
diff --git a/Flow/db_patches/patch-add-revision-content-length.sql b/Flow/db_patches/patch-add-revision-content-length.sql
new file mode 100644
index 00000000..95f4fd0a
--- /dev/null
+++ b/Flow/db_patches/patch-add-revision-content-length.sql
@@ -0,0 +1,3 @@
+ALTER TABLE flow_revision
+ ADD rev_content_length int NOT NULL DEFAULT 0,
+ ADD rev_previous_content_length int NOT NULL DEFAULT 0;
diff --git a/Flow/db_patches/patch-add-wiki.sql b/Flow/db_patches/patch-add-wiki.sql
new file mode 100644
index 00000000..969fd32f
--- /dev/null
+++ b/Flow/db_patches/patch-add-wiki.sql
@@ -0,0 +1,16 @@
+
+ALTER TABLE /*_*/flow_subscription ADD subscription_user_wiki varchar(32) binary not null;
+
+ALTER TABLE /*_*/flow_tree_revision ADD tree_orig_user_wiki varchar(32) binary not null;
+
+ALTER TABLE /*_*/flow_revision ADD rev_user_wiki varchar(32) binary not null;
+
+ALTER TABLE /*_*/flow_revision ADD rev_mod_user_wiki varchar(32) binary default null;
+
+ALTER TABLE /*_*/flow_revision ADD rev_edit_user_wiki varchar(32) binary default null;
+
+DROP INDEX /*i*/flow_subscription_unique_user_workflow ON /*_*/flow_subscription;
+CREATE UNIQUE INDEX /*i*/flow_subscription_unique_user_workflow ON /*_*/flow_subscription (subscription_workflow_id, subscription_user_id, subscription_user_wiki );
+
+DROP INDEX /*i*/flow_subscription_lookup ON /*_*/flow_subscription;
+CREATE INDEX /*i*/flow_subscription_lookup ON /*_*/flow_subscription (subscription_user_id, subscription_user_wiki, subscription_last_updated, subscription_workflow_id);
diff --git a/Flow/db_patches/patch-add_workflow_type.sql b/Flow/db_patches/patch-add_workflow_type.sql
new file mode 100644
index 00000000..8b6192af
--- /dev/null
+++ b/Flow/db_patches/patch-add_workflow_type.sql
@@ -0,0 +1,6 @@
+
+ALTER TABLE /*_*/flow_workflow ADD workflow_type varbinary(16);
+
+UPDATE /*_*/flow_workflow, /*_*/flow_definition
+ SET workflow_type = definition_type
+ WHERE workflow_definition_id = definition_id;
diff --git a/Flow/db_patches/patch-add_workflow_type.sqlite.sql b/Flow/db_patches/patch-add_workflow_type.sqlite.sql
new file mode 100644
index 00000000..fe8cbcbe
--- /dev/null
+++ b/Flow/db_patches/patch-add_workflow_type.sqlite.sql
@@ -0,0 +1,10 @@
+
+ALTER TABLE /*_*/flow_workflow ADD workflow_type varbinary(16);
+
+-- this is very inefficient, but sqlite will not allow an update
+-- against multiple tables.
+
+UPDATE /*_*/flow_workflow
+ SET workflow_type = ( SELECT definition_type
+ FROM /*_*/flow_definition
+ WHERE workflow_definition_id = definition_id );
diff --git a/Flow/db_patches/patch-censor_to_suppress.sql b/Flow/db_patches/patch-censor_to_suppress.sql
new file mode 100644
index 00000000..8bfd31f8
--- /dev/null
+++ b/Flow/db_patches/patch-censor_to_suppress.sql
@@ -0,0 +1,12 @@
+-- updates suppression terminology, which used to be called 'censor'
+
+UPDATE /*_*/flow_revision SET rev_change_type = 'suppress-post' WHERE rev_change_type = 'censor-post' AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'suppress-topic' WHERE rev_change_type = 'censor-topic' AND rev_type = 'post';
+
+UPDATE /*_*/logging SET log_action = 'flow-suppress-post' WHERE log_action = 'flow-censor-post' AND log_type = 'suppress';
+UPDATE /*_*/logging SET log_action = 'flow-suppress-topic' WHERE log_action = 'flow-censor-topic' AND log_type = 'suppress';
+
+-- recentchanges: this query is expensive & the code has fallbacks in place
+-- don't execute unless you only have few Flow data
+UPDATE /*_*/recentchanges SET rc_params = REPLACE(rc_params, 's:11:"censor-post"', 's:13:"suppress-post"') WHERE (rc_type = 142 OR rc_source = 'flow') AND rc_params LIKE '%s:11:"censor-post"%';
+UPDATE /*_*/recentchanges SET rc_params = REPLACE(rc_params, 's:12:"censor-topic"', 's:14:"suppress-topic"') WHERE (rc_type = 142 OR rc_source = 'flow') AND rc_params LIKE '%s:12:"censor-topic"%';
diff --git a/Flow/db_patches/patch-default_null_workflow_user.sql b/Flow/db_patches/patch-default_null_workflow_user.sql
new file mode 100644
index 00000000..9006d277
--- /dev/null
+++ b/Flow/db_patches/patch-default_null_workflow_user.sql
@@ -0,0 +1,4 @@
+ALTER TABLE /*_*/flow_workflow
+ CHANGE workflow_user_id workflow_user_id bigint unsigned default null,
+ CHANGE workflow_user_wiki workflow_user_wiki varchar(32) binary default null;
+
diff --git a/Flow/db_patches/patch-default_null_workflow_user.sqlite.sql b/Flow/db_patches/patch-default_null_workflow_user.sqlite.sql
new file mode 100644
index 00000000..598b59d8
--- /dev/null
+++ b/Flow/db_patches/patch-default_null_workflow_user.sqlite.sql
@@ -0,0 +1,30 @@
+ALTER TABLE /*_*/flow_workflow RENAME TO /*_*/temp_flow_workflow_default_null;
+
+CREATE TABLE /*_*/flow_workflow (
+ workflow_id binary(11) not null,
+ workflow_wiki varchar(16) binary not null,
+ workflow_namespace int not null,
+ workflow_page_id int unsigned not null,
+ workflow_title_text varchar(255) binary not null,
+ workflow_name varchar(255) binary not null,
+ workflow_last_update_timestamp binary(14) not null,
+ -- TODO: check what the new global user ids need for storage
+ workflow_user_id bigint unsigned default null,
+ workflow_user_ip varbinary(39) default null,
+ workflow_user_wiki varchar(32) binary default null,
+ -- TODO: is this usefull as a bitfield? may be premature optimization, a string
+ -- or list of strings may be simpler and use only a little more space.
+ workflow_lock_state int unsigned not null,
+ workflow_type varbinary(16) not null,
+ PRIMARY KEY (workflow_id)
+) /*$wgDBTableOptions*/;
+
+INSERT INTO /*_*/flow_workflow
+ (workflow_id, workflow_wiki, workflow_namespace, workflow_page_id, workflow_title_text, workflow_name, workflow_last_update_timestamp, workflow_user_id, workflow_user_ip, workflow_user_wiki, workflow_lock_state, workflow_type )
+ SELECT workflow_id, workflow_wiki, workflow_namespace, workflow_page_id, workflow_title_text, workflow_name, workflow_last_update_timestamp, workflow_user_id, workflow_user_ip, workflow_user_wiki, workflow_lock_state, workflow_type
+ FROM /*_*/temp_flow_workflow_default_null;
+
+DROP TABLE /*_*/temp_flow_workflow_default_null;
+
+CREATE INDEX /*i*/flow_workflow_lookup ON /*_*/flow_workflow (workflow_wiki, workflow_namespace, workflow_title_text);
+
diff --git a/Flow/db_patches/patch-drop_definition.sql b/Flow/db_patches/patch-drop_definition.sql
new file mode 100644
index 00000000..cef82f25
--- /dev/null
+++ b/Flow/db_patches/patch-drop_definition.sql
@@ -0,0 +1,3 @@
+DROP TABLE /*_*/flow_definition;
+ALTER TABLE /*_*/flow_workflow
+ DROP workflow_definition_id;
diff --git a/Flow/db_patches/patch-drop_workflow_user.sql b/Flow/db_patches/patch-drop_workflow_user.sql
new file mode 100644
index 00000000..85796f9c
--- /dev/null
+++ b/Flow/db_patches/patch-drop_workflow_user.sql
@@ -0,0 +1,4 @@
+ALTER TABLE /*_*/flow_workflow
+ DROP workflow_user_id,
+ DROP workflow_user_ip,
+ DROP workflow_user_wiki;
diff --git a/Flow/db_patches/patch-flow_tree_idx_fix.sql b/Flow/db_patches/patch-flow_tree_idx_fix.sql
new file mode 100644
index 00000000..4447ca3f
--- /dev/null
+++ b/Flow/db_patches/patch-flow_tree_idx_fix.sql
@@ -0,0 +1,4 @@
+
+DROP INDEX /*i*/flow_tree_descendant_id_revisions ON /*_*/flow_tree_revision;
+CREATE INDEX /*i*/flow_tree_descendant_rev_id ON /*_*/flow_tree_revision ( tree_rev_descendant_id, tree_rev_id );
+
diff --git a/Flow/db_patches/patch-increase_width_wiki_fields.sql b/Flow/db_patches/patch-increase_width_wiki_fields.sql
new file mode 100644
index 00000000..9b0d499e
--- /dev/null
+++ b/Flow/db_patches/patch-increase_width_wiki_fields.sql
@@ -0,0 +1,11 @@
+-- This patch doesn't need to be SQLite compatible (or re-implemented
+-- for SQLite) since SQLite doesn't care about column widths anyway.
+ALTER TABLE /*_*/flow_workflow MODIFY workflow_wiki varchar(64) binary not null;
+
+ALTER TABLE /*_*/flow_subscription MODIFY subscription_user_wiki varchar(64) binary not null;
+
+ALTER TABLE /*_*/flow_tree_revision MODIFY tree_orig_user_wiki varchar(64) binary not null;
+
+ALTER TABLE /*_*/flow_revision MODIFY rev_user_wiki varchar(64) binary not null,
+ MODIFY rev_mod_user_wiki varchar(64) binary default null,
+ MODIFY rev_edit_user_wiki varchar(64) binary default null;
diff --git a/Flow/db_patches/patch-moderation_reason.sql b/Flow/db_patches/patch-moderation_reason.sql
new file mode 100644
index 00000000..8bf2a243
--- /dev/null
+++ b/Flow/db_patches/patch-moderation_reason.sql
@@ -0,0 +1,3 @@
+-- Patch to add the "moderated reason" field to revision table
+
+ALTER TABLE /*_*/flow_revision ADD COLUMN rev_mod_reason varchar(255) binary; \ No newline at end of file
diff --git a/Flow/db_patches/patch-rc_source.sql b/Flow/db_patches/patch-rc_source.sql
new file mode 100644
index 00000000..0f9c2deb
--- /dev/null
+++ b/Flow/db_patches/patch-rc_source.sql
@@ -0,0 +1,4 @@
+-- Updates Flow's recentchanges entries to new rc_source column
+-- values for rc_source & rc_type are respectively RC_FLOW &
+-- Flow\Data\RecentChanges::SRC_FLOW, as defined in Flow.php
+UPDATE /*_*/recentchanges SET rc_source = "flow" WHERE rc_type = 142;
diff --git a/Flow/db_patches/patch-remove_unique_ref_indices.sql b/Flow/db_patches/patch-remove_unique_ref_indices.sql
new file mode 100644
index 00000000..7a0cc315
--- /dev/null
+++ b/Flow/db_patches/patch-remove_unique_ref_indices.sql
@@ -0,0 +1,15 @@
+-- drop unique constraint & recreate index
+DROP INDEX /*i*/flow_wiki_ref_pk ON /*_*/flow_wiki_ref;
+DROP INDEX /*i*/flow_wiki_ref_revision ON /*_*/flow_wiki_ref;
+
+CREATE INDEX /*i*/flow_wiki_ref_idx ON /*_*/flow_wiki_ref ( ref_src_namespace, ref_src_title, ref_type, ref_target_namespace, ref_target_title, ref_src_object_type, ref_src_object_id );
+CREATE INDEX /*i*/flow_wiki_ref_revision ON /*_*/flow_wiki_ref ( ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target_namespace, ref_target_title );
+
+-- drop unique constraint, change url column to blob & recreate index
+DROP INDEX /*i*/flow_ext_ref_pk ON /*_*/flow_ext_ref;
+DROP INDEX /*i*/flow_ext_ref_revision ON /*_*/flow_ext_ref;
+
+ALTER TABLE /*_*/flow_ext_ref CHANGE ref_target ref_target BLOB;
+
+CREATE INDEX /*i*/flow_ext_ref_idx ON /*_*/flow_ext_ref ( ref_src_namespace, ref_src_title, ref_type, ref_target(255), ref_src_object_type, ref_src_object_id );
+CREATE INDEX /*i*/flow_ext_ref_revision ON /*_*/flow_ext_ref ( ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target(255) );
diff --git a/Flow/db_patches/patch-remove_usernames.sql b/Flow/db_patches/patch-remove_usernames.sql
new file mode 100644
index 00000000..fd2833ee
--- /dev/null
+++ b/Flow/db_patches/patch-remove_usernames.sql
@@ -0,0 +1,12 @@
+
+ALTER TABLE /*_*/flow_tree_revision ADD tree_orig_user_ip varbinary(39) default null;
+UPDATE /*_*/flow_tree_revision SET tree_orig_user_ip = null WHERE tree_orig_user_id != 0;
+
+ALTER TABLE /*_*/flow_revision
+ ADD rev_user_ip varbinary(39) default null,
+ ADD rev_mod_user_ip varbinary(39) default null,
+ ADD rev_edit_user_ip varbinary(39) default null;
+
+UPDATE /*_*/flow_revision SET rev_user_ip = null WHERE rev_user_id != 0;
+UPDATE /*_*/flow_revision SET rev_mod_user_ip = null WHERE rev_mod_user_id != 0;
+UPDATE /*_*/flow_revision SET rev_edit_user_ip = null WHERE rev_edit_user_id != 0;
diff --git a/Flow/db_patches/patch-remove_usernames_2.sql b/Flow/db_patches/patch-remove_usernames_2.sql
new file mode 100644
index 00000000..e3f3f1e2
--- /dev/null
+++ b/Flow/db_patches/patch-remove_usernames_2.sql
@@ -0,0 +1,8 @@
+ALTER TABLE /*_*/flow_workflow DROP workflow_user_text;
+
+ALTER TABLE /*_*/flow_tree_revision DROP tree_orig_user_text;
+
+ALTER TABLE /*_*/flow_revision
+ DROP rev_user_text,
+ DROP rev_mod_user_text,
+ DROP rev_edit_user_text;
diff --git a/Flow/db_patches/patch-rev_change_type.sql b/Flow/db_patches/patch-rev_change_type.sql
new file mode 100644
index 00000000..63cd28f9
--- /dev/null
+++ b/Flow/db_patches/patch-rev_change_type.sql
@@ -0,0 +1,2 @@
+-- Changes rev_comment to rev_change_type
+ALTER TABLE /*_*/flow_revision CHANGE rev_comment rev_change_type varbinary(255) null;
diff --git a/Flow/db_patches/patch-rev_change_type.sqlite.sql b/Flow/db_patches/patch-rev_change_type.sqlite.sql
new file mode 100644
index 00000000..1352e7aa
--- /dev/null
+++ b/Flow/db_patches/patch-rev_change_type.sqlite.sql
@@ -0,0 +1,50 @@
+
+
+ALTER TABLE /*_*/flow_revision RENAME TO /*_*/temp_flow_revision_change_type;
+
+CREATE TABLE /*_*/flow_revision (
+ -- UID::newTimestampedUID128()
+ rev_id binary(16) not null,
+ -- What kind of revision is this: tree/summary/etc.
+ rev_type varchar(16) binary not null,
+ -- user id creating the revision
+ rev_user_id bigint unsigned not null,
+ -- name of user creating the revision, or ip address if anon
+ -- TODO: global user logins will obviate the need for this, but a round trip
+ -- will be needed to map from rev_user_id -> user name
+ rev_user_text varchar(255) binary not null default '',
+ -- rev_id of parent or null if no previous revision
+ rev_parent_id binary(16),
+ -- comma separated set of ascii flags.
+ rev_flags tinyblob not null,
+ -- content of the revision
+ rev_content mediumblob not null,
+ -- the type of change that was made. MW message key.
+ -- formerly rev_comment
+ rev_change_type varbinary(255) null,
+ -- current moderation state
+ rev_mod_state varchar(32) binary not null,
+ -- moderated by who?
+ rev_mod_user_id bigint unsigned,
+ rev_mod_user_text varchar(255) binary,
+ rev_mod_timestamp varchar(14) binary,
+
+ -- track who made the most recent content edit
+ rev_last_edit_id binary(16) null,
+ rev_edit_user_id bigint unsigned,
+ rev_edit_user_text varchar(255) binary,
+
+ PRIMARY KEY (rev_id)
+) /*$wgDBTableOptions*/;
+
+INSERT INTO /*_*/flow_revision
+ (rev_id, rev_type, rev_user_id, rev_user_text, rev_parent_id, rev_flags, rev_content, rev_change_type, rev_mod_state, rev_mod_user_id, rev_mod_user_text, rev_mod_timestamp, rev_last_edit_id, rev_edit_user_id, rev_edit_user_text )
+SELECT
+ rev_id, rev_type, rev_user_id, rev_user_text, rev_parent_id, rev_flags, rev_content, rev_comment, rev_mod_state, rev_mod_user_id, rev_mod_user_text, rev_mod_timestamp, rev_last_edit_id, rev_edit_user_id, rev_edit_user_text
+FROM
+ /*_*/temp_flow_revision_change_type;
+
+DROP TABLE /*_*/temp_flow_revision_change_type;
+
+CREATE UNIQUE INDEX /*i*/flow_revision_unique_parent ON
+ /*_*/flow_revision (rev_parent_id);
diff --git a/Flow/db_patches/patch-rev_change_type_update.sql b/Flow/db_patches/patch-rev_change_type_update.sql
new file mode 100644
index 00000000..a40ed7a6
--- /dev/null
+++ b/Flow/db_patches/patch-rev_change_type_update.sql
@@ -0,0 +1,14 @@
+-- Updates older change_type values to match with action names
+
+UPDATE /*_*/flow_revision SET rev_change_type = 'edit-title' WHERE rev_change_type IN('flow-rev-message-edit-title', 'flow-edit-title') AND rev_type = 'post';
+
+UPDATE /*_*/flow_revision SET rev_change_type = 'new-post' WHERE rev_change_type IN('flow-rev-message-new-post', 'flow-new-post') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'edit-post' WHERE rev_change_type IN('flow-rev-message-edit-post', 'flow-edit-post') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'reply' WHERE rev_change_type IN('flow-rev-message-reply', 'flow-reply') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'restore-post' WHERE rev_change_type IN('flow-rev-message-restored-post', 'flow-post-restored') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'hide-post' WHERE rev_change_type IN('flow-rev-message-hid-post', 'flow-post-hidden') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'delete-post' WHERE rev_change_type IN('flow-rev-message-deleted-post', 'flow-post-deleted') AND rev_type = 'post';
+UPDATE /*_*/flow_revision SET rev_change_type = 'censor-post' WHERE rev_change_type IN('flow-rev-message-censored-post', 'flow-post-censored') AND rev_type = 'post';
+
+UPDATE /*_*/flow_revision SET rev_change_type = 'edit-header' WHERE rev_change_type IN ('flow-rev-message-edit-header', 'flow-edit-summary') AND rev_type = 'header';
+UPDATE /*_*/flow_revision SET rev_change_type = 'create-header' WHERE rev_change_type IS NULL OR rev_change_type IN ('flow-rev-message-create-header', 'flow-create-summary', 'flow-create-header') AND rev_type = 'header';
diff --git a/Flow/db_patches/patch-rev_type_id.sql b/Flow/db_patches/patch-rev_type_id.sql
new file mode 100644
index 00000000..b569932d
--- /dev/null
+++ b/Flow/db_patches/patch-rev_type_id.sql
@@ -0,0 +1,4 @@
+
+ALTER TABLE /*_*/flow_revision ADD rev_type_id binary(11) not null default '';
+
+CREATE INDEX /*i*/flow_revision_type_id ON /*_*/flow_revision (rev_type, rev_type_id);
diff --git a/Flow/db_patches/patch-revision_last_editor.sql b/Flow/db_patches/patch-revision_last_editor.sql
new file mode 100644
index 00000000..adf430e0
--- /dev/null
+++ b/Flow/db_patches/patch-revision_last_editor.sql
@@ -0,0 +1,7 @@
+-- Patch to add infomation about the last content edit to flow revisions
+ALTER TABLE /*_*/flow_revision
+ ADD rev_last_edit_id binary(16) null,
+ ADD rev_edit_user_id bigint unsigned,
+ ADD rev_edit_user_text varchar(255) binary,
+ CHANGE rev_user_id rev_user_id bigint unsigned not null;
+
diff --git a/Flow/db_patches/patch-revision_user_idx.sql b/Flow/db_patches/patch-revision_user_idx.sql
new file mode 100644
index 00000000..1330705e
--- /dev/null
+++ b/Flow/db_patches/patch-revision_user_idx.sql
@@ -0,0 +1,2 @@
+-- Special:Contributions can do queries based on user id/ip
+CREATE INDEX /*i*/flow_revision_user ON /*_*/flow_revision (rev_user_id, rev_user_ip, rev_user_wiki);
diff --git a/Flow/db_patches/patch-revision_user_ip.sql b/Flow/db_patches/patch-revision_user_ip.sql
new file mode 100644
index 00000000..02a5f27e
--- /dev/null
+++ b/Flow/db_patches/patch-revision_user_ip.sql
@@ -0,0 +1,3 @@
+-- we used to store the username in there, when user was logged in
+-- now we need it blank to reliably query for Special:Contributions
+UPDATE /*_*/flow_revision SET rev_user_ip = NULL WHERE rev_user_id != 0;
diff --git a/Flow/db_patches/patch-subscription_user_id.sql b/Flow/db_patches/patch-subscription_user_id.sql
new file mode 100644
index 00000000..908fb450
--- /dev/null
+++ b/Flow/db_patches/patch-subscription_user_id.sql
@@ -0,0 +1,3 @@
+-- Patch to add infomation about the last content edit to flow revisions
+ALTER TABLE /*_*/flow_subscription CHANGE subscription_user_id subscription_user_id bigint unsigned not null;
+
diff --git a/Flow/db_patches/patch-summary2header.sql b/Flow/db_patches/patch-summary2header.sql
new file mode 100644
index 00000000..cff001d6
--- /dev/null
+++ b/Flow/db_patches/patch-summary2header.sql
@@ -0,0 +1,9 @@
+-- Renames "summaries" to "headers"
+ALTER TABLE /*_*/flow_summary_revision
+ RENAME TO flow_header_revision,
+ DROP PRIMARY KEY,
+ CHANGE COLUMN summary_workflow_id header_workflow_id binary(16) not null,
+ CHANGE COLUMN summary_rev_id header_rev_id binary(16) not null,
+ ADD PRIMARY KEY ( header_workflow_id, header_rev_id );
+
+UPDATE /*_*/flow_revision SET rev_type='header' WHERE rev_type='summary'; \ No newline at end of file
diff --git a/Flow/db_patches/patch-summary2header.sqlite.sql b/Flow/db_patches/patch-summary2header.sqlite.sql
new file mode 100644
index 00000000..806effb3
--- /dev/null
+++ b/Flow/db_patches/patch-summary2header.sqlite.sql
@@ -0,0 +1,21 @@
+-- Sqlites alter table statement can NOT change existing columns. The only
+-- option since we want to rename the table and its columns is to recreate
+-- the table and copy the data over
+
+
+CREATE TABLE /*_*/flow_header_revision (
+ header_workflow_id binary(16) not null,
+ header_rev_id binary(16) not null,
+ PRIMARY KEY (header_workflow_id, header_rev_id)
+);
+
+INSERT INTO /*_*/flow_header_revision
+ (header_workflow_id, header_rev_id)
+SELECT
+ summary_workflow_id, summary_rev_id
+FROM
+ /*_*/flow_summary_revision;
+
+DROP TABLE /*_*/flow_summary_revision;
+
+UPDATE /*_*/flow_revision SET rev_type='header' WHERE rev_type='summary';
diff --git a/Flow/db_patches/patch-topic_list_topic_id_idx.sql b/Flow/db_patches/patch-topic_list_topic_id_idx.sql
new file mode 100644
index 00000000..2d0d8df2
--- /dev/null
+++ b/Flow/db_patches/patch-topic_list_topic_id_idx.sql
@@ -0,0 +1 @@
+CREATE INDEX /*i*/flow_topic_list_topic_id ON /*_*/flow_topic_list (topic_id);
diff --git a/Flow/db_patches/patch-tree_orig_create_time.sql b/Flow/db_patches/patch-tree_orig_create_time.sql
new file mode 100644
index 00000000..605911c2
--- /dev/null
+++ b/Flow/db_patches/patch-tree_orig_create_time.sql
@@ -0,0 +1,2 @@
+ALTER TABLE /*_*/flow_tree_revision
+ DROP COLUMN tree_orig_create_time;
diff --git a/Flow/db_patches/patch-workflow_lookup_idx.sql b/Flow/db_patches/patch-workflow_lookup_idx.sql
new file mode 100644
index 00000000..4cbe6459
--- /dev/null
+++ b/Flow/db_patches/patch-workflow_lookup_idx.sql
@@ -0,0 +1 @@
+CREATE INDEX /*i*/flow_workflow_lookup ON /*_*/flow_workflow (workflow_wiki, workflow_namespace, workflow_title_text);
diff --git a/Flow/defines.php b/Flow/defines.php
new file mode 100644
index 00000000..fa633178
--- /dev/null
+++ b/Flow/defines.php
@@ -0,0 +1,5 @@
+<?php
+
+// Constants
+define( 'RC_FLOW', 142 ); // Random number chosen. Can be replaced with rc_source; see bug 72157.
+define( 'NS_TOPIC', 2600 );
diff --git a/Flow/flow.sql b/Flow/flow.sql
new file mode 100644
index 00000000..2ba529e7
--- /dev/null
+++ b/Flow/flow.sql
@@ -0,0 +1,171 @@
+-- Database schema for Flow
+-- This file contains only the unsharded global data
+
+CREATE TABLE /*_*/flow_workflow (
+ workflow_id binary(11) not null,
+ workflow_wiki varchar(64) binary not null,
+ workflow_namespace int not null,
+ workflow_page_id int unsigned not null,
+ workflow_title_text varchar(255) binary not null,
+ workflow_name varchar(255) binary not null,
+ workflow_last_update_timestamp binary(14) not null,
+ -- TODO: is this usefull as a bitfield? may be premature optimization, a string
+ -- or list of strings may be simpler and use only a little more space.
+ workflow_lock_state int unsigned not null,
+ workflow_type varbinary(16) not null,
+ PRIMARY KEY (workflow_id)
+) /*$wgDBTableOptions*/;
+
+CREATE INDEX /*i*/flow_workflow_lookup ON /*_*/flow_workflow (workflow_wiki, workflow_namespace, workflow_title_text);
+
+CREATE TABLE /*_*/flow_subscription (
+ subscription_workflow_id int unsigned not null,
+ subscription_user_id bigint unsigned not null,
+ subscription_user_wiki varchar(64) binary not null,
+ subscription_create_timestamp varchar(14) binary not null,
+ subscription_last_updated varchar(14) binary not null
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/flow_subscription_unique_user_workflow ON /*_*/flow_subscription (subscription_workflow_id, subscription_user_id, subscription_user_wiki );
+CREATE INDEX /*i*/flow_subscription_lookup ON /*_*/flow_subscription (subscription_user_id, subscription_user_wiki, subscription_last_updated, subscription_workflow_id);
+
+-- TopicList Tables
+CREATE TABLE /*_*/flow_topic_list (
+ topic_list_id binary(11) not null,
+ topic_id binary(11)
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/flow_topic_list_pk ON /*_*/flow_topic_list( topic_list_id, topic_id);
+CREATE INDEX /*i*/flow_topic_list_topic_id ON /*_*/flow_topic_list (topic_id);
+
+-- Post Content Revisions. Connects 1 Post to Many revisions.
+-- also denormalizes information commonly needed with a revision
+CREATE TABLE /*_*/flow_tree_revision (
+ -- the id of the post in the post tree
+ tree_rev_descendant_id binary(11) not null,
+ -- fk to flow_revision
+ tree_rev_id binary(11) not null,
+ -- denormalized so we don't need to keep finding the first revision of a post
+ tree_orig_user_id bigint unsigned not null,
+ tree_orig_user_ip varbinary(39) default null,
+ tree_orig_user_wiki varchar(64) binary not null,
+ -- denormalize post parent as well? Prevents an extra query when building
+ -- tree from closure table. unnecessary?
+ tree_parent_id binary(11),
+ PRIMARY KEY( tree_rev_id )
+) /*$wgDBTableOptions*/;
+
+CREATE INDEX /*i*/flow_tree_descendant_rev_id
+ ON /*_*/flow_tree_revision ( tree_rev_descendant_id, tree_rev_id );
+
+-- Content
+-- This is completely unoptimized right now, just a quick get-it-done for
+-- the prototype
+--
+-- NOTE: This doesn't directly link to whatever the revision is for. The rev_type field should
+-- be unique enough to know what to look in though. For example when rev_type === 'tree' then
+-- look in flow_tree_revision. Typical use case should not be to use this field, but to join
+-- from an id in the other direction.
+--
+-- Each revision has a timestamped id, and explicitly states who its parent is.
+-- Comparing to the ids in the matching flow_tree_revision table should allow for
+-- detecting edit conflicts, so they can be resolved? Idealy they are resolved before
+-- this point, but as a backup plan?
+--
+CREATE TABLE /*_*/flow_revision (
+ -- UID::newTimestampedUID128()
+ rev_id binary(11) not null,
+ -- What kind of revision is this: tree/header/etc.
+ rev_type varchar(16) binary not null,
+ -- The id of the object this is a revision of
+ -- For example, if rev_type is header, rev_type_id is the header's id.
+ -- If rev_type is post, it is the post's id, etc.
+ rev_type_id binary(11) not null default '',
+ -- user id creating the revision
+ rev_user_id bigint unsigned not null,
+ rev_user_ip varbinary(39) default null,
+ rev_user_wiki varchar(64) binary not null,
+ -- rev_id of parent or null if no previous revision
+ rev_parent_id binary(11) null,
+ -- comma separated set of ascii flags.
+ rev_flags tinyblob not null,
+ -- content of the revision
+ rev_content mediumblob not null,
+ -- the type of change that was made. MW message key.
+ -- formerly rev_comment
+ rev_change_type varbinary(255) null,
+ -- current moderation state
+ rev_mod_state varchar(32) binary not null,
+ -- moderated by who?
+ rev_mod_user_id bigint unsigned,
+ rev_mod_user_ip varbinary(39) default null,
+ rev_mod_user_wiki varchar(64) binary default null,
+ rev_mod_timestamp varchar(14) binary,
+ -- moderated why? (coming soon: how?, where? and what?)
+ rev_mod_reason varchar(255) binary,
+
+ -- track who made the most recent content edit
+ rev_last_edit_id binary(11) null,
+ rev_edit_user_id bigint unsigned,
+ rev_edit_user_ip varbinary(39) default null,
+ rev_edit_user_wiki varchar(64) binary default null,
+
+ rev_content_length int not null default 0,
+ rev_previous_content_length int not null default 0,
+
+ PRIMARY KEY (rev_id)
+) /*$wgDBTableOptions*/;
+
+-- Prevents inconsistency, but perhaps will hurt inserts?
+CREATE UNIQUE INDEX /*i*/flow_revision_unique_parent ON
+ /*_*/flow_revision (rev_parent_id);
+-- Primary key is automatically appended to all secondary index in InnoDB
+CREATE INDEX /*i*/flow_revision_type_id ON /*_*/flow_revision (rev_type, rev_type_id);
+
+-- Special:Contributions can do queries based on user id/ip
+CREATE INDEX /*i*/flow_revision_user ON
+ /*_*/flow_revision (rev_user_id, rev_user_ip, rev_user_wiki);
+
+-- Closure table implementation of tree storage in sql
+-- We may be able to go simpler than this
+CREATE TABLE /*_*/flow_tree_node (
+ tree_ancestor_id binary(11) not null,
+ tree_descendant_id binary(11) not null,
+ tree_depth smallint not null
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/flow_tree_node_pk ON /*_*/flow_tree_node (tree_ancestor_id, tree_descendant_id);
+CREATE UNIQUE INDEX /*i*/flow_tree_constraint ON /*_*/flow_tree_node (tree_descendant_id, tree_depth);
+
+CREATE TABLE /*_*/flow_wiki_ref (
+ ref_src_object_id binary(11) not null,
+ ref_src_object_type varbinary(32) not null,
+ ref_src_workflow_id binary(11) not null,
+ ref_src_namespace int not null,
+ ref_src_title varbinary(255) not null,
+ ref_target_namespace int not null,
+ ref_target_title varbinary(255) not null,
+ ref_type varbinary(16) not null
+) /*$wgDBTableOptions*/;
+
+CREATE INDEX /*i*/flow_wiki_ref_idx ON /*_*/flow_wiki_ref
+ (ref_src_namespace, ref_src_title, ref_type, ref_target_namespace, ref_target_title, ref_src_object_type, ref_src_object_id);
+
+CREATE INDEX /*i*/flow_wiki_ref_revision ON /*_*/flow_wiki_ref
+ (ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target_namespace, ref_target_title);
+
+CREATE TABLE /*_*/flow_ext_ref (
+ ref_src_object_id binary(11) not null,
+ ref_src_object_type varbinary(32) not null,
+ ref_src_workflow_id binary(11) not null,
+ ref_src_namespace int not null,
+ ref_src_title varbinary(255) not null,
+ ref_target varbinary(255) not null,
+ ref_type varbinary(16) not null
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/flow_ext_ref_idx ON /*_*/flow_ext_ref
+ (ref_src_namespace, ref_src_title, ref_type, ref_target, ref_src_object_type, ref_src_object_id);
+
+CREATE UNIQUE INDEX /*i*/flow_ext_ref_revision ON /*_*/flow_ext_ref
+ (ref_src_namespace, ref_src_title, ref_src_object_type, ref_src_object_id, ref_type, ref_target);
diff --git a/Flow/handlebars/compiled/flow_block_board-history.handlebars.php b/Flow/handlebars/compiled/flow_block_board-history.handlebars.php
new file mode 100644
index 00000000..9910c1a4
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_board-history.handlebars.php
@@ -0,0 +1,142 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'historyTimestamp' => 'Flow\TemplateHelper::historyTimestamp',
+ 'historyDescription' => 'Flow\TemplateHelper::historyDescription',
+ 'showCharacterDifference' => 'Flow\TemplateHelper::showCharacterDifference',
+ 'concat' => 'Flow\TemplateHelper::concat',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'ifCond' => 'Flow\TemplateHelper::ifCond',
+),
+ 'partials' => array('flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_history_line' => function ($cx, $in) {return '<span class="flow-pipelist">
+ ('.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span>'.((LCRun3::ifvar($cx, ((isset($in['links']['diff-cur']) && is_array($in['links'])) ? $in['links']['diff-cur'] : null))) ? '<a href="'.htmlentities((string)((isset($in['links']['diff-cur']['url']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['diff-cur']['title']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['diff-cur']['text']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>' : ''.LCRun3::ch($cx, 'l10n', array(array('cur'),array()), 'encq').'').'</span>
+ <span>
+'.((LCRun3::ifvar($cx, ((isset($in['links']['diff-prev']) && is_array($in['links'])) ? $in['links']['diff-prev'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['diff-prev']['url']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['diff-prev']['title']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['diff-prev']['text']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>' : ''.LCRun3::ch($cx, 'l10n', array(array('last'),array()), 'encq').'').'</span>'.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? ' <span><a href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['topic']['text']) && is_array($in['links']['topic'])) ? $in['links']['topic']['text'] : null), ENT_QUOTES, 'UTF-8').'</a></span>' : '').')
+</span>
+
+'.LCRun3::ch($cx, 'historyTimestamp', array(array($in),array()), 'encq').'
+
+<span class="mw-changeslist-separator">. .</span>
+'.LCRun3::ch($cx, 'historyDescription', array(array($in),array()), 'encq').'
+
+'.((LCRun3::ifvar($cx, ((isset($in['size']) && is_array($in)) ? $in['size'] : null))) ? ' <span class="mw-changeslist-separator">. .</span>
+ '.LCRun3::ch($cx, 'showCharacterDifference', array(array(((isset($in['size']['old']) && is_array($in['size'])) ? $in['size']['old'] : null),((isset($in['size']['new']) && is_array($in['size'])) ? $in['size']['new'] : null)),array()), 'encq').'
+' : '').'
+<ul class="flow-history-moderation-menu">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'history','moderationTarget'=>'post','moderationTemplate'=>'post','moderationMwUiClass'=>'mw-ui-anchor','moderationIcons'=>false))).'</ul>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board-history">
+ '.LCRun3::ch($cx, 'html', array(array(((isset($in['navbar']) && is_array($in)) ? $in['navbar'] : null)),array()), 'encq').'
+
+ <ul>
+'.LCRun3::sec($cx, ((isset($in['revisions']) && is_array($in)) ? $in['revisions'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::p($cx, 'flow_history_line', array(array($in),array())).'</li>
+';}).' </ul>
+
+ '.LCRun3::ch($cx, 'html', array(array(((isset($in['navbar']) && is_array($in)) ? $in['navbar'] : null)),array()), 'encq').'
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_header.handlebars.php b/Flow/handlebars/compiled/flow_block_header.handlebars.php
new file mode 100644
index 00000000..72755a23
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_header.handlebars.php
@@ -0,0 +1,52 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_header_detail' => function ($cx, $in) {return '<div class="flow-board-header-detail-view">
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['content']) && is_array($in['revision'])) ? $in['revision']['content'] : null))) ? ' '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['revision']['content']['format']) && is_array($in['revision']['content'])) ? $in['revision']['content']['format'] : null),((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null)),array()), 'encq').'
+' : '').' &nbsp;
+
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ' <div class="flow-board-header-nav">
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['actions']['edit']) && is_array($in['revision']['actions'])) ? $in['revision']['actions']['edit'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['revision']['actions']['edit']['url']) && is_array($in['revision']['actions']['edit'])) ? $in['revision']['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditHeader"
+ data-flow-api-target="< .flow-board-header"
+ data-flow-interactive-handler="apiRequest"
+ class="mw-ui-button mw-ui-progressive mw-ui-quiet flow-board-header-icon flow-ui-tooltip-target"
+ title="'.htmlentities((string)((isset($in['revision']['actions']['edit']['title']) && is_array($in['revision']['actions']['edit'])) ? $in['revision']['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'">
+ <span class="wikiglyph wikiglyph-pencil"></span>
+ </a>
+' : '').' </div>
+' : '').'</div>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board-header">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).''.LCRun3::p($cx, 'flow_header_detail', array(array($in),array())).'</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_header_diff_view.handlebars.php b/Flow/handlebars/compiled/flow_block_header_diff_view.handlebars.php
new file mode 100644
index 00000000..65dcd89a
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_header_diff_view.handlebars.php
@@ -0,0 +1,36 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffRevision' => 'Flow\TemplateHelper::diffRevision',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ '.LCRun3::ch($cx, 'l10nParse', array(array('flow-compare-revisions-header-header',((isset($in['revision']['new']['rev_view_links']['board']['title']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['title'] : null),((isset($in['revision']['new']['author']['name']) && is_array($in['revision']['new']['author'])) ? $in['revision']['new']['author']['name'] : null),((isset($in['revision']['new']['rev_view_links']['board']['url']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['url'] : null),((isset($in['revision']['new']['rev_view_links']['hist']['url']) && is_array($in['revision']['new']['rev_view_links']['hist'])) ? $in['revision']['new']['rev_view_links']['hist']['url'] : null)),array()), 'encq').'
+ </div>
+ <div class="flow-compare-revisions">
+ '.LCRun3::ch($cx, 'diffRevision', array(array(((isset($in['revision']['diff_content']) && is_array($in['revision'])) ? $in['revision']['diff_content'] : null),((isset($in['revision']['old']['human_timestamp']) && is_array($in['revision']['old'])) ? $in['revision']['old']['human_timestamp'] : null),((isset($in['revision']['new']['human_timestamp']) && is_array($in['revision']['new'])) ? $in['revision']['new']['human_timestamp'] : null),((isset($in['revision']['old']['author']['name']) && is_array($in['revision']['old']['author'])) ? $in['revision']['old']['author']['name'] : null),((isset($in['revision']['new']['author']['name']) && is_array($in['revision']['new']['author'])) ? $in['revision']['new']['author']['name'] : null),((isset($in['revision']['old']['rev_view_links']['single-view']['url']) && is_array($in['revision']['old']['rev_view_links']['single-view'])) ? $in['revision']['old']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['new']['rev_view_links']['single-view']['url']) && is_array($in['revision']['new']['rev_view_links']['single-view'])) ? $in['revision']['new']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['links']['previous']) && is_array($in['revision']['links'])) ? $in['revision']['links']['previous'] : null),((isset($in['revision']['links']['next']) && is_array($in['revision']['links'])) ? $in['revision']['links']['next'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_header_edit.handlebars.php b/Flow/handlebars/compiled/flow_block_header_edit.handlebars.php
new file mode 100644
index 00000000..ed7019e9
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_header_edit.handlebars.php
@@ -0,0 +1,67 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board-header">
+ <div class="flow-board-header-edit-view">
+ <form method="POST" action="'.htmlentities((string)((isset($in['revision']['actions']['edit']['url']) && is_array($in['revision']['actions']['edit'])) ? $in['revision']['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'" flow-api-action="edit-header">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['revisionId']) && is_array($in['revision'])) ? $in['revision']['revisionId'] : null))) ? ' <input type="hidden" name="header_prev_revision" value="'.htmlentities((string)((isset($in['revision']['revisionId']) && is_array($in['revision'])) ? $in['revision']['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+' : '').'
+ <div class="flow-editor">
+ <textarea name="header_content"
+ class="mw-ui-input"
+ data-flow-preview-template="flow_header_detail.partial"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-edit-header-placeholder'),array()), 'encq').'"
+ data-role="content"
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitHeader">'.LCRun3::ch($cx, 'l10n', array(array('flow-edit-header-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'</small>
+ </div>
+ </form>
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_header_single_view.handlebars.php b/Flow/handlebars/compiled/flow_block_header_single_view.handlebars.php
new file mode 100644
index 00000000..ddcb10d6
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_header_single_view.handlebars.php
@@ -0,0 +1,38 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['previousRevisionId']) && is_array($in['revision'])) ? $in['revision']['previousRevisionId'] : null))) ? ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-header',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-header-first',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+').' </div>
+
+ <div class="flow-revision-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['revision']['content']['format']) && is_array($in['revision']['content'])) ? $in['revision']['content']['format'] : null),((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_header_undo_edit.handlebars.php b/Flow/handlebars/compiled/flow_block_header_undo_edit.handlebars.php
new file mode 100644
index 00000000..26cebcaf
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_header_undo_edit.handlebars.php
@@ -0,0 +1,71 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffUndo' => 'Flow\TemplateHelper::diffUndo',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' <p>'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-content'),array()), 'encq').'</p>
+' : ' <p class="error">'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-failure'),array()), 'encq').'</p>
+').'
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' '.LCRun3::ch($cx, 'diffUndo', array(array(((isset($in['undo']['diff_content']) && is_array($in['undo'])) ? $in['undo']['diff_content'] : null)),array()), 'encq').'
+' : '').'
+ <form method="POST" action="'.htmlentities((string)((isset($in['links']['undo-edit-header']['url']) && is_array($in['links']['undo-edit-header'])) ? $in['links']['undo-edit-header']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="header_prev_revision" value="'.htmlentities((string)((isset($in['current']['revisionId']) && is_array($in['current'])) ? $in['current']['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+
+ <div class="flow-editor">
+ <textarea name="topic_content"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-template="flow_header_detail.partial"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ''.htmlentities((string)((isset($in['undo']['content']) && is_array($in['undo'])) ? $in['undo']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['current']['content']['content']) && is_array($in['current']['content'])) ? $in['current']['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">'.LCRun3::ch($cx, 'l10n', array(array('flow-edit-header-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'
+ </small>
+ </div>
+ </form>
+</div>
+
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_loop.handlebars.php b/Flow/handlebars/compiled/flow_block_loop.handlebars.php
new file mode 100644
index 00000000..5b67bc94
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_loop.handlebars.php
@@ -0,0 +1,28 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'block' => 'Flow\TemplateHelper::block',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return ''.LCRun3::sec($cx, ((isset($in['blocks']) && is_array($in)) ? $in['blocks'] : null), $in, true, function($cx, $in) {return ' '.LCRun3::ch($cx, 'block', array(array($in),array()), 'encq').'
+';}).'';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic.handlebars.php b/Flow/handlebars/compiled/flow_block_topic.handlebars.php
new file mode 100644
index 00000000..1b4b4d5e
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic.handlebars.php
@@ -0,0 +1,249 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'timestamp' => 'Flow\TemplateHelper::timestampHelper',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_topic_moderation_flag' => function ($cx, $in) {return '<span class="wikiglyph'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','lock'),array()), $in, false, function($cx, $in) {return ' wikiglyph-lock';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','hide'),array()), $in, false, function($cx, $in) {return ' wikiglyph-flag';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','delete'),array()), $in, false, function($cx, $in) {return ' wikiglyph-trash';}).'"></span>
+';},'flow_post_moderation_state' => function ($cx, $in) {return '<span class="plainlinks">'.((LCRun3::ifvar($cx, ((isset($in['replyToId']) && is_array($in)) ? $in['replyToId'] : null))) ? ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-post-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-title-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'').'</span>
+';},'flow_topic_titlebar_summary' => function ($cx, $in) {return '<div class="flow-topic-summary-container">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ' <div class="flow-topic-summary">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['summary']['format']) && is_array($in['summary'])) ? $in['summary']['format'] : null),((isset($in['summary']['content']) && is_array($in['summary'])) ? $in['summary']['content'] : null)),array()), 'encq').'
+ </div>
+ <br class="flow-ui-clear"/>
+' : '').'</div>
+';},'flow_topic_titlebar_content' => function ($cx, $in) {return '<h2 class="flow-topic-title flow-load-interactive"
+ data-flow-topic-title="'.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'"
+ data-flow-load-handler="topicTitle">'.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'</h2>
+<div class="flow-topic-meta">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-comments',((isset($in['reply_count']) && is_array($in)) ? $in['reply_count'] : null)),array()), 'encq').' &bull;
+
+ <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">
+'.((LCRun3::ifvar($cx, ((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null))) ? ' '.LCRun3::ch($cx, 'timestamp', array(array(((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null)),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), 'encq').'
+').' </a>
+</div>
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' <div class="flow-moderated-topic-title flow-ui-text-truncated">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').''.LCRun3::p($cx, 'flow_topic_moderation_flag', array(array($in),array())).'
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </div>
+ <div class="flow-moderated-topic-reason">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-moderated-reason-prefix'),array()), 'encq').'
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['moderateReason']['format']) && is_array($in['moderateReason'])) ? $in['moderateReason']['format'] : null),((isset($in['moderateReason']['content']) && is_array($in['moderateReason'])) ? $in['moderateReason']['content'] : null)),array()), 'encq').'
+ </div>
+' : '').'<span class="flow-reply-count"><span class="wikiglyph wikiglyph-speech-bubble"></span><span class="flow-reply-count-number">'.htmlentities((string)((isset($in['reply_count']) && is_array($in)) ? $in['reply_count'] : null), ENT_QUOTES, 'UTF-8').'</span></span>
+
+'.LCRun3::p($cx, 'flow_topic_titlebar_summary', array(array($in),array())).'';},'flow_topic_titlebar_watch' => function ($cx, $in) {return '<div class="flow-topic-watchlist flow-watch-link">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+ <a href="'.((LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? ''.htmlentities((string)((isset($in['links']['unwatch-topic']['url']) && is_array($in['links']['unwatch-topic'])) ? $in['links']['unwatch-topic']['url'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['links']['watch-topic']['url']) && is_array($in['links']['watch-topic'])) ? $in['links']['watch-topic']['url'] : null), ENT_QUOTES, 'UTF-8').'').'"
+ class="mw-ui-anchor mw-ui-constructive '.((!LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? 'mw-ui-quiet' : '').'
+'.((LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? 'flow-watch-link-unwatch' : 'flow-watch-link-watch').'"
+ data-flow-api-handler="watchItem"
+ data-flow-api-target="< .flow-topic-watchlist"
+ data-flow-api-method="POST">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span class="wikiglyph wikiglyph-star"></span>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').''.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span class="wikiglyph wikiglyph-unstar"></span>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</a>
+</div>
+';},'flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_topic_titlebar' => function ($cx, $in) {return '<div class="flow-topic-titlebar">
+'.LCRun3::p($cx, 'flow_topic_titlebar_content', array(array($in),array())).'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['watchable']) && is_array($in)) ? $in['watchable'] : null))) ? ''.LCRun3::p($cx, 'flow_topic_titlebar_watch', array(array($in),array())).'' : '').' <div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'topic','moderationTarget'=>'title','moderationTemplate'=>'topic','moderationContainerClass'=>'flow-menu','moderationMwUiClass'=>'mw-ui-button','moderationIcons'=>true))).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_reply_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <form class="flow-post flow-reply-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ id="flow-reply-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_replyTo" value="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'"
+ data-role="content"
+
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['submitted'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'';}).'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-reply'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_topic' => function ($cx, $in) {return '<div class="flow-topic flow-load-interactive
+ '.((LCRun3::ifvar($cx, ((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null))) ? 'flow-topic-moderatestate-'.htmlentities((string)((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null), ENT_QUOTES, 'UTF-8').'' : '').'
+ '.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? 'flow-topic-moderated' : '').'
+ "
+ id="flow-topic-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-load-handler="topic"
+ data-flow-toc-scroll-target=".flow-topic-titlebar"
+ data-flow-topic-timestamp-updated="'.htmlentities((string)((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null), ENT_QUOTES, 'UTF-8').'"
+>
+'.LCRun3::p($cx, 'flow_topic_titlebar', array(array($in),array())).'
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['posts']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['posts'] : null))) ? ''.LCRun3::sec($cx, ((isset($in['replies']) && is_array($in)) ? $in['replies'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ' <!-- eachPost topic -->
+ '.LCRun3::ch($cx, 'post', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), 'encq').'
+';}).'';}).'' : '').'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}, function($cx, $in) {return ''.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array('type'=>'replace','target'=>'~ a')), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}).' <a href="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['reply']['title']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-ui-input-replacement-anchor mw-ui-input"
+ >'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'</a>
+';}).'' : '').'' : '').'</div>
+';},'flow_topiclist_loop' => function ($cx, $in) {return ''.LCRun3::sec($cx, ((isset($in['roots']) && is_array($in)) ? $in['roots'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_topic', array(array($in),array())).'';}).'';}).'';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-topics">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::p($cx, 'flow_topiclist_loop', array(array($in),array())).' </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_diff_view.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_diff_view.handlebars.php
new file mode 100644
index 00000000..bf3cff50
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_diff_view.handlebars.php
@@ -0,0 +1,36 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffRevision' => 'Flow\TemplateHelper::diffRevision',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ '.LCRun3::ch($cx, 'l10nParse', array(array('flow-compare-revisions-header-post',((isset($in['revision']['new']['rev_view_links']['board']['title']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['title'] : null),((isset($in['revision']['new']['properties']['topic-of-post']) && is_array($in['revision']['new']['properties'])) ? $in['revision']['new']['properties']['topic-of-post'] : null),((isset($in['revision']['new']['author']['name']) && is_array($in['revision']['new']['author'])) ? $in['revision']['new']['author']['name'] : null),((isset($in['revision']['new']['rev_view_links']['board']['url']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['url'] : null),((isset($in['revision']['new']['rev_view_links']['root']['url']) && is_array($in['revision']['new']['rev_view_links']['root'])) ? $in['revision']['new']['rev_view_links']['root']['url'] : null),((isset($in['revision']['new']['rev_view_links']['hist']['url']) && is_array($in['revision']['new']['rev_view_links']['hist'])) ? $in['revision']['new']['rev_view_links']['hist']['url'] : null)),array()), 'encq').'
+ </div>
+ <div class="flow-compare-revisions">
+ '.LCRun3::ch($cx, 'diffRevision', array(array(((isset($in['revision']['diff_content']) && is_array($in['revision'])) ? $in['revision']['diff_content'] : null),((isset($in['revision']['old']['human_timestamp']) && is_array($in['revision']['old'])) ? $in['revision']['old']['human_timestamp'] : null),((isset($in['revision']['new']['human_timestamp']) && is_array($in['revision']['new'])) ? $in['revision']['new']['human_timestamp'] : null),((isset($in['revision']['old']['author']['name']) && is_array($in['revision']['old']['author'])) ? $in['revision']['old']['author']['name'] : null),((isset($in['revision']['new']['author']['name']) && is_array($in['revision']['new']['author'])) ? $in['revision']['new']['author']['name'] : null),((isset($in['revision']['old']['rev_view_links']['single-view']['url']) && is_array($in['revision']['old']['rev_view_links']['single-view'])) ? $in['revision']['old']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['new']['rev_view_links']['single-view']['url']) && is_array($in['revision']['new']['rev_view_links']['single-view'])) ? $in['revision']['new']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['links']['previous']) && is_array($in['revision']['links'])) ? $in['revision']['links']['previous'] : null),((isset($in['revision']['links']['next']) && is_array($in['revision']['links'])) ? $in['revision']['links']['next'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_edit_title.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_edit_title.handlebars.php
new file mode 100644
index 00000000..279f468a
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_edit_title.handlebars.php
@@ -0,0 +1,58 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_edit_topic_title' => function ($cx, $in) {return '<form method="POST" action="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_prev_revision" value="'.htmlentities((string)((isset($in['revisionId']) && is_array($in)) ? $in['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input name="topic_content" class="mw-ui-input" value="'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'" />
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ data-flow-api-handler="submitTopicTitle"
+ data-flow-api-target="< .flow-topic"
+ class="mw-ui-button mw-ui-constructive">'.LCRun3::ch($cx, 'l10n', array(array('flow-edit-title-submit'),array()), 'encq').'</button>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <button data-role="cancel"
+ type="reset"
+ data-flow-interactive-handler="cancelForm"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet">'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+ <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'</small>
+';}).' </div>
+</form>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+
+'.LCRun3::sec($cx, ((isset($in['roots']) && is_array($in)) ? $in['roots'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_edit_topic_title', array(array($in),array())).'';}).'';}).'</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_history.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_history.handlebars.php
new file mode 100644
index 00000000..19560919
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_history.handlebars.php
@@ -0,0 +1,144 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'historyTimestamp' => 'Flow\TemplateHelper::historyTimestamp',
+ 'historyDescription' => 'Flow\TemplateHelper::historyDescription',
+ 'showCharacterDifference' => 'Flow\TemplateHelper::showCharacterDifference',
+ 'concat' => 'Flow\TemplateHelper::concat',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'ifCond' => 'Flow\TemplateHelper::ifCond',
+),
+ 'partials' => array('flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_history_line' => function ($cx, $in) {return '<span class="flow-pipelist">
+ ('.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span>'.((LCRun3::ifvar($cx, ((isset($in['links']['diff-cur']) && is_array($in['links'])) ? $in['links']['diff-cur'] : null))) ? '<a href="'.htmlentities((string)((isset($in['links']['diff-cur']['url']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['diff-cur']['title']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['diff-cur']['text']) && is_array($in['links']['diff-cur'])) ? $in['links']['diff-cur']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>' : ''.LCRun3::ch($cx, 'l10n', array(array('cur'),array()), 'encq').'').'</span>
+ <span>
+'.((LCRun3::ifvar($cx, ((isset($in['links']['diff-prev']) && is_array($in['links'])) ? $in['links']['diff-prev'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['diff-prev']['url']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['diff-prev']['title']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['diff-prev']['text']) && is_array($in['links']['diff-prev'])) ? $in['links']['diff-prev']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>' : ''.LCRun3::ch($cx, 'l10n', array(array('last'),array()), 'encq').'').'</span>'.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? ' <span><a href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['links']['topic']['text']) && is_array($in['links']['topic'])) ? $in['links']['topic']['text'] : null), ENT_QUOTES, 'UTF-8').'</a></span>' : '').')
+</span>
+
+'.LCRun3::ch($cx, 'historyTimestamp', array(array($in),array()), 'encq').'
+
+<span class="mw-changeslist-separator">. .</span>
+'.LCRun3::ch($cx, 'historyDescription', array(array($in),array()), 'encq').'
+
+'.((LCRun3::ifvar($cx, ((isset($in['size']) && is_array($in)) ? $in['size'] : null))) ? ' <span class="mw-changeslist-separator">. .</span>
+ '.LCRun3::ch($cx, 'showCharacterDifference', array(array(((isset($in['size']['old']) && is_array($in['size'])) ? $in['size']['old'] : null),((isset($in['size']['new']) && is_array($in['size'])) ? $in['size']['new'] : null)),array()), 'encq').'
+' : '').'
+<ul class="flow-history-moderation-menu">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'history','moderationTarget'=>'post','moderationTemplate'=>'post','moderationMwUiClass'=>'mw-ui-anchor','moderationIcons'=>false))).'</ul>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-topic-histories">
+ '.LCRun3::ch($cx, 'html', array(array(((isset($in['navbar']) && is_array($in)) ? $in['navbar'] : null)),array()), 'encq').'
+
+ <ul>
+'.LCRun3::sec($cx, ((isset($in['revisions']) && is_array($in)) ? $in['revisions'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::p($cx, 'flow_history_line', array(array($in),array())).'</li>
+';}).' </ul>
+
+ '.LCRun3::ch($cx, 'html', array(array(((isset($in['navbar']) && is_array($in)) ? $in['navbar'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_lock.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_lock.handlebars.php
new file mode 100644
index 00000000..2a0dd0ff
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_lock.handlebars.php
@@ -0,0 +1,76 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_topic_titlebar_lock' => function ($cx, $in) {return '<div class="flow-topic-summary-container">
+ <div class="flow-topic-summary">
+ <form class="flow-edit-form" data-flow-initial-state="collapsed" method="POST"
+ action="'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ''.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'').'">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <div class="flow-editor">
+ <textarea name="flow_reason"
+ class="mw-ui-input"
+ type="text"
+ required
+ data-flow-preview-node="moderateReason"
+ data-flow-preview-template="flow_topic_titlebar.partial"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null), ENT_QUOTES, 'UTF-8').'' : '').'</textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-topic"
+ data-flow-api-handler="lockTopic"
+ >
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-action-unlock-topic'),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-action-lock-topic'),array()), 'encq').'
+').' </button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-unlock-topic'),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-lock-topic'),array()), 'encq').'
+').' </small>
+ </div>
+ </form>
+ </div>
+</div>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return ''.LCRun3::p($cx, 'flow_topic_titlebar_lock', array(array($in),array())).'
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_moderate_post.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_moderate_post.handlebars.php
new file mode 100644
index 00000000..ff8c4e4b
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_moderate_post.handlebars.php
@@ -0,0 +1,308 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'moderationAction' => 'Flow\TemplateHelper::moderationAction',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_moderate_post' => function ($cx, $in) {return '<form method="POST" action="'.LCRun3::ch($cx, 'moderationAction', array(array(((isset($in['actions']) && is_array($in)) ? $in['actions'] : null),((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null)),array()), 'encq').'">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <div class="flow-editor">
+ <textarea name="topic_reason"
+ required
+ data-flow-expandable="true"
+ class="mw-ui-input"
+ data-role="content"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-moderation-placeholder-',((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null),'-post'),array()), 'raw')),array()), 'encq').'"
+ autofocus
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null), ENT_QUOTES, 'UTF-8').'' : '').'</textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="moderatePost"
+ class="mw-ui-button mw-ui-constructive"
+ data-role="submit">'.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-moderation-confirm-',((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null),'-post'),array()), 'raw')),array()), 'encq').'</button>
+ <a data-flow-interactive-handler="cancelForm"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'">'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</a>
+ </div>
+</form>
+';},'flow_post_author' => function ($cx, $in) {return '<span class="flow-author">
+'.((LCRun3::ifvar($cx, ((isset($in['links']) && is_array($in)) ? $in['links'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['userpage']['url']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ '.((!LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? 'title="'.htmlentities((string)((isset($in['links']['userpage']['title']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['title'] : null), ENT_QUOTES, 'UTF-8').'"' : '').'
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['userpage']['exists']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['exists'] : null))) ? 'new ' : '').'mw-userlink">
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? ''.htmlentities((string)((isset($in['name']) && is_array($in)) ? $in['name'] : null), ENT_QUOTES, 'UTF-8').'' : ''.LCRun3::ch($cx, 'l10n', array(array('flow-anonymous'),array()), 'encq').'').''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? '</a>' : '').'<span class="mw-usertoollinks flow-pipelist">
+ ('.((LCRun3::ifvar($cx, ((isset($in['links']['talk']) && is_array($in['links'])) ? $in['links']['talk'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['talk']['url']) && is_array($in['links']['talk'])) ? $in['links']['talk']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['talk']['exists']) && is_array($in['links']['talk'])) ? $in['links']['talk']['exists'] : null))) ? 'new ' : '').'"
+ title="'.htmlentities((string)((isset($in['links']['talk']['title']) && is_array($in['links']['talk'])) ? $in['links']['talk']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('talkpagelinktext'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['contribs']) && is_array($in['links'])) ? $in['links']['contribs'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['contribs']['url']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['contribs']['title']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('contribslink'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['block']) && is_array($in['links'])) ? $in['links']['block'] : null))) ? '<span><a class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['block']['exists']) && is_array($in['links']['block'])) ? $in['links']['block']['exists'] : null))) ? 'new ' : '').'"
+ href="'.htmlentities((string)((isset($in['links']['block']['url']) && is_array($in['links']['block'])) ? $in['links']['block']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['block']['title']) && is_array($in['links']['block'])) ? $in['links']['block']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('blocklink'),array()), 'encq').'</a></span>' : '').')
+ </span>
+' : '').'</span>
+';},'flow_post_moderation_state' => function ($cx, $in) {return '<span class="plainlinks">'.((LCRun3::ifvar($cx, ((isset($in['replyToId']) && is_array($in)) ? $in['replyToId'] : null))) ? ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-post-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-title-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'').'</span>
+';},'flow_post_meta_actions' => function ($cx, $in) {return '<div class="flow-post-meta">
+ <span class="flow-post-meta-actions">
+'.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['reply']['title']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="mw-ui-anchor mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="activateReplyPost"
+
+ data-flow-eventlog-schema="FlowReplies"
+ data-flow-eventlog-action="initiate"
+ data-flow-eventlog-entrypoint="reply-post"
+ data-flow-eventlog-forward="
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'cancel\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'action\'][name=\'preview\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'submit\']
+ "
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['thank']) && is_array($in['actions'])) ? $in['actions']['thank'] : null))) ? ' <a class="mw-ui-anchor mw-ui-constructive mw-ui-quiet mw-thanks-flow-thank-link"
+ href="'.htmlentities((string)((isset($in['actions']['thank']['url']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['thank']['title']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['actions']['thank']['text']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').' </span>
+
+ <span class="flow-post-timestamp">
+'.((LCRun3::ifvar($cx, ((isset($in['isOriginalContent']) && is_array($in)) ? $in['isOriginalContent'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">
+ '.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), 'encq').'
+ </a>
+' : ' <span>
+'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null),'===',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), $in, false, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited'),array()), 'encq').'
+';}, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited-by',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), 'encq').'
+';}).' </span>
+ <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">'.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['lastEditId']) && is_array($in)) ? $in['lastEditId'] : null)),array()), 'encq').'</a>
+').' </span>
+</div>
+';},'flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_post_actions' => function ($cx, $in) {return '<div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'post','moderationTarget'=>'post','moderationTemplate'=>'post','moderationContainerClass'=>'flow-menu','moderationMwUiClass'=>'mw-ui-button','moderationIcons'=>true))).' </ul>
+</div>
+';},'flow_post_inner' => function ($cx, $in) {return '<div
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' class="flow-post-main flow-post-moderated flow-click-interactive flow-element-collapsible flow-element-collapsed"
+ data-flow-interactive-handler="collapserCollapsibleToggle"
+ tabindex="0"
+' : ' class="flow-post-main"
+').'>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::wi($cx, ((isset($in['creator']) && is_array($in)) ? $in['creator'] : null), $in, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_author', array(array($in),array())).'';}).'
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' <div class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </div>
+' : '').'
+ <div class="flow-post-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'
+ </div>
+
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_meta_actions', array(array($in),array())).''.LCRun3::p($cx, 'flow_post_actions', array(array($in),array())).'' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_edit_post' => function ($cx, $in) {return '<form class="flow-edit-post-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_prev_revision" value="'.htmlentities((string)((isset($in['revisionId']) && is_array($in)) ? $in['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea name="topic_content" class="mw-ui-input flow-form-collapsible"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-creator="'.htmlentities((string)((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-role="content"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-api-handler="submitEditPost">'.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'</small>
+ </div>
+</form>
+';},'flow_reply_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <form class="flow-post flow-reply-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ id="flow-reply-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_replyTo" value="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'"
+ data-role="content"
+
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['submitted'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'';}).'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-reply'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_post_replies' => function ($cx, $in) {return '<div class="flow-replies">
+'.LCRun3::sec($cx, ((isset($in['replies']) && is_array($in)) ? $in['replies'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), $in, false, function($cx, $in) {return ' <!-- eachPost nested replies -->
+ '.LCRun3::ch($cx, 'post', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), 'encq').'
+';}).'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','reply'),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}).'';}).'</div>
+';},'flow_post' => function ($cx, $in) {return ''.LCRun3::wi($cx, ((isset($in['revision']) && is_array($in)) ? $in['revision'] : null), $in, function($cx, $in) {return ' <div id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-post'.((LCRun3::ifvar($cx, ((isset($in['isMaxThreadingDepth']) && is_array($in)) ? $in['isMaxThreadingDepth'] : null))) ? ' flow-post-max-depth' : '').'"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ >
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['showPostId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['showPostId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}, function($cx, $in) {return ' <div class="flow-post-main flow-post-moderated">
+ <span class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </span>
+ </div>
+';}).'' : ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','edit-post'),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_edit_post', array(array($in),array())).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'').'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_replies', array(array($in),array())).'' : '').' </div>
+';}).'';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.LCRun3::sec($cx, ((isset($in['roots']) && is_array($in)) ? $in['roots'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_moderate_post', array(array($in),array())).''.LCRun3::p($cx, 'flow_post', array(array($in),array())).'';}).'';}).'</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_moderate_topic.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_moderate_topic.handlebars.php
new file mode 100644
index 00000000..b3cdc2f3
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_moderate_topic.handlebars.php
@@ -0,0 +1,308 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'moderationAction' => 'Flow\TemplateHelper::moderationAction',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_moderate_topic' => function ($cx, $in) {return '<form method="POST" action="'.LCRun3::ch($cx, 'moderationAction', array(array(((isset($in['actions']) && is_array($in)) ? $in['actions'] : null),((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null)),array()), 'encq').'">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <div class="flow-editor">
+ <textarea name="topic_reason"
+ required
+ data-flow-expandable="true"
+ class="mw-ui-input"
+ data-role="content"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-moderation-placeholder-',((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null),'-topic'),array()), 'raw')),array()), 'encq').'"
+ autofocus
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['reason']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['reason'] : null), ENT_QUOTES, 'UTF-8').'' : '').'</textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="moderateTopic"
+ data-role="submit">'.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-moderation-confirm-',((isset($cx['sp_vars']['root']['submitted']['moderationState']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['moderationState'] : null),'-topic'),array()), 'raw')),array()), 'encq').'</button>
+ <a class="mw-ui-button mw-ui-quiet mw-ui-destructive"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'"
+ data-flow-interactive-handler="cancelForm">'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</a>
+ </div>
+</form>
+';},'flow_post_author' => function ($cx, $in) {return '<span class="flow-author">
+'.((LCRun3::ifvar($cx, ((isset($in['links']) && is_array($in)) ? $in['links'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['userpage']['url']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ '.((!LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? 'title="'.htmlentities((string)((isset($in['links']['userpage']['title']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['title'] : null), ENT_QUOTES, 'UTF-8').'"' : '').'
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['userpage']['exists']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['exists'] : null))) ? 'new ' : '').'mw-userlink">
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? ''.htmlentities((string)((isset($in['name']) && is_array($in)) ? $in['name'] : null), ENT_QUOTES, 'UTF-8').'' : ''.LCRun3::ch($cx, 'l10n', array(array('flow-anonymous'),array()), 'encq').'').''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? '</a>' : '').'<span class="mw-usertoollinks flow-pipelist">
+ ('.((LCRun3::ifvar($cx, ((isset($in['links']['talk']) && is_array($in['links'])) ? $in['links']['talk'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['talk']['url']) && is_array($in['links']['talk'])) ? $in['links']['talk']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['talk']['exists']) && is_array($in['links']['talk'])) ? $in['links']['talk']['exists'] : null))) ? 'new ' : '').'"
+ title="'.htmlentities((string)((isset($in['links']['talk']['title']) && is_array($in['links']['talk'])) ? $in['links']['talk']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('talkpagelinktext'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['contribs']) && is_array($in['links'])) ? $in['links']['contribs'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['contribs']['url']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['contribs']['title']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('contribslink'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['block']) && is_array($in['links'])) ? $in['links']['block'] : null))) ? '<span><a class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['block']['exists']) && is_array($in['links']['block'])) ? $in['links']['block']['exists'] : null))) ? 'new ' : '').'"
+ href="'.htmlentities((string)((isset($in['links']['block']['url']) && is_array($in['links']['block'])) ? $in['links']['block']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['block']['title']) && is_array($in['links']['block'])) ? $in['links']['block']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('blocklink'),array()), 'encq').'</a></span>' : '').')
+ </span>
+' : '').'</span>
+';},'flow_post_moderation_state' => function ($cx, $in) {return '<span class="plainlinks">'.((LCRun3::ifvar($cx, ((isset($in['replyToId']) && is_array($in)) ? $in['replyToId'] : null))) ? ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-post-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-title-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'').'</span>
+';},'flow_post_meta_actions' => function ($cx, $in) {return '<div class="flow-post-meta">
+ <span class="flow-post-meta-actions">
+'.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['reply']['title']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="mw-ui-anchor mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="activateReplyPost"
+
+ data-flow-eventlog-schema="FlowReplies"
+ data-flow-eventlog-action="initiate"
+ data-flow-eventlog-entrypoint="reply-post"
+ data-flow-eventlog-forward="
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'cancel\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'action\'][name=\'preview\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'submit\']
+ "
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['thank']) && is_array($in['actions'])) ? $in['actions']['thank'] : null))) ? ' <a class="mw-ui-anchor mw-ui-constructive mw-ui-quiet mw-thanks-flow-thank-link"
+ href="'.htmlentities((string)((isset($in['actions']['thank']['url']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['thank']['title']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['actions']['thank']['text']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').' </span>
+
+ <span class="flow-post-timestamp">
+'.((LCRun3::ifvar($cx, ((isset($in['isOriginalContent']) && is_array($in)) ? $in['isOriginalContent'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">
+ '.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), 'encq').'
+ </a>
+' : ' <span>
+'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null),'===',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), $in, false, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited'),array()), 'encq').'
+';}, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited-by',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), 'encq').'
+';}).' </span>
+ <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">'.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['lastEditId']) && is_array($in)) ? $in['lastEditId'] : null)),array()), 'encq').'</a>
+').' </span>
+</div>
+';},'flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_post_actions' => function ($cx, $in) {return '<div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'post','moderationTarget'=>'post','moderationTemplate'=>'post','moderationContainerClass'=>'flow-menu','moderationMwUiClass'=>'mw-ui-button','moderationIcons'=>true))).' </ul>
+</div>
+';},'flow_post_inner' => function ($cx, $in) {return '<div
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' class="flow-post-main flow-post-moderated flow-click-interactive flow-element-collapsible flow-element-collapsed"
+ data-flow-interactive-handler="collapserCollapsibleToggle"
+ tabindex="0"
+' : ' class="flow-post-main"
+').'>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::wi($cx, ((isset($in['creator']) && is_array($in)) ? $in['creator'] : null), $in, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_author', array(array($in),array())).'';}).'
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' <div class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </div>
+' : '').'
+ <div class="flow-post-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'
+ </div>
+
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_meta_actions', array(array($in),array())).''.LCRun3::p($cx, 'flow_post_actions', array(array($in),array())).'' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_edit_post' => function ($cx, $in) {return '<form class="flow-edit-post-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_prev_revision" value="'.htmlentities((string)((isset($in['revisionId']) && is_array($in)) ? $in['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea name="topic_content" class="mw-ui-input flow-form-collapsible"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-creator="'.htmlentities((string)((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-role="content"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-api-handler="submitEditPost">'.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'</small>
+ </div>
+</form>
+';},'flow_reply_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <form class="flow-post flow-reply-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ id="flow-reply-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_replyTo" value="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'"
+ data-role="content"
+
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['submitted'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'';}).'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-reply'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_post_replies' => function ($cx, $in) {return '<div class="flow-replies">
+'.LCRun3::sec($cx, ((isset($in['replies']) && is_array($in)) ? $in['replies'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), $in, false, function($cx, $in) {return ' <!-- eachPost nested replies -->
+ '.LCRun3::ch($cx, 'post', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), 'encq').'
+';}).'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','reply'),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}).'';}).'</div>
+';},'flow_post' => function ($cx, $in) {return ''.LCRun3::wi($cx, ((isset($in['revision']) && is_array($in)) ? $in['revision'] : null), $in, function($cx, $in) {return ' <div id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-post'.((LCRun3::ifvar($cx, ((isset($in['isMaxThreadingDepth']) && is_array($in)) ? $in['isMaxThreadingDepth'] : null))) ? ' flow-post-max-depth' : '').'"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ >
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['showPostId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['showPostId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}, function($cx, $in) {return ' <div class="flow-post-main flow-post-moderated">
+ <span class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </span>
+ </div>
+';}).'' : ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','edit-post'),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_edit_post', array(array($in),array())).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'').'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_replies', array(array($in),array())).'' : '').' </div>
+';}).'';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.LCRun3::sec($cx, ((isset($in['roots']) && is_array($in)) ? $in['roots'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_moderate_topic', array(array($in),array())).''.LCRun3::p($cx, 'flow_post', array(array($in),array())).'';}).'';}).'</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_single_view.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_single_view.handlebars.php
new file mode 100644
index 00000000..79230ca9
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_single_view.handlebars.php
@@ -0,0 +1,39 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['previousRevisionId']) && is_array($in['revision'])) ? $in['revision']['previousRevisionId'] : null))) ? ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-post',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['board']['title']) && is_array($in['revision']['rev_view_links']['board'])) ? $in['revision']['rev_view_links']['board']['title'] : null),((isset($in['revision']['root']['content']) && is_array($in['revision']['root'])) ? $in['revision']['root']['content'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-post-first',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['board']['title']) && is_array($in['revision']['rev_view_links']['board'])) ? $in['revision']['rev_view_links']['board']['title'] : null),((isset($in['revision']['root']['content']) && is_array($in['revision']['root'])) ? $in['revision']['root']['content'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+').' </div>
+ <div class="flow-revision-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['revision']['content']['format']) && is_array($in['revision']['content'])) ? $in['revision']['content']['format'] : null),((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null)),array()), 'encq').'
+ </div>
+</div>
+
+
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topic_undo_edit.handlebars.php b/Flow/handlebars/compiled/flow_block_topic_undo_edit.handlebars.php
new file mode 100644
index 00000000..180c1433
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topic_undo_edit.handlebars.php
@@ -0,0 +1,73 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffUndo' => 'Flow\TemplateHelper::diffUndo',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' <p>'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-content'),array()), 'encq').'</p>
+' : ' <p class="error">'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-failure'),array()), 'encq').'</p>
+').'
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' '.LCRun3::ch($cx, 'diffUndo', array(array(((isset($in['undo']['diff_content']) && is_array($in['undo'])) ? $in['undo']['diff_content'] : null)),array()), 'encq').'
+' : '').'
+ <form method="POST" action="'.htmlentities((string)((isset($in['links']['undo-edit-post']['url']) && is_array($in['links']['undo-edit-post'])) ? $in['links']['undo-edit-post']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_prev_revision" value="'.htmlentities((string)((isset($in['current']['revisionId']) && is_array($in['current'])) ? $in['current']['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_postId" value="'.htmlentities((string)((isset($in['current']['postId']) && is_array($in['current'])) ? $in['current']['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+
+ <div class="flow-editor">
+ <textarea name="topic_content"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-username="'.htmlentities((string)((isset($in['current']['creator']['name']) && is_array($in['current']['creator'])) ? $in['current']['creator']['name'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ''.htmlentities((string)((isset($in['undo']['content']) && is_array($in['undo'])) ? $in['undo']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['current']['content']['content']) && is_array($in['current']['content'])) ? $in['current']['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">'.LCRun3::ch($cx, 'l10n', array(array('flow-edit-post-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'
+ </small>
+ </div>
+ </form>
+</div>
+
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topiclist.handlebars.php b/Flow/handlebars/compiled/flow_block_topiclist.handlebars.php
new file mode 100644
index 00000000..2978e4b9
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topiclist.handlebars.php
@@ -0,0 +1,368 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'timestamp' => 'Flow\TemplateHelper::timestampHelper',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_board_navigation' => function ($cx, $in) {return '
+<div class="flow-board-navigation flow-load-interactive" data-flow-load-handler="boardNavigation">
+ <div class="flow-error-container">
+ </div>
+ <div class="flow-board-navigation-inner">
+ <a href="javascript:void(0);"
+ class="flow-board-navigator-last flow-ui-tooltip-target"
+ data-tooltip-pointing="down"
+ title="'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['sortby']) && is_array($in)) ? $in['sortby'] : null),'===','updated'),array()), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10n', array(array('flow-sorting-tooltip-recent'),array()), 'encq').'';}, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10n', array(array('flow-sorting-tooltip-newest'),array()), 'encq').'';}).'"
+ data-flow-interactive-handler="menuToggle"
+ data-flow-menu-target="< .flow-board-navigation .flow-board-sort-menu">'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['sortby']) && is_array($in)) ? $in['sortby'] : null),'===','updated'),array()), $in, false, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-recent-topics'),array()), 'encq').'
+';}, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-newest-topics'),array()), 'encq').'
+';}).' <span class="wikiglyph wikiglyph-caret-down"></span>
+ </a>
+
+ <a href=""
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-board-navigation .flow-board-toc-menu .flow-list"
+ data-flow-api-handler="topicList"
+ data-flow-menu-target="< .flow-board-navigation .flow-board-toc-menu"
+ class="flow-board-navigator-active flow-board-navigator-first">
+ <span class="wikiglyph wikiglyph-stripe-toc"></span>
+ <span class="flow-load-interactive" data-flow-load-handler="boardNavigationTitle">'.LCRun3::ch($cx, 'l10n', array(array('flow-board-header-browse-topics-link'),array()), 'encq').'</span>
+ </a>
+ </div>
+
+ <div class="flow-board-header-menu">
+ <div class="flow-menu flow-menu-inverted flow-menu-scrollable flow-board-toc-menu flow-load-interactive"
+ data-flow-load-handler="menu"
+ data-flow-toc-target=".flow-list">
+ <div class="flow-menu-js-drop flow-menu-js-drop-hidden"><a href="javascript:void(0);" class="flow-board-header-menu-activator"></a></div>
+ <ul class="mw-ui-button-container flow-board-toc-list flow-list flow-load-interactive"
+ data-flow-load-handler="tocMenu"
+ data-flow-toc-target="li:not(.flow-load-more):last"
+ data-flow-template="flow_board_toc_loop.partial">
+ </ul>
+ </div>
+
+ <div class="flow-menu flow-board-sort-menu flow-load-interactive"
+ data-flow-load-handler="menu">
+ <div class="flow-menu-js-drop flow-menu-js-drop-hidden"><a href="javascript:void(0);" class="flow-board-header-menu-activator"></a></div>
+'.((LCRun3::ifvar($cx, ((isset($in['links']['board-sort']) && is_array($in['links'])) ? $in['links']['board-sort'] : null))) ? ' <ul class="mw-ui-button-container flow-list">'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['sortby']) && is_array($in)) ? $in['sortby'] : null),'===','updated'),array()), $in, false, function($cx, $in) {return ' <li><a class="mw-ui-button mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['board-sort']['newest']) && is_array($in['links']['board-sort'])) ? $in['links']['board-sort']['newest'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-component"
+ data-flow-api-handler="board"><span class="wikiglyph wikiglyph-star-circle"></span> '.LCRun3::ch($cx, 'l10n', array(array('flow-newest-topics'),array()), 'encq').'</a></li>
+';}, function($cx, $in) {return ' <li><a class="mw-ui-button mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['board-sort']['updated']) && is_array($in['links']['board-sort'])) ? $in['links']['board-sort']['updated'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-component"
+ data-flow-api-handler="board"><span class="wikiglyph wikiglyph-clock"></span> '.LCRun3::ch($cx, 'l10n', array(array('flow-recent-topics'),array()), 'encq').'</a></li>
+';}).' </ul>
+' : '').' </div>
+ </div>
+</div>
+';},'flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_newtopic_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['newtopic']) && is_array($in['actions'])) ? $in['actions']['newtopic'] : null))) ? ' <form action="'.htmlentities((string)((isset($in['actions']['newtopic']['url']) && is_array($in['actions']['newtopic'])) ? $in['actions']['newtopic']['url'] : null), ENT_QUOTES, 'UTF-8').'" method="POST" class="flow-newtopic-form" data-flow-initial-state="collapsed">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topiclist_replyTo" value="'.htmlentities((string)((isset($in['workflowId']) && is_array($in)) ? $in['workflowId'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input name="topiclist_topic" class="mw-ui-input mw-ui-input-large"
+ required
+ '.((LCRun3::ifvar($cx, ((isset($in['submitted']['topic']) && is_array($in['submitted'])) ? $in['submitted']['topic'] : null))) ? 'value="'.htmlentities((string)((isset($in['submitted']['topic']) && is_array($in['submitted'])) ? $in['submitted']['topic'] : null), ENT_QUOTES, 'UTF-8').'"' : '').'
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-start-placeholder'),array()), 'encq').'"
+ data-role="title"
+
+ data-flow-interactive-handler-focus="activateNewTopic"
+ />
+ <div class="flow-editor">
+ <textarea name="topiclist_content"
+ data-flow-preview-template="flow_topic.partial"
+ data-flow-preview-title-generator="newTopic"
+ class="mw-ui-input flow-form-collapsible mw-ui-input-large"
+ '.((LCRun3::ifvar($cx, ((isset($in['isOnFlowBoard']) && is_array($in)) ? $in['isOnFlowBoard'] : null))) ? 'style="display:none;"' : '').'
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-content-placeholder',((isset($cx['sp_vars']['root']['title']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['title'] : null)),array()), 'encq').'"
+ data-role="content"
+ required
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible"
+ '.((LCRun3::ifvar($cx, ((isset($in['isOnFlowBoard']) && is_array($in)) ? $in['isOnFlowBoard'] : null))) ? 'style="display:none;"' : '').'>
+ <button data-role="submit" data-flow-api-handler="newTopic"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-eventlog-action="save-attempt"
+ class="mw-ui-button mw-ui-constructive mw-ui-flush-right">'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-save'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-new-topic'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_topic_moderation_flag' => function ($cx, $in) {return '<span class="wikiglyph'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','lock'),array()), $in, false, function($cx, $in) {return ' wikiglyph-lock';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','hide'),array()), $in, false, function($cx, $in) {return ' wikiglyph-flag';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'===','delete'),array()), $in, false, function($cx, $in) {return ' wikiglyph-trash';}).'"></span>
+';},'flow_post_moderation_state' => function ($cx, $in) {return '<span class="plainlinks">'.((LCRun3::ifvar($cx, ((isset($in['replyToId']) && is_array($in)) ? $in['replyToId'] : null))) ? ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-post-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-title-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'').'</span>
+';},'flow_topic_titlebar_summary' => function ($cx, $in) {return '<div class="flow-topic-summary-container">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ' <div class="flow-topic-summary">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['summary']['format']) && is_array($in['summary'])) ? $in['summary']['format'] : null),((isset($in['summary']['content']) && is_array($in['summary'])) ? $in['summary']['content'] : null)),array()), 'encq').'
+ </div>
+ <br class="flow-ui-clear"/>
+' : '').'</div>
+';},'flow_topic_titlebar_content' => function ($cx, $in) {return '<h2 class="flow-topic-title flow-load-interactive"
+ data-flow-topic-title="'.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'"
+ data-flow-load-handler="topicTitle">'.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'</h2>
+<div class="flow-topic-meta">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-comments',((isset($in['reply_count']) && is_array($in)) ? $in['reply_count'] : null)),array()), 'encq').' &bull;
+
+ <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">
+'.((LCRun3::ifvar($cx, ((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null))) ? ' '.LCRun3::ch($cx, 'timestamp', array(array(((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null)),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), 'encq').'
+').' </a>
+</div>
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' <div class="flow-moderated-topic-title flow-ui-text-truncated">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').''.LCRun3::p($cx, 'flow_topic_moderation_flag', array(array($in),array())).'
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </div>
+ <div class="flow-moderated-topic-reason">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-moderated-reason-prefix'),array()), 'encq').'
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['moderateReason']['format']) && is_array($in['moderateReason'])) ? $in['moderateReason']['format'] : null),((isset($in['moderateReason']['content']) && is_array($in['moderateReason'])) ? $in['moderateReason']['content'] : null)),array()), 'encq').'
+ </div>
+' : '').'<span class="flow-reply-count"><span class="wikiglyph wikiglyph-speech-bubble"></span><span class="flow-reply-count-number">'.htmlentities((string)((isset($in['reply_count']) && is_array($in)) ? $in['reply_count'] : null), ENT_QUOTES, 'UTF-8').'</span></span>
+
+'.LCRun3::p($cx, 'flow_topic_titlebar_summary', array(array($in),array())).'';},'flow_topic_titlebar_watch' => function ($cx, $in) {return '<div class="flow-topic-watchlist flow-watch-link">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+ <a href="'.((LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? ''.htmlentities((string)((isset($in['links']['unwatch-topic']['url']) && is_array($in['links']['unwatch-topic'])) ? $in['links']['unwatch-topic']['url'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['links']['watch-topic']['url']) && is_array($in['links']['watch-topic'])) ? $in['links']['watch-topic']['url'] : null), ENT_QUOTES, 'UTF-8').'').'"
+ class="mw-ui-anchor mw-ui-constructive '.((!LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? 'mw-ui-quiet' : '').'
+'.((LCRun3::ifvar($cx, ((isset($in['isWatched']) && is_array($in)) ? $in['isWatched'] : null))) ? 'flow-watch-link-unwatch' : 'flow-watch-link-watch').'"
+ data-flow-api-handler="watchItem"
+ data-flow-api-target="< .flow-topic-watchlist"
+ data-flow-api-method="POST">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span class="wikiglyph wikiglyph-star"></span>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').''.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<span class="wikiglyph wikiglyph-unstar"></span>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</a>
+</div>
+';},'flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_topic_titlebar' => function ($cx, $in) {return '<div class="flow-topic-titlebar">
+'.LCRun3::p($cx, 'flow_topic_titlebar_content', array(array($in),array())).'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['watchable']) && is_array($in)) ? $in['watchable'] : null))) ? ''.LCRun3::p($cx, 'flow_topic_titlebar_watch', array(array($in),array())).'' : '').' <div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'topic','moderationTarget'=>'title','moderationTemplate'=>'topic','moderationContainerClass'=>'flow-menu','moderationMwUiClass'=>'mw-ui-button','moderationIcons'=>true))).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_reply_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <form class="flow-post flow-reply-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ id="flow-reply-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_replyTo" value="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'"
+ data-role="content"
+
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['submitted'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'';}).'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-reply'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_topic' => function ($cx, $in) {return '<div class="flow-topic flow-load-interactive
+ '.((LCRun3::ifvar($cx, ((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null))) ? 'flow-topic-moderatestate-'.htmlentities((string)((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null), ENT_QUOTES, 'UTF-8').'' : '').'
+ '.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? 'flow-topic-moderated' : '').'
+ "
+ id="flow-topic-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-load-handler="topic"
+ data-flow-toc-scroll-target=".flow-topic-titlebar"
+ data-flow-topic-timestamp-updated="'.htmlentities((string)((isset($in['last_updated']) && is_array($in)) ? $in['last_updated'] : null), ENT_QUOTES, 'UTF-8').'"
+>
+'.LCRun3::p($cx, 'flow_topic_titlebar', array(array($in),array())).'
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['posts']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['posts'] : null))) ? ''.LCRun3::sec($cx, ((isset($in['replies']) && is_array($in)) ? $in['replies'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ' <!-- eachPost topic -->
+ '.LCRun3::ch($cx, 'post', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), 'encq').'
+';}).'';}).'' : '').'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}, function($cx, $in) {return ''.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array('type'=>'replace','target'=>'~ a')), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}).' <a href="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['reply']['title']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-ui-input-replacement-anchor mw-ui-input"
+ >'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'</a>
+';}).'' : '').'' : '').'</div>
+';},'flow_topiclist_loop' => function ($cx, $in) {return ''.LCRun3::sec($cx, ((isset($in['roots']) && is_array($in)) ? $in['roots'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']) && is_array($cx['sp_vars'])) ? $cx['sp_vars']['root'] : null),$in),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_topic', array(array($in),array())).'';}).'';}).'';},'flow_load_more' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['loadMoreObject']) && is_array($in)) ? $in['loadMoreObject'] : null))) ? ' <div class="flow-load-more">
+ <div class="flow-error-container">
+ </div>
+
+ <a data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="'.htmlentities((string)((isset($in['loadMoreApiHandler']) && is_array($in)) ? $in['loadMoreApiHandler'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-load-more"
+ data-flow-load-handler="loadMore"
+ data-flow-scroll-target="'.htmlentities((string)((isset($in['loadMoreTarget']) && is_array($in)) ? $in['loadMoreTarget'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-scroll-container="'.htmlentities((string)((isset($in['loadMoreContainer']) && is_array($in)) ? $in['loadMoreContainer'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-template="'.htmlentities((string)((isset($in['loadMoreTemplate']) && is_array($in)) ? $in['loadMoreTemplate'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['loadMoreObject']['url']) && is_array($in['loadMoreObject'])) ? $in['loadMoreObject']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['loadMoreObject']['title']) && is_array($in['loadMoreObject'])) ? $in['loadMoreObject']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="mw-ui-button mw-ui-progressive flow-load-interactive flow-ui-fallback-element"><span class="wikiglyph wikiglyph-article"></span> '.LCRun3::ch($cx, 'l10n', array(array('flow-load-more'),array()), 'encq').'</a>
+ </div>
+' : ' <div class="flow-no-more">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-no-more-fwd'),array()), 'encq').'
+ </div>
+').'';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return ''.LCRun3::p($cx, 'flow_board_navigation', array(array($in),array())).'
+<div class="flow-board" data-flow-sortby="'.htmlentities((string)((isset($in['sortby']) && is_array($in)) ? $in['sortby'] : null), ENT_QUOTES, 'UTF-8').'">
+ <div class="flow-newtopic-container">
+ <div class="flow-nojs">
+ <a class="mw-ui-input mw-ui-input-large flow-ui-input-replacement-anchor"
+ href="'.htmlentities((string)((isset($in['links']['newtopic']) && is_array($in['links'])) ? $in['links']['newtopic'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-start-placeholder'),array()), 'encq').'</a>
+ </div>
+
+ <div class="flow-js">
+'.LCRun3::p($cx, 'flow_newtopic_form', array(array($in),array('isOnFlowBoard'=>true))).' </div>
+ </div>
+
+ <div class="flow-topics">
+'.LCRun3::p($cx, 'flow_topiclist_loop', array(array($in),array())).'
+'.LCRun3::p($cx, 'flow_load_more', array(array($in),array('loadMoreApiHandler'=>'loadMoreTopics','loadMoreTarget'=>'window','loadMoreContainer'=>'< .flow-topics','loadMoreTemplate'=>'flow_topiclist_loop.partial','loadMoreObject'=>((isset($in['links']['pagination']['fwd']) && is_array($in['links']['pagination'])) ? $in['links']['pagination']['fwd'] : null)))).' </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topiclist_newtopic.handlebars.php b/Flow/handlebars/compiled/flow_block_topiclist_newtopic.handlebars.php
new file mode 100644
index 00000000..ca86f2f7
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topiclist_newtopic.handlebars.php
@@ -0,0 +1,90 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_newtopic_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['newtopic']) && is_array($in['actions'])) ? $in['actions']['newtopic'] : null))) ? ' <form action="'.htmlentities((string)((isset($in['actions']['newtopic']['url']) && is_array($in['actions']['newtopic'])) ? $in['actions']['newtopic']['url'] : null), ENT_QUOTES, 'UTF-8').'" method="POST" class="flow-newtopic-form" data-flow-initial-state="collapsed">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['editToken']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topiclist_replyTo" value="'.htmlentities((string)((isset($in['workflowId']) && is_array($in)) ? $in['workflowId'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input name="topiclist_topic" class="mw-ui-input mw-ui-input-large"
+ required
+ '.((LCRun3::ifvar($cx, ((isset($in['submitted']['topic']) && is_array($in['submitted'])) ? $in['submitted']['topic'] : null))) ? 'value="'.htmlentities((string)((isset($in['submitted']['topic']) && is_array($in['submitted'])) ? $in['submitted']['topic'] : null), ENT_QUOTES, 'UTF-8').'"' : '').'
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-start-placeholder'),array()), 'encq').'"
+ data-role="title"
+
+ data-flow-interactive-handler-focus="activateNewTopic"
+ />
+ <div class="flow-editor">
+ <textarea name="topiclist_content"
+ data-flow-preview-template="flow_topic.partial"
+ data-flow-preview-title-generator="newTopic"
+ class="mw-ui-input flow-form-collapsible mw-ui-input-large"
+ '.((LCRun3::ifvar($cx, ((isset($in['isOnFlowBoard']) && is_array($in)) ? $in['isOnFlowBoard'] : null))) ? 'style="display:none;"' : '').'
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-content-placeholder',((isset($cx['sp_vars']['root']['title']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['title'] : null)),array()), 'encq').'"
+ data-role="content"
+ required
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible"
+ '.((LCRun3::ifvar($cx, ((isset($in['isOnFlowBoard']) && is_array($in)) ? $in['isOnFlowBoard'] : null))) ? 'style="display:none;"' : '').'>
+ <button data-role="submit" data-flow-api-handler="newTopic"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-eventlog-action="save-attempt"
+ class="mw-ui-button mw-ui-constructive mw-ui-flush-right">'.LCRun3::ch($cx, 'l10n', array(array('flow-newtopic-save'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-new-topic'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.LCRun3::p($cx, 'flow_newtopic_form', array(array($in),array())).'</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topicsummary_diff_view.handlebars.php b/Flow/handlebars/compiled/flow_block_topicsummary_diff_view.handlebars.php
new file mode 100644
index 00000000..7b964ef4
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topicsummary_diff_view.handlebars.php
@@ -0,0 +1,36 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffRevision' => 'Flow\TemplateHelper::diffRevision',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ '.LCRun3::ch($cx, 'l10nParse', array(array('flow-compare-revisions-header-postsummary',((isset($in['revision']['new']['rev_view_links']['board']['title']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['title'] : null),((isset($in['revision']['new']['properties']['post-of-summary']) && is_array($in['revision']['new']['properties'])) ? $in['revision']['new']['properties']['post-of-summary'] : null),((isset($in['revision']['new']['rev_view_links']['board']['url']) && is_array($in['revision']['new']['rev_view_links']['board'])) ? $in['revision']['new']['rev_view_links']['board']['url'] : null),((isset($in['revision']['new']['rev_view_links']['root']['url']) && is_array($in['revision']['new']['rev_view_links']['root'])) ? $in['revision']['new']['rev_view_links']['root']['url'] : null),((isset($in['revision']['new']['rev_view_links']['hist']['url']) && is_array($in['revision']['new']['rev_view_links']['hist'])) ? $in['revision']['new']['rev_view_links']['hist']['url'] : null)),array()), 'encq').'
+ </div>
+ <div class="flow-compare-revisions">
+ '.LCRun3::ch($cx, 'diffRevision', array(array(((isset($in['revision']['diff_content']) && is_array($in['revision'])) ? $in['revision']['diff_content'] : null),((isset($in['revision']['old']['human_timestamp']) && is_array($in['revision']['old'])) ? $in['revision']['old']['human_timestamp'] : null),((isset($in['revision']['new']['human_timestamp']) && is_array($in['revision']['new'])) ? $in['revision']['new']['human_timestamp'] : null),((isset($in['revision']['old']['author']['name']) && is_array($in['revision']['old']['author'])) ? $in['revision']['old']['author']['name'] : null),((isset($in['revision']['new']['author']['name']) && is_array($in['revision']['new']['author'])) ? $in['revision']['new']['author']['name'] : null),((isset($in['revision']['old']['rev_view_links']['single-view']['url']) && is_array($in['revision']['old']['rev_view_links']['single-view'])) ? $in['revision']['old']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['new']['rev_view_links']['single-view']['url']) && is_array($in['revision']['new']['rev_view_links']['single-view'])) ? $in['revision']['new']['rev_view_links']['single-view']['url'] : null),((isset($in['revision']['links']['previous']) && is_array($in['revision']['links'])) ? $in['revision']['links']['previous'] : null),((isset($in['revision']['links']['next']) && is_array($in['revision']['links'])) ? $in['revision']['links']['next'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topicsummary_edit.handlebars.php b/Flow/handlebars/compiled/flow_block_topicsummary_edit.handlebars.php
new file mode 100644
index 00000000..d2f1bb33
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topicsummary_edit.handlebars.php
@@ -0,0 +1,75 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-topic-summary-container">
+ <div class="flow-topic-summary">
+ <form class="flow-edit-form" data-flow-initial-state="collapsed" method="POST" action="'.htmlentities((string)((isset($in['revision']['actions']['summarize']['url']) && is_array($in['revision']['actions']['summarize'])) ? $in['revision']['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'">
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($in['editToken']) && is_array($in)) ? $in['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['revisionId']) && is_array($in['revision'])) ? $in['revision']['revisionId'] : null))) ? ' <input type="hidden" name="'.htmlentities((string)((isset($in['type']) && is_array($in)) ? $in['type'] : null), ENT_QUOTES, 'UTF-8').'_prev_revision" value="'.htmlentities((string)((isset($in['revision']['revisionId']) && is_array($in['revision'])) ? $in['revision']['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+' : '').'
+ <div class="flow-editor">
+ <textarea class="mw-ui-input"
+ required
+ name="'.htmlentities((string)((isset($in['type']) && is_array($in)) ? $in['type'] : null), ENT_QUOTES, 'UTF-8').'_summary"
+ data-flow-preview-node="summary"
+ data-flow-preview-template="flow_topic_titlebar_summary.partial"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['revision']['articleTitle']) && is_array($in['revision'])) ? $in['revision']['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ type="text"
+ data-role="content"
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['summary']) && is_array($in['submitted'])) ? $in['submitted']['summary'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['summary']) && is_array($in['submitted'])) ? $in['submitted']['summary'] : null), ENT_QUOTES, 'UTF-8').'' : ''.((LCRun3::ifvar($cx, ((isset($in['revision']['revisionId']) && is_array($in['revision'])) ? $in['revision']['revisionId'] : null))) ? ''.htmlentities((string)((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null), ENT_QUOTES, 'UTF-8').'' : '').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button
+ data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="summarizeTopic"
+ data-flow-api-target="< .flow-topic-summary-container">
+ '.LCRun3::ch($cx, 'l10n', array(array('flow-topic-action-summarize-topic'),array()), 'encq').'
+ </button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-summarize'),array()), 'encq').'</small>
+ </div>
+ </form>
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topicsummary_single_view.handlebars.php b/Flow/handlebars/compiled/flow_block_topicsummary_single_view.handlebars.php
new file mode 100644
index 00000000..6992b88d
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topicsummary_single_view.handlebars.php
@@ -0,0 +1,37 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+'.((LCRun3::ifvar($cx, ((isset($in['revision']['previousRevisionId']) && is_array($in['revision'])) ? $in['revision']['previousRevisionId'] : null))) ? ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-postsummary',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['board']['title']) && is_array($in['revision']['rev_view_links']['board'])) ? $in['revision']['rev_view_links']['board']['title'] : null),((isset($in['revision']['root']['content']) && is_array($in['revision']['root'])) ? $in['revision']['root']['content'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+' : ' '.LCRun3::ch($cx, 'l10nParse', array(array('flow-revision-permalink-warning-postsummary-first',((isset($in['revision']['human_timestamp']) && is_array($in['revision'])) ? $in['revision']['human_timestamp'] : null),((isset($in['revision']['rev_view_links']['board']['title']) && is_array($in['revision']['rev_view_links']['board'])) ? $in['revision']['rev_view_links']['board']['title'] : null),((isset($in['revision']['root']['content']) && is_array($in['revision']['root'])) ? $in['revision']['root']['content'] : null),((isset($in['revision']['rev_view_links']['hist']['url']) && is_array($in['revision']['rev_view_links']['hist'])) ? $in['revision']['rev_view_links']['hist']['url'] : null),((isset($in['revision']['rev_view_links']['diff']['url']) && is_array($in['revision']['rev_view_links']['diff'])) ? $in['revision']['rev_view_links']['diff']['url'] : null)),array()), 'encq').'
+').' </div>
+ <div class="flow-revision-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['revision']['content']['format']) && is_array($in['revision']['content'])) ? $in['revision']['content']['format'] : null),((isset($in['revision']['content']['content']) && is_array($in['revision']['content'])) ? $in['revision']['content']['content'] : null)),array()), 'encq').'
+ </div>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_block_topicsummary_undo_edit.handlebars.php b/Flow/handlebars/compiled/flow_block_topicsummary_undo_edit.handlebars.php
new file mode 100644
index 00000000..e5d39fb6
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_block_topicsummary_undo_edit.handlebars.php
@@ -0,0 +1,72 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffUndo' => 'Flow\TemplateHelper::diffUndo',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="flow-board">
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' <p>'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-content'),array()), 'encq').'</p>
+' : ' <p class="error">'.LCRun3::ch($cx, 'l10n', array(array('flow-undo-edit-failure'),array()), 'encq').'</p>
+').'
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ' '.LCRun3::ch($cx, 'diffUndo', array(array(((isset($in['undo']['diff_content']) && is_array($in['undo'])) ? $in['undo']['diff_content'] : null)),array()), 'encq').'
+' : '').'
+ <form method="POST" action="'.htmlentities((string)((isset($in['links']['undo-edit-header']['url']) && is_array($in['links']['undo-edit-header'])) ? $in['links']['undo-edit-header']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topicsummary_prev_revision" value="'.htmlentities((string)((isset($in['current']['revisionId']) && is_array($in['current'])) ? $in['current']['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+
+ <div class="flow-editor">
+ <textarea name="topicsummary_summary"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-node="summary"
+ data-flow-preview-template="flow_topic_titlebar_summary.partial"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.((LCRun3::ifvar($cx, ((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($in['submitted']['content']) && is_array($in['submitted'])) ? $in['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.((LCRun3::ifvar($cx, ((isset($in['undo']['possible']) && is_array($in['undo'])) ? $in['undo']['possible'] : null))) ? ''.htmlentities((string)((isset($in['undo']['content']) && is_array($in['undo'])) ? $in['undo']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['current']['content']['content']) && is_array($in['current']['content'])) ? $in['current']['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">'.LCRun3::ch($cx, 'l10n', array(array('flow-topic-action-summarize-topic'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-summarize'),array()), 'encq').'
+ </small>
+ </div>
+ </form>
+</div>
+
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_post.handlebars.php b/Flow/handlebars/compiled/flow_post.handlebars.php
new file mode 100644
index 00000000..28078b44
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_post.handlebars.php
@@ -0,0 +1,282 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array( 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+),
+ 'partials' => array('flow_errors' => function ($cx, $in) {return '<div class="flow-error-container">
+'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null))) ? ' <div class="flow-errors errorbox">
+ <ul>
+'.LCRun3::sec($cx, ((isset($cx['sp_vars']['root']['errors']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['errors'] : null), $in, true, function($cx, $in) {return ' <li>'.LCRun3::ch($cx, 'html', array(array(((isset($in['message']) && is_array($in)) ? $in['message'] : null)),array()), 'encq').'</li>
+';}).' </ul>
+ </div>
+' : '').'</div>
+';},'flow_post_author' => function ($cx, $in) {return '<span class="flow-author">
+'.((LCRun3::ifvar($cx, ((isset($in['links']) && is_array($in)) ? $in['links'] : null))) ? ''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['userpage']['url']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ '.((!LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? 'title="'.htmlentities((string)((isset($in['links']['userpage']['title']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['title'] : null), ENT_QUOTES, 'UTF-8').'"' : '').'
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['userpage']['exists']) && is_array($in['links']['userpage'])) ? $in['links']['userpage']['exists'] : null))) ? 'new ' : '').'mw-userlink">
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['name']) && is_array($in)) ? $in['name'] : null))) ? ''.htmlentities((string)((isset($in['name']) && is_array($in)) ? $in['name'] : null), ENT_QUOTES, 'UTF-8').'' : ''.LCRun3::ch($cx, 'l10n', array(array('flow-anonymous'),array()), 'encq').'').''.((LCRun3::ifvar($cx, ((isset($in['links']['userpage']) && is_array($in['links'])) ? $in['links']['userpage'] : null))) ? '</a>' : '').'<span class="mw-usertoollinks flow-pipelist">
+ ('.((LCRun3::ifvar($cx, ((isset($in['links']['talk']) && is_array($in['links'])) ? $in['links']['talk'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['talk']['url']) && is_array($in['links']['talk'])) ? $in['links']['talk']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['talk']['exists']) && is_array($in['links']['talk'])) ? $in['links']['talk']['exists'] : null))) ? 'new ' : '').'"
+ title="'.htmlentities((string)((isset($in['links']['talk']['title']) && is_array($in['links']['talk'])) ? $in['links']['talk']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('talkpagelinktext'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['contribs']) && is_array($in['links'])) ? $in['links']['contribs'] : null))) ? '<span><a href="'.htmlentities((string)((isset($in['links']['contribs']['url']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['url'] : null), ENT_QUOTES, 'UTF-8').'" title="'.htmlentities((string)((isset($in['links']['contribs']['title']) && is_array($in['links']['contribs'])) ? $in['links']['contribs']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('contribslink'),array()), 'encq').'</a></span>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['block']) && is_array($in['links'])) ? $in['links']['block'] : null))) ? '<span><a class="'.((!LCRun3::ifvar($cx, ((isset($in['links']['block']['exists']) && is_array($in['links']['block'])) ? $in['links']['block']['exists'] : null))) ? 'new ' : '').'"
+ href="'.htmlentities((string)((isset($in['links']['block']['url']) && is_array($in['links']['block'])) ? $in['links']['block']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['block']['title']) && is_array($in['links']['block'])) ? $in['links']['block']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('blocklink'),array()), 'encq').'</a></span>' : '').')
+ </span>
+' : '').'</span>
+';},'flow_post_moderation_state' => function ($cx, $in) {return '<span class="plainlinks">'.((LCRun3::ifvar($cx, ((isset($in['replyToId']) && is_array($in)) ? $in['replyToId'] : null))) ? ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-post-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10nParse', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderateState']) && is_array($in)) ? $in['moderateState'] : null),'-title-content'),array()), 'raw'),((isset($in['moderator']['name']) && is_array($in['moderator'])) ? $in['moderator']['name'] : null),((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null)),array()), 'encq').'').'</span>
+';},'flow_post_meta_actions' => function ($cx, $in) {return '<div class="flow-post-meta">
+ <span class="flow-post-meta-actions">
+'.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['reply']['title']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="mw-ui-anchor mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="activateReplyPost"
+
+ data-flow-eventlog-schema="FlowReplies"
+ data-flow-eventlog-action="initiate"
+ data-flow-eventlog-entrypoint="reply-post"
+ data-flow-eventlog-forward="
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'cancel\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'action\'][name=\'preview\'],
+ < .flow-post:not([data-flow-post-max-depth=\'1\']) .flow-reply-form [data-role=\'submit\']
+ "
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['thank']) && is_array($in['actions'])) ? $in['actions']['thank'] : null))) ? ' <a class="mw-ui-anchor mw-ui-constructive mw-ui-quiet mw-thanks-flow-thank-link"
+ href="'.htmlentities((string)((isset($in['actions']['thank']['url']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['thank']['title']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.htmlentities((string)((isset($in['actions']['thank']['text']) && is_array($in['actions']['thank'])) ? $in['actions']['thank']['text'] : null), ENT_QUOTES, 'UTF-8').'</a>
+' : '').' </span>
+
+ <span class="flow-post-timestamp">
+'.((LCRun3::ifvar($cx, ((isset($in['isOriginalContent']) && is_array($in)) ? $in['isOriginalContent'] : null))) ? ' <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">
+ '.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), 'encq').'
+ </a>
+' : ' <span>
+'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null),'===',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), $in, false, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited'),array()), 'encq').'
+';}, function($cx, $in) {return ' '.LCRun3::ch($cx, 'l10n', array(array('flow-edited-by',((isset($in['lastEditUser']['name']) && is_array($in['lastEditUser'])) ? $in['lastEditUser']['name'] : null)),array()), 'encq').'
+';}).' </span>
+ <a href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-anchor">'.LCRun3::ch($cx, 'uuidTimestamp', array(array(((isset($in['lastEditId']) && is_array($in)) ? $in['lastEditId'] : null)),array()), 'encq').'</a>
+').' </span>
+</div>
+';},'flow_moderation_actions_list' => function ($cx, $in) {return '<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','topic'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li class="flow-js">'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-edit-title'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic-history']) && is_array($in['links'])) ? $in['links']['topic-history'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic-history']['url']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic-history']['title']) && is_array($in['links']['topic-history'])) ? $in['links']['topic-history']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-clock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-history'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['topic']) && is_array($in['links'])) ? $in['links']['topic'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['topic']['url']) && is_array($in['links']['topic'])) ? $in['links']['topic']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['topic']['title']) && is_array($in['links']['topic'])) ? $in['links']['topic']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['summarize']) && is_array($in['actions'])) ? $in['actions']['summarize'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['summarize']['url']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['summarize']['title']) && is_array($in['actions']['summarize'])) ? $in['actions']['summarize']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-stripe-toc"></span> ' : '').''.((LCRun3::ifvar($cx, ((isset($in['summary']) && is_array($in)) ? $in['summary'] : null))) ? ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-resummarize-topic'),array()), 'raw')),array()), 'encq').'' : ''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-summarize-topic'),array()), 'raw')),array()), 'encq').'').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','post'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['edit']) && is_array($in['actions'])) ? $in['actions']['edit'] : null))) ? '<li>
+ <a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-progressive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['edit']['title']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-pencil"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post'),array()), 'encq').'</a>
+ </li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['links']['post']) && is_array($in['links'])) ? $in['links']['post'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['links']['post']['url']) && is_array($in['links']['post'])) ? $in['links']['post']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['links']['post']['title']) && is_array($in['links']['post'])) ? $in['links']['post']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-link"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-view'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+
+<section>'.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['undo']) && is_array($in['actions'])) ? $in['actions']['undo'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undo']['url']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ >'.htmlentities((string)((isset($in['actions']['undo']['title']) && is_array($in['actions']['undo'])) ? $in['actions']['undo']['title'] : null), ENT_QUOTES, 'UTF-8').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).''.((LCRun3::ifvar($cx, ((isset($in['actions']['hide']) && is_array($in['actions'])) ? $in['actions']['hide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['hide']['url']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['hide']['title']) && is_array($in['actions']['hide'])) ? $in['actions']['hide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="hide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-hide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unhide']) && is_array($in['actions'])) ? $in['actions']['unhide'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unhide']['url']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unhide']['title']) && is_array($in['actions']['unhide'])) ? $in['actions']['unhide']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unhide">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-flag"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unhide-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['delete']) && is_array($in['actions'])) ? $in['actions']['delete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['delete']['url']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['delete']['title']) && is_array($in['actions']['delete'])) ? $in['actions']['delete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="delete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-delete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['undelete']) && is_array($in['actions'])) ? $in['actions']['undelete'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['undelete']['url']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['undelete']['title']) && is_array($in['actions']['undelete'])) ? $in['actions']['undelete']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="undelete">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-trash"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-undelete-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['suppress']) && is_array($in['actions'])) ? $in['actions']['suppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['suppress']['url']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['suppress']['title']) && is_array($in['actions']['suppress'])) ? $in['actions']['suppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="suppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-suppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unsuppress']) && is_array($in['actions'])) ? $in['actions']['unsuppress'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ href="'.htmlentities((string)((isset($in['actions']['unsuppress']['url']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unsuppress']['title']) && is_array($in['actions']['unsuppress'])) ? $in['actions']['unsuppress']['title'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_'.htmlentities((string)((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null), ENT_QUOTES, 'UTF-8').'.partial"
+ data-role="unsuppress">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-block"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unsuppress-',((isset($in['moderationTemplate']) && is_array($in)) ? $in['moderationTemplate'] : null)),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'===','history'),array()), $in, false, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}, function($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['lock']) && is_array($in['actions'])) ? $in['actions']['lock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['lock']['url']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['lock']['title']) && is_array($in['actions']['lock'])) ? $in['actions']['lock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-lock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-lock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').''.((LCRun3::ifvar($cx, ((isset($in['actions']['unlock']) && is_array($in['actions'])) ? $in['actions']['unlock'] : null))) ? '<li>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'<a class="'.htmlentities((string)((isset($in['moderationMwUiClass']) && is_array($in)) ? $in['moderationMwUiClass'] : null), ENT_QUOTES, 'UTF-8').' mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="'.htmlentities((string)((isset($in['actions']['unlock']['url']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ title="'.htmlentities((string)((isset($in['actions']['unlock']['title']) && is_array($in['actions']['unlock'])) ? $in['actions']['unlock']['title'] : null), ENT_QUOTES, 'UTF-8').'">'.((LCRun3::ifvar($cx, ((isset($in['moderationIcons']) && is_array($in)) ? $in['moderationIcons'] : null))) ? '<span class="wikiglyph wikiglyph-unlock"></span> ' : '').''.LCRun3::ch($cx, 'l10n', array(array(LCRun3::ch($cx, 'concat', array(array('flow-',((isset($in['moderationType']) && is_array($in)) ? $in['moderationType'] : null),'-action-unlock-topic'),array()), 'raw')),array()), 'encq').'</a>'.htmlentities((string)((isset($in['noop']) && is_array($in)) ? $in['noop'] : null), ENT_QUOTES, 'UTF-8').'</li>' : '').'';}).'</section>
+';},'flow_post_actions' => function ($cx, $in) {return '<div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+'.LCRun3::p($cx, 'flow_moderation_actions_list', array(array($in),array('moderationType'=>'post','moderationTarget'=>'post','moderationTemplate'=>'post','moderationContainerClass'=>'flow-menu','moderationMwUiClass'=>'mw-ui-button','moderationIcons'=>true))).' </ul>
+</div>
+';},'flow_post_inner' => function ($cx, $in) {return '<div
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' class="flow-post-main flow-post-moderated flow-click-interactive flow-element-collapsible flow-element-collapsed"
+ data-flow-interactive-handler="collapserCollapsibleToggle"
+ tabindex="0"
+' : ' class="flow-post-main"
+').'>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::wi($cx, ((isset($in['creator']) && is_array($in)) ? $in['creator'] : null), $in, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_author', array(array($in),array())).'';}).'
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ' <div class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </div>
+' : '').'
+ <div class="flow-post-content">
+ '.LCRun3::ch($cx, 'escapeContent', array(array(((isset($in['content']['format']) && is_array($in['content'])) ? $in['content']['format'] : null),((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null)),array()), 'encq').'
+ </div>
+
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_meta_actions', array(array($in),array())).''.LCRun3::p($cx, 'flow_post_actions', array(array($in),array())).'' : '').'</div>
+';},'flow_anon_warning' => function ($cx, $in) {return '<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'down','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+
+'.LCRun3::hbch($cx, 'progressiveEnhancement', array(array(),array()), $in, false, function($cx, $in) {return ' <div class="flow-anon-warning-desktop">
+'.LCRun3::hbch($cx, 'tooltip', array(array(),array('positionClass'=>'left','contextClass'=>'progressive','extraClass'=>'flow-form-collapsible','isBlock'=>true)), $in, false, function($cx, $in) {return ''.LCRun3::ch($cx, 'l10nParse', array(array('flow-anon-warning',LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin'),array()), 'raw'),LCRun3::ch($cx, 'linkWithReturnTo', array(array('Special:UserLogin/signup'),array()), 'raw')),array()), 'encq').'';}).' </div>
+';}).'</div>
+';},'flow_form_buttons' => function ($cx, $in) {return '<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+>'.LCRun3::ch($cx, 'l10n', array(array('flow-cancel'),array()), 'encq').'</button>
+';},'flow_edit_post' => function ($cx, $in) {return '<form class="flow-edit-post-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['edit']['url']) && is_array($in['actions']['edit'])) ? $in['actions']['edit']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+>
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).' <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_prev_revision" value="'.htmlentities((string)((isset($in['revisionId']) && is_array($in)) ? $in['revisionId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea name="topic_content" class="mw-ui-input flow-form-collapsible"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-creator="'.htmlentities((string)((isset($in['creator']['name']) && is_array($in['creator'])) ? $in['creator']['name'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-role="content"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null))) ? ''.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['submitted']['content']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'' : ''.htmlentities((string)((isset($in['content']['content']) && is_array($in['content'])) ? $in['content']['content'] : null), ENT_QUOTES, 'UTF-8').'').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-api-handler="submitEditPost">'.LCRun3::ch($cx, 'l10n', array(array('flow-post-action-edit-post-submit'),array()), 'encq').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-edit'),array()), 'encq').'</small>
+ </div>
+</form>
+';},'flow_reply_form' => function ($cx, $in) {return ''.((LCRun3::ifvar($cx, ((isset($in['actions']['reply']) && is_array($in['actions'])) ? $in['actions']['reply'] : null))) ? ' <form class="flow-post flow-reply-form"
+ method="POST"
+ action="'.htmlentities((string)((isset($in['actions']['reply']['url']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['url'] : null), ENT_QUOTES, 'UTF-8').'"
+ id="flow-reply-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="'.htmlentities((string)((isset($cx['sp_vars']['root']['rootBlock']['editToken']) && is_array($cx['sp_vars']['root']['rootBlock'])) ? $cx['sp_vars']['root']['rootBlock']['editToken'] : null), ENT_QUOTES, 'UTF-8').'" />
+ <input type="hidden" name="topic_replyTo" value="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'" />
+'.LCRun3::p($cx, 'flow_errors', array(array($in),array())).'
+'.LCRun3::hbch($cx, 'ifAnonymous', array(array(),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_anon_warning', array(array($in),array())).'';}).'
+ <div class="flow-editor">
+ <textarea id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="'.htmlentities((string)((isset($in['articleTitle']) && is_array($in)) ? $in['articleTitle'] : null), ENT_QUOTES, 'UTF-8').'"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="'.LCRun3::ch($cx, 'l10n', array(array('flow-reply-topic-title-placeholder',((isset($in['properties']['topic-of-post']) && is_array($in['properties'])) ? $in['properties']['topic-of-post'] : null)),array()), 'encq').'"
+ data-role="content"
+
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >'.((LCRun3::ifvar($cx, ((isset($cx['sp_vars']['root']['submitted']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['submitted'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['submitted']['postId']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.htmlentities((string)((isset($cx['sp_vars']['root']['submitted']['content']) && is_array($cx['sp_vars']['root']['submitted'])) ? $cx['sp_vars']['root']['submitted']['content'] : null), ENT_QUOTES, 'UTF-8').'';}).'' : '').'</textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >'.htmlentities((string)((isset($in['actions']['reply']['text']) && is_array($in['actions']['reply'])) ? $in['actions']['reply']['text'] : null), ENT_QUOTES, 'UTF-8').'</button>
+'.LCRun3::p($cx, 'flow_form_buttons', array(array($in),array())).' <small class="flow-terms-of-use plainlinks">'.LCRun3::ch($cx, 'l10nParse', array(array('flow-terms-of-use-reply'),array()), 'encq').'</small>
+ </div>
+ </form>
+' : '').'';},'flow_post_replies' => function ($cx, $in) {return '<div class="flow-replies">
+'.LCRun3::sec($cx, ((isset($in['replies']) && is_array($in)) ? $in['replies'] : null), $in, true, function($cx, $in) {return ''.LCRun3::hbch($cx, 'eachPost', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), $in, false, function($cx, $in) {return ' <!-- eachPost nested replies -->
+ '.LCRun3::ch($cx, 'post', array(array(((isset($cx['sp_vars']['root']['rootBlock']) && is_array($cx['sp_vars']['root'])) ? $cx['sp_vars']['root']['rootBlock'] : null),$in),array()), 'encq').'
+';}).'';}).''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','reply'),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_reply_form', array(array($in),array())).'';}).'';}).'</div>
+';},),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return ''.LCRun3::wi($cx, ((isset($in['revision']) && is_array($in)) ? $in['revision'] : null), $in, function($cx, $in) {return ' <div id="flow-post-'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-post'.((LCRun3::ifvar($cx, ((isset($in['isMaxThreadingDepth']) && is_array($in)) ? $in['isMaxThreadingDepth'] : null))) ? ' flow-post-max-depth' : '').'"
+ data-flow-id="'.htmlentities((string)((isset($in['postId']) && is_array($in)) ? $in['postId'] : null), ENT_QUOTES, 'UTF-8').'"
+ >
+'.((LCRun3::ifvar($cx, ((isset($in['isModerated']) && is_array($in)) ? $in['isModerated'] : null))) ? ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['showPostId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['showPostId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}, function($cx, $in) {return ' <div class="flow-post-main flow-post-moderated">
+ <span class="flow-moderated-post-content">
+'.LCRun3::p($cx, 'flow_post_moderation_state', array(array($in),array())).' </span>
+ </div>
+';}).'' : ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['action']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['action'] : null),'===','edit-post'),array()), $in, false, function($cx, $in) {return ''.LCRun3::hbch($cx, 'ifCond', array(array(((isset($cx['sp_vars']['root']['rootBlock']['submitted']['postId']) && is_array($cx['sp_vars']['root']['rootBlock']['submitted'])) ? $cx['sp_vars']['root']['rootBlock']['submitted']['postId'] : null),'===',((isset($in['postId']) && is_array($in)) ? $in['postId'] : null)),array()), $in, false, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_edit_post', array(array($in),array())).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'';}, function($cx, $in) {return ''.LCRun3::p($cx, 'flow_post_inner', array(array($in),array())).'';}).'').'
+'.((!LCRun3::ifvar($cx, ((isset($in['isPreview']) && is_array($in)) ? $in['isPreview'] : null))) ? ''.LCRun3::p($cx, 'flow_post_replies', array(array($in),array())).'' : '').' </div>
+';}).'';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_revision_diff_header.handlebars.php b/Flow/handlebars/compiled/flow_revision_diff_header.handlebars.php
new file mode 100644
index 00000000..3ceab989
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_revision_diff_header.handlebars.php
@@ -0,0 +1,33 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div><a href="'.htmlentities((string)((isset($in['link']) && is_array($in)) ? $in['link'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-diff-revision-link">
+ '.LCRun3::ch($cx, 'l10nParse', array(array('flow-compare-revisions-revision-header',((isset($in['timestamp']) && is_array($in)) ? $in['timestamp'] : null),((isset($in['author']) && is_array($in)) ? $in['author'] : null)),array()), 'encq').'
+</a></div>
+'.((LCRun3::ifvar($cx, ((isset($in['previous']) && is_array($in)) ? $in['previous'] : null))) ? ' <div><a href="'.htmlentities((string)((isset($in['previous']) && is_array($in)) ? $in['previous'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('flow-previous-diff'),array()), 'encq').'</a></div>
+' : '').''.((LCRun3::ifvar($cx, ((isset($in['next']) && is_array($in)) ? $in['next'] : null))) ? ' <div><a href="'.htmlentities((string)((isset($in['next']) && is_array($in)) ? $in['next'] : null), ENT_QUOTES, 'UTF-8').'">'.LCRun3::ch($cx, 'l10n', array(array('flow-next-diff'),array()), 'encq').'</a></div>
+' : '').'';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/flow_tooltip.handlebars.php b/Flow/handlebars/compiled/flow_tooltip.handlebars.php
new file mode 100644
index 00000000..3a72a57d
--- /dev/null
+++ b/Flow/handlebars/compiled/flow_tooltip.handlebars.php
@@ -0,0 +1,29 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array( 'html' => 'Flow\TemplateHelper::htmlHelper',
+),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return '<div class="'.htmlentities((string)((isset($in['extraClass']) && is_array($in)) ? $in['extraClass'] : null), ENT_QUOTES, 'UTF-8').' flow-ui-tooltip '.htmlentities((string)((isset($in['contextClass']) && is_array($in)) ? $in['contextClass'] : null), ENT_QUOTES, 'UTF-8').' '.htmlentities((string)((isset($in['positionClass']) && is_array($in)) ? $in['positionClass'] : null), ENT_QUOTES, 'UTF-8').' '.htmlentities((string)((isset($in['blockClass']) && is_array($in)) ? $in['blockClass'] : null), ENT_QUOTES, 'UTF-8').' plainlinks">'.LCRun3::ch($cx, 'html', array(array(((isset($in['content']) && is_array($in)) ? $in['content'] : null)),array()), 'encq').'<span class="flow-ui-tooltip-triangle"></span>
+</div>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/compiled/timestamp.handlebars.php b/Flow/handlebars/compiled/timestamp.handlebars.php
new file mode 100644
index 00000000..d74de933
--- /dev/null
+++ b/Flow/handlebars/compiled/timestamp.handlebars.php
@@ -0,0 +1,33 @@
+<?php return function ($in, $debugopt = 1) {
+ $cx = array(
+ 'flags' => array(
+ 'jstrue' => false,
+ 'jsobj' => false,
+ 'spvar' => true,
+ 'prop' => false,
+ 'method' => false,
+ 'mustlok' => false,
+ 'mustsec' => false,
+ 'echo' => false,
+ 'debug' => $debugopt,
+ ),
+ 'constants' => array(),
+ 'helpers' => array(),
+ 'blockhelpers' => array(),
+ 'hbhelpers' => array(),
+ 'partials' => array(),
+ 'scopes' => array($in),
+ 'sp_vars' => array('root' => $in),
+
+ );
+
+ return ''.((LCRun3::ifvar($cx, ((isset($in['guid']) && is_array($in)) ? $in['guid'] : null))) ? ' <span datetime="'.htmlentities((string)((isset($in['time_iso']) && is_array($in)) ? $in['time_iso'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp">
+' : ' <span datetime="'.htmlentities((string)((isset($in['time_iso']) && is_array($in)) ? $in['time_iso'] : null), ENT_QUOTES, 'UTF-8').'"
+ class="flow-timestamp flow-load-interactive"
+ data-flow-load-handler="timestamp">
+').' <span class="flow-timestamp-user-formatted">'.htmlentities((string)((isset($in['time_readable']) && is_array($in)) ? $in['time_readable'] : null), ENT_QUOTES, 'UTF-8').'</span>
+ <span id="'.htmlentities((string)((isset($in['guid']) && is_array($in)) ? $in['guid'] : null), ENT_QUOTES, 'UTF-8').'" class="flow-timestamp-ago">'.htmlentities((string)((isset($in['time_ago']) && is_array($in)) ? $in['time_ago'] : null), ENT_QUOTES, 'UTF-8').'</span>
+</span>
+';
+}
+?> \ No newline at end of file
diff --git a/Flow/handlebars/flow_anon_warning.partial.handlebars b/Flow/handlebars/flow_anon_warning.partial.handlebars
new file mode 100644
index 00000000..c9d4e2e1
--- /dev/null
+++ b/Flow/handlebars/flow_anon_warning.partial.handlebars
@@ -0,0 +1,27 @@
+<div class="flow-anon-warning">
+ <div class="flow-anon-warning-mobile">
+ {{!-- mobile warning --}}
+ {{#tooltip
+ positionClass="down"
+ contextClass="progressive"
+ extraClass="flow-form-collapsible"
+ isBlock=true
+ }}
+ {{~l10nParse "flow-anon-warning" (linkWithReturnTo "Special:UserLogin") (linkWithReturnTo "Special:UserLogin/signup")~}}
+ {{/tooltip}}
+ </div>
+
+ {{!-- desktop warning --}}
+ {{#progressiveEnhancement}}
+ <div class="flow-anon-warning-desktop">
+ {{#tooltip
+ positionClass="left"
+ contextClass="progressive"
+ extraClass="flow-form-collapsible"
+ isBlock=true
+ }}
+ {{~l10nParse "flow-anon-warning" (linkWithReturnTo "Special:UserLogin") (linkWithReturnTo "Special:UserLogin/signup")~}}
+ {{/tooltip}}
+ </div>
+ {{/progressiveEnhancement}}
+</div>
diff --git a/Flow/handlebars/flow_block_board-history.handlebars b/Flow/handlebars/flow_block_board-history.handlebars
new file mode 100644
index 00000000..1ea48fd9
--- /dev/null
+++ b/Flow/handlebars/flow_block_board-history.handlebars
@@ -0,0 +1,11 @@
+<div class="flow-board-history">
+ {{html navbar}}
+
+ <ul>
+ {{#each revisions}}
+ <li>{{> flow_history_line}}</li>
+ {{/each}}
+ </ul>
+
+ {{html navbar}}
+</div>
diff --git a/Flow/handlebars/flow_block_header.handlebars b/Flow/handlebars/flow_block_header.handlebars
new file mode 100644
index 00000000..5952a9c4
--- /dev/null
+++ b/Flow/handlebars/flow_block_header.handlebars
@@ -0,0 +1,4 @@
+<div class="flow-board-header">
+ {{> flow_errors}}
+ {{> flow_header_detail}}
+</div>
diff --git a/Flow/handlebars/flow_block_header_diff_view.handlebars b/Flow/handlebars/flow_block_header_diff_view.handlebars
new file mode 100644
index 00000000..c647e3e4
--- /dev/null
+++ b/Flow/handlebars/flow_block_header_diff_view.handlebars
@@ -0,0 +1,21 @@
+<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ {{l10nParse "flow-compare-revisions-header-header"
+ revision.new.rev_view_links.board.title
+ revision.new.author.name
+ revision.new.rev_view_links.board.url
+ revision.new.rev_view_links.hist.url }}
+ </div>
+ <div class="flow-compare-revisions">
+ {{diffRevision revision.diff_content
+ revision.old.human_timestamp
+ revision.new.human_timestamp
+ revision.old.author.name
+ revision.new.author.name
+ revision.old.rev_view_links.single-view.url
+ revision.new.rev_view_links.single-view.url
+ revision.links.previous
+ revision.links.next
+ }}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_header_edit.handlebars b/Flow/handlebars/flow_block_header_edit.handlebars
new file mode 100644
index 00000000..1e70f169
--- /dev/null
+++ b/Flow/handlebars/flow_block_header_edit.handlebars
@@ -0,0 +1,37 @@
+<div class="flow-board-header">
+ <div class="flow-board-header-edit-view">
+ <form method="POST" action="{{revision.actions.edit.url}}" flow-api-action="edit-header">
+ {{> flow_errors }}
+ <input type="hidden" name="wpEditToken" value="{{@root.editToken}}" />
+ {{#if revision.revisionId}}
+ <input type="hidden" name="header_prev_revision" value="{{revision.revisionId}}" />
+ {{/if}}
+
+ <div class="flow-editor">
+ <textarea name="header_content"
+ class="mw-ui-input"
+ data-flow-preview-template="flow_header_detail.partial"
+ placeholder="{{l10n "flow-edit-header-placeholder"}}"
+ data-role="content"
+ >
+ {{~#if submitted.content~}}
+ {{~submitted.content~}}
+ {{~else~}}
+ {{~revision.content.content~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitHeader">
+ {{~l10n "flow-edit-header-submit"~}}
+ </button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">{{l10nParse "flow-terms-of-use-edit"}}</small>
+ </div>
+ </form>
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_header_single_view.handlebars b/Flow/handlebars/flow_block_header_single_view.handlebars
new file mode 100644
index 00000000..640a0486
--- /dev/null
+++ b/Flow/handlebars/flow_block_header_single_view.handlebars
@@ -0,0 +1,19 @@
+<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+ {{#if revision.previousRevisionId}}
+ {{l10nParse "flow-revision-permalink-warning-header"
+ revision.human_timestamp
+ revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{else}}
+ {{l10nParse "flow-revision-permalink-warning-header-first"
+ revision.human_timestamp
+ revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{/if}}
+ </div>
+
+ <div class="flow-revision-content">
+ {{escapeContent revision.content.format revision.content.content}}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_header_undo_edit.handlebars b/Flow/handlebars/flow_block_header_undo_edit.handlebars
new file mode 100644
index 00000000..8d6358aa
--- /dev/null
+++ b/Flow/handlebars/flow_block_header_undo_edit.handlebars
@@ -0,0 +1,48 @@
+<div class="flow-board">
+ {{#if undo.possible}}
+ <p>{{l10n "flow-undo-edit-content"}}</p>
+ {{else}}
+ <p class="error">{{l10n "flow-undo-edit-failure"}}</p>
+ {{/if}}
+
+ {{> flow_errors}}
+
+ {{#if undo.possible}}
+ {{diffUndo undo.diff_content}}
+ {{/if}}
+
+ <form method="POST" action="{{links.undo-edit-header.url}}" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="{{@root.rootBlock.editToken}}" />
+ <input type="hidden" name="header_prev_revision" value="{{current.revisionId}}" />
+
+ <div class="flow-editor">
+ <textarea name="topic_content"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-template="flow_header_detail.partial"
+ data-flow-preview-title="{{articleTitle}}"
+ >
+ {{~#if submitted.content~}}
+ {{~submitted.content~}}
+ {{~else~}}
+ {{~#if undo.possible~}}
+ {{~undo.content~}}
+ {{~else~}}
+ {{~current.content.content~}}
+ {{~/if~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">
+ {{~l10n "flow-edit-header-submit"~}}
+ </button>
+ {{> flow_form_buttons}}
+ <small class="flow-terms-of-use plainlinks">
+ {{~l10nParse "flow-terms-of-use-edit"}}
+ </small>
+ </div>
+ </form>
+</div>
+
diff --git a/Flow/handlebars/flow_block_loop.handlebars b/Flow/handlebars/flow_block_loop.handlebars
new file mode 100644
index 00000000..4a822bf4
--- /dev/null
+++ b/Flow/handlebars/flow_block_loop.handlebars
@@ -0,0 +1,3 @@
+{{#each blocks}}
+ {{block this}}
+{{/each}} \ No newline at end of file
diff --git a/Flow/handlebars/flow_block_topic.handlebars b/Flow/handlebars/flow_block_topic.handlebars
new file mode 100644
index 00000000..c858a5bc
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic.handlebars
@@ -0,0 +1,8 @@
+<div class="flow-board">
+ <div class="flow-topics">
+ {{> flow_errors}}
+
+ {{!-- There is only one topic, but we use same api response structure --}}
+ {{> flow_topiclist_loop}}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topic_diff_view.handlebars b/Flow/handlebars/flow_block_topic_diff_view.handlebars
new file mode 100644
index 00000000..dd8208db
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_diff_view.handlebars
@@ -0,0 +1,24 @@
+<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ {{l10nParse "flow-compare-revisions-header-post"
+ revision.new.rev_view_links.board.title
+ revision.new.properties.topic-of-post.
+ revision.new.author.name
+ revision.new.rev_view_links.board.url
+ revision.new.rev_view_links.root.url
+ revision.new.rev_view_links.hist.url
+ }}
+ </div>
+ <div class="flow-compare-revisions">
+ {{diffRevision revision.diff_content
+ revision.old.human_timestamp
+ revision.new.human_timestamp
+ revision.old.author.name
+ revision.new.author.name
+ revision.old.rev_view_links.single-view.url
+ revision.new.rev_view_links.single-view.url
+ revision.links.previous
+ revision.links.next
+ }}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topic_edit_title.handlebars b/Flow/handlebars/flow_block_topic_edit_title.handlebars
new file mode 100644
index 00000000..2143dd65
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_edit_title.handlebars
@@ -0,0 +1,9 @@
+<div class="flow-board">
+ {{!-- There is only one post, but the output format matches multi-post output --}}
+
+ {{#each roots}}
+ {{#eachPost @root this}}
+ {{> flow_edit_topic_title}}
+ {{/eachPost}}
+ {{/each}}
+</div>
diff --git a/Flow/handlebars/flow_block_topic_history.handlebars b/Flow/handlebars/flow_block_topic_history.handlebars
new file mode 100644
index 00000000..34fa631d
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_history.handlebars
@@ -0,0 +1,13 @@
+<div class="flow-board">
+ <div class="flow-topic-histories">
+ {{html navbar}}
+
+ <ul>
+ {{#each revisions}}
+ <li>{{> flow_history_line}}</li>
+ {{/each}}
+ </ul>
+
+ {{html navbar}}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topic_lock.handlebars b/Flow/handlebars/flow_block_topic_lock.handlebars
new file mode 100644
index 00000000..7774b0f2
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_lock.handlebars
@@ -0,0 +1,2 @@
+{{>flow_topic_titlebar_lock}}
+
diff --git a/Flow/handlebars/flow_block_topic_moderate_post.handlebars b/Flow/handlebars/flow_block_topic_moderate_post.handlebars
new file mode 100644
index 00000000..0d63436b
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_moderate_post.handlebars
@@ -0,0 +1,9 @@
+<div class="flow-board">
+ {{!-- There is only one post, but the output format matches multi-post output --}}
+ {{#each roots}}
+ {{#eachPost @root this}}
+ {{> flow_moderate_post}}
+ {{> flow_post}}
+ {{/eachPost}}
+ {{/each}}
+</div>
diff --git a/Flow/handlebars/flow_block_topic_moderate_topic.handlebars b/Flow/handlebars/flow_block_topic_moderate_topic.handlebars
new file mode 100644
index 00000000..687e3d52
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_moderate_topic.handlebars
@@ -0,0 +1,9 @@
+<div class="flow-board">
+ {{!-- There is only one post, but the output format matches multi-post output --}}
+ {{#each roots}}
+ {{#eachPost @root this}}
+ {{> flow_moderate_topic}}
+ {{> flow_post}}
+ {{/eachPost}}
+ {{/each}}
+</div>
diff --git a/Flow/handlebars/flow_block_topic_single_view.handlebars b/Flow/handlebars/flow_block_topic_single_view.handlebars
new file mode 100644
index 00000000..dca9b8e9
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_single_view.handlebars
@@ -0,0 +1,24 @@
+<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+ {{#if revision.previousRevisionId}}
+ {{l10nParse "flow-revision-permalink-warning-post"
+ revision.human_timestamp
+ revision.rev_view_links.board.title
+ revision.root.content revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{else}}
+ {{l10nParse
+ "flow-revision-permalink-warning-post-first"
+ revision.human_timestamp
+ revision.rev_view_links.board.title
+ revision.root.content
+ revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{/if}}
+ </div>
+ <div class="flow-revision-content">
+ {{escapeContent revision.content.format revision.content.content}}
+ </div>
+</div>
+
+
diff --git a/Flow/handlebars/flow_block_topic_undo_edit.handlebars b/Flow/handlebars/flow_block_topic_undo_edit.handlebars
new file mode 100644
index 00000000..4eb91f98
--- /dev/null
+++ b/Flow/handlebars/flow_block_topic_undo_edit.handlebars
@@ -0,0 +1,50 @@
+<div class="flow-board">
+ {{#if undo.possible}}
+ <p>{{l10n "flow-undo-edit-content"}}</p>
+ {{else}}
+ <p class="error">{{l10n "flow-undo-edit-failure"}}</p>
+ {{/if}}
+
+ {{> flow_errors}}
+
+ {{#if undo.possible}}
+ {{diffUndo undo.diff_content}}
+ {{/if}}
+
+ <form method="POST" action="{{links.undo-edit-post.url}}" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="{{@root.rootBlock.editToken}}" />
+ <input type="hidden" name="topic_prev_revision" value="{{current.revisionId}}" />
+ <input type="hidden" name="topic_postId" value="{{current.postId}}" />
+
+ <div class="flow-editor">
+ <textarea name="topic_content"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="{{articleTitle}}"
+ data-flow-username="{{current.creator.name}}"
+ >
+ {{~#if submitted.content~}}
+ {{~submitted.content~}}
+ {{~else~}}
+ {{~#if undo.possible~}}
+ {{~undo.content~}}
+ {{~else~}}
+ {{~current.content.content~}}
+ {{~/if~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">
+ {{~l10n "flow-edit-post-submit"~}}
+ </button>
+ {{> flow_form_buttons}}
+ <small class="flow-terms-of-use plainlinks">
+ {{~l10nParse "flow-terms-of-use-edit"}}
+ </small>
+ </div>
+ </form>
+</div>
+
diff --git a/Flow/handlebars/flow_block_topiclist.handlebars b/Flow/handlebars/flow_block_topiclist.handlebars
new file mode 100644
index 00000000..fddf4715
--- /dev/null
+++ b/Flow/handlebars/flow_block_topiclist.handlebars
@@ -0,0 +1,21 @@
+{{> flow_board_navigation}}
+
+<div class="flow-board" data-flow-sortby="{{sortby}}">
+ <div class="flow-newtopic-container">
+ {{! No-JS gets a link to separate page with newtopic form }}
+ <div class="flow-nojs">
+ <a class="mw-ui-input mw-ui-input-large flow-ui-input-replacement-anchor"
+ href="{{links.newtopic}}">{{l10n "flow-newtopic-start-placeholder"}}</a>
+ </div>
+
+ <div class="flow-js">
+ {{> flow_newtopic_form isOnFlowBoard=true }}
+ </div>
+ </div>
+
+ <div class="flow-topics">
+ {{> flow_topiclist_loop}}
+
+ {{> flow_load_more this loadMoreApiHandler="loadMoreTopics" loadMoreTarget="window" loadMoreContainer="< .flow-topics" loadMoreTemplate="flow_topiclist_loop.partial" loadMoreObject=links.pagination.fwd}}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topiclist_newtopic.handlebars b/Flow/handlebars/flow_block_topiclist_newtopic.handlebars
new file mode 100644
index 00000000..295e40af
--- /dev/null
+++ b/Flow/handlebars/flow_block_topiclist_newtopic.handlebars
@@ -0,0 +1,3 @@
+<div class="flow-board">
+ {{> flow_newtopic_form}}
+</div>
diff --git a/Flow/handlebars/flow_block_topicsummary_diff_view.handlebars b/Flow/handlebars/flow_block_topicsummary_diff_view.handlebars
new file mode 100644
index 00000000..396d21e0
--- /dev/null
+++ b/Flow/handlebars/flow_block_topicsummary_diff_view.handlebars
@@ -0,0 +1,22 @@
+<div class="flow-board">
+ <div class="flow-compare-revisions-header plainlinks">
+ {{l10nParse "flow-compare-revisions-header-postsummary"
+ revision.new.rev_view_links.board.title
+ revision.new.properties.post-of-summary
+ revision.new.rev_view_links.board.url
+ revision.new.rev_view_links.root.url
+ revision.new.rev_view_links.hist.url }}
+ </div>
+ <div class="flow-compare-revisions">
+ {{diffRevision revision.diff_content
+ revision.old.human_timestamp
+ revision.new.human_timestamp
+ revision.old.author.name
+ revision.new.author.name
+ revision.old.rev_view_links.single-view.url
+ revision.new.rev_view_links.single-view.url
+ revision.links.previous
+ revision.links.next
+ }}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topicsummary_edit.handlebars b/Flow/handlebars/flow_block_topicsummary_edit.handlebars
new file mode 100644
index 00000000..13a813f1
--- /dev/null
+++ b/Flow/handlebars/flow_block_topicsummary_edit.handlebars
@@ -0,0 +1,45 @@
+<div class="flow-topic-summary-container">
+ <div class="flow-topic-summary">
+ <form class="flow-edit-form" data-flow-initial-state="collapsed" method="POST" action="{{revision.actions.summarize.url}}">
+ {{> flow_errors }}
+ <input type="hidden" name="wpEditToken" value="{{editToken}}" />
+
+ {{#if revision.revisionId}}
+ <input type="hidden" name="{{type}}_prev_revision" value="{{revision.revisionId}}" />
+ {{/if}}
+
+ <div class="flow-editor">
+ <textarea class="mw-ui-input"
+ required
+ name="{{type}}_summary"
+ data-flow-preview-node="summary"
+ data-flow-preview-template="flow_topic_titlebar_summary.partial"
+ data-flow-preview-title="{{revision.articleTitle}}"
+ type="text"
+ data-role="content"
+ >
+ {{~#if submitted.summary~}}
+ {{~submitted.summary~}}
+ {{~else~}}
+ {{~#if revision.revisionId~}}
+ {{~revision.content.content~}}
+ {{~/if~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button
+ data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="summarizeTopic"
+ data-flow-api-target="< .flow-topic-summary-container">
+ {{l10n "flow-topic-action-summarize-topic"}}
+ </button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">{{l10nParse "flow-terms-of-use-summarize"}}</small>
+ </div>
+ </form>
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topicsummary_single_view.handlebars b/Flow/handlebars/flow_block_topicsummary_single_view.handlebars
new file mode 100644
index 00000000..30643f9c
--- /dev/null
+++ b/Flow/handlebars/flow_block_topicsummary_single_view.handlebars
@@ -0,0 +1,22 @@
+<div class="flow-board">
+ <div class="flow-revision-permalink-warning plainlinks">
+ {{#if revision.previousRevisionId}}
+ {{l10nParse "flow-revision-permalink-warning-postsummary"
+ revision.human_timestamp
+ revision.rev_view_links.board.title
+ revision.root.content
+ revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{else}}
+ {{l10nParse "flow-revision-permalink-warning-postsummary-first"
+ revision.human_timestamp
+ revision.rev_view_links.board.title
+ revision.root.content
+ revision.rev_view_links.hist.url
+ revision.rev_view_links.diff.url}}
+ {{/if}}
+ </div>
+ <div class="flow-revision-content">
+ {{escapeContent revision.content.format revision.content.content}}
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_block_topicsummary_undo_edit.handlebars b/Flow/handlebars/flow_block_topicsummary_undo_edit.handlebars
new file mode 100644
index 00000000..ecaf4b29
--- /dev/null
+++ b/Flow/handlebars/flow_block_topicsummary_undo_edit.handlebars
@@ -0,0 +1,49 @@
+<div class="flow-board">
+ {{#if undo.possible}}
+ <p>{{l10n "flow-undo-edit-content"}}</p>
+ {{else}}
+ <p class="error">{{l10n "flow-undo-edit-failure"}}</p>
+ {{/if}}
+
+ {{> flow_errors}}
+
+ {{#if undo.possible}}
+ {{diffUndo undo.diff_content}}
+ {{/if}}
+
+ <form method="POST" action="{{links.undo-edit-header.url}}" class="flow-post">
+ <input type="hidden" name="wpEditToken" value="{{@root.rootBlock.editToken}}" />
+ <input type="hidden" name="topicsummary_prev_revision" value="{{current.revisionId}}" />
+
+ <div class="flow-editor">
+ <textarea name="topicsummary_summary"
+ class="mw-ui-input"
+ data-role="content"
+ data-flow-preview-node="summary"
+ data-flow-preview-template="flow_topic_titlebar_summary.partial"
+ data-flow-preview-title="{{articleTitle}}"
+ >
+ {{~#if submitted.content~}}
+ {{~submitted.content~}}
+ {{~else~}}
+ {{~#if undo.possible~}}
+ {{~undo.content~}}
+ {{~else~}}
+ {{~current.content.content~}}
+ {{~/if~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive">
+ {{~l10n "flow-topic-action-summarize-topic"~}}
+ </button>
+ {{> flow_form_buttons}}
+ <small class="flow-terms-of-use plainlinks">
+ {{~l10nParse "flow-terms-of-use-summarize"}}
+ </small>
+ </div>
+ </form>
+</div>
+
diff --git a/Flow/handlebars/flow_board_navigation.partial.handlebars b/Flow/handlebars/flow_board_navigation.partial.handlebars
new file mode 100644
index 00000000..630f7af9
--- /dev/null
+++ b/Flow/handlebars/flow_board_navigation.partial.handlebars
@@ -0,0 +1,75 @@
+
+<div class="flow-board-navigation flow-load-interactive" data-flow-load-handler="boardNavigation">
+ <div class="flow-error-container">
+ {{!-- placeholder for javascript injected errors --}}
+ </div>
+ <div class="flow-board-navigation-inner">
+ {{!-- Click for sorting options, not sure what this url should be --}}
+ <a href="javascript:void(0);"
+ class="flow-board-navigator-last flow-ui-tooltip-target"
+ data-tooltip-pointing="down"
+ title="
+ {{~#ifCond sortby "===" "updated"~}}
+ {{~l10n "flow-sorting-tooltip-recent"~}}
+ {{~else~}}
+ {{~l10n "flow-sorting-tooltip-newest"~}}
+ {{~/ifCond~}}
+ "
+ data-flow-interactive-handler="menuToggle"
+ data-flow-menu-target="< .flow-board-navigation .flow-board-sort-menu">
+ {{~#ifCond sortby "===" "updated"}}
+ {{l10n "flow-recent-topics"}}
+ {{else}}
+ {{l10n "flow-newest-topics"}}
+ {{/ifCond}}
+ <span class="wikiglyph wikiglyph-caret-down"></span>
+ </a>
+
+ <a href=""
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-board-navigation .flow-board-toc-menu .flow-list"
+ data-flow-api-handler="topicList" {{!-- also triggers menuToggle --}}
+ data-flow-menu-target="< .flow-board-navigation .flow-board-toc-menu"
+ class="flow-board-navigator-active flow-board-navigator-first">
+ <span class="wikiglyph wikiglyph-stripe-toc"></span>
+ <span class="flow-load-interactive" data-flow-load-handler="boardNavigationTitle">{{l10n "flow-board-header-browse-topics-link"}}</span>
+ </a>
+ </div>
+
+ <div class="flow-board-header-menu">
+ {{!-- Table of contents --}}
+ <div class="flow-menu flow-menu-inverted flow-menu-scrollable flow-board-toc-menu flow-load-interactive"
+ data-flow-load-handler="menu"
+ data-flow-toc-target=".flow-list">
+ <div class="flow-menu-js-drop flow-menu-js-drop-hidden"><a href="javascript:void(0);" class="flow-board-header-menu-activator"></a></div>
+ <ul class="mw-ui-button-container flow-board-toc-list flow-list flow-load-interactive"
+ data-flow-load-handler="tocMenu"
+ data-flow-toc-target="li:not(.flow-load-more):last"
+ data-flow-template="flow_board_toc_loop.partial">
+ </ul>
+ </div>
+
+ {{!-- Topics sort menu --}}
+ <div class="flow-menu flow-board-sort-menu flow-load-interactive"
+ data-flow-load-handler="menu">
+ <div class="flow-menu-js-drop flow-menu-js-drop-hidden"><a href="javascript:void(0);" class="flow-board-header-menu-activator"></a></div>
+ {{#if links.board-sort}}
+ <ul class="mw-ui-button-container flow-list">
+ {{~#ifCond sortby "===" "updated"}}
+ <li><a class="mw-ui-button mw-ui-quiet"
+ href="{{links.board-sort.newest}}"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-component"
+ data-flow-api-handler="board"><span class="wikiglyph wikiglyph-star-circle"></span> {{l10n "flow-newest-topics"}}</a></li>
+ {{else}}
+ <li><a class="mw-ui-button mw-ui-quiet"
+ href="{{links.board-sort.updated}}"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-component"
+ data-flow-api-handler="board"><span class="wikiglyph wikiglyph-clock"></span> {{l10n "flow-recent-topics"}}</a></li>
+ {{/ifCond}}
+ </ul>
+ {{/if}}
+ </div>
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_board_toc_loop.partial.handlebars b/Flow/handlebars/flow_board_toc_loop.partial.handlebars
new file mode 100644
index 00000000..9abb4ffc
--- /dev/null
+++ b/Flow/handlebars/flow_board_toc_loop.partial.handlebars
@@ -0,0 +1,23 @@
+{{#each roots}}
+{{!-- eachPost topiclist --}}
+ {{#eachPost @root this}}
+ <li class="flow-menu-section"><a class="mw-ui-button mw-ui-quiet mw-ui-progressive"
+ href="javascript:void(0);"
+ data-flow-interactive-handler="jumpToTopic"
+ data-flow-id="{{../this}}">
+ <span class="wikiglyph wikiglyph-stripe-expanded"></span>
+ {{escapeContent content.format content.content~}}
+ </a></li>
+ {{/eachPost}}
+{{/each}}
+
+{{#if links.pagination.fwd}}
+ {{#unless noLoadMore}}
+ {{> flow_load_more this
+ loadMoreApiHandler="topicList"
+ loadMoreTarget="< .flow-list"
+ loadMoreContainer="< .flow-list"
+ loadMoreTemplate="flow_board_toc_loop.partial"
+ loadMoreObject=links.pagination.fwd}}
+ {{/unless}}
+{{/if}}
diff --git a/Flow/handlebars/flow_edit_post.partial.handlebars b/Flow/handlebars/flow_edit_post.partial.handlebars
new file mode 100644
index 00000000..7b6b8827
--- /dev/null
+++ b/Flow/handlebars/flow_edit_post.partial.handlebars
@@ -0,0 +1,37 @@
+<form class="flow-edit-post-form"
+ method="POST"
+ action="{{actions.edit.url}}"
+>
+ {{> flow_errors}}
+ <input type="hidden" name="wpEditToken" value="{{@root.rootBlock.editToken}}" />
+ <input type="hidden" name="topic_prev_revision" value="{{revisionId}}" />
+ {{#ifAnonymous}}
+ {{> flow_anon_warning }}
+ {{/ifAnonymous}}
+
+ <div class="flow-editor">
+ <textarea name="topic_content" class="mw-ui-input flow-form-collapsible"
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="{{articleTitle}}"
+ data-flow-creator="{{creator.name}}"
+ data-role="content"
+ >
+ {{~#if @root.rootBlock.submitted.content~}}
+ {{~@root.rootBlock.submitted.content~}}
+ {{~else~}}
+ {{~content.content~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-api-handler="submitEditPost">
+ {{~l10n "flow-post-action-edit-post-submit"~}}
+ </button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">
+ {{~l10nParse "flow-terms-of-use-edit"~}}
+ </small>
+ </div>
+</form>
diff --git a/Flow/handlebars/flow_edit_post_ajax.partial.handlebars b/Flow/handlebars/flow_edit_post_ajax.partial.handlebars
new file mode 100644
index 00000000..c18790b5
--- /dev/null
+++ b/Flow/handlebars/flow_edit_post_ajax.partial.handlebars
@@ -0,0 +1,3 @@
+{{#with revision}}
+ {{> flow_edit_post}}
+{{/with}}
diff --git a/Flow/handlebars/flow_edit_topic_title.partial.handlebars b/Flow/handlebars/flow_edit_topic_title.partial.handlebars
new file mode 100644
index 00000000..163b32ca
--- /dev/null
+++ b/Flow/handlebars/flow_edit_topic_title.partial.handlebars
@@ -0,0 +1,27 @@
+<form method="POST" action="{{actions.edit.url}}">
+ {{> flow_errors }}
+ <input type="hidden" name="wpEditToken" value="{{@root.editToken}}" />
+ {{!-- @todo should this be a part of the url? --}}
+ <input type="hidden" name="topic_prev_revision" value="{{revisionId}}" />
+ <input name="topic_content" class="mw-ui-input" value="
+ {{~#if @root.submitted.content~}}
+ {{~@root.submitted.content~}}
+ {{~else~}}
+ {{~content.content~}}
+ {{~/if~}}
+ " />
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ data-flow-api-handler="submitTopicTitle"
+ data-flow-api-target="< .flow-topic"
+ class="mw-ui-button mw-ui-constructive">{{l10n "flow-edit-title-submit"}}</button>
+
+ {{#progressiveEnhancement}}
+ <button data-role="cancel"
+ type="reset"
+ data-flow-interactive-handler="cancelForm"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet">{{l10n "flow-cancel"}}</button>
+ <small class="flow-terms-of-use plainlinks">{{l10nParse "flow-terms-of-use-edit"}}</small>
+ {{/progressiveEnhancement}}
+ </div>
+</form>
diff --git a/Flow/handlebars/flow_editor_switcher.partial.handlebars b/Flow/handlebars/flow_editor_switcher.partial.handlebars
new file mode 100644
index 00000000..812b0933
--- /dev/null
+++ b/Flow/handlebars/flow_editor_switcher.partial.handlebars
@@ -0,0 +1,18 @@
+<div class="flow-switcher-controls">
+ <div>
+ {{! this message is manually constructed in ext.flow.editors.none.js !}}
+ <p class="flow-wikitext-editor-help">{{html help_text}}</p>
+
+ {{#if enable_switcher}}
+ <a href="#"
+ title="{{l10n "flow-wikitext-switch-editor-tooltip"}}"
+ class="mw-ui-button mw-ui-constructive flow-js flow-editor-color"
+ data-flow-interactive-handler="switchEditor"
+ data-flow-target="< form textarea"
+ >
+ &lt;/&gt;
+ </a>
+ {{/if}}
+ </div>
+ <div class="flow-ui-clear"></div>
+</div>
diff --git a/Flow/handlebars/flow_errors.partial.handlebars b/Flow/handlebars/flow_errors.partial.handlebars
new file mode 100644
index 00000000..17ebc0fa
--- /dev/null
+++ b/Flow/handlebars/flow_errors.partial.handlebars
@@ -0,0 +1,11 @@
+<div class="flow-error-container">
+{{#if @root.errors}}
+ <div class="flow-errors errorbox">
+ <ul>
+ {{#each @root.errors}}
+ <li>{{~html message~}}</li>
+ {{/each}}
+ </ul>
+ </div>
+{{/if}}
+</div>
diff --git a/Flow/handlebars/flow_form_buttons.partial.handlebars b/Flow/handlebars/flow_form_buttons.partial.handlebars
new file mode 100644
index 00000000..540816f8
--- /dev/null
+++ b/Flow/handlebars/flow_form_buttons.partial.handlebars
@@ -0,0 +1,10 @@
+<button data-flow-interactive-handler="cancelForm"
+ data-role="cancel"
+ type="reset"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet mw-ui-flush-right flow-js"
+
+ {{!-- No data-flow-eventlog-action here; we'll do that in code to make sure it's run before cancel-success & cancel-abort --}}
+ {{!-- funnel id will have been forwarded to this button though, so we can access that from the code --}}
+>
+ {{~l10n "flow-cancel"~}}
+</button>
diff --git a/Flow/handlebars/flow_header_detail.partial.handlebars b/Flow/handlebars/flow_header_detail.partial.handlebars
new file mode 100644
index 00000000..e898438f
--- /dev/null
+++ b/Flow/handlebars/flow_header_detail.partial.handlebars
@@ -0,0 +1,21 @@
+<div class="flow-board-header-detail-view">
+ {{#if revision.content}}
+ {{escapeContent revision.content.format revision.content.content}}
+ {{/if}}
+ &nbsp;
+
+ {{#unless isPreview}}
+ <div class="flow-board-header-nav">
+ {{#if revision.actions.edit}}
+ <a href="{{revision.actions.edit.url}}"
+ data-flow-api-handler="activateEditHeader"
+ data-flow-api-target="< .flow-board-header"
+ data-flow-interactive-handler="apiRequest"
+ class="mw-ui-button mw-ui-progressive mw-ui-quiet flow-board-header-icon flow-ui-tooltip-target"
+ title="{{revision.actions.edit.title}}">
+ <span class="wikiglyph wikiglyph-pencil"></span>
+ </a>
+ {{/if}}
+ </div>
+ {{/unless}}
+</div>
diff --git a/Flow/handlebars/flow_history_line.partial.handlebars b/Flow/handlebars/flow_history_line.partial.handlebars
new file mode 100644
index 00000000..9e851326
--- /dev/null
+++ b/Flow/handlebars/flow_history_line.partial.handlebars
@@ -0,0 +1,44 @@
+{{! partial~}}
+<span class="flow-pipelist">
+ (
+ {{~noop~}}
+ <span>
+ {{~#if links.diff-cur~}}
+ <a href="{{links.diff-cur.url}}" title="{{links.diff-cur.title}}">
+ {{~links.diff-cur.text~}}
+ </a>
+ {{~else~}}
+ {{~l10n "cur"~}}
+ {{~/if~}}
+ </span>
+ <span>
+ {{#if links.diff-prev}}
+ <a href="{{links.diff-prev.url}}" title="{{links.diff-prev.title}}">
+ {{~links.diff-prev.text~}}
+ </a>
+ {{~else~}}
+ {{~l10n "last"~}}
+ {{~/if~}}
+ </span>
+ {{~#if links.topic}}
+ <span><a href="{{links.topic.url}}" title="{{links.topic.title}}">
+ {{~links.topic.text~}}
+ </a></span>
+ {{~/if~}}
+ )
+</span>
+
+{{historyTimestamp this}}
+
+<span class="mw-changeslist-separator">. .</span>
+{{historyDescription this}}
+
+{{#if size}}
+ <span class="mw-changeslist-separator">. .</span>
+ {{showCharacterDifference size.old size.new}}
+{{/if}}
+
+<ul class="flow-history-moderation-menu">
+ {{!-- Inserts each common flow-history-moderation-action --}}
+ {{> flow_moderation_actions_list this moderationType="history" moderationTarget="post" moderationTemplate="post" moderationMwUiClass="mw-ui-anchor" moderationIcons=false}}
+</ul>
diff --git a/Flow/handlebars/flow_load_more.partial.handlebars b/Flow/handlebars/flow_load_more.partial.handlebars
new file mode 100644
index 00000000..d013205a
--- /dev/null
+++ b/Flow/handlebars/flow_load_more.partial.handlebars
@@ -0,0 +1,23 @@
+{{#if loadMoreObject}}
+ <div class="flow-load-more">
+ <div class="flow-error-container">
+ {{!-- placeholder for javascript injected errors --}}
+ </div>
+
+ <a data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="{{loadMoreApiHandler}}"
+ data-flow-api-target="< .flow-load-more"
+ data-flow-load-handler="loadMore"
+ data-flow-scroll-target="{{loadMoreTarget}}"
+ data-flow-scroll-container="{{loadMoreContainer}}"
+ data-flow-template="{{loadMoreTemplate}}"
+ href="{{loadMoreObject.url}}"
+ title="{{loadMoreObject.title}}"
+ class="mw-ui-button mw-ui-progressive flow-load-interactive flow-ui-fallback-element"><span class="wikiglyph wikiglyph-article"></span> {{l10n "flow-load-more"}}</a>
+ </div>
+{{else}}
+ <div class="flow-no-more">
+ {{!-- TODO: Does this i18n message need to be generalized? --}}
+ {{l10n "flow-no-more-fwd"}}
+ </div>
+{{/if}}
diff --git a/Flow/handlebars/flow_moderate_post.partial.handlebars b/Flow/handlebars/flow_moderate_post.partial.handlebars
new file mode 100644
index 00000000..f6f06c30
--- /dev/null
+++ b/Flow/handlebars/flow_moderate_post.partial.handlebars
@@ -0,0 +1,32 @@
+<form method="POST" action="{{moderationAction actions @root.submitted.moderationState}}">
+ {{> flow_errors}}
+ <input type="hidden" name="wpEditToken" value="{{@root.editToken}}" />
+ <div class="flow-editor">
+ <textarea name="topic_reason"
+ required
+ data-flow-expandable="true"
+ class="mw-ui-input"
+ data-role="content"
+ placeholder="{{l10n (concat "flow-moderation-placeholder-" @root.submitted.moderationState "-post")}}"
+ autofocus
+ >
+ {{~#if @root.submitted.reason~}}
+ {{~@root.submitted.reason~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="moderatePost"
+ class="mw-ui-button mw-ui-constructive"
+ data-role="submit">
+ {{~l10n (concat "flow-moderation-confirm-" @root.submitted.moderationState "-post")~}}
+ </button>
+ <a data-flow-interactive-handler="cancelForm"
+ class="mw-ui-button mw-ui-destructive mw-ui-quiet"
+ href="{{links.topic.url}}"
+ title="{{l10n "flow-cancel"}}">
+ {{~l10n "flow-cancel"~}}
+ </a>
+ </div>
+</form>
diff --git a/Flow/handlebars/flow_moderate_post_confirmation.partial.handlebars b/Flow/handlebars/flow_moderate_post_confirmation.partial.handlebars
new file mode 100644
index 00000000..48a0f779
--- /dev/null
+++ b/Flow/handlebars/flow_moderate_post_confirmation.partial.handlebars
@@ -0,0 +1,42 @@
+<div class="flow-post-main">
+ {{> flow_errors}}
+
+ <span class="flow-moderated-post-content">
+ {{> flow_post_moderation_state}}
+ </span>
+ <span class="flow-undo">
+ {{#if actions.unhide}}
+ <form action="{{actions.unhide.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n "flow-post-undo-hide"}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderatePost">
+ </form>
+ {{/if}}
+ {{#if actions.undelete}}
+ <form action="{{actions.undelete.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n 'flow-post-undo-delete'}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderatePost">
+ </form>
+ {{/if}}
+ {{#if actions.unsuppress}}
+ <form action="{{actions.unsuppress.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n 'flow-post-undo-suppress'}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderatePost">
+ </form>
+ {{/if}}
+ </span>
+</div>
diff --git a/Flow/handlebars/flow_moderate_topic.partial.handlebars b/Flow/handlebars/flow_moderate_topic.partial.handlebars
new file mode 100644
index 00000000..6e04eb1c
--- /dev/null
+++ b/Flow/handlebars/flow_moderate_topic.partial.handlebars
@@ -0,0 +1,32 @@
+<form method="POST" action="{{moderationAction actions @root.submitted.moderationState}}">
+ {{> flow_errors}}
+ <input type="hidden" name="wpEditToken" value="{{@root.editToken}}" />
+ <div class="flow-editor">
+ <textarea name="topic_reason"
+ required
+ data-flow-expandable="true"
+ class="mw-ui-input"
+ data-role="content"
+ placeholder="{{l10n (concat "flow-moderation-placeholder-" @root.submitted.moderationState "-topic")}}"
+ autofocus
+ >
+ {{~#if @root.submitted.reason~}}
+ {{~@root.submitted.reason~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="moderateTopic"
+ data-role="submit">
+ {{~l10n (concat "flow-moderation-confirm-" @root.submitted.moderationState "-topic")~}}
+ </button>
+ <a class="mw-ui-button mw-ui-quiet mw-ui-destructive"
+ href="{{links.topic.url}}"
+ title="{{l10n "flow-cancel"}}"
+ data-flow-interactive-handler="cancelForm">
+ {{~l10n "flow-cancel"~}}
+ </a>
+ </div>
+</form>
diff --git a/Flow/handlebars/flow_moderate_topic_confirmation.partial.handlebars b/Flow/handlebars/flow_moderate_topic_confirmation.partial.handlebars
new file mode 100644
index 00000000..d8a7d642
--- /dev/null
+++ b/Flow/handlebars/flow_moderate_topic_confirmation.partial.handlebars
@@ -0,0 +1,45 @@
+<div class="flow-topic flow-topic-moderated">
+ {{> flow_errors}}
+
+ <div class="flow-topic-titlebar">
+ <div class="flow-moderated-topic-title">
+ {{~noop~}}{{> flow_topic_moderation_flag}}
+ <span>{{~l10n (concat 'flow-moderation-confirmation-' moderateState '-topic')~}}</span>
+ <div class="flow-undo">
+ {{#if actions.unhide}}
+ <form action="{{actions.unhide.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n "flow-topic-undo-hide"}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderateTopic">
+ </form>
+ {{/if}}
+ {{#if actions.undelete}}
+ <form action="{{actions.undelete.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n "flow-topic-undo-delete"}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderateTopic">
+ </form>
+ {{/if}}
+ {{#if actions.unsuppress}}
+ <form action="{{actions.unsuppress.url}}" method="POST">
+ <input type="hidden"
+ name="topic_reason"
+ value="{{l10n "flow-topic-undo-suppress"}}">
+ <input type="button"
+ class="mw-ui-button mw-ui-quiet"
+ value="{{l10n 'flow-post-action-undo-moderation'}}"
+ data-flow-api-handler="moderateTopic">
+ </form>
+ {{/if}}
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_moderation_actions_list.partial.handlebars b/Flow/handlebars/flow_moderation_actions_list.partial.handlebars
new file mode 100644
index 00000000..a9b82aaf
--- /dev/null
+++ b/Flow/handlebars/flow_moderation_actions_list.partial.handlebars
@@ -0,0 +1,272 @@
+<section>
+ {{~#ifCond moderationType "===" "topic"~}}
+ {{!-- Topic only --}}
+ {{~#if actions.edit~}}
+ <li class="flow-js">
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{actions.edit.url}}"
+ title="{{actions.edit.title}}"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateEditTitle"
+ data-flow-api-target="< .flow-topic-titlebar"
+ >
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-pencil"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-edit-title")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if links.topic-history~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{links.topic-history.url}}"
+ title="{{links.topic-history.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-clock"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-history")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if links.topic~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{links.topic.url}}"
+ title="{{links.topic.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-link"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-view")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.summarize~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateSummarizeTopic"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="{{actions.summarize.url}}"
+ title="{{actions.summarize.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-stripe-toc"></span> {{/if~}}
+ {{~#if summary~}}
+ {{~l10n (concat "flow-" moderationType "-action-resummarize-topic")~}}
+ {{else~}}
+ {{~l10n (concat "flow-" moderationType "-action-summarize-topic")~}}
+ {{~/if~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~/ifCond~}}
+
+ {{~#ifCond moderationType "===" "post"~}}
+ {{!-- Post only --}}
+ {{~#if actions.edit~}}
+ <li>
+ <a class="{{moderationMwUiClass}} mw-ui-progressive mw-ui-quiet"
+ href="{{actions.edit.url}}"
+ title="{{actions.edit.title}}"
+ data-flow-api-handler="activateEditPost"
+ data-flow-api-target="< .flow-post-main"
+ data-flow-interactive-handler="apiRequest"
+ >
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-pencil"></span> {{/if~}}
+ {{~l10n "flow-post-action-edit-post"~}}
+ </a>
+ </li>
+ {{~/if~}}
+ {{~#if links.post~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{links.post.url}}"
+ title="{{links.post.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-link"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-view")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~/ifCond~}}
+
+</section>
+
+<section>
+ {{~#ifCond moderationType "===" "history"~}}
+ {{!-- Board history only --}}
+ {{~#if actions.undo~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{actions.undo.url}}"
+ >
+ {{~actions.undo.title~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~/ifCond~}}
+ {{~#if actions.hide~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{actions.hide.url}}"
+ title="{{actions.hide.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="hide">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-flag"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-hide-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.unhide~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-quiet"
+ href="{{actions.unhide.url}}"
+ title="{{actions.unhide.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="unhide">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-flag"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-unhide-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.delete~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ href="{{actions.delete.url}}"
+ title="{{actions.delete.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="delete">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-trash"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-delete-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.undelete~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ href="{{actions.undelete.url}}"
+ title="{{actions.undelete.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="undelete">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-trash"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-undelete-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.suppress~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ href="{{actions.suppress.url}}"
+ title="{{actions.suppress.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="suppress">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-block"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-suppress-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.unsuppress~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ href="{{actions.unsuppress.url}}"
+ title="{{actions.unsuppress.title}}"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_{{moderationTemplate}}.partial"
+ data-role="unsuppress">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-block"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-unsuppress-" moderationTemplate)~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+
+ {{~#ifCond moderationType "===" "history"~}}
+ {{!-- The history page uses a modal, while the topic view puts this in the title bar --}}
+ {{~#if actions.lock~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="lock"
+ data-flow-id="{{postId}}"
+ href="{{actions.lock.url}}"
+ title="{{actions.lock.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-lock"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-lock-topic")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.unlock~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="moderationDialog"
+ data-flow-template="flow_moderate_topic.partial"
+ data-role="unlock"
+ data-flow-id="{{postId}}"
+ href="{{actions.unlock.url}}"
+ title="{{actions.unlock.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-unlock"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-unlock-topic")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{else}}
+ {{!-- @todo Maybe we should change the topic view so that it also uses this modal? Consistency! --}}
+ {{~#if actions.lock~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="{{postId}}"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="{{actions.lock.url}}"
+ title="{{actions.lock.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-lock"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-lock-topic")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~#if actions.unlock~}}
+ <li>
+ {{~noop~}}
+ <a class="{{moderationMwUiClass}} mw-ui-destructive mw-ui-quiet"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="activateLockTopic"
+ data-flow-id="{{postId}}"
+ data-flow-api-target="< .flow-topic-titlebar .flow-topic-summary-container"
+ href="{{actions.unlock.url}}"
+ title="{{actions.unlock.title}}">
+ {{~#if moderationIcons}}<span class="wikiglyph wikiglyph-unlock"></span> {{/if~}}
+ {{~l10n (concat "flow-" moderationType "-action-unlock-topic")~}}
+ </a>
+ {{~noop~}}
+ </li>
+ {{~/if~}}
+ {{~/ifCond~}}
+</section>
diff --git a/Flow/handlebars/flow_newtopic_form.partial.handlebars b/Flow/handlebars/flow_newtopic_form.partial.handlebars
new file mode 100644
index 00000000..8d50625a
--- /dev/null
+++ b/Flow/handlebars/flow_newtopic_form.partial.handlebars
@@ -0,0 +1,50 @@
+{{#if actions.newtopic}}
+ <form action="{{actions.newtopic.url}}" method="POST" class="flow-newtopic-form" data-flow-initial-state="collapsed">
+ {{> flow_errors}}
+
+ {{#ifAnonymous}}
+ {{> flow_anon_warning }}
+ {{/ifAnonymous}}
+
+ <input type="hidden" name="wpEditToken" value="{{ @root.editToken }}" />
+ <input type="hidden" name="topiclist_replyTo" value="{{ workflowId }}" />
+ <input name="topiclist_topic" class="mw-ui-input mw-ui-input-large"
+ required
+ {{#if submitted.topic}}value="{{submitted.topic}}"{{/if}}
+ type="text"
+ placeholder="{{l10n "flow-newtopic-start-placeholder"}}"
+ data-role="title"
+
+ {{!--
+ You'd expect data-flow-eventlog-* data here (this one
+ needs to be clicked to expand the form). That stuff will be
+ in JS though, since we only want it on initial focus (activating
+ the form)
+ --}}
+ data-flow-interactive-handler-focus="activateNewTopic"
+ />
+ <div class="flow-editor">
+ <textarea name="topiclist_content"
+ data-flow-preview-template="flow_topic.partial"
+ data-flow-preview-title-generator="newTopic"
+ class="mw-ui-input flow-form-collapsible mw-ui-input-large"
+ {{#if isOnFlowBoard}}style="display:none;"{{/if}}
+ placeholder="{{l10n "flow-newtopic-content-placeholder" @root.title}}"
+ data-role="content"
+ required
+ >
+ {{~#if submitted.content~}}{{~submitted.content~}}{{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible"
+ {{#if isOnFlowBoard}}style="display:none;"{{/if}}>
+ <button data-role="submit" data-flow-api-handler="newTopic"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-eventlog-action="save-attempt"
+ class="mw-ui-button mw-ui-constructive mw-ui-flush-right">{{l10n "flow-newtopic-save"}}</button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">{{l10nParse "flow-terms-of-use-new-topic"}}</small>
+ </div>
+ </form>
+{{/if}}
diff --git a/Flow/handlebars/flow_post.handlebars b/Flow/handlebars/flow_post.handlebars
new file mode 120000
index 00000000..7306d1f9
--- /dev/null
+++ b/Flow/handlebars/flow_post.handlebars
@@ -0,0 +1 @@
+flow_post.partial.handlebars \ No newline at end of file
diff --git a/Flow/handlebars/flow_post.partial.handlebars b/Flow/handlebars/flow_post.partial.handlebars
new file mode 100644
index 00000000..dc00a42d
--- /dev/null
+++ b/Flow/handlebars/flow_post.partial.handlebars
@@ -0,0 +1,33 @@
+{{#with revision}}
+ <div id="flow-post-{{postId}}"
+ class="flow-post{{#if isMaxThreadingDepth}} flow-post-max-depth{{/if}}"
+ data-flow-id="{{postId}}"
+ >
+ {{#if isModerated}}
+ {{#ifCond @root.rootBlock.submitted.showPostId "===" postId}}
+ {{> flow_post_inner}}
+ {{else}}
+ <div class="flow-post-main flow-post-moderated">
+ <span class="flow-moderated-post-content">
+ {{> flow_post_moderation_state}}
+ </span>
+ </div>
+ {{/ifCond}}
+ {{else}}
+ {{#ifCond @root.rootBlock.submitted.action "===" "edit-post"}}
+ {{#ifCond @root.rootBlock.submitted.postId "===" postId}}
+ {{> flow_edit_post}}
+ {{else}}
+ {{> flow_post_inner}}
+ {{/ifCond}}
+ {{else}}
+ {{> flow_post_inner}}
+ {{/ifCond}}
+ {{/if}}
+
+ {{!-- This stuff is also not needed in preview mode --}}
+ {{#unless isPreview}}
+ {{> flow_post_replies}}
+ {{/unless}}
+ </div>
+{{/with}}
diff --git a/Flow/handlebars/flow_post_actions.partial.handlebars b/Flow/handlebars/flow_post_actions.partial.handlebars
new file mode 100644
index 00000000..f785b1cb
--- /dev/null
+++ b/Flow/handlebars/flow_post_actions.partial.handlebars
@@ -0,0 +1,7 @@
+<div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+ {{!-- Inserts each common flow-menu-moderation-action --}}
+ {{> flow_moderation_actions_list this moderationType="post" moderationTarget="post" moderationTemplate="post" moderationContainerClass="flow-menu" moderationMwUiClass="mw-ui-button" moderationIcons=true}}
+ </ul>
+</div>
diff --git a/Flow/handlebars/flow_post_author.partial.handlebars b/Flow/handlebars/flow_post_author.partial.handlebars
new file mode 100644
index 00000000..4521bb4c
--- /dev/null
+++ b/Flow/handlebars/flow_post_author.partial.handlebars
@@ -0,0 +1,39 @@
+<span class="flow-author">
+ {{#if links}}
+ {{#if links.userpage}}
+ <a href="{{links.userpage.url}}"
+ {{#unless name}}title="{{links.userpage.title}}"{{/unless}}
+ class="{{#unless links.userpage.exists}}new {{/unless}}mw-userlink">
+ {{/if}}
+ {{~#if name~}}
+ {{~name~}}
+ {{~else~}}
+ {{~l10n "flow-anonymous"}}
+ {{~/if~}}
+ {{~#if links.userpage~}}</a>{{~/if~}}
+
+ <span class="mw-usertoollinks flow-pipelist">
+ (
+ {{~#if links.talk~}}
+ <span><a href="{{links.talk.url}}"
+ class="{{#unless links.talk.exists}}new {{/unless}}"
+ title="{{links.talk.title}}">
+ {{~l10n "talkpagelinktext"~}}
+ </a></span>
+ {{~/if~}}
+ {{~#if links.contribs~}}
+ <span><a href="{{links.contribs.url}}" title="{{links.contribs.title}}">
+ {{~l10n "contribslink"~}}
+ </a></span>
+ {{~/if~}}
+ {{~#if links.block~}}
+ <span><a class="{{#unless links.block.exists}}new {{/unless}}"
+ href="{{links.block.url}}"
+ title="{{links.block.title}}">
+ {{~l10n "blocklink"~}}
+ </a></span>
+ {{~/if~}}
+ )
+ </span>
+ {{/if}}
+</span>
diff --git a/Flow/handlebars/flow_post_inner.partial.handlebars b/Flow/handlebars/flow_post_inner.partial.handlebars
new file mode 100644
index 00000000..dc21e645
--- /dev/null
+++ b/Flow/handlebars/flow_post_inner.partial.handlebars
@@ -0,0 +1,31 @@
+<div
+ {{#if isModerated}}
+ class="flow-post-main flow-post-moderated flow-click-interactive flow-element-collapsible flow-element-collapsed"
+ data-flow-interactive-handler="collapserCollapsibleToggle"
+ tabindex="0"
+ {{else}}
+ class="flow-post-main"
+ {{/if}}
+>
+ {{> flow_errors}}
+
+ {{#with creator}}
+ {{> flow_post_author}}
+ {{/with}}
+
+ {{#if isModerated}}
+ <div class="flow-moderated-post-content">
+ {{> flow_post_moderation_state}}
+ </div>
+ {{/if}}
+
+ <div class="flow-post-content">
+ {{escapeContent content.format content.content}}
+ </div>
+
+ {{!-- This stuff is not needed in preview mode --}}
+ {{#unless isPreview}}
+ {{> flow_post_meta_actions}}
+ {{> flow_post_actions}}
+ {{/unless}}
+</div>
diff --git a/Flow/handlebars/flow_post_meta_actions.partial.handlebars b/Flow/handlebars/flow_post_meta_actions.partial.handlebars
new file mode 100644
index 00000000..b5d605be
--- /dev/null
+++ b/Flow/handlebars/flow_post_meta_actions.partial.handlebars
@@ -0,0 +1,66 @@
+<div class="flow-post-meta">
+ <span class="flow-post-meta-actions">
+ {{#if actions.reply}}
+ <a href="{{actions.reply.url}}"
+ title="{{actions.reply.title}}"
+ class="mw-ui-anchor mw-ui-progressive mw-ui-quiet"
+ data-flow-interactive-handler="activateReplyPost"
+
+ {{!--
+ Initialize EventLogging:
+ * action: name of the action param
+ * schema: name of the schema (will be forwarded)
+ * entrypoint: name of the entrypoint (will be forwarded)
+ * forward: nodes to forward this funnel to
+ We want to keep track of multiple actions in the same "funnel".
+ Having a node without data-flow-eventlog-funnel-id (this node)
+ will result in a funnel being created. That funnel id will then
+ be forwarded to all specified nodes, so if you later click on one
+ of the forwarded nodes, it'll recognize and find the funnel. All
+ that is needed there, is a specific data-flow-eventlog-action,
+ all other details (log, entrypoint, funnel id, ...) are inherited
+ --}}
+ data-flow-eventlog-schema="FlowReplies"
+ data-flow-eventlog-action="initiate"
+ data-flow-eventlog-entrypoint="reply-post"
+ data-flow-eventlog-forward="
+ < .flow-post:not([data-flow-post-max-depth='1']) .flow-reply-form [data-role='cancel'],
+ < .flow-post:not([data-flow-post-max-depth='1']) .flow-reply-form [data-role='action'][name='preview'],
+ < .flow-post:not([data-flow-post-max-depth='1']) .flow-reply-form [data-role='submit']
+ "
+ >
+ {{~actions.reply.text~}}
+ </a>
+ {{/if}}
+ {{#if actions.thank}}
+ {{!--
+ progressive enhancement happens in the Thank extension
+ based on the mw-thanks-flow-thank-link class
+ --}}
+ <a class="mw-ui-anchor mw-ui-constructive mw-ui-quiet mw-thanks-flow-thank-link"
+ href="{{actions.thank.url}}"
+ title="{{actions.thank.title}}">
+ {{~actions.thank.text~}}
+ </a>
+ {{/if}}
+ </span>
+
+ <span class="flow-post-timestamp">
+ {{#if isOriginalContent}}
+ <a href="{{links.topic-history.url}}" class="flow-timestamp-anchor">
+ {{uuidTimestamp postId}}
+ </a>
+ {{else}}
+ <span>
+ {{#ifCond creator.name "===" lastEditUser.name}}
+ {{l10n "flow-edited"}}
+ {{else}}
+ {{l10n "flow-edited-by" lastEditUser.name}}
+ {{/ifCond}}
+ </span>
+ <a href="{{links.topic-history.url}}" class="flow-timestamp-anchor">
+ {{~uuidTimestamp lastEditId~}}
+ </a>
+ {{/if}}
+ </span>
+</div>
diff --git a/Flow/handlebars/flow_post_moderation_state.partial.handlebars b/Flow/handlebars/flow_post_moderation_state.partial.handlebars
new file mode 100644
index 00000000..3f18a4f5
--- /dev/null
+++ b/Flow/handlebars/flow_post_moderation_state.partial.handlebars
@@ -0,0 +1,7 @@
+<span class="plainlinks">
+ {{~#if replyToId~}}
+ {{l10nParse (concat "flow-" moderateState "-post-content") moderator.name links.topic-history.url}}
+ {{~else~}}
+ {{l10nParse (concat "flow-" moderateState "-title-content") moderator.name links.topic-history.url}}
+ {{~/if~}}
+</span>
diff --git a/Flow/handlebars/flow_post_replies.partial.handlebars b/Flow/handlebars/flow_post_replies.partial.handlebars
new file mode 100644
index 00000000..967a8c80
--- /dev/null
+++ b/Flow/handlebars/flow_post_replies.partial.handlebars
@@ -0,0 +1,13 @@
+<div class="flow-replies">
+ {{#each replies}}
+ {{#eachPost @root.rootBlock this}}
+ <!-- eachPost nested replies -->
+ {{post @root.rootBlock this}}
+ {{/eachPost}}
+ {{/each}}
+ {{#ifCond @root.rootBlock.submitted.postId "===" postId}}
+ {{#ifCond @root.rootBlock.submitted.action "===" "reply"}}
+ {{> flow_reply_form}}
+ {{/ifCond}}
+ {{/ifCond}}
+</div>
diff --git a/Flow/handlebars/flow_preview.partial.handlebars b/Flow/handlebars/flow_preview.partial.handlebars
new file mode 100644
index 00000000..fbf9ed34
--- /dev/null
+++ b/Flow/handlebars/flow_preview.partial.handlebars
@@ -0,0 +1,12 @@
+<div class="flow-content-preview">
+ {{#if title}}
+ <div class="flow-preview-sub-container flow-topic-title">
+ {{title}}
+ </div>
+ {{/if}}
+ {{#if content}}
+ <div class="flow-preview-sub-container">
+ {{escapeContent content.format content.content}}
+ </div>
+ {{/if}}
+</div>
diff --git a/Flow/handlebars/flow_preview_warning.partial.handlebars b/Flow/handlebars/flow_preview_warning.partial.handlebars
new file mode 100644
index 00000000..a6cae95b
--- /dev/null
+++ b/Flow/handlebars/flow_preview_warning.partial.handlebars
@@ -0,0 +1,6 @@
+<div class="flow-preview-warning">
+ {{~l10n "flow-preview-warning"~}}
+</div>
+{{#ifAnonymous}}
+ {{> flow_anon_warning}}
+{{/ifAnonymous}}
diff --git a/Flow/handlebars/flow_reply_form.partial.handlebars b/Flow/handlebars/flow_reply_form.partial.handlebars
new file mode 100644
index 00000000..862bbaea
--- /dev/null
+++ b/Flow/handlebars/flow_reply_form.partial.handlebars
@@ -0,0 +1,63 @@
+{{#if actions.reply}}
+ <form class="flow-post flow-reply-form"
+ method="POST"
+ action="{{actions.reply.url}}"
+ id="flow-reply-{{postId}}"
+ data-flow-initial-state="collapsed"
+ >
+ <input type="hidden" name="wpEditToken" value="{{@root.rootBlock.editToken}}" />
+ <input type="hidden" name="topic_replyTo" value="{{postId}}" />
+ {{> flow_errors }}
+
+ {{#ifAnonymous}}
+ {{> flow_anon_warning }}
+ {{/ifAnonymous}}
+
+ <div class="flow-editor">
+ <textarea id="flow-post-{{postId}}-form-content"
+ name="topic_content"
+ required
+ data-flow-preview-template="flow_post"
+ data-flow-preview-title="{{articleTitle}}"
+ data-flow-expandable="true"
+ class="mw-ui-input flow-click-interactive"
+ type="text"
+ placeholder="{{l10n "flow-reply-topic-title-placeholder" properties.topic-of-post}}"
+ data-role="content"
+
+ {{!--
+ You'd expect data-flow-eventlog-* data here (this one
+ needs to be clicked to expand the form).
+ However, this form is used in multiple places: as topic-
+ level reply form (activated by clicking the textarea to
+ expand), or to reply to a post (activated by clicking the
+ "reply" link).
+ We only want to track the former, so we'll do that in JS so
+ we can ignore all focuses for this textarea when it's not
+ used to activate the topic-level reply form.
+ --}}
+ data-flow-interactive-handler-focus="activateReplyTopic"
+ >
+ {{~#if @root.submitted~}}
+ {{~#ifCond @root.submitted.postId "===" postId~}}
+ {{~@root.submitted.content~}}
+ {{~/ifCond~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-handler="submitReply"
+ data-flow-api-target="< .flow-topic"
+ data-flow-eventlog-action="save-attempt"
+ >
+ {{~actions.reply.text~}}
+ </button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">{{l10nParse "flow-terms-of-use-reply"}}</small>
+ </div>
+ </form>
+{{/if}}
diff --git a/Flow/handlebars/flow_revision_diff_header.handlebars b/Flow/handlebars/flow_revision_diff_header.handlebars
new file mode 100644
index 00000000..24decbbe
--- /dev/null
+++ b/Flow/handlebars/flow_revision_diff_header.handlebars
@@ -0,0 +1,9 @@
+<div><a href="{{link}}" class="flow-diff-revision-link">
+ {{l10nParse "flow-compare-revisions-revision-header" timestamp author}}
+</a></div>
+{{#if previous}}
+ <div><a href="{{previous}}">{{l10n "flow-previous-diff"}}</a></div>
+{{/if}}
+{{#if next}}
+ <div><a href="{{next}}">{{l10n "flow-next-diff"}}</a></div>
+{{/if}}
diff --git a/Flow/handlebars/flow_subscribed.partial.handlebars b/Flow/handlebars/flow_subscribed.partial.handlebars
new file mode 100644
index 00000000..21c57710
--- /dev/null
+++ b/Flow/handlebars/flow_subscribed.partial.handlebars
@@ -0,0 +1,7 @@
+<div class="flow-notification-tooltip-icon"><span class="wikiglyph wikiglyph-star-list mw-ui-constructive"></span></div>
+<p class="flow-notification-tooltip-title">
+ {{l10n (concat "flow-" type "-notification-subscribe-title") username}}
+</p>
+<p class="flow-notification-tooltip-content">
+ {{l10n (concat "flow-" type "-notification-subscribe-description") username}}
+</p>
diff --git a/Flow/handlebars/flow_tooltip.handlebars b/Flow/handlebars/flow_tooltip.handlebars
new file mode 100644
index 00000000..df7c61a8
--- /dev/null
+++ b/Flow/handlebars/flow_tooltip.handlebars
@@ -0,0 +1,4 @@
+<div class="{{extraClass}} flow-ui-tooltip {{contextClass}} {{positionClass}} {{blockClass}} plainlinks">
+ {{~html content~}}
+ <span class="flow-ui-tooltip-triangle"></span>
+</div>
diff --git a/Flow/handlebars/flow_tooltip_subscribed.partial.handlebars b/Flow/handlebars/flow_tooltip_subscribed.partial.handlebars
new file mode 100644
index 00000000..383f6648
--- /dev/null
+++ b/Flow/handlebars/flow_tooltip_subscribed.partial.handlebars
@@ -0,0 +1,6 @@
+{{#tooltip
+ positionClass="left"
+ extraClass="flow-notification-tooltip-topicsub"
+}}
+ {{> flow_subscribed}}
+{{/tooltip}}
diff --git a/Flow/handlebars/flow_topic.partial.handlebars b/Flow/handlebars/flow_topic.partial.handlebars
new file mode 100644
index 00000000..3872f111
--- /dev/null
+++ b/Flow/handlebars/flow_topic.partial.handlebars
@@ -0,0 +1,39 @@
+<div class="flow-topic flow-load-interactive
+ {{#if moderateState}}flow-topic-moderatestate-{{moderateState}}{{/if}}
+ {{#if isModerated}}flow-topic-moderated{{/if}}
+ "
+ id="flow-topic-{{postId}}"
+ data-flow-id="{{postId}}"
+ data-flow-load-handler="topic"
+ data-flow-toc-scroll-target=".flow-topic-titlebar"
+ data-flow-topic-timestamp-updated="{{last_updated}}"
+>
+ {{>flow_topic_titlebar}}
+
+ {{#if @root.posts}}
+ {{#each replies}}
+ {{#eachPost @root this}}
+ <!-- eachPost topic -->
+ {{post @root this}}
+ {{/eachPost}}
+ {{/each}}
+ {{/if}}
+
+ {{#unless isPreview}}
+ {{#if actions.reply}}
+ {{#ifCond @root.submitted.postId "===" postId}}
+ {{> flow_reply_form}}
+ {{else}}
+ {{#progressiveEnhancement type="replace" target="~ a"}}
+ {{> flow_reply_form}}
+ {{/progressiveEnhancement}}
+ <a href="{{actions.reply.url}}"
+ title="{{actions.reply.title}}"
+ class="flow-ui-input-replacement-anchor mw-ui-input"
+ >
+ {{~l10n "flow-reply-topic-title-placeholder" properties.topic-of-post~}}
+ </a>
+ {{/ifCond}}
+ {{/if}}
+ {{/unless}}
+</div>
diff --git a/Flow/handlebars/flow_topic_moderation_flag.partial.handlebars b/Flow/handlebars/flow_topic_moderation_flag.partial.handlebars
new file mode 100644
index 00000000..a2f8f7c5
--- /dev/null
+++ b/Flow/handlebars/flow_topic_moderation_flag.partial.handlebars
@@ -0,0 +1,4 @@
+<span class="wikiglyph
+ {{~#ifCond moderateState "===" "lock"}} wikiglyph-lock{{/ifCond~}}
+ {{~#ifCond moderateState "===" "hide"}} wikiglyph-flag{{/ifCond~}}
+ {{~#ifCond moderateState "===" "delete"}} wikiglyph-trash{{/ifCond~}}"></span>
diff --git a/Flow/handlebars/flow_topic_titlebar.partial.handlebars b/Flow/handlebars/flow_topic_titlebar.partial.handlebars
new file mode 100644
index 00000000..20ca436c
--- /dev/null
+++ b/Flow/handlebars/flow_topic_titlebar.partial.handlebars
@@ -0,0 +1,16 @@
+<div class="flow-topic-titlebar">
+ {{> flow_topic_titlebar_content}}
+
+ {{#unless isPreview}}
+ {{#if watchable}}
+ {{> flow_topic_titlebar_watch}}
+ {{/if}}
+ <div class="flow-menu flow-menu-hoverable">
+ <div class="flow-menu-js-drop"><a href="javascript:void(0);"><span class="wikiglyph wikiglyph-ellipsis"></span></a></div>
+ <ul class="mw-ui-button-container flow-list">
+ {{!-- Inserts each common flow-menu-moderation-action --}}
+ {{> flow_moderation_actions_list this moderationType="topic" moderationTarget="title" moderationTemplate="topic" moderationContainerClass="flow-menu" moderationMwUiClass="mw-ui-button" moderationIcons=true}}
+ </ul>
+ </div>
+ {{/unless}}
+</div>
diff --git a/Flow/handlebars/flow_topic_titlebar_content.partial.handlebars b/Flow/handlebars/flow_topic_titlebar_content.partial.handlebars
new file mode 100644
index 00000000..bf515b4b
--- /dev/null
+++ b/Flow/handlebars/flow_topic_titlebar_content.partial.handlebars
@@ -0,0 +1,27 @@
+<h2 class="flow-topic-title flow-load-interactive"
+ data-flow-topic-title="{{escapeContent content.format content.content}}"
+ data-flow-load-handler="topicTitle">{{escapeContent content.format content.content}}</h2>
+<div class="flow-topic-meta">
+ {{l10n "flow-topic-comments" reply_count}} &bull;
+
+ <a href="{{links.topic-history.url}}" class="flow-timestamp-anchor">
+ {{#if last_updated}}
+ {{timestamp last_updated}}
+ {{else}}
+ {{uuidTimestamp postId}}
+ {{/if}}
+ </a>
+</div>
+{{#if isModerated}}
+ <div class="flow-moderated-topic-title flow-ui-text-truncated">
+ {{~noop~}}{{> flow_topic_moderation_flag}}
+ {{> flow_post_moderation_state}}
+ </div>
+ <div class="flow-moderated-topic-reason">
+ {{l10n "flow-topic-moderated-reason-prefix"}}
+ {{escapeContent moderateReason.format moderateReason.content}}
+ </div>
+{{/if}}
+<span class="flow-reply-count"><span class="wikiglyph wikiglyph-speech-bubble"></span><span class="flow-reply-count-number">{{reply_count}}</span></span>
+
+{{> flow_topic_titlebar_summary}}
diff --git a/Flow/handlebars/flow_topic_titlebar_lock.partial.handlebars b/Flow/handlebars/flow_topic_titlebar_lock.partial.handlebars
new file mode 100644
index 00000000..3cd280fd
--- /dev/null
+++ b/Flow/handlebars/flow_topic_titlebar_lock.partial.handlebars
@@ -0,0 +1,50 @@
+<div class="flow-topic-summary-container">
+ <div class="flow-topic-summary">
+ <form class="flow-edit-form" data-flow-initial-state="collapsed" method="POST"
+ action="
+ {{~#if isModerated~}}
+ {{~actions.unlock.url~}}
+ {{~else~}}
+ {{~actions.lock.url~}}
+ {{~/if~}}">
+ {{> flow_errors }}
+ <input type="hidden" name="wpEditToken" value="{{@root.editToken}}" />
+ <div class="flow-editor">
+ <textarea name="flow_reason"
+ class="mw-ui-input"
+ type="text"
+ required
+ data-flow-preview-node="moderateReason"
+ data-flow-preview-template="flow_topic_titlebar.partial"
+ data-flow-preview-title="{{articleTitle}}"
+ >
+ {{~#if @root.submitted.reason~}}
+ {{~@root.submitted.reason~}}
+ {{~/if~}}
+ </textarea>
+ </div>
+ <div class="flow-form-actions flow-form-collapsible">
+ <button data-role="submit"
+ class="mw-ui-button mw-ui-constructive"
+ data-flow-interactive-handler="apiRequest"
+ data-flow-api-target="< .flow-topic"
+ data-flow-api-handler="lockTopic"
+ >
+ {{#if isModerated}}
+ {{l10n "flow-topic-action-unlock-topic"}}
+ {{else}}
+ {{l10n "flow-topic-action-lock-topic"}}
+ {{/if}}
+ </button>
+ {{> flow_form_buttons }}
+ <small class="flow-terms-of-use plainlinks">
+ {{#if isModerated}}
+ {{l10nParse "flow-terms-of-use-unlock-topic"}}
+ {{else}}
+ {{l10nParse "flow-terms-of-use-lock-topic"}}
+ {{/if}}
+ </small>
+ </div>
+ </form>
+ </div>
+</div>
diff --git a/Flow/handlebars/flow_topic_titlebar_summary.partial.handlebars b/Flow/handlebars/flow_topic_titlebar_summary.partial.handlebars
new file mode 100644
index 00000000..48df93a4
--- /dev/null
+++ b/Flow/handlebars/flow_topic_titlebar_summary.partial.handlebars
@@ -0,0 +1,9 @@
+<div class="flow-topic-summary-container">
+ {{> flow_errors }}
+ {{#if summary}}
+ <div class="flow-topic-summary">
+ {{escapeContent summary.format summary.content}}
+ </div>
+ <br class="flow-ui-clear"/>
+ {{/if}}
+</div>
diff --git a/Flow/handlebars/flow_topic_titlebar_watch.partial.handlebars b/Flow/handlebars/flow_topic_titlebar_watch.partial.handlebars
new file mode 100644
index 00000000..17ac2cc1
--- /dev/null
+++ b/Flow/handlebars/flow_topic_titlebar_watch.partial.handlebars
@@ -0,0 +1,17 @@
+<div class="flow-topic-watchlist flow-watch-link">
+ {{> flow_errors}}
+
+ <a href="{{#if isWatched}}{{links.unwatch-topic.url}}{{else}}{{links.watch-topic.url}}{{/if}}"
+ class="mw-ui-anchor mw-ui-constructive {{#unless isWatched}}mw-ui-quiet{{/unless}}
+ {{#if isWatched~}}
+ flow-watch-link-unwatch
+ {{~else~}}
+ flow-watch-link-watch
+ {{~/if~}}"
+ data-flow-api-handler="watchItem"
+ data-flow-api-target="< .flow-topic-watchlist"
+ data-flow-api-method="POST">
+ {{~noop~}}<span class="wikiglyph wikiglyph-star"></span>{{~noop~}}
+ {{~noop~}}<span class="wikiglyph wikiglyph-unstar"></span>{{~noop~}}
+ </a>
+</div>
diff --git a/Flow/handlebars/flow_topiclist_loop.partial.handlebars b/Flow/handlebars/flow_topiclist_loop.partial.handlebars
new file mode 100644
index 00000000..c141b2aa
--- /dev/null
+++ b/Flow/handlebars/flow_topiclist_loop.partial.handlebars
@@ -0,0 +1,6 @@
+{{#each roots}}
+ {{!-- eachPost topiclist --}}
+ {{#eachPost @root this}}
+ {{> flow_topic}}
+ {{/eachPost}}
+{{/each}}
diff --git a/Flow/handlebars/form_element.partial.handlebars b/Flow/handlebars/form_element.partial.handlebars
new file mode 100644
index 00000000..44b01627
--- /dev/null
+++ b/Flow/handlebars/form_element.partial.handlebars
@@ -0,0 +1,24 @@
+<label class="mw-ui-field mw-ui-fieldtag-{{tag}} {{#if fieldtype}}mw-ui-fieldtype-{{fieldtype}}{{/if}}">{{!
+ }}<{{tag}}
+ {{#if class}}class="{{class}}"{{/if}}
+ {{#if type}}type="{{type}}"{{/if}}
+ {{#if name}}name="{{name}}"{{/if}}
+ {{#if placeholder}}placeholder="{{placeholder}}"{{/if}}
+ {{#if value}}value="{{value}}"{{/if}}
+ {{#if role}}data-role="{{role}}"{{/if}}
+ {{#if expandable}}data-flow-expandable="true"{{/if}}
+ {{#if min}}min="{{min}}"{{/if}}
+ {{#if max}}max="{{max}}"{{/if}}
+ {{#if maxlength}}maxlength="{{maxlength}}"{{/if}}
+ {{#if pattern}}pattern="{{pattern}}"{{/if}}
+{{!--
+ {{#if required}}required="required"{{/if}}
+--}}
+ {{#if closing_tag}}>{{else}}/>{{/if}}{{!
+ }}{{#if radio}}<span class="mw-ui-radio"></span>{{/if}}{{!
+ }}{{#if checkbox}}<span class="mw-ui-checkbox"></span>{{/if}}{{!
+ }}{{content}}{{!
+ }}{{#if closing_tag}}</{{closing_tag}}>{{/if}}{{!
+}}{{#if validation}}<span class="mw-ui-field-icon mw-ui-validation-icon"></span>{{/if}}{{!
+}}<a href="javascript:void(0);" class="mw-ui-field-icon mw-ui-uls-icon"></a>{{!
+}}</label>
diff --git a/Flow/handlebars/timestamp.handlebars b/Flow/handlebars/timestamp.handlebars
new file mode 100644
index 00000000..9bde7279
--- /dev/null
+++ b/Flow/handlebars/timestamp.handlebars
@@ -0,0 +1,13 @@
+{{!-- Using <span> instead of <time> to support old browsers (e.g. IE 8).
+ IE 8 will treat it <time> a self-closing tag, without a
+ createElement call before the element. --}}
+{{#if guid}}
+ <span datetime="{{time_iso}}" class="flow-timestamp">
+{{else}}
+ <span datetime="{{time_iso}}"
+ class="flow-timestamp flow-load-interactive"
+ data-flow-load-handler="timestamp">
+{{/if}}
+ <span class="flow-timestamp-user-formatted">{{time_readable}}</span>
+ <span id="{{guid}}" class="flow-timestamp-ago">{{time_ago}}</span>
+</span>
diff --git a/Flow/hooks.txt b/Flow/hooks.txt
new file mode 100644
index 00000000..f70c0ec9
--- /dev/null
+++ b/Flow/hooks.txt
@@ -0,0 +1,16 @@
+This document describes how event hooks work in the Flow extension.
+
+== Events and parameters ==
+
+This is a list of known events and parameters; please add to it if you're going
+to add events to the Flow extension.
+
+'FlowAddPostInteractionLinks': Called when a post is rendered, allow other
+extensions to add interaction links to the post besides 'Edit' and other links.
+$rev: Flow PostRevision object that the links belong to
+$user: User object to display the link for
+&$links: array of interaction links to be displayed, caller should append the
+ link element to the array
+
+'FlowAddModules': Allows other extensions to add relevant modules.
+$output: OutputPage object
diff --git a/Flow/i18n/ace.json b/Flow/i18n/ace.json
new file mode 100644
index 00000000..ca616b49
--- /dev/null
+++ b/Flow/i18n/ace.json
@@ -0,0 +1,19 @@
+{
+ "@metadata": {
+ "authors": [
+ "Rachmat.Wahidi"
+ ]
+ },
+ "flow-topic-action-hide-topic": "Peusom topik",
+ "flow-topic-action-delete-topic": "Sampôh topik",
+ "flow-topic-action-restore-topic": "Peuriwang topik",
+ "flow-rev-message-hid-topic": "[[Ureuëng Nguy:$1|$1]] {{GENDER:$1|geupeusom}} [topic $3].",
+ "flow-rev-message-deleted-topic": "[[Ureuëng Nguy:$1|$1]] {{GENDER:$1|sampôh}} [kumènta $3].",
+ "flow-rev-message-restored-topic": "[[Ureuëng Nguy:$1|$1]] {{GENDER:$1|peuriwang}} [topik $3].",
+ "flow-moderation-confirm-delete-topic": "Sampôh",
+ "flow-moderation-confirm-hide-topic": "Peusom",
+ "flow-moderation-title-delete-topic": "Sampôh topik?",
+ "flow-moderation-title-hide-topic": "Peusom topik?",
+ "flow-moderation-placeholder-delete-topic": "Tulông peutrang pakön droeneuh neuneuk sampôh topik nyoe.",
+ "flow-moderation-placeholder-hide-topic": "Neutulông peutrang pakön peusom topik nyoe."
+}
diff --git a/Flow/i18n/ar.json b/Flow/i18n/ar.json
new file mode 100644
index 00000000..02745264
--- /dev/null
+++ b/Flow/i18n/ar.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Claw eg",
+ "مشعل الحربي",
+ "Ms.Rainbow"
+ ]
+ },
+ "flow-post-actions": "الإجراءات",
+ "flow-topic-actions": "الإجراءات",
+ "flow-error-http": "حدث خطأ أثناء الاتصال بالخادم.",
+ "flow-error-external": "حدث خطأ.<br />رسالة الخطأ المتلقاة هي: $1",
+ "flow-notification-mention": "$1 {{GENDER:$1|المشار إليه}} {{GENDER:$5|هو أنت}} في {{GENDER:$1}} <span class=\"plainlinks\">[$2 رسالة]</span> في\"$3\" في \"$4\".",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|المشار إليه}} {{GENDER:$4|هو أنت}} في {{GENDER:$1}}\nرسالته في \"$2\" في \"$3\"",
+ "flow-topic-permalink-warning": "بدأ هذا الموضوع في [$2 $1]"
+}
diff --git a/Flow/i18n/as.json b/Flow/i18n/as.json
new file mode 100644
index 00000000..aa14e7f3
--- /dev/null
+++ b/Flow/i18n/as.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bishnu Saikia"
+ ]
+ },
+ "flow-moderation-confirm-unhide-post": "দেখুৱাওক",
+ "flow-moderation-confirm-unhide-topic": "দেখুৱাওক",
+ "flow-edited-by": "$1 দ্বাৰা সম্পাদিত"
+}
diff --git a/Flow/i18n/ast.json b/Flow/i18n/ast.json
new file mode 100644
index 00000000..df98799a
--- /dev/null
+++ b/Flow/i18n/ast.json
@@ -0,0 +1,214 @@
+{
+ "@metadata": {
+ "authors": [
+ "Xuacu"
+ ]
+ },
+ "flow-desc": "Sistema de xestión del fluxu de trabayu",
+ "flow-talk-taken-over": "Esta páxina d'alderique ta usando [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "log-name-flow": "Rexistru d'actividá de Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|desanició}} un [$4 mensaxe]'n [[$3]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restauró}} un [$4 mensaxe]'n [[$3]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|suprimió}} un [$4 mensaxe]'n [[$3]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|desanició}} un [$4 mensaxe]'n [[$3]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|desanició}} un [$4 asuntu] en [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|restauró}} un [$4 asuntu] en [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|suprimió}} un [$4 asuntu] en [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|desanició}} un [$4 asuntu] en [[$3]]",
+ "flow-user-moderated": "Usuariu moderáu",
+ "flow-edit-header-link": "Editar la testera",
+ "flow-post-moderated-toggle-hide-show": "Amosar el comentariu {{GENDER:$1|tapecíu}} por $2",
+ "flow-post-moderated-toggle-delete-show": "Amosar el comentariu {{GENDER:$1|desaniciáu}} por $2",
+ "flow-post-moderated-toggle-suppress-show": "Amosar el comentariu {{GENDER:$1|suprimíu}} por $2",
+ "flow-post-moderated-toggle-hide-hide": "Tapecer el comentariu {{GENDER:$1|tapecíu}} por $2",
+ "flow-post-moderated-toggle-delete-hide": "Tapecer el comentariu {{GENDER:$1|desaniciáu}} por $2",
+ "flow-post-moderated-toggle-suppress-hide": "Tapecer el comentariu {{GENDER:$1|suprimíu}} por $2",
+ "flow-hide-post-content": "Esti comentariu {{GENDER:$1|tapecióse}} por $1",
+ "flow-hide-title-content": "Esti asuntu {{GENDER:$1|tapecióse}} por $1",
+ "flow-hide-header-content": "{{GENDER:$1|Tapecíu}} por $2",
+ "flow-delete-post-content": "Esti comentariu {{GENDER:$1|desanicióse}} por $1",
+ "flow-delete-title-content": "Esti asuntu {{GENDER:$1|desanicióse}} por $1",
+ "flow-delete-header-content": "{{GENDER:$1|Desaniciáu}} por $2",
+ "flow-suppress-post-content": "Esti comentariu {{GENDER:$1|suprimióse}} por $1",
+ "flow-suppress-title-content": "Esti asuntu {{GENDER:$1|suprimióse}} por $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimíu}} por $2",
+ "flow-suppress-usertext": "<em>Nome d'usuariu suprimíu</em>",
+ "flow-post-actions": "Aiciones",
+ "flow-topic-actions": "Aiciones",
+ "flow-cancel": "Encaboxar",
+ "flow-preview": "Vista previa",
+ "flow-show-change": "Amosar cambeos",
+ "flow-last-modified-by": "Últimu {{GENDER:$1|cambiu}} por $1",
+ "flow-stub-post-content": "''Por un fallu téunicu, esti mensaxe nun pudo recuperase.''",
+ "flow-newtopic-title-placeholder": "Nuevu asuntu",
+ "flow-newtopic-content-placeholder": "Unviar un mensaxe nuevu a «$1»",
+ "flow-newtopic-header": "Amestar un nuevu asuntu",
+ "flow-newtopic-save": "Amestar un asuntu",
+ "flow-newtopic-start-placeholder": "Principiar un nuevu asuntu",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentar}} sobro «$2»",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Agradecer}}",
+ "flow-post-edited": "Mensaxe {{GENDER:$1|editáu}} por $1 $2",
+ "flow-post-action-view": "Enllaz permanente",
+ "flow-post-action-post-history": "Historial",
+ "flow-post-action-suppress-post": "Suprimir",
+ "flow-post-action-delete-post": "Desaniciar",
+ "flow-post-action-hide-post": "Tapecer",
+ "flow-post-action-edit-post": "Editar",
+ "flow-post-action-unsuppress-post": "De-suprimir",
+ "flow-post-action-undelete-post": "Restaurar",
+ "flow-post-action-unhide-post": "Destapecer",
+ "flow-post-action-restore-post": "Restaurar",
+ "flow-topic-action-view": "Enllaz permanente",
+ "flow-topic-action-watchlist": "Llista de siguimientu",
+ "flow-topic-action-edit-title": "Editar el títulu",
+ "flow-topic-action-history": "Historial",
+ "flow-topic-action-hide-topic": "Tapecer esti asuntu",
+ "flow-topic-action-delete-topic": "Desaniciar esti asuntu",
+ "flow-topic-action-suppress-topic": "Suprimir esti asuntu",
+ "flow-topic-action-unhide-topic": "Destapecer l'asuntu",
+ "flow-topic-action-undelete-topic": "Restaurar l'asuntu",
+ "flow-topic-action-unsuppress-topic": "De-suprimir l'asuntu",
+ "flow-topic-action-restore-topic": "Restaurar esti asuntu",
+ "flow-error-http": "Hebo un error al comunicase col sirvidor.",
+ "flow-error-other": "Hebo un fallu inesperáu.",
+ "flow-error-external": "Hebo un error.<br />El mensaxe d'error recibíu ye: $1",
+ "flow-error-edit-restricted": "Nun tien permisu pa editar esti mensaxe.",
+ "flow-error-external-multi": "Alcontráronse errores.<br />$1",
+ "flow-error-missing-content": "El mensaxe nun tien conteníu. El conteníu ye obligatoriu pa guardar un mensaxe.",
+ "flow-error-missing-summary": "El resume nun tien conteníu. El conteníu ye obligatoriu pa guardar un resume.",
+ "flow-error-missing-title": "L'asuntu nun tien títulu. El títulu ye obligatoriu pa guardar un asuntu.",
+ "flow-error-parsoid-failure": "Nun ye posible analizar el conteníu por un fallu de Parsoid.",
+ "flow-error-missing-replyto": "Nun se dio nengún parámetru «responder a». Esti parámetru ye obligatoriu pa l'aición \"responder\".",
+ "flow-error-invalid-replyto": "El parámetru «responder a» yera inválidu. Nun pudo alcontrase'l mensaxe especificáu.",
+ "flow-error-delete-failure": "Falló'l desaniciu d'esti elementu.",
+ "flow-error-hide-failure": "Falló'l tapecer esti elementu.",
+ "flow-error-missing-postId": "Nun se dio nengún parámetru «postId». Esti parámetru ye obligatoriu p'actuar sobro un mensaxe.",
+ "flow-error-invalid-postId": "El parámetru «postId» yera inválidu. Nun pudo alcontrase'l mensaxe especificáu ($1).",
+ "flow-error-restore-failure": "Falló la restauración d'esti elementu.",
+ "flow-error-invalid-moderation-state": "Diose un valor inválidu pa moderationState.",
+ "flow-error-invalid-moderation-reason": "Por favor, ufra un motivu pa la moderación.",
+ "flow-error-not-allowed": "Nun tien permisu bastante pa executar esta aición.",
+ "flow-error-title-too-long": "Los títulos del asuntu tan llendaos a $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-no-existing-workflow": "Esti fluxu de trabayu inda nun esiste.",
+ "flow-error-not-a-post": "El títulu del asuntu nun pue guardase como mensaxe.",
+ "flow-error-missing-header-content": "La testera nun tien conteníu. El conteníu ye obligatoriu pa guardar una testera.",
+ "flow-error-missing-prev-revision-identifier": "Falta l'identificador de revisión anterior.",
+ "flow-error-prev-revision-mismatch": "Otru usuariu acaba d'editar esti mensaxe hai dellos segundos. ¿Ta seguru de que quier sobreescribir esi cambiu?",
+ "flow-error-prev-revision-does-not-exist": "Nun pudo alcontrase la revisión anterior.",
+ "flow-error-default": "Hebo un error.",
+ "flow-error-invalid-input": "Dióse un valor inválidu pa cargar el conteníu de fluxu.",
+ "flow-error-invalid-title": "Dióse un títulu de páxina inválidu.",
+ "flow-error-fail-load-history": "Falló la carga del conteníu del historial.",
+ "flow-error-missing-revision": "Nun pudo alcontrase una revisión pa cargar conteníu de fluxu.",
+ "flow-error-fail-commit": "Nun pudo guardase'l conteníu del fluxu.",
+ "flow-error-insufficient-permission": "Nun tien permisu bastante pa tener accesu al conteníu.",
+ "flow-error-revision-comparison": "La operación diff sólo pue facese ente dos revisiones del mesmu mensaxe.",
+ "flow-error-missing-topic-title": "Nun pue alcontrase'l títulu del asuntu del fluxu de trabayu actual.",
+ "flow-error-fail-load-data": "Nun pudieron cargase los datos solicitaos.",
+ "flow-error-invalid-workflow": "Nun pudo alcontrase'l fluxu de trabayu solicitáu.",
+ "flow-error-process-data": "Hebo un error al procesar los datos de la solicitú.",
+ "flow-error-process-wikitext": "Hebo un error al procesar la conversión HTML/testu wiki.",
+ "flow-error-no-index": "Nun s'alcontró un índiz pa facer la gueta de datos.",
+ "flow-edit-header-submit": "Guardar testera",
+ "flow-edit-header-submit-overwrite": "Sobreescribir testera",
+ "flow-edit-title-submit": "Camudar el títulu",
+ "flow-edit-title-submit-overwrite": "Sobreescribir el títulu",
+ "flow-edit-post-submit": "Unviar los cambios",
+ "flow-edit-post-submit-overwrite": "Sobreescribir los cambios",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|editó}} un [$3 comentariu] sobro «$4».",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|comentó}}] sobro «$4» (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "{{PLURAL:$1|Amestóse|Amestáronse}} <strong>$1 {{PLURAL:$1|comentariu|comentarios}}</strong>.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|creó}} l'asuntu «[$3 $4]».",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|camudó}}'l títulu del asuntu de «$5» a «[$3 $4]».",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|creó}} la testera.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|editó}} la testera.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|creó}} un resume d'asuntu sobro $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|editó}} un resume d'asuntu sobro $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|tapeció}} un [$4 comentariu] sobro «$6» (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|desanició}} un [$4 comentariu] sobro «$6» (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suprimió}} un [$4 comentariu] sobro «$6» (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restauró}} un [$4 comentariu] sobro «$6» (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|tapeció}} l'[$4 asuntu] «$6» (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|desanició}} l'[$4 asuntu] «$6» (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|suprimió}} l'[$4 asuntu] «$6» (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|restauró}} l'[$4 asuntu] «$6» (<em>$5</em>).",
+ "flow-board-history": "Historial de «$1»",
+ "flow-board-history-empty": "Esti tableru actualmente nun tien historial.",
+ "flow-topic-history": "Historial del asuntu «$1»",
+ "flow-post-history": "Historial del mensaxe \"Comentariu de {{GENDER:$2|$2}}\"",
+ "flow-history-last4": "Últimes 4 hores",
+ "flow-history-day": "Güei",
+ "flow-history-week": "Cabera selmana",
+ "flow-history-pages-topic": "Apaez nel [$1 tableru «$2»]",
+ "flow-history-pages-post": "Apaez en [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 comentariu|$1 comentarios|0=¡Comenta tú {{GENDER:$2|el primeru|la primera}}!}}",
+ "flow-comment-restored": "Comentariu restauráu",
+ "flow-comment-deleted": "Comentariu desaniciáu",
+ "flow-comment-hidden": "Comentariu tapecíu",
+ "flow-comment-moderated": "Comentariu moderáu",
+ "flow-last-modified": "Últimu cambiu hai como $1",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|contestó}} en '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 y {{PLURAL:$6|otru|otros $5}} más {{GENDER:$1|contestaron}} en '''$3'''.",
+ "flow-notification-edit": "$1 {{GENDER:$1|editó}} un <span class=\"plainlinks\">[$5 comentariu]</span> sobro \"$2\" en [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 y {{PLURAL:$6|otru|otros $5}} {{GENDER:$1|editaron}} un <span class=\"plainlinks\">[$4 comentariu]</span> sobro \"$2\" en \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|creó}} un asuntu nuevu en '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|cambió}}'l títulu de <span class=\"plainlinks\">[$2 $3]</span> a «$4» en [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|mencionóte}} nel {{GENDER:$1|so}} <span class=\"plainlinks\">[$2 mensaxe]</span> sobro \"$3\" en \"$4\".",
+ "flow-notification-link-text-view-post": "Ver el mensaxe",
+ "flow-notification-link-text-view-topic": "Ver el filu",
+ "flow-notification-reply-email-subject": "$2 en $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondió}} a \"$2\" en \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 y {{PLURAL:$5|otra persona|otres $4 persones}} {{GENDER:$1|respondieron}} a \"$2\" en \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|mencionóte}} en \"$2\".",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|mencionóte}} nel {{GENDER:$1|so}} mensaxe sobro \"$2\" en \"$3\".",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|editó}} un mensaxe",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|editó}} un mensaxe sobro \"$2\" en \"$3\".",
+ "flow-notification-edit-email-batch-bundle-body": "$1 y $4 {{PLURAL:$5|más}}{{GENDER:$1|editaron}} un mensaxe sobro \"$2\" en \"$3\".",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|renomó'l}} so asuntu",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|renomó'l}} so asuntu «$2» a «$3» en «$4»",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|creó}} un asuntu nuevu en «$2»",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|creó}} un asuntu nuevu col títulu «$2» en $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Avisame cuando heba aiciones rellacionaes conmigo en Flow.",
+ "flow-link-post": "mensaxe",
+ "flow-link-topic": "asuntu",
+ "flow-link-history": "historial",
+ "flow-link-post-revision": "revisión de mensaxe",
+ "flow-link-topic-revision": "revisión d'asuntu",
+ "flow-link-header-revision": "revisión de testera",
+ "flow-moderation-title-suppress-post": "¿Suprimir mensaxe?",
+ "flow-moderation-title-delete-post": "¿Desaniciar mensaxe?",
+ "flow-moderation-title-hide-post": "¿Tapecer mensaxe?",
+ "flow-moderation-title-unsuppress-post": "¿De-suprimir el mensaxe?",
+ "flow-moderation-title-undelete-post": "¿Restaurar el mensaxe?",
+ "flow-moderation-title-unhide-post": "¿Amosar el mensaxe?",
+ "flow-moderation-placeholder-suppress-post": "Por favor, {{GENDER:$3|esplica}} por qué vas suprimir esta publicación.",
+ "flow-moderation-placeholder-delete-post": "Por favor, {{GENDER:$3|esplique}} por qué va desaniciar esta publicación.",
+ "flow-moderation-placeholder-hide-post": "Por favor, {{GENDER:$3|esplique}} por qué va tapecer esta publicación.",
+ "flow-moderation-placeholder-unsuppress-post": "Por favor, {{GENDER:$3|esplica}} por qué vas des-suprimir esta publicación.",
+ "flow-moderation-placeholder-undelete-post": "Por favor, {{GENDER:$3|esplica}} por qué vas restaurar esta publicación.",
+ "flow-moderation-placeholder-unhide-post": "Por favor, {{GENDER:$3|esplique}} por qué va amosar esta publicación.",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Desaniciar",
+ "flow-moderation-confirm-hide-post": "Tapecer",
+ "flow-moderation-confirm-unsuppress-post": "De-suprimir",
+ "flow-moderation-confirm-undelete-post": "Restaurar",
+ "flow-moderation-confirm-unhide-post": "Revelar",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Desaniciar",
+ "flow-moderation-confirm-hide-topic": "Tapecer",
+ "flow-moderation-confirm-unsuppress-topic": "De-suprimir",
+ "flow-moderation-confirm-undelete-topic": "Restaurar",
+ "flow-moderation-confirm-unhide-topic": "Revelar",
+ "flow-moderation-confirmation-suppress-post": "El mensaxe suprimióse correutamente.\n{{GENDER:$2|Considere}} aldericar con $1 tocante a esti mensaxe.",
+ "flow-moderation-confirmation-delete-post": "El mensaxe desanicióse correutamente.\n{{GENDER:$2|Considere}} aldericar con $1 tocante a esti mensaxe.",
+ "flow-moderation-confirmation-hide-post": "El mensaxe tapecióse correutamente.\n{{GENDER:$2|Considere}} aldericar con $1 tocante a esti mensaxe.",
+ "flow-topic-collapsed-one-line": "Vista pequeña",
+ "flow-topic-collapsed-full": "Vista colapsada",
+ "flow-topic-complete": "Vista completa",
+ "flow-terms-of-use-new-topic": "Al calcar \"{{int:flow-newtopic-save}}\", aceuta les condiciones d'usu d'esta wiki.",
+ "flow-terms-of-use-reply": "Al calcar \"{{int:flow-reply-submit}}\", aceuta les condiciones d'usu d'esta wiki.",
+ "flow-terms-of-use-edit": "Al guardar los cambios, aceuta les condiciones d'usu d'esta wiki."
+}
diff --git a/Flow/i18n/az.json b/Flow/i18n/az.json
new file mode 100644
index 00000000..69e603f9
--- /dev/null
+++ b/Flow/i18n/az.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Wertuose"
+ ]
+ },
+ "flow-post-action-undelete-post": "Bərpa et",
+ "flow-moderation-confirm-undelete-post": "Bərpa et",
+ "flow-moderation-confirm-undelete-topic": "Bərpa et"
+}
diff --git a/Flow/i18n/bcc.json b/Flow/i18n/bcc.json
new file mode 100644
index 00000000..ad028dc8
--- /dev/null
+++ b/Flow/i18n/bcc.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Baloch Afghanistan"
+ ]
+ },
+ "flow-newtopic-content-placeholder": "دیم داتین یک نوکین پیامی بی \"$1\"",
+ "flow-newtopic-first-heading": "شروع کورتین یک نوکین موضوعی بی $1",
+ "flow-topic-notification-subscribe-title": "شما گون ای موضوعا شریک بوتیت."
+}
diff --git a/Flow/i18n/bcl.json b/Flow/i18n/bcl.json
new file mode 100644
index 00000000..f3f53878
--- /dev/null
+++ b/Flow/i18n/bcl.json
@@ -0,0 +1,43 @@
+{
+ "@metadata": {
+ "authors": [
+ "Geopoet"
+ ]
+ },
+ "flow-talk-username": "Tagamaneho kan taguytoy sa pahina nin orolay",
+ "flow-board-notification-subscribe-title": "Ika nakasubskribo sa pisara kaining Daluydoy",
+ "flow-error-move": "Pagbabalyo nin sarong pisara nin diskusyon sa presente bakong suportado.",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|pinagliwat}} an sarong [$3 komentaryo] kan $4.",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|pinagkomentaryohan}}] on $4.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|pinagmukna}} an kapamayuhan.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|pinagliwat}} an kapamayuhan.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|itinago}} an sarong [$4 komentaryo] kan $6 (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|pinagpura}} an sarong [$4 komentaryo] kan $6 (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|pinag-untok}} an sarong [$4 komentaryo] kan $6 (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|pinagbalikwat}} an sarong [$4 komentaryo] kan $6 (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|itinago}} an [$4 na tema] $6 (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|pinagpura}} an [$4 na tema] $6 (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|pinag-untok}} an [$4 na tema] $6 (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|pinagbalikwat}} an [$4 na tema] $6 (<em>$5</em>).",
+ "flow-workflow": "taguytoy-nin-trabaho",
+ "flow-started-ago-day": "Pinagpoonan $1{{PLURAL:$1|aldaw|mga aldaw}} na an nakaagi",
+ "flow-started-ago-hour": "Pinagpoonan $1{{PLURAL:$1|oras|mga oras}} na an nakaagi",
+ "flow-started-ago-minute": "Pinagpoonan $1 {{PLURAL:$1|minuto|minutos}} na an nakaagi",
+ "flow-started-ago-second": "Pinagpoonan $1 {{PLURAL:$1|segundo|segundos}} na an nakaagi",
+ "flow-started-ago-week": "Pinagpoonan $1 {{PLURAL:$1|semana|mga semana}} na an nakaagi",
+ "flow-edited-ago-day": "Pinagliwat $1 {{PLURAL:$1|na aldaw|mga aldaw}} an nakaagi",
+ "flow-edited-ago-hour": "Pinagliwat $1{{PLURAL:$1|oras|mga oras}} na an nakaagi",
+ "flow-edited-ago-minute": "Pinagliwat $1 {{PLURAL:$1|minuto|minutos}} na an nakaagi",
+ "flow-edited-ago-second": "Pinagliwat $1 {{PLURAL:$1|segundo|segundos}} na an nakaagi",
+ "flow-edited-ago-week": "Pinagliwat $1 {{PLURAL:$1|semana|mga semana}} na an nakaagi",
+ "flow-active-ago-day": "Aktibo $1{{PLURAL:$1|aldaw|mga aldaw}} na an nakaagi",
+ "flow-active-ago-hour": "Aktibo $1 {{PLURAL:$1|oras|mga oras}} na an nakaagi",
+ "flow-active-ago-minute": "Aktibo $1 {{PLURAL:$1|minuto|minutos}} na an nakaagi",
+ "flow-active-ago-second": "Aktibo $1 {{PLURAL:$1|segundo|segundos}} na an nakaagi",
+ "flow-active-ago-week": "Aktibo $1 {{PLURAL:$1|semana|mga semana}} na an nakaagi",
+ "flow-time-ago-day": "$1 {{PLURAL:$1|aldaw|mga aldaw}} na an nakaagi",
+ "flow-time-ago-hour": "$1 {{PLURAL:$1|oras|mga oras}} na an nakaagi",
+ "flow-time-ago-minute": "$1 {{PLURAL:$1|minuto|minutos}} na an nakaagi",
+ "flow-time-ago-second": "$1 {{PLURAL:$1|segundo|segundos}} na an nakaagi",
+ "flow-time-ago-week": "$1 {{PLURAL:$1|semana|mga semana}} na an nakaagi"
+}
diff --git a/Flow/i18n/be.json b/Flow/i18n/be.json
new file mode 100644
index 00000000..86f36c4d
--- /dev/null
+++ b/Flow/i18n/be.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Дзяніс Тутэйшы"
+ ]
+ },
+ "flow-post-action-hide-post": "Схаваць"
+}
diff --git a/Flow/i18n/bg.json b/Flow/i18n/bg.json
new file mode 100644
index 00000000..c323896f
--- /dev/null
+++ b/Flow/i18n/bg.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "DCLXVI",
+ "Mitzev",
+ "Borislav"
+ ]
+ },
+ "flow-cancel": "Отказване",
+ "flow-newtopic-header": "Добавяне на нова тема",
+ "flow-newtopic-save": "Добавяне на тема",
+ "flow-newtopic-start-placeholder": "Започване на нова тема",
+ "flow-topic-action-watchlist": "Списък за наблюдение",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|коментира}}] в $4 (<em>$5</em>).",
+ "echo-pref-tooltip-flow-discussion": "Известяване при засягащи ме действия във Flow (новата дискусионна система).",
+ "flow-link-history": "история",
+ "flow-revision-permalink-warning-header": "Това е постоянна препратка към единична версия на заглавката.\nТази версия е от $1. Можете да видите [$3 разликите с предишната версия] или да видите други версии от [$2 историята на страницата].",
+ "flow-revision-permalink-warning-header-first": "Това е постоянна препратка към първата версия на заглавката.\nМожете да видите по-нови версии от [$2 историята на страницата].",
+ "flow-compare-revisions-header-header": "Тази страница показва {{GENDER:$2|промените}} между две версии на заглавката на [$3 $1].\nМожете да видите други версии на [$4 историята на страницата]."
+}
diff --git a/Flow/i18n/bn.json b/Flow/i18n/bn.json
new file mode 100644
index 00000000..8cd4cfef
--- /dev/null
+++ b/Flow/i18n/bn.json
@@ -0,0 +1,37 @@
+{
+ "@metadata": {
+ "authors": [
+ "Tauhid16",
+ "Aftab1995"
+ ]
+ },
+ "flow-post-moderated-toggle-delete-show": "$2-এর {{GENDER:$1|অপসারিত}} মন্তব্যসমূহ দেখাও।",
+ "flow-post-moderated-toggle-suppress-hide": "$2-এর {{GENDER:$1|গোপনকৃত}} মন্তব্যসমূহ লুকাও।",
+ "flow-newtopic-first-heading": "$1-এ নতুন প্রসঙ্গ শুরু করুন",
+ "flow-thank-link": "{{GENDER:$1|ধন্যবাদ}}",
+ "flow-lock-link": "{{GENDER:$1|বন্দী করুন}}",
+ "flow-post-action-post-history": "ইতিহাস",
+ "flow-post-action-delete-post": "অপসারণ",
+ "flow-post-action-hide-post": "আড়াল করো",
+ "flow-post-action-edit-post": "সম্পাদনা",
+ "flow-post-action-edit-post-submit": "পরিবর্তন সংরক্ষণ",
+ "flow-topic-action-watchlist": "নজরতালিকা",
+ "flow-topic-action-history": "ইতিহাস",
+ "flow-board-notification-subscribe-title": "আপনি এই আলোচনা বোর্ডে সদস্যতা নিয়েছে!",
+ "flow-lock-topic-submit": "বিষয় বন্দী করুন",
+ "flow-lock-topic-submit-overwrite": "বন্দী বিষয় সারাংশ আবার লিখুন",
+ "flow-unlock-topic-submit": "বিষয় মুক্ত করুন",
+ "flow-rev-message-new-post-recentchanges-summary": "নতুন প্রসঙ্গ {{GENDER:$2|তৈরি হয়েছে}}",
+ "flow-history-day": "আজ",
+ "flow-history-week": "গত সপ্তাহ",
+ "flow-notification-reply-email-subject": "$3-এ $2",
+ "flow-moderation-confirmation-suppress-post": "আপনার পোস্টটি সফলভাবে গোপন করা হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "flow-moderation-confirmation-delete-post": "আপনার পোস্টটি সফলভাবে মুছে ফেলা হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "flow-moderation-confirmation-hide-post": "আপনার পোস্টটি সফলভাবে লুকানো হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "flow-moderation-confirmation-suppress-topic": "আপনার পোস্টটি সফলভাবে গোপন করা হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "flow-moderation-confirmation-delete-topic": "আপনার পোস্টটি সফলভাবে মুছে ফেলা হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "flow-moderation-confirmation-hide-topic": "আপনার পোস্টটি সফলভাবে লুকানো হয়েছে। \nপোস্টটির উপর প্রতিক্রিয়া {{GENDER:$1|প্রকাশের}} $1 মাধ্যমে বিবেচনা করুন।",
+ "apihelp-flow+view-topic-example-1": "[[Topic:S2tycnas4hcucw8w]] দেখুন",
+ "flow-edited": "সম্পাদিত",
+ "flow-edited-by": "$1 দ্বারা সম্পাদিত"
+}
diff --git a/Flow/i18n/bo.json b/Flow/i18n/bo.json
new file mode 100644
index 00000000..6dd9a392
--- /dev/null
+++ b/Flow/i18n/bo.json
@@ -0,0 +1,37 @@
+{
+ "@metadata": {
+ "authors": [
+ "Phurbutsering"
+ ]
+ },
+ "flow-post-action-unsuppress-post": "རྩ་མེད་མི་བཟོ་རོགས།",
+ "flow-post-action-undelete-post": "མི་བསུབས།",
+ "flow-post-action-unhide-post": "མི་སྦས་རོགས།",
+ "flow-topic-action-unhide-topic": "བརྗོད་དོན་མ་སྦས་རོགས།",
+ "flow-topic-action-undelete-topic": "བརྗོད་དོན་མི་སུབས།",
+ "flow-topic-action-unsuppress-topic": "བརྗོད་དོན་རྩ་མེད་བཟོ་མི་དགོས།",
+ "flow-moderation-title-unsuppress-post": "གསལ་བསྒྲགས་རྩ་མེད་བཟོ་མི་དགོས་སམ།",
+ "flow-moderation-title-undelete-post": "གསལ་བསྒྲགས་སྦས་མི་དགོས་སམ།",
+ "flow-moderation-title-unhide-post": "གསལ་བསྒྲགས་སྦས་མི་དགོས་སམ།",
+ "flow-moderation-placeholder-unsuppress-post": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་གསལ་སྒྲགས་འདི་རྩ་མེད་མ་བཟོས་དོན་་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།",
+ "flow-moderation-placeholder-undelete-post": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་གསལ་སྒྲགས་འདི་མི་སུབས་དོན་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།",
+ "flow-moderation-placeholder-unhide-post": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་གསལ་སྒྲགས་འདི་མ་སྦས་དོན་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།",
+ "flow-moderation-confirm-unsuppress-post": "རྩ་མེད་མི་བཟོ་རོགས།",
+ "flow-moderation-confirm-undelete-post": "མི་བསུབས།",
+ "flow-moderation-confirm-unhide-post": "མ་སྦས་རོགས།",
+ "flow-moderation-confirm-unsuppress-topic": "རྩ་མེད་མི་བཟོ་རོགས།",
+ "flow-moderation-confirm-undelete-topic": "མི་བསུབས།",
+ "flow-moderation-confirm-unhide-topic": "མ་སྦས་རོགས།",
+ "flow-moderation-confirmation-unsuppress-post": "ཁྱེད་ཀྱིས་གོང་གི་གསལ་བསྒྲགས་འདི་རྩ་མེད་མི་བཟོ་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-confirmation-undelete-post": "ཁྱེད་ཁྱིས་གོང་གི་གསལ་བསྒྲགས་འདི་མི་སུབས་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-confirmation-unhide-post": "ཁྱེད་ཀྱིས་གོང་གི་གསལ་བསྒྲགས་མི་སྦས་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-confirmation-unsuppress-topic": "ཁྱེད་ཀྱིས་བརྗོད་དོན་འདི་རྩ་མེད་མི་བཟོ་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-confirmation-undelete-topic": "ཁྱེད་ཁྱིས་བརྗོད་དོན་འདི་མི་སུབས་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-confirmation-unhide-topic": "ཁྱེད་ཁྱིས་བརྗོད་དོན་མི་སྦས་རྒྱུ་ལེགས་གྲུབ་བྱུང་སོང།",
+ "flow-moderation-title-unsuppress-topic": "བརྗོད་དོན་རྩ་མེད་བཟོ་མི་དགོས་སམ།",
+ "flow-moderation-title-undelete-topic": "བརྗོད་དོན་སྦས་མི་དགོས་སམ།",
+ "flow-moderation-title-unhide-topic": "བརྗོད་དོན་སྦས་མི་དགོས་སམ།",
+ "flow-moderation-placeholder-unsuppress-topic": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་བརྗོད་དོན་འདི་རྩ་མེད་མ་བཟོས་དོན་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།",
+ "flow-moderation-placeholder-undelete-topic": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་བརྗོད་དོན་འདི་མི་སུབས་དོན་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།",
+ "flow-moderation-placeholder-unhide-topic": "རྒྱུ་རྐྱེན་གང་ཞིག་གི་རྐྱེན་པས་ཁྱེད་ཀྱིས་བརྗོད་དོན་འདི་མ་སྦས་དོན་{{GENDER:$3|གསལ་སྟོན་}}གནང་རོགས།"
+}
diff --git a/Flow/i18n/br.json b/Flow/i18n/br.json
new file mode 100644
index 00000000..0468931d
--- /dev/null
+++ b/Flow/i18n/br.json
@@ -0,0 +1,115 @@
+{
+ "@metadata": {
+ "authors": [
+ "Fohanno",
+ "Y-M D"
+ ]
+ },
+ "flow-user-moderated": "Implijer habaskaet",
+ "flow-edit-header-link": "Aozañ an talbenn",
+ "flow-post-moderated-toggle-hide-show": "Diskouez an evezhiadenn {{GENDER:$1|kuzhet}} gant $2",
+ "flow-post-moderated-toggle-delete-show": "Diskouez an evezhiadenn {{GENDER:$1|dilamet}} gant $2",
+ "flow-post-moderated-toggle-suppress-show": "Diskouez an evezhiadenn {{GENDER:$1|lamet}} gant $2",
+ "flow-post-moderated-toggle-hide-hide": "Kuzhat an evezhiadenn {{GENDER:$1|kuzhet}} gant $2",
+ "flow-post-moderated-toggle-delete-hide": "Kuzhat an evezhiadenn {{GENDER:$1|dilamet}} gant $2",
+ "flow-post-moderated-toggle-suppress-hide": "Kuzhat an evezhiadenn {{GENDER:$1|lamet}} gant $2",
+ "flow-topic-moderated-reason-prefix": "Abeg :",
+ "flow-hide-post-content": "An evezhiadenn-mañ a oa bet {{GENDER:$1|kuzhet}} gant $1",
+ "flow-hide-header-content": "{{GENDER:$1|Kuzhet}} gant $2",
+ "flow-delete-post-content": "An evezhiadenn a oa bet {{GENDER:$1|dilamet}} gant $1",
+ "flow-delete-header-content": "{{GENDER:$1|Dilamet}} gant $2",
+ "flow-suppress-post-content": "An evezhiadenn-mañ a oa bet {{GENDER:$1|dilamet}} gant $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Dilamet}} gant $2",
+ "flow-suppress-usertext": "<em>Anv implijer lamet</em>",
+ "flow-post-actions": "Oberoù",
+ "flow-topic-actions": "Oberoù",
+ "flow-cancel": "Nullañ",
+ "flow-preview": "Rakwelet",
+ "flow-show-change": "Diskouez ar c'hemmoù",
+ "flow-last-modified-by": "{{GENDER:$1|kemmet}} da ziwezhañ gant $1",
+ "flow-newtopic-title-placeholder": "Kaoz nevez",
+ "flow-newtopic-content-placeholder": "Postañ ur gemennadenn nevez da \"$1\"",
+ "flow-newtopic-header": "Ouzhpennañ ur gaoz nevez",
+ "flow-newtopic-save": "Ouzhpennañ ur gaoz",
+ "flow-newtopic-start-placeholder": "Kregiñ gant ur gaoz nevez",
+ "flow-summarize-topic-placeholder": "Diverrit an diviz-mañ, mar plij",
+ "flow-reply-topic-title-placeholder": "Respont da \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Respont}}",
+ "flow-reply-link": "{{GENDER:$1|Respont}}",
+ "flow-thank-link": "{{GENDER:$1|Trugarez}}",
+ "flow-post-edited": "Kemennadenn {{GENDER:$1|aozet}} gant $1 $2",
+ "flow-post-action-view": "Peurliamm",
+ "flow-post-action-post-history": "Istor",
+ "flow-post-action-suppress-post": "Lemel",
+ "flow-post-action-delete-post": "Dilemel",
+ "flow-post-action-hide-post": "Kuzhat",
+ "flow-post-action-edit-post": "Aozañ",
+ "flow-post-action-edit-post-submit": "Enrollañ ar c'hemmoù",
+ "flow-post-action-unhide-post": "Diguzhat",
+ "flow-post-action-restore-post": "Assevel",
+ "flow-topic-action-view": "Peurliamm",
+ "flow-topic-action-watchlist": "Roll evezhiañ",
+ "flow-topic-action-edit-title": "Kemmañ an titl",
+ "flow-topic-action-history": "Istor",
+ "flow-topic-action-hide-topic": "Kuzhat ar gaoz",
+ "flow-topic-action-delete-topic": "Dilemel ar gaoz",
+ "flow-topic-action-summarize-topic": "Diverrañ",
+ "flow-topic-action-suppress-topic": "Dilemel ar gaoz",
+ "flow-topic-action-restore-topic": "Assevel ar gaoz",
+ "flow-topic-action-undo-moderation": "Dizober",
+ "flow-error-other": "Ur fazi dic'hortoz zo bet.",
+ "flow-error-external": "Ur fazi zo bet.<br />Ar gemennadenn fazi resevet a oa : $1",
+ "flow-error-edit-restricted": "N'oc'h ket aotreet da aozañ ar gemennadenn-mañ.",
+ "flow-error-external-multi": "Fazioù zo bet.<br />$1",
+ "flow-error-delete-failure": "C'hwitet eo bet diverkadenn an elfenn-mañ",
+ "flow-error-hide-failure": "N'eus ket bet gallet kuzhat an elfenn-mañ.",
+ "flow-error-default": "C'hoarvezet ez eus ur fazi.",
+ "flow-error-invalid-topic-uuid-title": "Titl fall",
+ "flow-edit-header-submit": "Enrollañ an talbenn",
+ "flow-summarize-topic-submit": "Diverrañ",
+ "flow-edit-title-submit": "Cheñch an titl",
+ "flow-edit-post-submit": "Kas ar c'hemmoù",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|en deus|he deus}} krouet an talbenn.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|en deus|he deus}} aozet an talbenn.",
+ "flow-rc-topic-of-board": "$1 war $2",
+ "flow-board-history": "Istor \"$1\"",
+ "flow-history-last4": "4 eur diwezhañ",
+ "flow-history-day": "Hiziv",
+ "flow-history-week": "Er sizhun baseet",
+ "flow-comment-restored": "Evezhiadenn assavet",
+ "flow-comment-deleted": "Evezhiadenn dilamet",
+ "flow-comment-hidden": "Evezhiadenn kuzhet",
+ "flow-comment-moderated": "Evezhiadenn habaskaet",
+ "flow-last-modified": "Kemm diwezhañ war-dro $1",
+ "flow-notification-link-text-view-post": "Gwelet ar gemennadenn",
+ "flow-notification-reply-email-subject": "$1 {{GENDER:$1|en deus|he deus}} respontet d'ho kemennadenn",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|en deus|he deus}} respontet d'ho kemennadenn e-barzh $2 war \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|en deus|he deus}} meneget ac'hanoc'h war $2",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|en deus|he deus}} aozet ur gemennadenn",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|en deus|he deus}} aozet ur gemennadenn e-barzh $2 war \"$3\"",
+ "flow-link-post": "kemennadenn",
+ "flow-link-history": "istor",
+ "flow-moderation-title-suppress-post": "Lemel ar gemennadenn ?",
+ "flow-moderation-title-delete-post": "Dilemel ar gemennadenn ?",
+ "flow-moderation-title-hide-post": "Kuzhat ar gemennadenn ?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|Displegit}}, mar plij, perak e tilamit ar gemennadenn-mañ.",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Displegit}}, mar plij perak e tilamit ar gemennadenn-mañ.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Displegit}}, lar plij, perak e kuzhit ar gemennadenn-mañ.",
+ "flow-moderation-confirm-suppress-post": "Lemel",
+ "flow-moderation-confirm-delete-post": "Dilemel",
+ "flow-moderation-confirm-hide-post": "Kuzhat",
+ "flow-moderation-confirm-unhide-post": "Diguzhat",
+ "flow-moderation-confirm-suppress-topic": "Lemel",
+ "flow-moderation-confirm-delete-topic": "Diverkañ",
+ "flow-moderation-confirm-hide-topic": "Kuzhat",
+ "flow-moderation-confirm-unhide-topic": "Diguzhat",
+ "flow-topic-collapsed-one-line": "Gwel bihan",
+ "flow-topic-complete": "Gwel klok",
+ "flow-load-more": "Kargañ muioc'h",
+ "flow-special-type": "Seurt",
+ "flow-special-type-post": "Postañ",
+ "flow-special-uuid": "UUID",
+ "flow-preview-return-edit-post": "Kenderc'hel da aozañ",
+ "flow-anonymous": "Dizanv",
+ "flow-embedding-unsupported": "An divizoù ne c'hallont ket bezañ enframmet c'hoazh."
+}
diff --git a/Flow/i18n/bs.json b/Flow/i18n/bs.json
new file mode 100644
index 00000000..4852d177
--- /dev/null
+++ b/Flow/i18n/bs.json
@@ -0,0 +1,22 @@
+{
+ "@metadata": {
+ "authors": [
+ "DzWiki"
+ ]
+ },
+ "flow-edit-header-link": "Uredi zaglavlje",
+ "flow-post-actions": "Akcije",
+ "flow-topic-actions": "Akcije",
+ "flow-cancel": "Otkaži",
+ "flow-show-change": "Prikaži izmjene",
+ "flow-newtopic-title-placeholder": "Nova tema",
+ "flow-newtopic-save": "Dodaj temu",
+ "flow-post-action-post-history": "Historija",
+ "flow-post-action-delete-post": "Obriši",
+ "flow-post-action-hide-post": "Sakrij",
+ "flow-post-action-edit-post": "Uredi",
+ "flow-topic-action-edit-title": "Uredi naslov",
+ "flow-topic-action-history": "Historija",
+ "flow-edit-post-submit": "Pošalji promjene",
+ "flow-history-day": "Danas"
+}
diff --git a/Flow/i18n/bxr.json b/Flow/i18n/bxr.json
new file mode 100644
index 00000000..1bdd9c4d
--- /dev/null
+++ b/Flow/i18n/bxr.json
@@ -0,0 +1,27 @@
+{
+ "@metadata": {
+ "authors": [
+ "Elvonudinium"
+ ]
+ },
+ "flow-started-ago-day": "$1 {{PLURAL:$1|үдэр}} урда эхилһэн",
+ "flow-started-ago-hour": "$1 {{PLURAL:$1|саг}} урда эхилһэн",
+ "flow-started-ago-minute": "$1 {{PLURAL:$1|минута}} урда эхилһэн",
+ "flow-started-ago-second": "$1 {{PLURAL:$1|секунда}} урда эхилһэн",
+ "flow-started-ago-week": "$1 {{PLURAL:$1|долоо хоног}} урда эхилһэн",
+ "flow-edited-ago-day": "$1 {{PLURAL:$1|үдэр}} урда заһабарилагдаһан",
+ "flow-edited-ago-hour": "$1 {{PLURAL:$1|саг}} урда заһабарилагдаһан",
+ "flow-edited-ago-minute": "$1 {{PLURAL:$1|минута}} урда заһабарилагдаһан",
+ "flow-edited-ago-second": "$1 {{PLURAL:$1|секунда}} урда заһабарилагдаһан",
+ "flow-edited-ago-week": "$1 {{PLURAL:$1|долоо хоног}} урда заһабарилагдаһан",
+ "flow-active-ago-day": "$1 {{PLURAL:$1|үдэр}} урда эдэбхитэй байгаа",
+ "flow-active-ago-hour": "$1 {{PLURAL:$1|саг}} урда эдэбхитэй байгаа",
+ "flow-active-ago-minute": "$1 {{PLURAL:$1|минута}} урда эдэбхитэй байгаа",
+ "flow-active-ago-second": "$1 {{PLURAL:$1|секунда}} урда эдэбхитэй байгаа",
+ "flow-active-ago-week": "$1 {{PLURAL:$1|долоо хоног}} урда эдэбхитэй байгаа",
+ "flow-time-ago-day": "$1 {{PLURAL:$1|үдэр}} урда",
+ "flow-time-ago-hour": "$1 {{PLURAL:$1|саг}} урда",
+ "flow-time-ago-minute": "$1 {{PLURAL:$1|минута}} урда",
+ "flow-time-ago-second": "$1 {{PLURAL:$1|секунда}} урда",
+ "flow-time-ago-week": "$1 {{PLURAL:$1|долоо хоног}} урда"
+}
diff --git a/Flow/i18n/ca.json b/Flow/i18n/ca.json
new file mode 100644
index 00000000..b6b71150
--- /dev/null
+++ b/Flow/i18n/ca.json
@@ -0,0 +1,254 @@
+{
+ "@metadata": {
+ "authors": [
+ "Fitoschido",
+ "Hiperpobla",
+ "Vriullop",
+ "Unapersona",
+ "QuimGil",
+ "Lluis tgn",
+ "Toniher",
+ "Xavier Dengra"
+ ]
+ },
+ "enableflow": "Habilita el Flow",
+ "flow-desc": "Sistema de gestió del flux de treball",
+ "flow-talk-taken-over": "Aquesta pàgina de discussió utilitza [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestor de pàgines de discussió del Flow",
+ "log-name-flow": "Registre d'activitat del Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|ha esborrat}} un [$4 missatge] sobre «[[$3|$5]]» a [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|ha restaurat}} un [$4 missatge] sobre «[[$3|$5]]» a [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|ha suprimit}} un [$4 missatge] sobre «[[$3|$5]]» a [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|ha esborrat}} un [$4 missatge] a [[$3]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|ha esborrat}} un [$4 tema] a [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|ha restaurat}} un [$4 tema] a [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|ha suprimit}} un [$4 tema] a [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|ha esborrat}} un [$4 tema] a [[$3]]",
+ "flow-user-moderated": "Usuari moderat",
+ "flow-board-header-browse-topics-link": "Navega els temes",
+ "flow-edit-header-link": "Modifica l'encapçalament",
+ "flow-post-moderated-toggle-hide-show": "Mostra el comentari {{GENDER:$1|amagat}} per $2",
+ "flow-post-moderated-toggle-delete-show": "Mostra el comentari {{GENDER:$1|esborrat}} per $2",
+ "flow-post-moderated-toggle-suppress-show": "Mostra el comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-post-moderated-toggle-hide-hide": "Amaga el comentari {{GENDER:$1|amagat}} per $2",
+ "flow-post-moderated-toggle-delete-hide": "Amaga el comentari {{GENDER:$1|esborrat}} per $2",
+ "flow-post-moderated-toggle-suppress-hide": "Amaga el comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-topic-moderated-reason-prefix": "Motiu:",
+ "flow-hide-post-content": "Aquest comentari va ser {{GENDER:$1|amagat}} per $1 ([$2 historial])",
+ "flow-hide-title-content": "Aquest tema de discussió ha estat {{GENDER:$1|amagat}} per $1",
+ "flow-lock-title-content": "Aquest tema de discussió ha estat {{GENDER:$1|tancat}} per $1",
+ "flow-hide-header-content": "{{GENDER:$1|Amagat}} per $2",
+ "flow-delete-post-content": "Aquest comentari va ser {{GENDER:$1|esborrat}} per $1 ([$2 historial])",
+ "flow-delete-title-content": "Aquest tema de discussió ha estat {{GENDER:$1|esborrat}} per $1",
+ "flow-delete-header-content": "{{GENDER:$1|Esborrat}} per $2",
+ "flow-suppress-post-content": "Aquest comentari va ser {{GENDER:$1|suprimit}} per $1 ([$2 historial])",
+ "flow-suppress-title-content": "Aquest tema de discussió ha estat {{GENDER:$1|suprimit}} per $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimit}} per $2",
+ "flow-suppress-usertext": "<em>Nom d'usuari suprimit</em>",
+ "flow-post-actions": "Accions",
+ "flow-topic-actions": "Accions",
+ "flow-cancel": "Cancel·la",
+ "flow-preview": "Previsualitza",
+ "flow-show-change": "Mostra els canvis",
+ "flow-last-modified-by": "Darrera {{GENDER:$1|modificació}} per $1",
+ "flow-stub-post-content": "\"A causa d'un error tècnic, aquesta publicació no s'ha pogut recuperar.\"",
+ "flow-newtopic-title-placeholder": "Nou tema",
+ "flow-newtopic-content-placeholder": "Envia un missatge nou a «$1»",
+ "flow-newtopic-header": "Afegir un nou tema",
+ "flow-newtopic-save": "Afegeix el tema",
+ "flow-newtopic-start-placeholder": "Comenceu un nou tema",
+ "flow-newtopic-first-heading": "Comença un nou tema sobre $1",
+ "flow-summarize-topic-placeholder": "Si us plau, resumiu aquesta discussió",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentari}} a \"$2\"",
+ "flow-reply-topic-title-placeholder": "Resposta a «$1»",
+ "flow-reply-submit": "{{GENDER:$1|Respon}}",
+ "flow-reply-link": "{{GENDER:$1|Respon}}",
+ "flow-thank-link": "{{GENDER:$1|Agraeix}}",
+ "flow-lock-link": "{{GENDER:$1|Tancar}}",
+ "flow-thank-link-title": "Agraïu l'autor públicament",
+ "flow-history-action-suppress-post": "suprimeix",
+ "flow-history-action-delete-post": "eliminar",
+ "flow-history-action-hide-post": "amaga",
+ "flow-history-action-unsuppress-post": "restaura",
+ "flow-history-action-undelete-post": "restaura",
+ "flow-history-action-unhide-post": "mostrar",
+ "flow-history-action-restore-post": "restaura",
+ "flow-history-action-lock-topic": "Bloca",
+ "flow-history-action-unlock-topic": "desbloca",
+ "flow-post-edited": "Missatge {{GENDER:$1|modificat}} per $1 $2",
+ "flow-post-action-view": "Enllaç permanent",
+ "flow-post-action-post-history": "Historial",
+ "flow-post-action-suppress-post": "suprimeix",
+ "flow-post-action-delete-post": "Elimina",
+ "flow-post-action-hide-post": "Amaga",
+ "flow-post-action-edit-post": "Modifica",
+ "flow-post-action-edit-post-submit": "Desa els canvis",
+ "flow-post-action-unsuppress-post": "Restaura",
+ "flow-post-action-undelete-post": "Restaura",
+ "flow-post-action-unhide-post": "Mostrar",
+ "flow-post-action-restore-post": "Restaura",
+ "flow-post-action-undo-moderation": "Desfés",
+ "flow-topic-action-view": "Enllaç permanent",
+ "flow-topic-action-watchlist": "Llista de seguiment",
+ "flow-topic-action-edit-title": "Modifica el títol",
+ "flow-topic-action-history": "Historial",
+ "flow-topic-action-hide-topic": "Amaga el tema",
+ "flow-topic-action-delete-topic": "Esborra el tema",
+ "flow-topic-action-lock-topic": "Tanca el tema",
+ "flow-topic-action-unlock-topic": "Desbloquejar el fil de discussió",
+ "flow-topic-action-summarize-topic": "Resum",
+ "flow-topic-action-resummarize-topic": "Modifica el resum",
+ "flow-topic-action-suppress-topic": "Suprimeix el tema",
+ "flow-topic-action-unhide-topic": "Mostrar el tema",
+ "flow-topic-action-undelete-topic": "Restaura el tema",
+ "flow-topic-action-unsuppress-topic": "Restaura el tema",
+ "flow-topic-action-restore-topic": "Restaura el tema",
+ "flow-topic-action-undo-moderation": "Desfés",
+ "flow-topic-notification-subscribe-title": "S’ha afegit aquest tema a {{GENDER:$1|la vostra}} llista de seguiment.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Rebreu}} notificacions de tota activitat sobre aquest tema.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Esteu}} subscrit a aquest tauler de discussió!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Rebreu}} una notificació quan es creï un nou tema en aquest tauler.",
+ "flow-error-http": "S'ha produït un error mentre es contactava amb el servidor.",
+ "flow-error-other": "S'ha produït un error inesperat.",
+ "flow-error-external": "S'ha produït un error.<br />El missatge d'error rebre és: $1",
+ "flow-error-edit-restricted": "No teniu permís per modificar aquest missatge.",
+ "flow-error-topic-is-locked": "Aquest tema està bloquejat per a altres activitats.",
+ "flow-error-lock-moderated-post": "No pots tancar una publicació moderada",
+ "flow-error-external-multi": "S'han trobat errors.<br />$1",
+ "flow-error-missing-content": "La publicació no té contingut. El contingut és necessari per desar una publicació.",
+ "flow-error-missing-summary": "El resum no té cap contingut. El contingut és necessari per desar un resum.",
+ "flow-error-missing-title": "EL tema no té títol. El títol és necessari per desar un tema.",
+ "flow-error-delete-failure": "La supressió d'aquest element ha fallat.",
+ "flow-error-hide-failure": "Ha fallat amagar aquest fitxer.",
+ "flow-error-restore-failure": "La recuperació d'aquest element ha fallat.",
+ "flow-error-not-allowed-hide": "Aquest tema ha estat amagat.",
+ "flow-error-not-allowed-delete": "Aquest tema ha estat esborrat.",
+ "flow-error-not-allowed-suppress": "Aquest tema ha estat suprimit.",
+ "flow-error-title-too-long": "Els títols dels temes estan limitats a $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-not-a-post": "El títol del tema no es pot desar com un apunt.",
+ "flow-error-prev-revision-does-not-exist": "No s'ha pogut trobar la revisió anterior.",
+ "flow-error-default": "S'ha produït un error.",
+ "flow-error-fail-load-history": "Error en carregar l'historial de continguts.",
+ "flow-error-fail-commit": "No s'ha pogut desar el contingut de Flow.",
+ "flow-error-insufficient-permission": "Permisos insuficients per accedir a aquest contingut.",
+ "flow-error-fail-load-data": "Error en carregar les dades sol·licitades.",
+ "flow-error-no-commit": "No s'ha pogut desar l'acció especificada.",
+ "flow-error-invalid-topic-uuid-title": "El títol no és correcte",
+ "flow-error-unknown-workflow-id-title": "Tema desconegut.",
+ "flow-error-unknown-workflow-id": "El tema sol·licitat no existeix.",
+ "flow-edit-header-placeholder": "Descriviu aquest tauler de discussió",
+ "flow-edit-header-submit": "Desa l'encapçalament",
+ "flow-summarize-topic-submit": "Resumeix",
+ "flow-lock-topic-submit": "Bloqueja el tema",
+ "flow-unlock-topic-submit": "Desbloqueja el tema",
+ "flow-edit-title-submit": "Canvia el títol",
+ "flow-edit-title-submit-overwrite": "Sobreescriu el títol",
+ "flow-edit-post-submit": "Publica els canvis",
+ "flow-edit-post-submit-overwrite": "Sobreescriu els canvis",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|ha modificat}} un [$3 comentari] a «$4»",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|ha afegit}}] un comentari a «$4» (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|comentari|comentaris}}</strong> {{PLURAL:$1|ha estat afegit|han estat afegits}}",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|ha creat}} el tema «[$3 $4]»",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|ha canviat}} el títol del tema \"$5\" a \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|ha creat}} l'encapçalament",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|ha modificat}} l'encapçalament",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|ha creat}} un resum del tema a $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|ha modificat}} el resum del tema a $3",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|ha tancat}} el [$4 tema] $6 (<em>$5</em>)",
+ "flow-rc-topic-of-board": "$1 a $2",
+ "flow-board-history-empty": "Aquest tauler no té actualment cap historial.",
+ "flow-topic-history": "Historial del tema «$1»",
+ "flow-history-last4": "Darreres 4 hores",
+ "flow-history-day": "Avui",
+ "flow-history-week": "Darrera setmana",
+ "flow-history-pages-topic": "Apareix en el [$1 tauler «$2»]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 comentari|$1 comentaris|0=Sigueu {{GENDER:$2|el primer|la primera}} en comentar!}}",
+ "flow-comment-restored": "Comentari restaurat",
+ "flow-comment-deleted": "Comentari esborrat",
+ "flow-comment-hidden": "Comentari amagat",
+ "flow-comment-moderated": "Comentari revisat.",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|ha contestat}} a '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 i $5 {{PLURAL:$6|més}} han {{GENDER:$1|contestat}} a '''$3'''.",
+ "flow-notification-edit-bundle": "$1 i $5 {{PLURAL:$6|més}} han {{GENDER:$1|modificat}} un <span class=\"plainlinks\">[$4 apunt]</span> de «$2» a «$3».",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|ha creat}} un nou tema a '''$3'''.",
+ "flow-notification-mention": "$1 {{GENDER:$5|us}} {{GENDER:$1|ha mencionat}} en {{GENDER:$1|el seu}} <span class=\"plainlinks\">[$2 apunt]</span> de \"$3\" a \"$4\".",
+ "flow-notification-link-text-view-post": "Mostra l'apunt",
+ "flow-notification-link-text-view-topic": "Mostra el tema",
+ "flow-notification-reply-email-batch-bundle-body": "$1 i $4 {{PLURAL:$5|més}} {{GENDER:$1|han respost}} sobre «$2» a «$3»",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|us}} {{GENDER:$1|ha mencionat}} a «$2»",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|us}} {{GENDER:$1|ha mencionat}} en {{GENDER:$1|el seu}} apunt de «$2» a «$3»",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|ha modificat}} un apunt",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|ha modificat}} un apunt de «$2» a «$3»",
+ "flow-notification-edit-email-batch-bundle-body": "$1 i $4 {{PLURAL:$5|més}} {{GENDER:$1|han modificat}} un apunt de «$2» a «$3»",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|ha reanomenat}} el vostre tema",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|ha reanomenat}} el tema «$2» a «$3», a «$4»",
+ "echo-category-title-flow-discussion": "Flux de discussió",
+ "echo-pref-tooltip-flow-discussion": "Notifica'm quan hi hagi accions que em concerneixin en el flux de discussió",
+ "flow-link-post": "apunt",
+ "flow-link-topic": "tema",
+ "flow-link-history": "historial",
+ "flow-link-post-revision": "versió de l'apunt",
+ "flow-link-topic-revision": "versió del tema",
+ "flow-link-header-revision": "versió de l'encapçalament",
+ "flow-moderation-title-hide-post": "Voleu amagar l'apunt?",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Expliqueu}} per què esborreu aquest missatge.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Expliqueu}} per què amagueu aquest missatge.",
+ "flow-moderation-placeholder-unsuppress-post": "{{GENDER:$3|Expliqueu}} per què recupereu aquest missatge.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|Expliqueu}} per què restaureu aquest missatge.",
+ "flow-moderation-placeholder-unhide-post": "{{GENDER:$3|Expliqueu}} per què mostreu de nou aquest missatge.",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Elimina",
+ "flow-moderation-confirm-hide-post": "Amaga",
+ "flow-moderation-confirm-unsuppress-post": "Restaura",
+ "flow-moderation-confirm-undelete-post": "Restaura",
+ "flow-moderation-confirm-unhide-post": "Mostrar",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Elimina",
+ "flow-moderation-confirm-hide-topic": "Amaga",
+ "flow-moderation-confirm-lock-topic": "Bloqueja",
+ "flow-moderation-confirm-unsuppress-topic": "Restaura",
+ "flow-moderation-confirm-undelete-topic": "Restaura",
+ "flow-moderation-confirm-unhide-topic": "Mostra",
+ "flow-moderation-confirm-unlock-topic": "Desbloqueja",
+ "flow-moderation-confirmation-hide-topic": "Aquest tema ha estat amagat.",
+ "flow-moderation-title-hide-topic": "Voleu amagar el tema?",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|Expliqueu}} per què amagueu aquest tema.",
+ "flow-moderation-placeholder-lock-topic": "{{GENDER:$3|Expliqueu}} per què bloqueu aquest tema.",
+ "flow-compare-revisions-revision-header": "Versió de {{GENDER:$2|$2}} de $1",
+ "flow-compare-revisions-header-header": "Aquesta pàgina mostra els {{GENDER:$2|canvis}} entre dues versions de l'encapçalament de [$3 $1].\nPodeu veure altres versions de l'encapçalament en la [$4 pàgina d'historial].",
+ "flow-terms-of-use-new-topic": "En clicar «{{int:flow-newtopic-save}}», esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-terms-of-use-reply": "En clicar «{{int:flow-replay-submit}}», esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-terms-of-use-edit": "En desar el canvis, esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-anon-warning": "No heu iniciat cap sessió. Per rebre l'atribució amb el vostre nom en lloc de l'adreça IP, podeu [$1 registrar-vos] o [$2 crear un compte].",
+ "flow-cancel-warning": "Heu introduït text en aquest formulari. Esteu segurs que voleu descartar-lo?",
+ "flow-topic-first-heading": "Tema de $1",
+ "flow-topic-count": "Temes ($1)",
+ "flow-load-more": "Carrega'n més",
+ "flow-no-more-fwd": "Cap més tema anterior",
+ "flow-add-topic": "Afegeix el tema",
+ "flow-newest-topics": "Temes més recents",
+ "flow-recent-topics": "Temes amb activitat més recent",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Esteu}} veient primer els temes més recents. Cliqueu per altres opcions d'ordenació.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Esteu}} veient primer els temes amb activitat més recent. Cliqueu per altres opcions d'ordenació.",
+ "flow-toggle-small-topics": "Canvia a la vista reduïda de temes",
+ "flow-toggle-topics": "Canvia a la vista plegada de temes",
+ "flow-toggle-topics-posts": "Canvia a la vista de temes i apunts",
+ "flow-terms-of-use-summarize": "En clicar «{{int:flow-summarize-topic-submit}}», esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-terms-of-use-lock-topic": "En clicar «{{int:flow-lock-topic-submit}}», esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-terms-of-use-unlock-topic": "En clicar «{{int:flow-unlock-topic-submit}}», esteu acceptant les condicions d'ús d'aquest wiki.",
+ "flow-special-type": "Tipus",
+ "flow-special-type-post": "Apunt",
+ "flow-special-type-workflow": "Flux de treball",
+ "flow-spam-confirmedit-form": "Confirmeu que no és una edició mecànica desxifrant el captcha següent: $1",
+ "flow-preview-warning": "Aquesta és una vista prèvia. Envieu el formulari per acabar de publicar-la, o cliqueu «{{int:flow-preview-return-edit-post}}» per continuar escrivint.",
+ "flow-preview-return-edit-post": "Segueix editant",
+ "flow-anonymous": "Anònim",
+ "flow-edited": "Modificat",
+ "flow-ve-mention-inspector-title": "Menció",
+ "flow-ve-mention-inspector-remove-label": "Suprimeix",
+ "flow-ve-mention-tool-title": "Menciona un usuari",
+ "flow-ve-mention-template": "ping",
+ "flow-ve-mention-inspector-invalid-user": "L'usuari «$1» no està registrat.",
+ "flow-wikitext-editor-help-preview-the-result": "previsualitza el resultat",
+ "flow-wikitext-switch-editor-tooltip": "Canvia a l'editor visual"
+}
diff --git a/Flow/i18n/ce.json b/Flow/i18n/ce.json
new file mode 100644
index 00000000..c2f8978e
--- /dev/null
+++ b/Flow/i18n/ce.json
@@ -0,0 +1,84 @@
+{
+ "@metadata": {
+ "authors": [
+ "Умар"
+ ]
+ },
+ "flow-hide-post-content": "ХӀара къамел хьулдина {{GENDER:$1|декъашхочо}} $1",
+ "flow-hide-title-content": "ХӀара къамел хьулдина {{GENDER:$1|декъашхочо}} $1",
+ "flow-delete-post-content": "ХӀара къамел дӀадаьккхина {{GENDER:$1|декъашхочо}} $1",
+ "flow-post-actions": "дийраш",
+ "flow-topic-actions": "Дийраш",
+ "flow-cancel": "Цаоьшу",
+ "flow-preview": "Хьалха хьажар",
+ "flow-show-change": "Гайта хийцам",
+ "flow-last-modified-by": "ТӀехьара бина {{GENDER:$1|хийцам}} цу $1",
+ "flow-newtopic-title-placeholder": "Керла тема",
+ "flow-newtopic-content-placeholder": "ДӀбазбе керла хаам «$1» чохь",
+ "flow-reply-submit": "{{GENDER:$1|Жоп}}",
+ "flow-reply-link": "{{GENDER:$1|Жоп}}",
+ "flow-thank-link": "{{GENDER:$1|Баркалла аьлла}}",
+ "flow-lock-link": "{{GENDER:$1|Блок}}",
+ "flow-history-action-delete-post": "дӀаяккха",
+ "flow-history-action-hide-post": "къайлаяккха",
+ "flow-post-edited": "Хаам табина {{GENDER:$1|декъашхочо}} $1 $2",
+ "flow-post-action-view": "Гуттура йолу хьажорг",
+ "flow-post-action-post-history": "Истори",
+ "flow-post-action-delete-post": "ДӀаяккха",
+ "flow-post-action-hide-post": "Къайлаяккха",
+ "flow-post-action-edit-post": "Тае",
+ "flow-post-action-undelete-post": "МеттахӀоттае",
+ "flow-post-action-unhide-post": "Гайта",
+ "flow-post-action-restore-post": "МеттахӀоттае",
+ "flow-topic-action-view": "Гуттура йолу хьажорг",
+ "flow-topic-action-watchlist": "Тергаме могӀам",
+ "flow-topic-action-edit-title": "Табе корта",
+ "flow-topic-action-history": "Истори",
+ "flow-topic-action-hide-topic": "Хьулле тема",
+ "flow-topic-action-delete-topic": "ДӀаяккха тема",
+ "flow-error-no-commit": "Нисдинарг Ӏалашдан йиш яц.",
+ "flow-error-invalid-topic-uuid-title": "Цамегаш йолу цӀе",
+ "flow-edit-header-submit-overwrite": "Юха дӀаязбе корта",
+ "flow-unlock-topic-submit": "Теман тӀера блокдӀаяккха",
+ "flow-edit-title-submit-overwrite": "Юха дӀаязъе цӀе",
+ "flow-edit-post-submit-overwrite": "Юха дӀаязде хийцамаш",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|тадина}} [$3 къамел] темехь $4.",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|къамел диттина}}] темехь $4.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|кхоьллина}} къамел ''[$3 $4]''.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|кхоьллина}} корта.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|табина}} корта.",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2||дӀадяьккхина}} [$4 къамел] темехь $6(<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2||дӀаяьккхина}} [$4 къамел] $6(<em>$5</em>) чохь.",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2||дӀаяьккхина}} [$4 тема] $6(<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|дӀаяьккхина}} тема [$4 topic] $6 (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 оцу $2",
+ "flow-board-history": "\"$1\" истори",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|кхоьллина}} '''$3''' агӀонгахь керла хьедар.",
+ "flow-notification-mention": "$1 {{GENDER:$1|хьахийна}} {{GENDER:$5|хьо}} {{GENDER:$1|шен}} <span class=\"plainlinks\">[$2 хаамехь]</span> темехь «$3» агӀонгахь «$4».",
+ "flow-notification-link-text-view-post": "Хьажа хааме",
+ "flow-notification-reply-email-subject": "$2 $3 тӀе",
+ "flow-notification-reply-email-batch-body": "$1 хан хааан {{GENDER:$1||жоп делла}} темехь «$2» «$3» чохь",
+ "flow-notification-mention-email-subject": "$1 хьо {{GENDER:$1|хьахийна}} «$2» чохь",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|хьахийна}} {{GENDER:$4|хьо}} {{GENDER:$1|шен}} хаамехь «$2» агӀонгахь «$3»",
+ "flow-link-post": "хаам",
+ "flow-link-history": "истори",
+ "flow-moderation-title-delete-post": "ДӀабаккха хаам",
+ "flow-moderation-title-hide-post": "Къайлабаккха хаам?",
+ "flow-moderation-confirm-delete-post": "ДӀаяккха",
+ "flow-moderation-confirm-hide-post": "Къайлаяккха",
+ "flow-moderation-confirm-undelete-post": "ДӀаяккха",
+ "flow-moderation-confirm-unhide-post": "Гайта",
+ "flow-moderation-confirm-delete-topic": "ДӀаяккха",
+ "flow-moderation-confirm-hide-topic": "Къайлаяккха",
+ "flow-moderation-confirm-undelete-topic": "МеттахӀоттае",
+ "flow-moderation-confirm-unhide-topic": "Гайта",
+ "flow-topic-first-heading": "$1 чура хьедар",
+ "flow-topic-html-title": "$1 $2 тӀе",
+ "flow-recent-topics": "Дукху хан йоцуш жигара хьедарш",
+ "flow-spam-confirmedit-form": "Хьой адам делахь сурт тӀера йоза язде: $1",
+ "flow-post-undo-delete": "юхадаккха дӀадаккхар",
+ "flow-previous-diff": "← Хьалхдоьда нисдинарг",
+ "flow-next-diff": "ТӀаьхьа догӀа нисдинарг →",
+ "flow-undo": "йохо",
+ "flow-undo-your-text": "Хьан йоза"
+}
diff --git a/Flow/i18n/cs.json b/Flow/i18n/cs.json
new file mode 100644
index 00000000..7aa695eb
--- /dev/null
+++ b/Flow/i18n/cs.json
@@ -0,0 +1,21 @@
+{
+ "@metadata": {
+ "authors": [
+ "Michaelbrabec",
+ "Mormegil",
+ "Paxt"
+ ]
+ },
+ "flow-post-moderated-toggle-hide-show": "Ukázat komentář {{GENDER:$1|skrytý}} od $2",
+ "flow-post-moderated-toggle-delete-show": "Ukázat komentář {{GENDER:$1|odstraněný}} od $2",
+ "flow-post-moderated-toggle-suppress-show": "Ukázat komentář {{GENDER:$1|odstraněný}} od $2",
+ "flow-post-moderated-toggle-hide-hide": "Skrýt komentář {{GENDER:$1|skrytý}} od $2",
+ "flow-post-moderated-toggle-delete-hide": "Skrýt komentář {{GENDER:$1|odstraněný}} od $2",
+ "flow-post-moderated-toggle-suppress-hide": "Skrýt komentář {{GENDER:$1|potlačený}} od $2",
+ "flow-cancel": "Storno",
+ "flow-newtopic-title-placeholder": "Nové téma",
+ "flow-post-action-post-history": "Historie",
+ "flow-post-action-edit-post": "Editovat",
+ "flow-topic-action-edit-title": "Upravit název",
+ "flow-topic-action-history": "Historie"
+}
diff --git a/Flow/i18n/de.json b/Flow/i18n/de.json
new file mode 100644
index 00000000..9eaa9c24
--- /dev/null
+++ b/Flow/i18n/de.json
@@ -0,0 +1,482 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kghbln",
+ "Metalhead64",
+ "Inkowik"
+ ]
+ },
+ "enableflow": "Flow aktivieren",
+ "flow-desc": "Ermöglicht ein Verwaltungssystem zu Benutzerdiskussionen",
+ "flow-talk-taken-over": "Diese Diskussionsseite verwendet [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Flow-Diskussionsseitenverwalter",
+ "log-name-flow": "Flow-Aktivitätslogbuch",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|löschte}} einen [$4 Beitrag] auf „[[$3|$5]]“ auf [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|stellte}} einen [$4 Beitrag] auf „[[$3|$5]]“ auf [[$6]] wieder her",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|unterdrückte}} einen [$4 Beitrag] auf „[[$3|$5]]“ auf [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|löschte}} einen [$4 Beitrag] auf „[[$3|$5]]“ auf [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|löschte}} das Thema „[[$3|$5]]“ auf [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|stellte}} das Thema „[[$3|$5]]“ auf [[$6]] wieder her",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|unterdrückte}} das Thema „[[$3|$5]]“ auf [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|löschte}} das Thema „[[$3|$5]]“ auf [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] auf [[$3]] wurde von LiquidThreads zu Flow importiert",
+ "flow-user-moderated": "Moderierter Benutzer",
+ "flow-board-header-browse-topics-link": "Themen durchsuchen",
+ "flow-edit-header-link": "Kopfbereich bearbeiten",
+ "flow-post-moderated-toggle-hide-show": "Kommentar anzeigen, der von $2 {{GENDER:$1|versteckt}} wurde.",
+ "flow-post-moderated-toggle-delete-show": "Kommentar anzeigen, der von $2 {{GENDER:$1|gelöscht}} wurde.",
+ "flow-post-moderated-toggle-suppress-show": "Kommentar anzeigen, der von $2 {{GENDER:$1|unterdrückt}} wurde.",
+ "flow-post-moderated-toggle-hide-hide": "Kommentar ausblenden, der von $2 {{GENDER:$1|versteckt}} wurde.",
+ "flow-post-moderated-toggle-delete-hide": "Kommentar ausblenden, der von $2 {{GENDER:$1|gelöscht}} wurde.",
+ "flow-post-moderated-toggle-suppress-hide": "Kommentar ausblenden, der von $2 {{GENDER:$1|unterdrückt}} wurde.",
+ "flow-topic-moderated-reason-prefix": "Grund:",
+ "flow-hide-post-content": "Dieser Kommentar wurde {{GENDER:$1|versteckt}} von $1 ([$2 Verlauf])",
+ "flow-hide-title-content": "Dieses Thema wurde {{GENDER:$1|versteckt}} von $1",
+ "flow-lock-title-content": "Dieses Thema wurde {{GENDER:$1|gesperrt}} von $1",
+ "flow-hide-header-content": "{{GENDER:$1|Versteckt}} von $2",
+ "flow-delete-post-content": "Dieser Kommentar wurde {{GENDER:$1|gelöscht}} von $1 ([$2 Verlauf])",
+ "flow-delete-title-content": "Dieses Thema wurde {{GENDER:$1|gelöscht}} von $1",
+ "flow-delete-header-content": "{{GENDER:$1|Gelöscht}} von $2",
+ "flow-suppress-post-content": "Dieser Kommentar wurde {{GENDER:$1|unterdrückt}} von $1 ([$2 Verlauf])",
+ "flow-suppress-title-content": "Dieses Thema wurde {{GENDER:$1|unterdrückt}} von $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Unterdrückt}} von $2",
+ "flow-suppress-usertext": "<em>Benutzername unterdrückt</em>",
+ "flow-post-actions": "Aktionen",
+ "flow-topic-actions": "Aktionen",
+ "flow-cancel": "Abbrechen",
+ "flow-preview": "Vorschau",
+ "flow-show-change": "Änderungen anzeigen",
+ "flow-last-modified-by": "Zuletzt {{GENDER:$1|geändert}} von $1",
+ "flow-stub-post-content": "''Aufgrund eines technischen Fehlers konnte dieser Beitrag nicht abgerufen werden.''",
+ "flow-newtopic-title-placeholder": "Neues Thema",
+ "flow-newtopic-content-placeholder": "Eine neue Nachricht zu „$1“ posten",
+ "flow-newtopic-header": "Ein neues Thema hinzufügen",
+ "flow-newtopic-save": "Thema hinzufügen",
+ "flow-newtopic-start-placeholder": "Ein neues Thema starten",
+ "flow-newtopic-first-heading": "Ein neues Thema zu $1 starten",
+ "flow-summarize-topic-placeholder": "Bitte fasse diese Diskussion zusammen",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Kommentieren}} auf „$2“",
+ "flow-reply-topic-title-placeholder": "Antworten zu „$1“",
+ "flow-reply-submit": "{{GENDER:$1|Antworten}}",
+ "flow-reply-link": "{{GENDER:$1|Antworten}}",
+ "flow-thank-link": "{{GENDER:$1|Danken}}",
+ "flow-lock-link": "{{GENDER:$1|Sperren}}",
+ "flow-thank-link-title": "Dem Beitragsersteller öffentlich danken",
+ "flow-history-action-suppress-post": "unterdrücken",
+ "flow-history-action-delete-post": "löschen",
+ "flow-history-action-hide-post": "verstecken",
+ "flow-history-action-unsuppress-post": "nicht mehr unterdrücken",
+ "flow-history-action-undelete-post": "wiederherstellen",
+ "flow-history-action-unhide-post": "einblenden",
+ "flow-history-action-restore-post": "wiederherstellen",
+ "flow-history-action-lock-topic": "sperren",
+ "flow-history-action-unlock-topic": "freigeben",
+ "flow-post-edited": "Beitrag {{GENDER:$1|bearbeitet}} von $1 $2",
+ "flow-post-action-view": "Permanentlink",
+ "flow-post-action-post-history": "Verlauf",
+ "flow-post-action-suppress-post": "Unterdrücken",
+ "flow-post-action-delete-post": "Löschen",
+ "flow-post-action-hide-post": "Verstecken",
+ "flow-post-action-edit-post": "Bearbeiten",
+ "flow-post-action-edit-post-submit": "Änderungen speichern",
+ "flow-post-action-unsuppress-post": "Nicht mehr unterdrücken",
+ "flow-post-action-undelete-post": "Wiederherstellen",
+ "flow-post-action-unhide-post": "Einblenden",
+ "flow-post-action-restore-post": "Wiederherstellen",
+ "flow-post-action-undo-moderation": "Rückgängig machen",
+ "flow-topic-action-view": "Permanentlink",
+ "flow-topic-action-watchlist": "Beobachtungsliste",
+ "flow-topic-action-edit-title": "Titel bearbeiten",
+ "flow-topic-action-history": "Verlauf",
+ "flow-topic-action-hide-topic": "Thema verstecken",
+ "flow-topic-action-delete-topic": "Thema löschen",
+ "flow-topic-action-lock-topic": "Thema sperren",
+ "flow-topic-action-unlock-topic": "Thema freigeben",
+ "flow-topic-action-summarize-topic": "Zusammenfassen",
+ "flow-topic-action-resummarize-topic": "Themenzusammenfassung bearbeiten",
+ "flow-topic-action-suppress-topic": "Thema unterdrücken",
+ "flow-topic-action-unhide-topic": "Thema einblenden",
+ "flow-topic-action-undelete-topic": "Thema wiederherstellen",
+ "flow-topic-action-unsuppress-topic": "Thema nicht mehr unterdrücken",
+ "flow-topic-action-restore-topic": "Thema wiederherstellen",
+ "flow-topic-action-undo-moderation": "Rückgängig machen",
+ "flow-topic-notification-subscribe-title": "Dieses Thema wurde {{GENDER:$1|deiner}} Beobachtungsliste hinzugefügt.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Du}} erhältst Benachrichtigungen zu allen Aktivitäten zu diesem Thema.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Du}} hast dieses Diskussions-Board abonniert!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Du}} erhältst eine Benachrichtigung, wenn ein neues Thema auf diesem Board erstellt wird.",
+ "flow-error-http": "Beim Kontaktieren des Servers ist ein Fehler aufgetreten.",
+ "flow-error-other": "Ein unerwarteter Fehler ist aufgetreten.",
+ "flow-error-external": "Es ist ein Fehler aufgetreten.<br />Die empfangene Fehlermeldung lautete: $1",
+ "flow-error-edit-restricted": "Du bist nicht berechtigt, diesen Beitrag zu bearbeiten.",
+ "flow-error-topic-is-locked": "Dieses Thema ist für weitere Aktivitäten gesperrt.",
+ "flow-error-lock-moderated-post": "Du kannst keinen moderierten Beitrag sperren.",
+ "flow-error-external-multi": "Es sind Fehler aufgetreten.<br />$1",
+ "flow-error-missing-content": "Der Beitrag hat keinen Inhalt. Dieser ist erforderlich, um einen Beitrag zu speichern.",
+ "flow-error-missing-summary": "Die Zusammenfassung hat keinen Inhalt. Zur Speicherung einer Zusammenfassung ist ein Inhalt erforderlich.",
+ "flow-error-missing-title": "Das Thema hat keinen Titel. Dieser ist erforderlich, um ein Thema zu speichern.",
+ "flow-error-parsoid-failure": "Aufgrund eines Parsoid-Fehlers konnte der Inhalt nicht geparst werden.",
+ "flow-error-missing-replyto": "Es wurde kein Parameter „Antworten an“ angegeben. Dieser Parameter ist für die „Antworten“-Aktion erforderlich.",
+ "flow-error-invalid-replyto": "Der Parameter „Antworten an“ war ungültig. Der angegebene Beitrag konnte nicht gefunden werden.",
+ "flow-error-delete-failure": "Das Löschen dieses Objektes ist fehlgeschlagen.",
+ "flow-error-hide-failure": "Das Verstecken dieses Objektes ist fehlgeschlagen.",
+ "flow-error-missing-postId": "Es wurde kein Parameter „postId“ angegeben. Dieser Parameter ist zum Löschen/Wiederherstellen eines Beitrags erforderlich.",
+ "flow-error-invalid-postId": "Der Parameter „postId“ war ungültig. Der angegebene Beitrag ($1) konnte nicht gefunden werden.",
+ "flow-error-restore-failure": "Das Wiederherstellen dieses Objektes ist fehlgeschlagen.",
+ "flow-error-invalid-moderation-state": "An die Flow-API wurde ein ungültiger Wert für einen Parameter („moderationState“) übermittelt.",
+ "flow-error-invalid-moderation-reason": "Bitte gib einen Grund für die Moderation an",
+ "flow-error-not-allowed": "Keine ausreichenden Berechtigungen zum Ausführen dieser Aktion",
+ "flow-error-not-allowed-hide": "Dieses Thema wurde versteckt.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Du kannst nicht antworten, da dieses Thema versteckt wurde.",
+ "flow-error-not-allowed-delete": "Dieses Thema wurde gelöscht.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Du kannst nicht antworten, da dieses Thema gelöscht wurde.",
+ "flow-error-not-allowed-suppress": "Dieses Thema wurde gelöscht.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Du kannst nicht antworten, da dieses Thema gelöscht wurde.",
+ "flow-error-not-allowed-hide-extract": "Dieses Thema wurde versteckt. Zur Information wird unten das Versteck-Logbuch für das Thema angezeigt.",
+ "flow-error-not-allowed-delete-extract": "Dieses Thema wurde gelöscht. Zur Information wird unten das Lösch-Logbuch für das Thema angezeigt.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Du kannst nicht antworten, da dieses Thema gelöscht wurde. Zur Information wird unten ein Auszug aus dem Lösch-Logbuch des Themas angezeigt.",
+ "flow-error-not-allowed-suppress-extract": "Dieses Thema wurde gelöscht. Zur Information wird unten ein Auszug aus dem Lösch-Logbuch des Themas angezeigt.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Du kannst nicht antworten, da dieses Thema unterdrückt wurde. Zur Information wird unten ein Auszug aus dem Unterdrückungs-Logbuch des Themas angezeigt.",
+ "flow-error-title-too-long": "Thementitel sind beschränkt auf {{PLURAL:$1|ein Byte|$1 Bytes}}.",
+ "flow-error-no-existing-workflow": "Dieses Workflow ist noch nicht vorhanden.",
+ "flow-error-not-a-post": "Der Thementitel kann nicht als Beitrag gespeichert werden.",
+ "flow-error-missing-header-content": "Die Überschrift hat keinen Inhalt. Um eine Überschrift zu speichern, ist ein Inhalt erforderlich.",
+ "flow-error-missing-prev-revision-identifier": "Eine Kennung der vorherigen Version fehlt.",
+ "flow-error-prev-revision-mismatch": "Ein anderer Benutzer hat diesen Beitrag soeben vor einigen Sekunden bearbeitet. Bist {{GENDER:$3|du}} sicher, dass du die letzte Änderung überschreiben möchtest?",
+ "flow-error-prev-revision-does-not-exist": "Die vorherige Version konnte nicht gefunden werden.",
+ "flow-error-core-topic-deletion": "Um ein Thema zu löschen, verwende das Menü „…“ auf dem Flow-Board oder der [$1 Themenseite]. Besuche nicht direkt action=delete für das Thema.",
+ "flow-error-default": "Es ist ein Fehler aufgetreten.",
+ "flow-error-invalid-input": "Für das Laden des Flow-Inhalts wurde ein ungültiger Wert angegeben.",
+ "flow-error-invalid-title": "Es wurde ein ungültiger Seitentitel angegeben.",
+ "flow-error-fail-load-history": "Der Inhalt des Verlaufs konnte nicht geladen werden.",
+ "flow-error-missing-revision": "Zum Laden des Flow-Inhalts konnte keine Version gefunden werden.",
+ "flow-error-fail-commit": "Der Flow-Inhalt konnte nicht gespeichert werden.",
+ "flow-error-insufficient-permission": "Keine ausreichenden Berechtigungen, um auf den Inhalt zugreifen zu können.",
+ "flow-error-revision-comparison": "Der Unterschiedsvorgang kann nur für zwei Versionen des gleichen Beitrags ausgeführt werden.",
+ "flow-error-missing-topic-title": "Der Thementitel für das aktuelle Workflow konnte nicht gefunden werden.",
+ "flow-error-missing-metadata": "Die erforderlichen Metadaten für diese Version konnten nicht gefunden werden.",
+ "flow-error-fail-load-data": "Die angeforderten Daten konnten nicht geladen werden.",
+ "flow-error-invalid-workflow": "Das angeforderte Workflow konnte nicht gefunden werden.",
+ "flow-error-process-data": "Beim Verarbeiten der Daten in deiner Anfrage ist ein Fehler aufgetreten.",
+ "flow-error-process-wikitext": "Beim Verarbeiten der HTML-/Wikitext-Umwandlung ist ein Fehler aufgetreten.",
+ "flow-error-no-index": "Es konnte kein Index zum Ausführen der Datensuche gefunden werden.",
+ "flow-error-no-render": "Die angegebene Aktion wurde nicht erkannt.",
+ "flow-error-no-commit": "Die angegebene Aktion konnte nicht gespeichert werden.",
+ "flow-error-fetch-after-lock": "Beim Anfordern der neuen Daten ist ein Fehler aufgetreten. Die Sperren-/Freigeben-Operation war jedoch erfolgreich. Die Fehlernachricht war: $1",
+ "flow-error-content-too-long": "Der Inhalt ist zu groß. Der Inhalt nach der Expandierung ist beschränkt auf {{PLURAL:$1|ein Byte|$1 Bytes}}.",
+ "flow-error-move": "Das Verschieben eines Diskussions-Boards wird derzeit nicht unterstützt.",
+ "flow-error-invalid-topic-uuid-title": "Ungültiger Titel",
+ "flow-error-invalid-topic-uuid": "Der angeforderte Seitentitel war ungültig. Seiten im Themennamensraum werden von Flow automatisch erstellt.",
+ "flow-error-unknown-workflow-id-title": "Unbekanntes Thema",
+ "flow-error-unknown-workflow-id": "Das angeforderte Thema ist nicht vorhanden.",
+ "flow-edit-header-placeholder": "Dieses Diskussions-Board beschreiben",
+ "flow-edit-header-submit": "Kopfbereich speichern",
+ "flow-edit-header-submit-overwrite": "Überschrift überschreiben",
+ "flow-summarize-topic-submit": "Zusammenfassen",
+ "flow-summarize-topic-submit-overwrite": "Zusammenfassung überschreiben",
+ "flow-lock-topic-submit": "Thema sperren",
+ "flow-lock-topic-submit-overwrite": "Themensperr-Zusammenfassung überschreiben",
+ "flow-unlock-topic-submit": "Thema freigeben",
+ "flow-unlock-topic-submit-overwrite": "Themenfreigabe-Zusammenfassung überschreiben",
+ "flow-edit-title-submit": "Titel ändern",
+ "flow-edit-title-submit-overwrite": "Titel überschreiben",
+ "flow-edit-post-submit": "Änderungen übertragen",
+ "flow-edit-post-submit-overwrite": "Änderungen überschreiben",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|bearbeitete}} einen [$3 Kommentar] auf „$4“.",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Bearbeitete}} einen Beitrag",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|kommentierte}}] auf „$4“ (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "{{PLURAL:$1|<strong>Ein Kommentar</strong> wurde|<strong>$1 Kommentare</strong> wurden}} hinzugefügt.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|erstellte}} das Thema „[$3 $4]“.",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Erstellte}} ein neues Thema",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|änderte}} den Thementitel von „$5“ zu „[$3 $4]“.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|erstellte}} die Überschrift.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|bearbeitete}} die Überschrift.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|erstellte}} die Themenzusammenfassung zu $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|bearbeitete}} die Themenzusammenfassung zu $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|versteckte}} einen [$4 Kommentar] auf „$6“ (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|löschte}} einen [$4 Kommentar] auf „$6“ (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|unterdrückte}} einen [$4 Kommentar] auf „$6“ (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|stellte}} einen [$4 Kommentar] auf „$6“ wieder her (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|versteckte}} das [$4 Thema] „$6“ (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|löschte}} das [$4 Thema] „$6“ (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|unterdrückte}} das [$4 Thema] „$6“ (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|sperrte}} das [$4 Thema] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|stellte}} das [$4 Thema] „$6“ wieder her (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 auf $2",
+ "flow-board-history": "Verlauf von „$1“",
+ "flow-board-history-empty": "Dieses Board hat derzeit keinen Verlauf.",
+ "flow-topic-history": "Themenverlauf von „$1“",
+ "flow-post-history": "Beitragsverlauf – Kommentar von {{GENDER:$2|$2}}",
+ "flow-history-last4": "Letzte 4 Stunden",
+ "flow-history-day": "Heute",
+ "flow-history-week": "Letzte Woche",
+ "flow-history-pages-topic": "Erscheint auf dem [$1 Board „$2“]",
+ "flow-history-pages-post": "Erscheint auf [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|Ein Kommentar|$1 Kommentare|0=Sei {{GENDER:$2|der|die|der}} erste!}}",
+ "flow-comment-restored": "Kommentar wiederhergestellt",
+ "flow-comment-deleted": "Kommentar gelöscht",
+ "flow-comment-hidden": "Versteckter Kommentar",
+ "flow-comment-moderated": "Kommentar moderiert",
+ "flow-last-modified": "Zuletzt geändert $1",
+ "flow-workflow": "workflow",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|antwortete}} auf '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 und $5 {{PLURAL:$6|ein anderer|andere}} {{GENDER:$1|antworteten}} auf '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 hat deinen <span class=\"plainlinks\">[$5 Beitrag]</span> auf [[$3|$4]] {{GENDER:$1|bearbeitet}}.",
+ "flow-notification-edit-bundle": "$1 und {{PLURAL:$6|ein anderer|$5 andere}} {{GENDER:$1|bearbeiteten}} einen <span class=\"plainlinks\">[$4 Beitrag]</span> in „$2“ auf „$3“.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|erstellte}} ein neues Thema auf '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|Ein neues Thema|250=Mehr als 250 neue Themen}} zu '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|änderte}} den Titel von <span class=\"plainlinks\">[$2 $3]</span> nach „$4“ auf [[$5|$6]].",
+ "flow-notification-mention": "$1 hat {{GENDER:$5|dich}} in {{GENDER:$1|seinem|ihrem|dem}} <span class=\"plainlinks\">[$2 Beitrag]</span> in „$3“ auf Seite „$4“ erwähnt.",
+ "flow-notification-link-text-view-post": "Beitrag ansehen",
+ "flow-notification-link-text-view-topic": "Thema ansehen",
+ "flow-notification-reply-email-subject": "$2 auf $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|antwortete}} auf „$2“ auf „$3“",
+ "flow-notification-reply-email-batch-bundle-body": "$1 und {{PLURAL:$5|ein anderer|$4 andere}} {{GENDER:$1|antworteten}} auf „$2“ auf „$3“",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|erwähnte}} {{GENDER:$3|dich}} auf „$2“",
+ "flow-notification-mention-email-batch-body": "$1 hat {{GENDER:$4|dich}} in {{GENDER:$1|seinem|ihrem|dem}} Beitrag in „$2“ auf der Seite „$3“ erwähnt",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|bearbeitete}} einen Beitrag",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|bearbeitete}} einen Beitrag in „$2“ auf der Seite „$3“",
+ "flow-notification-edit-email-batch-bundle-body": "$1 und {{PLURAL:$5|ein anderer|$4 andere}} {{GENDER:$1|bearbeiteten}} einen Beitrag in „$2“ auf der Seite „$3“",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|benannte}} dein Thema um",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|benannte}} dein Thema „$2“ in „$3“ auf der Seite „$4“ um",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|erstellte}} ein neues Thema auf „$2“",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|erstellte}} ein neues Thema mit dem Titel „$2“ auf $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Benachrichtige mich, wenn mich betreffende Aktionen in Flow stattfinden.",
+ "flow-link-post": "Beitrag",
+ "flow-link-topic": "Thema",
+ "flow-link-history": "Verlauf",
+ "flow-link-post-revision": "Version des Beitrags",
+ "flow-link-topic-revision": "Version des Themas",
+ "flow-link-header-revision": "Version der Überschrift",
+ "flow-link-summary-revision": "Zusammenfassungsversion",
+ "flow-moderation-title-suppress-post": "Beitrag unterdrücken?",
+ "flow-moderation-title-delete-post": "Beitrag löschen?",
+ "flow-moderation-title-hide-post": "Beitrag verstecken?",
+ "flow-moderation-title-unsuppress-post": "Beitrag nicht mehr unterdrücken?",
+ "flow-moderation-title-undelete-post": "Beitrag wiederherstellen?",
+ "flow-moderation-title-unhide-post": "Beitrag einblenden?",
+ "flow-moderation-placeholder-suppress-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag unterdrückst.",
+ "flow-moderation-placeholder-delete-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag löschst.",
+ "flow-moderation-placeholder-hide-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag versteckst.",
+ "flow-moderation-placeholder-unsuppress-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag nicht mehr unterdrückst.",
+ "flow-moderation-placeholder-undelete-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag wiederherstellst.",
+ "flow-moderation-placeholder-unhide-post": "Bitte {{GENDER:$3|erkläre}}, warum du diesen Beitrag einblendest.",
+ "flow-moderation-confirm-suppress-post": "Unterdrücken",
+ "flow-moderation-confirm-delete-post": "Löschen",
+ "flow-moderation-confirm-hide-post": "Verstecken",
+ "flow-moderation-confirm-unsuppress-post": "Nicht mehr unterdrücken",
+ "flow-moderation-confirm-undelete-post": "Wiederherstellen",
+ "flow-moderation-confirm-unhide-post": "Einblenden",
+ "flow-moderation-confirm-suppress-topic": "Unterdrücken",
+ "flow-moderation-confirm-delete-topic": "Löschen",
+ "flow-moderation-confirm-hide-topic": "Verstecken",
+ "flow-moderation-confirm-lock-topic": "Sperren",
+ "flow-moderation-confirm-unsuppress-topic": "Nicht mehr unterdrücken",
+ "flow-moderation-confirm-undelete-topic": "Wiederherstellen",
+ "flow-moderation-confirm-unhide-topic": "Einblenden",
+ "flow-moderation-confirm-unlock-topic": "Freigeben",
+ "flow-moderation-confirmation-suppress-post": "Der Beitrag wurde erfolgreich unterdrückt.\n{{GENDER:$2|Ziehe}} in Erwägung, $1 eine Rückmeldung für diesen Beitrag zu geben.",
+ "flow-moderation-confirmation-delete-post": "Der Beitrag wurde erfolgreich gelöscht.\n{{GENDER:$2|Ziehe}} in Erwägung, $1 eine Rückmeldung für diesen Beitrag zu geben.",
+ "flow-moderation-confirmation-hide-post": "Der Beitrag wurde erfolgreich versteckt.\n{{GENDER:$2|Ziehe}} in Erwägung, $1 eine Rückmeldung für diesen Beitrag zu geben.",
+ "flow-moderation-confirmation-unsuppress-post": "Du hast die Unterdrückung des oben stehenden Beitrags erfolgreich aufgehoben.",
+ "flow-moderation-confirmation-undelete-post": "Du hast den oben stehenden Beitrag erfolgreich wiederhergestellt.",
+ "flow-moderation-confirmation-unhide-post": "Du hast den oben stehenden Beitrag erfolgreich eingeblendet.",
+ "flow-moderation-confirmation-suppress-topic": "Dieses Thema wurde unterdrückt.",
+ "flow-moderation-confirmation-delete-topic": "Dieses Thema wurde gelöscht.",
+ "flow-moderation-confirmation-hide-topic": "Dieses Thema wurde versteckt.",
+ "flow-moderation-confirmation-unsuppress-topic": "Du hast die Unterdrückung dieses Themas erfolgreich aufgehoben.",
+ "flow-moderation-confirmation-undelete-topic": "Du hast dieses Thema erfolgreich wiederhergestellt.",
+ "flow-moderation-confirmation-unhide-topic": "Du hast dieses Thema erfolgreich eingeblendet.",
+ "flow-moderation-title-suppress-topic": "Thema unterdrücken?",
+ "flow-moderation-title-delete-topic": "Thema löschen?",
+ "flow-moderation-title-hide-topic": "Thema verstecken?",
+ "flow-moderation-title-unsuppress-topic": "Thema nicht mehr unterdrücken?",
+ "flow-moderation-title-undelete-topic": "Thema wiederherstellen?",
+ "flow-moderation-title-unhide-topic": "Thema einblenden?",
+ "flow-moderation-placeholder-suppress-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema unterdrückst.",
+ "flow-moderation-placeholder-delete-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema löschst.",
+ "flow-moderation-placeholder-hide-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema versteckst.",
+ "flow-moderation-placeholder-lock-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema sperrst.",
+ "flow-moderation-placeholder-unsuppress-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema nicht mehr unterdrückst.",
+ "flow-moderation-placeholder-undelete-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema wiederherstellst.",
+ "flow-moderation-placeholder-unhide-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema einblendest.",
+ "flow-moderation-placeholder-unlock-topic": "Bitte {{GENDER:$3|erkläre}}, warum du dieses Thema freigibst.",
+ "flow-topic-permalink-warning": "Dieses Thema wurde gestartet auf [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Dieses Thema wurde gestartet auf dem [$2 Board von {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Dies ist ein Permanentlink zu einer einzelnen Version dieses Beitrags.\nDiese Version ist vom $1.\nDu kannst die [$5 Unterschiede von der vorherigen Version] oder andere Versionen im [$4 Verlauf] ansehen.",
+ "flow-revision-permalink-warning-post-first": "Dies ist ein Permanentlink zur ersten Version dieses Beitrags.\nDu kannst spätere Versionen im [$4 Verlauf] ansehen.",
+ "flow-revision-permalink-warning-postsummary": "Dies ist ein Permanentlink zu einer einzelnen Version der Zusammenfassung für diesen Beitrag. Diese Version ist von $1.\nDu kannst die [$5 Unterschiede von der vorherigen Version] oder andere Versionen auf der [$4 Beitragsverlaufsseite] ansehen.",
+ "flow-revision-permalink-warning-postsummary-first": "Dies ist ein Permanentlink zur ersten Version dieser Beitragszusammenfassung.\nDu kannst neuere Versionen auf der [$4 Beitragsverlaufsseite] ansehen.",
+ "flow-revision-permalink-warning-header": "Dies ist ein Permanentlink zu einer einzelnen Version der Überschrift.\nDiese Version ist von $1. Du kannst die [$3 Unterschiede von der vorherigen Version] oder andere Versionen im [$2 Verlauf des Boards] ansehen.",
+ "flow-revision-permalink-warning-header-first": "Dies ist ein Permanentlink zur ersten Version der Überschrift.\nDu kannst neuere Versionen im [$2 Verlauf des Boards] ansehen.",
+ "flow-compare-revisions-revision-header": "Version von {{GENDER:$2|$2}} vom $1",
+ "flow-compare-revisions-header-post": "Diese Seite zeigt die {{GENDER:$3|Änderungen}} zwischen zwei Versionen eines Beitrags von $3 im Thema „[$5 $2]“ auf [$4 $1] an.\nDu kannst andere Versionen dieses Beitrags im [$6 Verlauf] ansehen.",
+ "flow-compare-revisions-header-postsummary": "Diese Seite zeigt die Änderungen zwischen zwei Versionen einer Beitragszusammenfassung im Beitrag „[$4 $2]“ auf [$3 $1].\nDu kannst andere Versionen dieses Beitrags in seiner [$5 Verlaufsseite] ansehen.",
+ "flow-compare-revisions-header-header": "Diese Seite zeigt die {{GENDER:$2|Änderungen}} zwischen zwei Versionen der Überschrift von [$3 $1] an.\nDu kannst andere Versionen der Überschrift in ihrem [$4 Verlauf] einsehen.",
+ "action-flow-create-board": "Flow-Boards an beliebigen Orten zu erstellen",
+ "right-flow-create-board": "Flow-Boards an jedem Ort erstellen",
+ "right-flow-hide": "Flow-Themen und -Beiträge verstecken",
+ "right-flow-lock": "Flow-Themen sperren",
+ "right-flow-delete": "Flow-Themen und -Beiträge löschen",
+ "right-flow-edit-post": "Flow-Beiträge von anderen Benutzern bearbeiten",
+ "right-flow-suppress": "Flow-Versionen unterdrücken",
+ "flow-terms-of-use-new-topic": "Mit dem Klicken auf „{{int:flow-newtopic-save}}“ stimmst du unseren Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-terms-of-use-reply": "Mit dem Klicken auf „{{int:flow-reply-submit}}“ stimmst du unseren Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-terms-of-use-edit": "Mit dem Speichern deiner Änderungen stimmst du unseren Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-anon-warning": "Du bist nicht angemeldet. Um eine Zuordnung mit deinem Namen anstatt deiner IP-Adresse zu erhalten, kannst du dich [$1 anmelden] oder [$2 ein Benutzerkonto erstellen].",
+ "flow-cancel-warning": "Du hast Text in dieses Formular eingegeben. Bist du sicher, dass du ihn verwerfen möchtest?",
+ "flow-topic-first-heading": "Thema auf $1",
+ "flow-topic-html-title": "$1 auf $2",
+ "flow-topic-count": "Themen ($1)",
+ "flow-load-more": "Mehr laden",
+ "flow-no-more-fwd": "Es gibt keine älteren Themen",
+ "flow-add-topic": "Thema hinzufügen",
+ "flow-newest-topics": "Neueste Themen",
+ "flow-recent-topics": "Zuletzt aktive Themen",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Du}} liest derzeit zuerst die neuesten Themen. Klicke für weitere Sortierungsoptionen.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Du}} liest derzeit zuerst die aktivsten Themen. Klicke für weitere Sortieroptionen.",
+ "flow-toggle-small-topics": "Zur Kleine-Themen-Ansicht wechseln",
+ "flow-toggle-topics": "Zur Nur-Themen-Ansicht wechseln",
+ "flow-toggle-topics-posts": "Zur Themen-und-Beiträge-Ansicht wechseln",
+ "flow-terms-of-use-summarize": "Mit dem Klicken auf „{{int:flow-summarize-topic-submit}}“ stimmst du den Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-terms-of-use-lock-topic": "Mit dem Klicken auf „{{int:flow-lock-topic-submit}}“ stimmst du den Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-terms-of-use-unlock-topic": "Mit dem Klicken auf „{{int:flow-unlock-topic-submit}}“ stimmst du den Nutzungsbedingungen für dieses Wiki zu.",
+ "flow-whatlinkshere-post": "von einem [$1 Beitrag]",
+ "flow-whatlinkshere-header": "von der [$1 Überschrift]",
+ "flow": "Flow",
+ "flow-special-desc": "Diese Spezialseite leitet mithilfe einer UUID auf ein Flow-Workflow oder einen Flow-Beitrag weiter.",
+ "flow-special-type": "Typ",
+ "flow-special-type-post": "Beitrag",
+ "flow-special-type-workflow": "Workflow",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Es konnte kein Inhalt mit dem Typ und der UUID gefunden werden.",
+ "flow-special-enableflow-legend": "Flow auf einer neuen Seite aktivieren",
+ "flow-special-enableflow-page": "Seite, auf der Flow aktiviert werden soll",
+ "flow-special-enableflow-header": "Erste Überschrift des Flow-Boards (Wikitext)",
+ "flow-special-enableflow-board-already-exists": "Es gibt bereits ein Flow-Board unter [[$1]].",
+ "flow-special-enableflow-invalid-title": "Die angegebene Seite ist kein gültiger Seitentitel",
+ "flow-special-enableflow-page-already-exists": "Es gibt bereits eine Nicht-Flow-Seite unter [[$1]]. Falls du hier ein Flow-Board erstellen möchtest, verschiebe bitte die vorhandene Seite in ein Archiv, lösche die Weiterleitung und benutze erneut „Spezial:Flow aktivieren“. Schließe den Namen des Archivs in der Überschrift ein.",
+ "flow-special-enableflow-confirmation": "Du hast erfolgreich ein Flow-Board unter [[$1]] erstellt.",
+ "flow-spam-confirmedit-form": "Bitte bestätige, dass du ein Mensch bist, indem du das unten stehende Captcha löst: $1",
+ "flow-preview-warning": "Du siehst eine Vorschau. Klicke zum Absenden auf „{{int:flow-newtopic-save}}“ oder auf „{{int:flow-preview-return-edit-post}}“, um mit dem Schreiben fortzufahren.",
+ "flow-preview-return-edit-post": "Weiter bearbeiten",
+ "flow-anonymous": "Anonym",
+ "flow-embedding-unsupported": "Diskussionen können noch nicht eingebettet werden.",
+ "mw-ui-unsubmitted-confirm": "Du hast auf dieser Seite ungespeicherte Änderungen. Bist du sicher, dass du diese Seite verlassen möchtest und die Arbeit verloren gehen soll?",
+ "flow-post-undo-hide": "Verstecken rückgängig gemacht",
+ "flow-post-undo-delete": "Löschen rückgängig gemacht",
+ "flow-post-undo-suppress": "Unterdrücken rückgängig gemacht",
+ "flow-topic-undo-hide": "Verstecken rückgängig gemacht",
+ "flow-topic-undo-delete": "Löschen rückgängig gemacht",
+ "flow-topic-undo-suppress": "Unterdrücken rückgängig gemacht",
+ "flow-importer-lqt-moved-thread-template": "Durch LQT verschobenen Thread-Stub zu Flow konvertiert",
+ "flow-importer-lqt-converted-template": "LiquidThreads-Seite nach Flow konvertiert",
+ "flow-importer-lqt-converted-archive-template": "Archiv für konvertierte LiquidThreads-Seite",
+ "flow-importer-wt-converted-template": "Wikitext-Diskussionsseite zu Flow konvertiert",
+ "flow-importer-wt-converted-archive-template": "Archiv für konvertierte Wikitext-Diskussionsseite",
+ "flow-importer-lqt-suppressed-user-template": "Diese Version wurde von LiquidThreads mit einem unterdrückten Benutzer importiert. Sie wurde dem aktuellen Benutzer neu zugeordnet.",
+ "apihelp-flow-param-submodule": "Das aufzurufende Flow-Submodul.",
+ "apihelp-flow-param-page": "Die Seite, auf die die Aktion angewandt werden soll.",
+ "apihelp-flow-example-1": "Bearbeitet die Überschrift von „[[Talk:Sandbox]]“",
+ "apihelp-flow+close-open-topic-param-reason": "Grund für das Sperren oder Freigeben des Themas.",
+ "apihelp-flow+edit-header-description": "Bearbeitet die Überschrift eines Boards.",
+ "apihelp-flow+edit-header-param-content": "Inhalt für die Überschrift.",
+ "apihelp-flow+edit-header-param-format": "Format der Überschrift (wikitext oder html)",
+ "apihelp-flow+edit-header-example-1": "Bearbeitet die Überschrift von [[Talk:Sandbox]]",
+ "apihelp-flow+edit-post-description": "Bearbeitet den Inhalt eines Beitrags.",
+ "apihelp-flow+edit-post-param-postId": "Beitragskennung.",
+ "apihelp-flow+edit-post-param-content": "Inhalt für den Beitrag.",
+ "apihelp-flow+edit-post-param-format": "Format des Beitrags (wikitext oder html)",
+ "apihelp-flow+edit-post-example-1": "Bearbeitet einen Beitrag in [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-description": "Bearbeitet den Titel eines Themas.",
+ "apihelp-flow+edit-title-param-prev_revision": "Versionskennung der aktuellen Titelversion zum Erkennen von Bearbeitungskonflikten.",
+ "apihelp-flow+edit-title-param-content": "Inhalt für den Titel.",
+ "apihelp-flow+edit-title-example-1": "Bearbeitet den Titel von [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-description": "Bearbeitet den Inhalt einer Themenzusammenfassung.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Inhalt für die Zusammenfassung.",
+ "apihelp-flow+edit-topic-summary-param-format": "Format der Zusammenfassung (wikitext oder html)",
+ "apihelp-flow+edit-topic-summary-example-1": "Bearbeitet die Zusammenfassung von [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-description": "Sperrt ein Flow-Thema oder gibt es wieder frei.",
+ "apihelp-flow+lock-topic-param-reason": "Grund für das Sperren oder Freigeben des Themas.",
+ "apihelp-flow+lock-topic-example-1": "Sperrt [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-description": "Moderiert einen Flow-Beitrag.",
+ "apihelp-flow+moderate-post-param-reason": "Grund für die Moderation.",
+ "apihelp-flow+moderate-post-param-postId": "Kennung des zu moderierenden Beitrags.",
+ "apihelp-flow+moderate-post-example-1": "Löscht einen Beitrag im Thema [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-description": "Moderiert ein Flow-Thema.",
+ "apihelp-flow+moderate-topic-param-reason": "Grund für die Moderation.",
+ "apihelp-flow+moderate-topic-example-1": "Löscht das Thema [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+new-topic-param-topic": "Text für den Titel des neuen Themas.",
+ "apihelp-flow+new-topic-param-content": "Inhalt für die erste Antwort des Themas.",
+ "apihelp-flow+new-topic-param-format": "Format des Ausgangsbeitrags des neuen Themas (wikitext oder html)",
+ "apihelp-flow+new-topic-example-1": "Erstellt ein neues Thema auf [[Talk:Sandbox]]",
+ "apihelp-flow+reply-description": "Antwortet auf einen Beitrag.",
+ "apihelp-flow+reply-param-replyTo": "Kennung des Beitrags, auf den geantwortet werden soll.",
+ "apihelp-flow+reply-param-content": "Inhalt für den neuen Beitrag.",
+ "apihelp-flow+reply-param-format": "Format des neuen Beitrags (wikitext oder html)",
+ "apihelp-flow+reply-example-1": "Antwortet auf einen Beitrag auf [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-header-description": "Betrachtet eine Board-Überschrift.",
+ "apihelp-flow+view-header-param-revId": "Lädt diese anstelle der aktuellsten Version.",
+ "apihelp-flow+view-post-description": "Betrachtet einen Beitrag.",
+ "apihelp-flow+view-post-param-postId": "Kennung des anzuzeigenden Beitrags.",
+ "apihelp-flow+view-post-param-contentFormat": "Format, in dem der Inhalt zurückgegeben werden soll.",
+ "apihelp-flow+view-topic-description": "Betrachtet ein Thema.",
+ "apihelp-flow+view-topic-example-1": "[[Topic:S2tycnas4hcucw8w]] ansehen",
+ "apihelp-flow+view-topic-summary-description": "Betrachtet eine Themenzusammenfassung.",
+ "apihelp-flow+view-topic-summary-example-1": "Zeigt die Zusammenfassung für [[Topic:S2tycnas4hcucw8w]] als Wikitext an",
+ "apihelp-flow+view-topiclist-description": "Betrachtet eine Liste der Themen.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Sortierrichtung der Themen.",
+ "apihelp-flow+view-topiclist-param-sortby": "Sortieroption der Themen.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Speichert die Sortieroption, falls festgelegt.",
+ "apihelp-flow+view-topiclist-param-limit": "Anzahl der abzurufenden Themen.",
+ "apihelp-flow+view-topiclist-param-render": "Zeigt die Themen in HTML an.",
+ "apihelp-flow+view-topiclist-example-1": "Themen zu [[Talk:Sandbox]] auflisten",
+ "apihelp-flow-parsoid-utils-param-content": "Zu konvertierender Inhalt.",
+ "apihelp-flow-parsoid-utils-param-title": "Titel der Seite. Kann nicht zusammen mit $1pageid verwendet werden.",
+ "apihelp-flow-parsoid-utils-param-pageid": "Kennung der Seite. Kann nicht zusammen mit $1title verwendet werden.",
+ "apihelp-flow-parsoid-utils-example-1": "Wandelt den Wikitext <nowiki>'''lorem''' ''blah''</nowiki> in HTML um",
+ "apihelp-query+flowinfo-description": "Ruft Basis-Flow-Informationen über eine Seite ab.",
+ "apihelp-flow+undo-edit-header-description": "Bezieht die zum Rückgängigmachen einer Bearbeitung der Überschrift nötigen Informationen.",
+ "apihelp-flow+undo-edit-header-param-startId": "Versionskennung, bei der mit dem Rückgängigmachen begonnen werden soll.",
+ "apihelp-flow+undo-edit-header-param-endId": "Versionskennung, auf die zurückgesetzt werden soll.",
+ "apihelp-flow+undo-edit-header-example-1": "Bezieht Informationen zum Rückgängigmachen einer Bearbeitung in der Überschrift von [[Talk:Sandbox]]",
+ "apihelp-flow+undo-edit-post-description": "Bezieht die zum Rückgängigmachen einer Beitragsbearbeitung nötigen Informationen.",
+ "apihelp-flow+undo-edit-post-param-postId": "Kennung des Beitrages, dessen Bearbeitung rückgängig gemacht werden soll.",
+ "apihelp-flow+undo-edit-post-param-startId": "Versionskennung, bei der mit dem Rückgängigmachen begonnen werden soll.",
+ "apihelp-flow+undo-edit-post-param-endId": "Versionskennung, auf die zurückgesetzt werden soll.",
+ "apihelp-flow+undo-edit-post-example-1": "Bezieht Informationen zum Rückgängigmachen einer Beitragsbearbeitung in einem bestimmten Thema.",
+ "apihelp-flow+undo-edit-topic-summary-description": "Bezieht die zum Rückgängigmachen einer Bearbeitung der Themenzusammenfassung nötigen Informationen.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "Versionskennung, bei der mit dem Rückgängigmachen begonnen werden soll.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "Versionskennung, auf die zurückgesetzt werden soll.",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "Bezieht Informationen zum Rückgängigmachen einer Bearbeitung der Themenzusammenfassung eines bestimmten Themas",
+ "flow-edited": "Bearbeitet",
+ "flow-edited-by": "Bearbeitet von $1",
+ "flow-lqt-redirect-reason": "Eingeschlafenen LiquidThreads-Beitrag zum konvertierten Flow-Beitrag umgeleitet",
+ "flow-talk-conversion-move-reason": "Konvertierung der Wikitext-Diskussion zu Flow von $1",
+ "flow-talk-conversion-archive-edit-reason": "Wikitext-Diskussion-zu-Flow-Konvertierung",
+ "flow-previous-diff": "← Ältere Bearbeitung",
+ "flow-next-diff": "Neuere Bearbeitung →",
+ "flow-undo": "rückgängig machen",
+ "flow-undo-latest-revision": "Aktuelle Version",
+ "flow-undo-your-text": "Dein Text",
+ "flow-undo-edit-header": "Überschrift bearbeiten",
+ "flow-undo-edit-topic-summary": "Themenzusammenfassung bearbeiten",
+ "flow-undo-edit-post": "Beitrag bearbeiten",
+ "flow-undo-edit-content": "Die Bearbeitung kann rückgängig gemacht werden. Bitte überprüfe den unten stehenden Vergleich, um zu verifizieren, dass das ist, was du tun möchtest, und speichere die Änderungen unten, um die Rückgängigmachung der Bearbeitung abzuschließen.",
+ "flow-undo-edit-failure": "Die Bearbeitung konnte aufgrund kollidierender dazwischenliegender Bearbeitungen nicht rückgängig gemacht werden.",
+ "group-flow-bot": "Flow-Bots",
+ "group-flow-bot-member": "Flow-Bot",
+ "grouppage-flow-bot": "Project:Flow-Bots",
+ "flow-ve-mention-context-item-label": "Erwähnung",
+ "flow-ve-mention-inspector-title": "Erwähnung",
+ "flow-ve-mention-inspector-remove-label": "Entfernen",
+ "flow-ve-mention-tool-title": "Einen Benutzer erwähnen",
+ "flow-ve-mention-template": "ping",
+ "flow-ve-mention-inspector-invalid-user": "Der Benutzername „$1“ ist nicht registriert.",
+ "flow-wikitext-editor-help": "Wikitext $1.",
+ "flow-wikitext-editor-help-and-preview": "Wikitext $1 und du kannst jederzeit $2.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|verwendet Markup]]",
+ "flow-wikitext-editor-help-preview-the-result": "eine Vorschau des Ergebnisses anzeigen",
+ "flow-wikitext-switch-editor-tooltip": "Zum VisualEditor wechseln",
+ "flow-ve-switch-editor-tool-title": "Zum Wikitext-Editor wechseln"
+}
diff --git a/Flow/i18n/el.json b/Flow/i18n/el.json
new file mode 100644
index 00000000..9b46f330
--- /dev/null
+++ b/Flow/i18n/el.json
@@ -0,0 +1,25 @@
+{
+ "@metadata": {
+ "authors": [
+ "Astralnet",
+ "Evropi",
+ "Geraki",
+ "Nikosguard"
+ ]
+ },
+ "flow-post-moderated-toggle-delete-show": "Εμφάνιση σχολίου {{GENDER:$1|διαγραφή}} $2",
+ "flow-topic-actions": "Ενέργειες",
+ "flow-preview": "Προεπισκόπηση",
+ "flow-newtopic-content-placeholder": "Δημοσιεύστε ένα νέο μήνυμα στην \"$1\"",
+ "flow-newtopic-first-heading": "Ξεκινήσετε ένα νέο θέμα στην $1",
+ "flow-post-action-post-history": "Ιστορικό",
+ "flow-post-action-edit-post": "Επεξεργασία",
+ "flow-topic-notification-subscribe-title": "Αυτό το θέμα έχει προστεθεί στη λίστα παρακολούθησής {{GENDER:$1|σας}}.",
+ "flow-topic-notification-subscribe-description": "Θα {{GENDER:$1|λαμβάνετε}} ειδοποιήσεις για όλες τις δραστηριότητες σχετικά με αυτό το θέμα.",
+ "flow-board-notification-subscribe-title": "Είστε {{GENDER:$1|εγγεγραμμένος|εγγεγραμμένη}} σε αυτό τον πίνακα συζητήσεων!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Θα}} λάβετε μια ειδοποίηση όταν υπάρχει ένα νέο θέμα που έχει δημιουργηθεί σε αυτόν τον πίνακα.",
+ "flow-history-last4": "Τελευταίες 4 ώρες",
+ "flow-history-day": "Σήμερα",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />{{GENDER:$1|Ο|Η}} $1 απάντησε στην '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />{{GENDER:$1|Ο|Η}} $1 και $5 {{PLURAL:$6|άλλος|άλλοι}} απάντησαν στην '''$3'''."
+}
diff --git a/Flow/i18n/en-gb.json b/Flow/i18n/en-gb.json
new file mode 100644
index 00000000..30c17e16
--- /dev/null
+++ b/Flow/i18n/en-gb.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Shirayuki",
+ "Mdann52",
+ "Caliburn"
+ ]
+ },
+ "flow-summarize-topic-placeholder": "Please summarise this discussion",
+ "flow-topic-action-summarize-topic": "Summarise",
+ "flow-error-no-render": "The specified action was not recognised.",
+ "flow-summarize-topic-submit": "Summarise",
+ "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-previous-diff": "← Older edit",
+ "flow-next-diff": "Newer edit →"
+}
diff --git a/Flow/i18n/en.json b/Flow/i18n/en.json
new file mode 100644
index 00000000..1584875a
--- /dev/null
+++ b/Flow/i18n/en.json
@@ -0,0 +1,549 @@
+{
+ "@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": "<em>Username suppressed</em>",
+ "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": "&#32;•&#32;",
+ "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.<br />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.<br />$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": "Moving a discussion board is currently not supported.",
+ "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-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\" (<em>$5</em>)",
+ "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": "<strong>$1 {{PLURAL:$1|comment|comments}}</strong> {{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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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 (<em>$5</em>)",
+ "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\" (<em>$5</em>)",
+ "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": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|responded}} on '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 and $5 {{PLURAL:$6|other|others}} {{GENDER:$1|responded}} on '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 has {{GENDER:$1|edited}} your <span class=\"plainlinks\">[$5 post]</span> on [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 and $5 {{PLURAL:$6|other|others}} {{GENDER:$1|edited}} a <span class=\"plainlinks\">[$4 post]</span> in \"$2\" on \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|created}} a new topic on '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} new {{PLURAL:$1|topic|topics}} on '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|changed}} the title of <span class=\"plainlinks\">[$2 $3]</span> to \"$4\" on [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|mentioned}} {{GENDER:$5|you}} in {{GENDER:$1|his|her|their}} <span class=\"plainlinks\">[$2 post]</span> 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-add-topic": "Add Topic ",
+ "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": "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.",
+ "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-contentFormat": "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-contentFormat": "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-contentFormat": "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 <nowiki>'''lorem''' ''blah''</nowiki> 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/Flow/i18n/eo.json b/Flow/i18n/eo.json
new file mode 100644
index 00000000..0800041b
--- /dev/null
+++ b/Flow/i18n/eo.json
@@ -0,0 +1,26 @@
+{
+ "@metadata": {
+ "authors": [
+ "Happy5214"
+ ]
+ },
+ "flow-post-actions": "Agoj",
+ "flow-topic-actions": "Agoj",
+ "flow-cancel": "Nuligi",
+ "flow-post-action-view": "Konstanta ligilo",
+ "flow-post-action-post-history": "Historio",
+ "flow-post-action-delete-post": "Forigi",
+ "flow-post-action-hide-post": "Kaŝi",
+ "flow-post-action-edit-post": "Redakti",
+ "flow-post-action-undelete-post": "Malforigi",
+ "flow-topic-action-watchlist": "Atentaro",
+ "flow-topic-action-history": "Historio",
+ "flow-board-history": "\"$1\" historio",
+ "flow-history-day": "Hodiaŭ",
+ "flow-history-week": "Lasta semajno",
+ "flow-link-history": "historio",
+ "flow-moderation-confirm-delete-post": "Forigi",
+ "flow-moderation-confirm-hide-post": "Kaŝi",
+ "flow-moderation-confirm-delete-topic": "Forigi",
+ "flow-moderation-confirm-hide-topic": "Kaŝi"
+}
diff --git a/Flow/i18n/es-formal.json b/Flow/i18n/es-formal.json
new file mode 100644
index 00000000..eea46e50
--- /dev/null
+++ b/Flow/i18n/es-formal.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Baffo"
+ ]
+ },
+ "flow-topic-moderated-reason-prefix": "Razón:",
+ "flow-topic-action-undo-moderation": "Anulada",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|creó}} un tema nuevo en '''$3'''.",
+ "flow-moderation-confirmation-suppress-topic": "Esté tema ha sido suprimida.",
+ "flow-moderation-confirmation-delete-topic": "El tema ha sido eliminado.",
+ "flow-moderation-confirmation-hide-topic": "Este tema se ha ocultado."
+}
diff --git a/Flow/i18n/es.json b/Flow/i18n/es.json
new file mode 100644
index 00000000..b8ab2d3f
--- /dev/null
+++ b/Flow/i18n/es.json
@@ -0,0 +1,369 @@
+{
+ "@metadata": {
+ "authors": [
+ "Benfutbol10",
+ "Carlitosag",
+ "Carlosz22",
+ "Ciencia Al Poder",
+ "Csbotero",
+ "Epicfaace",
+ "Fitoschido",
+ "Ihojose",
+ "Ovruni",
+ "Sethladan",
+ "Frammm",
+ "Mcervera",
+ "Ralgis",
+ "Vfrico",
+ "Macofe",
+ "Koavf",
+ "Themasterriot",
+ "JosouNoAria",
+ "Mor",
+ "Effy"
+ ]
+ },
+ "enableflow": "Activar Flow",
+ "flow-desc": "Sistema de gestión de flujo de trabajo",
+ "flow-talk-taken-over": "Esta página de discusión está usando [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestor de páginas de discusión de Flow",
+ "log-name-flow": "Registro de actividad de Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|borró}} una [$4 publicación] sobre «[[$3|$5]]» en [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restauró}} una [$4 publicación] sobre «[[$3|$5]]» en [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|suprimió}} una [$4 publicación] sobre «[[$3|$5]]» en [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|borró}} una [$4 publicación] en «[[$3|$5]]» en [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|borró}} el tema «[[$3|$5]]» en [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|restauró}} el tema «[[$3|$5]]» en [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|suprimió}} el tema «[[$3|$5]]» en [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|borró}} el tema \"[[$3|$5]]\" en [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] en [[$3]] se ha importado a Flow desde LiquidThreads",
+ "flow-user-moderated": "Usuario moderado",
+ "flow-board-header-browse-topics-link": "Explorar temas",
+ "flow-edit-header-link": "Editar cabecera",
+ "flow-post-moderated-toggle-hide-show": "Mostrar comentarios {{GENDER:$1|hidden}} por $2",
+ "flow-post-moderated-toggle-delete-show": "Mostrar comentario {{GENDER:$1|deleted}} por $2",
+ "flow-post-moderated-toggle-suppress-show": "Mostrar comentario {{GENDER:$1|suppresed}} por $2",
+ "flow-post-moderated-toggle-hide-hide": "Ocultar comentario {{GENDER:$1|hidden}} por $2",
+ "flow-post-moderated-toggle-delete-hide": "Ocultar comentario {{GENDER:$1|eliminado}} por $2",
+ "flow-post-moderated-toggle-suppress-hide": "Ocultar comentario {{GENDER:$1|suprimido}} por $2",
+ "flow-topic-moderated-reason-prefix": "Motivo:",
+ "flow-hide-post-content": "$1 {{GENDER:$1|ocultó}} este tema ([$2 historial])",
+ "flow-hide-title-content": "$1 {{GENDER:$1|ocultó}} este tema",
+ "flow-lock-title-content": "$1 {{GENDER:$1|bloqueó}} el tema",
+ "flow-hide-header-content": "{{GENDER:$1|Ocultado}} por $2",
+ "flow-delete-post-content": "$1 {{GENDER:$1|eliminó}} este comentario ([$2 historial])",
+ "flow-delete-title-content": "$1 {{GENDER:$1|eliminó}} este tema",
+ "flow-delete-header-content": "{{GENDER:$1|Eliminado}} por $2",
+ "flow-suppress-post-content": "$1 {{GENDER:$1|suprimió}} este comentario ([$2 historial])",
+ "flow-suppress-title-content": "$1 {{GENDER:$1|suprimió}} este tema",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimido}} por $2",
+ "flow-suppress-usertext": "<em>Nombre de usuario suprimido</em>",
+ "flow-post-actions": "Acciones",
+ "flow-topic-actions": "Acciones",
+ "flow-cancel": "Cancelar",
+ "flow-preview": "Previsualizar",
+ "flow-show-change": "Mostrar cambios",
+ "flow-last-modified-by": "Última modificación por $1",
+ "flow-stub-post-content": "''No se pudo recuperar esta publicación a causa de un error técnico.''",
+ "flow-newtopic-title-placeholder": "Tema nuevo",
+ "flow-newtopic-content-placeholder": "Publicar un mensaje nuevo en «$1»",
+ "flow-newtopic-header": "Añadir un nuevo tema",
+ "flow-newtopic-save": "Añadir tema",
+ "flow-newtopic-start-placeholder": "Iniciar un tema nuevo",
+ "flow-newtopic-first-heading": "Iniciar un tema nuevo en $1",
+ "flow-summarize-topic-placeholder": "Pro favor resume esta discusión",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentario}} en «$2»",
+ "flow-reply-topic-title-placeholder": "Responder a «$1»",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Agradecer}}",
+ "flow-lock-link": "{{GENDER:$1|Bloquear}}",
+ "flow-thank-link-title": "Agradecer públicamente al autor de la publicación",
+ "flow-history-action-delete-post": "eliminar",
+ "flow-history-action-hide-post": "ocultar",
+ "flow-history-action-restore-post": "restaurar",
+ "flow-history-action-lock-topic": "bloquear",
+ "flow-history-action-unlock-topic": "desbloquear",
+ "flow-post-edited": "Mensaje {{GENDER:$1|editado}} por $1 $2",
+ "flow-post-action-view": "Enlace permanente",
+ "flow-post-action-post-history": "Historial",
+ "flow-post-action-suppress-post": "Censurar mensaje",
+ "flow-post-action-delete-post": "Eliminar",
+ "flow-post-action-hide-post": "Ocultar",
+ "flow-post-action-edit-post": "Editar",
+ "flow-post-action-edit-post-submit": "Guardar cambios",
+ "flow-post-action-unsuppress-post": "Desactivar supresión",
+ "flow-post-action-undelete-post": "Restaurar",
+ "flow-post-action-unhide-post": "Mostrar",
+ "flow-post-action-restore-post": "Restaurar",
+ "flow-post-action-undo-moderation": "Deshacer",
+ "flow-topic-action-view": "Enlace permanente",
+ "flow-topic-action-watchlist": "Lista de seguimiento",
+ "flow-topic-action-edit-title": "Editar título",
+ "flow-topic-action-history": "Historial",
+ "flow-topic-action-hide-topic": "Ocultar el tema",
+ "flow-topic-action-delete-topic": "Eliminar el tema",
+ "flow-topic-action-lock-topic": "Bloquear tema",
+ "flow-topic-action-unlock-topic": "Desbloquear tema",
+ "flow-topic-action-summarize-topic": "Resumir",
+ "flow-topic-action-resummarize-topic": "Editar el resumen del tema",
+ "flow-topic-action-suppress-topic": "Suprimir el tema",
+ "flow-topic-action-unhide-topic": "Mostrar tema",
+ "flow-topic-action-undelete-topic": "Restaurar tema",
+ "flow-topic-action-unsuppress-topic": "Desactivar supresión del tema",
+ "flow-topic-action-restore-topic": "Restaurar el tema",
+ "flow-topic-action-undo-moderation": "Deshacer",
+ "flow-topic-notification-subscribe-title": "Este tema se agregó a {{GENDER:$1|tu}} lista de seguimiento.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Recibirás}} notificaciones de todas las actividades sobre este tema.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Te}} has suscrito a este panel de discusión.",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Recibirás}} una notificación cuando se cree un tema nuevo en este panel de discusión.",
+ "flow-error-http": "Ha ocurrido un error mientras se contactaba al servidor.",
+ "flow-error-other": "Ha ocurrido un error inesperado.",
+ "flow-error-external": "Se ha producido un error.<br />El mensaje de error recibido es: $1",
+ "flow-error-edit-restricted": "No tienes permitido editar esta entrada.",
+ "flow-error-topic-is-locked": "El tema está cerrado para evitar cualquier actividad adicional.",
+ "flow-error-lock-moderated-post": "No puedes bloquear una publicación moderada.",
+ "flow-error-external-multi": "Se han encontrado errores.<br />$1",
+ "flow-error-missing-content": "La entrada no tiene contenido. Para guardarla necesitas añadir contenido.",
+ "flow-error-missing-summary": "El resumen no tiene contenido. Es necesario el contenido para guardar el resumen.",
+ "flow-error-missing-title": "El tema no tiene título. Para guardarlo necesitas añadirle un título.",
+ "flow-error-parsoid-failure": "No se puede analizar el contenido debido a una falla de Parsoid.",
+ "flow-error-missing-replyto": "Ningún parámetro \"replyTo\" fue suministrado. Este parámetro es requerido para hacer la acción \"responder\".",
+ "flow-error-invalid-replyto": "El parámetro \"replyTo\" era inválido. La publicación especificada no su pudo encontrar.",
+ "flow-error-delete-failure": "Falló la eliminación de este elemento.",
+ "flow-error-hide-failure": "Falló el ocultamiento de este elemento.",
+ "flow-error-missing-postId": "Ningún parámetro \"postId\" fue suministrado. Es requerido este parámetro para manipular una publicación.",
+ "flow-error-invalid-postId": "El parámetro \"postId\" era inválido. La publicación especificada ($1) no pudo ser encontrada.",
+ "flow-error-restore-failure": "Falló la restauración de este elemento.",
+ "flow-error-invalid-moderation-state": "Se envió un valor de parámetro no válido («moderationState») a la API de Flow.",
+ "flow-error-invalid-moderation-reason": "Proporciona el motivo de la moderación.",
+ "flow-error-not-allowed": "Permisos insuficientes para ejecutar esta acción.",
+ "flow-error-not-allowed-hide": "Este tema se ha ocultado",
+ "flow-error-not-allowed-reply-to-hide-topic": "No puedes responder porque este tema se ha ocultado",
+ "flow-error-not-allowed-reply-to-delete-topic": "No puedes responder porque este tema ha sido borrado.",
+ "flow-error-not-allowed-suppress": "Este tema se eliminó.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "No puedes responder porque este tema ha sido borrado.",
+ "flow-error-title-too-long": "Los títulos del tema han sido restringidos a $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-no-existing-workflow": "Este flujo de trabajo todavía no existe.",
+ "flow-error-not-a-post": "El título del tema no pudo ser guardado como una publicación.",
+ "flow-error-missing-header-content": "La cabecera no tiene contenido. Para guardar una cabecera es necesario que tenga contenido.",
+ "flow-error-missing-prev-revision-identifier": "El identificador de la revisión anterior no se encuentra.",
+ "flow-error-prev-revision-mismatch": "Otro usuario acaba de editar esta publicación hace algunos segundos. ¿Estás {{GENDER:$3|seguro|segura}} que deseas sobrescribir el cambio reciente?",
+ "flow-error-prev-revision-does-not-exist": "La revisión anterior no pudo ser encontrada.",
+ "flow-error-default": "Se ha producido un error.",
+ "flow-error-invalid-input": "Se proporcionó un valor de carga de contenido de Flow no válido.",
+ "flow-error-invalid-title": "Se proporcionó un título de página no válido.",
+ "flow-error-fail-load-history": "No se pudo cargar el contenido del historial.",
+ "flow-error-missing-revision": "No se pudo encontrar una revisión para cargar el contenido dinámico.",
+ "flow-error-fail-commit": "Error al guardar el contenido dinámico.",
+ "flow-error-insufficient-permission": "Permisos insuficientes para acceder al contenido.",
+ "flow-error-revision-comparison": "La operación de comparación sólo puede hacerse entre dos revisiones que pertenecen a una misma publicación.",
+ "flow-error-missing-topic-title": "No se pudo encontrar el título del tema para el flujo de trabajo actual.",
+ "flow-error-fail-load-data": "No se pudieron cargar los datos solicitados.",
+ "flow-error-invalid-workflow": "No se pudo encontrar el flujo de trabajo solicitado.",
+ "flow-error-process-data": "Un error ha ocurrido durante el procesamiento de los datos en tu solicitud.",
+ "flow-error-process-wikitext": "Un error ha ocurrido al procesar la conversión de HTML/wikitexto.",
+ "flow-error-no-index": "No se pudo encontrar un índice para realizar la búsqueda de datos.",
+ "flow-error-no-render": "No se reconoció la acción especificada.",
+ "flow-error-no-commit": "No se pudo guardar la acción especificada.",
+ "flow-error-content-too-long": "El contenido es demasiado largo. El contenido después de su expansión está limitado a $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-move": "No se admite el traslado de los paneles de discusión.",
+ "flow-error-invalid-topic-uuid-title": "Título incorrecto",
+ "flow-error-unknown-workflow-id-title": "Tema desconocido",
+ "flow-error-unknown-workflow-id": "El tema solicitado no existe.",
+ "flow-edit-header-placeholder": "Describir este panel de discusión",
+ "flow-edit-header-submit": "Guardar cabecera",
+ "flow-edit-header-submit-overwrite": "Sobrescribir cabecera",
+ "flow-summarize-topic-submit": "Resumir",
+ "flow-summarize-topic-submit-overwrite": "Sobrescribir resumen",
+ "flow-lock-topic-submit": "Bloquear tema",
+ "flow-unlock-topic-submit": "Desbloquear tema",
+ "flow-edit-title-submit": "Cambiar el título",
+ "flow-edit-title-submit-overwrite": "Sobrescribir título",
+ "flow-edit-post-submit": "Enviar cambios",
+ "flow-edit-post-submit-overwrite": "Sobrescribir cambios",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|editó}} un [$3 comentario] en \"$4\"",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Editó}} una publicación",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|comentó}}] en \"$4\" (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|comentario|comentarios}}</strong>\n{{PLURAL:$1|fue agregado|fueron agregados}}",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|creó}} el tema \"[$3 $4]\"",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Creó}} un nuevo tema",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|cambió}} el título del tema de \"$5\" a \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|creó}} la cabecera",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|editó}} la cabecera",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|creó}} el resumen del tema en $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|editó}} el resumen del tema en $3",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|ocultó}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|eliminó}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suprimió}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restauró}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|ocultó}} el [$4 tema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|eliminó}} el [$4 tema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|suprimió}} el [$4 tema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|bloqueó}} el [$4 tema] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|restauró}} el [$4 tema] \"$6\" (<em>$5</em>)",
+ "flow-rc-topic-of-board": "$1 en $2",
+ "flow-board-history": "Historial de «$1»",
+ "flow-board-history-empty": "Este panel de discusión no tiene historial actualmente.",
+ "flow-topic-history": "Historial del tema «$1»",
+ "flow-post-history": "Historial de publicación «Comentario por {{GENDER:$2|$2}}»",
+ "flow-history-last4": "Últimas 4 horas",
+ "flow-history-day": "Hoy",
+ "flow-history-week": "Semana pasada",
+ "flow-history-pages-topic": "Aparece en el [$1 panel de discusión «$2»]",
+ "flow-history-pages-post": "Aparece en [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 comentario|$1 comentarios|0={{GENDER:$2|¡Sé el primero}} en comentar!}}",
+ "flow-comment-restored": "Comentario restaurado",
+ "flow-comment-deleted": "Comentario eliminado",
+ "flow-comment-hidden": "Comentario oculto",
+ "flow-comment-moderated": "Comentario moderado",
+ "flow-last-modified": "Última modificación hace $1",
+ "flow-workflow": "flujo de trabajo",
+ "flow-notification-reply": "$1 {{GENDER:$1|respondió}} a <span class=\"plainlinks\">[$5 $2]</span> en «$4».",
+ "flow-notification-reply-bundle": "$1 y $5 {{PLURAL:$6|otro|otros}} {{GENDER:$1|respondieron}} a <span class=\"plainlinks\">[$4 $2]</span> en «$3».",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 ha {{GENDER:$1|editado}} tu <span class=\"plainlinks\">[$5 publicación]</span> en [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 y {{PLURAL:$6|$5 más|otros $5}} {{GENDER:$1|editaron}} una <span class=\"plainlinks\">[$4 publicación]</span> en \"$2\" el \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|creó}} un tema nuevo en '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|tema nuevo|temas nuevos}} en '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 cambió el título de <span class=\"plainlinks\">[$2 $3]</span> a «$4» en [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$5|te}} {{GENDER:$1|mencionó}} en {{GENDER:$1|su}} <span class=\"plainlinks\">[$2 publicación]</span> en «$3» en «$4».",
+ "flow-notification-link-text-view-post": "Ver la entrada",
+ "flow-notification-link-text-view-topic": "Ver el tema",
+ "flow-notification-reply-email-subject": "$2 en $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondió}} a «$2» en «$3»",
+ "flow-notification-reply-email-batch-bundle-body": "$1 y {{PLURAL:$5|otro|otros}} $4 {{GENDER:$1|respondieron}} a \"$2\" en \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|te}} {{GENDER:$1|mencionó}} en «$2»",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|te}} {{GENDER:$1|mencionó}} en {{GENDER:$1|su}} publicación en «$2» en «$3»",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|editó}} una publicación",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|editó}} una publicación en \"$2\" en \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 y {{PLURAL:$5|otro|otros}} $4 {{GENDER:$1|editaron}} una publicación en \"$2\" en \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|renombró}} tu tema",
+ "flow-notification-rename-email-batch-body": "$1 renombró tu tema «$2» a «$3» en «$4»",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|creó}} un nuevo tema en \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|creó}} un nuevo tema titulado \"$2\" en $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Notificarme cuando se produzcan acciones relacionadas conmigo en Flow.",
+ "flow-link-post": "publicación",
+ "flow-link-topic": "tema",
+ "flow-link-history": "historial",
+ "flow-link-post-revision": "revisión de la publicación",
+ "flow-link-topic-revision": "revisión del tema",
+ "flow-link-header-revision": "revisión de la cabecera",
+ "flow-link-summary-revision": "revisión del resumen",
+ "flow-moderation-title-suppress-post": "¿Quieres suprimir la entrada?",
+ "flow-moderation-title-delete-post": "¿Quieres eliminar la entrada?",
+ "flow-moderation-title-hide-post": "¿Quieres ocultar la entrada?",
+ "flow-moderation-placeholder-suppress-post": "Por favor, {{GENDER:$3|explica}} por qué vas a suprimir esta publicación.",
+ "flow-moderation-placeholder-delete-post": "Por favor, {{GENDER:$3|explica}} por qué vas a eliminar esta publicación.",
+ "flow-moderation-placeholder-hide-post": "Por favor, {{GENDER:$3|explica}} por qué vas a ocultar esta publicación.",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Eliminar",
+ "flow-moderation-confirm-hide-post": "Ocultar",
+ "flow-moderation-confirm-undelete-post": "Restaurar",
+ "flow-moderation-confirm-unhide-post": "Revelar",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Eliminar",
+ "flow-moderation-confirm-hide-topic": "Ocultar",
+ "flow-moderation-confirm-lock-topic": "Bloquear",
+ "flow-moderation-confirm-undelete-topic": "Restaurar",
+ "flow-moderation-confirm-unhide-topic": "Revelar",
+ "flow-moderation-confirm-unlock-topic": "Desbloquear",
+ "flow-moderation-confirmation-suppress-post": "La entrada fue suprimida con éxito.\n{{GENDER:$2|Considera}} entregar un comentario $1 sobre esta entrada.",
+ "flow-moderation-confirmation-delete-post": "La entrada fue eliminada con éxito.\n{{GENDER:$2|Considera}} entregar un comentario $1 sobre esta entrada.",
+ "flow-moderation-confirmation-hide-post": "La entrada fue ocultada con éxito.\n{{GENDER:$2|Considera}} entregar un comentario $1 sobre esta entrada.",
+ "flow-moderation-confirmation-suppress-topic": "Este tema se eliminó.",
+ "flow-moderation-confirmation-delete-topic": "Este tema se eliminó.",
+ "flow-moderation-confirmation-hide-topic": "Este tema se ocultó.",
+ "flow-moderation-title-suppress-topic": "¿Quieres suprimir el tema?",
+ "flow-moderation-title-delete-topic": "¿Quieres eliminar el tema?",
+ "flow-moderation-title-hide-topic": "¿Quieres ocultar el tema?",
+ "flow-moderation-title-unhide-topic": "¿Revelar el tema?",
+ "flow-moderation-placeholder-suppress-topic": "Por favor, {{GENDER:$3|explica}} por qué vas a suprimir este tema.",
+ "flow-moderation-placeholder-delete-topic": "Por favor, {{GENDER:$3|explica}} por qué vas a eliminar este tema.",
+ "flow-moderation-placeholder-hide-topic": "Por favor, {{GENDER:$3|explica}} por qué vas a ocultar este tema.",
+ "flow-topic-permalink-warning": "Este tema se inició en [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Este tema se inició en el [$2 panel de discusión de {{GENDER:$1|$1}}]",
+ "flow-compare-revisions-revision-header": "Versión de {{GENDER:$2|$2}} del $1",
+ "action-flow-create-board": "crear paneles de discusión Flow en cualquier ubicación",
+ "right-flow-create-board": "Crear paneles de discusión Flow en cualquier ubicación",
+ "right-flow-hide": "Ocultar temas y publicaciones de Flow",
+ "right-flow-lock": "Bloquear temas de Flow",
+ "right-flow-delete": "Borrar temas y publicaciones de Flow",
+ "right-flow-edit-post": "Editar publicaciones de Flow de otros usuarios",
+ "right-flow-suppress": "Suprimir revisiones de Flow",
+ "flow-terms-of-use-new-topic": "Al pulsar en «{{int:flow-newtopic-save}}» aceptas los términos de uso de este wiki.",
+ "flow-terms-of-use-reply": "Al pulsar en «{{int:flow-reply-submit}}» aceptas los términos de uso de este wiki.",
+ "flow-terms-of-use-edit": "Al guardar los cambios aceptas los términos de uso de este wiki.",
+ "flow-anon-warning": "No has iniciado sesión. Para recibir atribución con tu nombre en lugar de su dirección IP, puedes [$1 iniciar sesión] o [$2 crear una cuenta].",
+ "flow-cancel-warning": "Has escrito texto en este formulario. ¿Estás seguro de que quieres descartarlo?",
+ "flow-topic-first-heading": "Tema en $1",
+ "flow-topic-html-title": "$1 en $2",
+ "flow-topic-count": "Temas ($1)",
+ "flow-load-more": "Cargar más",
+ "flow-no-more-fwd": "No hay temas más antiguos",
+ "flow-add-topic": "Añadir tema",
+ "flow-newest-topics": "Temas creados más recientemente",
+ "flow-recent-topics": "Temas con actividad más reciente",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Estás}} leyendo los temas creados más recientemente primero. Haz clic para ver más opciones de clasificación.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Estás}} leyendo los temas que han tenido actividad más recientemente primero. Haz clic para ver más opciones de clasificación.",
+ "flow-terms-of-use-summarize": "Al hacer clic en \"{{int:flow-summarize-topic-submit}}\", aceptas los términos de uso de este wiki.",
+ "flow-terms-of-use-lock-topic": "Al pulsar en «{{int:flow-lock-topic-submit}}» aceptas los términos de uso de este wiki.",
+ "flow-terms-of-use-unlock-topic": "Al pulsar en «{{int:flow-unlock-topic-submit}}» aceptas los términos de uso de este wiki.",
+ "flow-whatlinkshere-post": "desde un [$1 post]",
+ "flow-whatlinkshere-header": "desde el [$1 header]",
+ "flow": "Flujo",
+ "flow-special-type": "Tipo",
+ "flow-special-type-post": "Publicación",
+ "flow-special-type-workflow": "Flujo de trabajo",
+ "flow-special-uuid": "UUID",
+ "flow-special-enableflow-legend": "Activar Flow en una página nueva",
+ "flow-special-enableflow-page": "Página donde se activará Flow",
+ "flow-special-enableflow-header": "Cabecera inicial del panel de discusión Flow (wikitexto)",
+ "flow-special-enableflow-board-already-exists": "Ya existe un panel de mensajes Flow en [[$1]]",
+ "flow-special-enableflow-invalid-title": "La página dada no es un título de página válido",
+ "flow-special-enableflow-page-already-exists": "En estos momentos hay una página en [[$1]] que no es parte de Flow. Si quieres colocar un panel de discusión aquí debes mover la página actual a un archivo, borrar la redirección, y volver a usar esta funcionalidad. Incluye el nombre de la página archivada en la cabecera",
+ "flow-special-enableflow-confirmation": "Has creado un nuevo panel de discusión en [[$1]].",
+ "flow-spam-confirmedit-form": "Confirma que eres un humano resolviendo el siguiente captha: $1",
+ "flow-preview-warning": "Estás viendo una vista previa. Haz clic en \"{{int:flow-newtopic-save}}\" para publicar o en \"{{int:flow-preview-return-edit-post}}\" para seguir escribiendo.",
+ "flow-preview-return-edit-post": "Continuar editando",
+ "flow-anonymous": "Anónimo",
+ "flow-embedding-unsupported": "Las discusiones todavía no pueden ser incrustadas.",
+ "mw-ui-unsubmitted-confirm": "Tienes cambios no enviados en esta página. ¿Estás seguro que deseas salir y perder tu trabajo?",
+ "flow-post-undo-hide": "deshacer ocultar",
+ "flow-post-undo-delete": "deshacer borrar",
+ "flow-post-undo-suppress": "deshacer suprimir",
+ "flow-topic-undo-hide": "deshacer ocultar",
+ "flow-topic-undo-delete": "deshacer borrar",
+ "flow-topic-undo-suppress": "deshacer suprimir",
+ "apihelp-flow+edit-header-param-format": "Formato de la cabecera (wikitexto|html)",
+ "apihelp-flow+edit-post-param-postId": "ID de la publicación.",
+ "apihelp-flow+edit-post-param-content": "Contenido para publicar.",
+ "apihelp-flow+edit-post-param-format": "Formato del contenido de la publicación (wikitexto|html)",
+ "apihelp-flow+edit-title-description": "Edita el título de un tema.",
+ "apihelp-flow+edit-topic-summary-param-format": "Formato del resumen (wikitexto|html)",
+ "apihelp-flow+new-topic-param-format": "Formato de la respuesta inicial del nuevo tema (wikitexto|html)",
+ "apihelp-flow+reply-param-format": "Formato de la publicación nueva (wikitexto|html)",
+ "apihelp-flow-parsoid-utils-param-content": "El contenido que se convertirá.",
+ "apihelp-flow+undo-edit-header-description": "Obtener la información necesaria para deshacer ediciones de cabeceras.",
+ "apihelp-flow+undo-edit-post-description": "Obtener la información necesaria para deshacer ediciones de publicaciones.",
+ "flow-edited": "Editada",
+ "flow-edited-by": "Editado por $1",
+ "flow-previous-diff": "← Edición anterior",
+ "flow-next-diff": "Edición siguiente →",
+ "flow-undo": "deshacer",
+ "flow-undo-latest-revision": "Última revisión",
+ "flow-undo-your-text": "Tu texto",
+ "flow-undo-edit-header": "Edición de la cabecera",
+ "flow-undo-edit-topic-summary": "Edición del resumen del tema",
+ "flow-undo-edit-post": "Edición de una publicación",
+ "flow-ve-mention-context-item-label": "Mencionar",
+ "flow-ve-mention-inspector-title": "Mención",
+ "flow-ve-mention-inspector-remove-label": "Eliminar",
+ "flow-ve-mention-tool-title": "Mencionar a un usuario",
+ "flow-ve-mention-inspector-invalid-user": "El nombre de usuario «$1» no está registrado.",
+ "flow-wikitext-editor-help": "El wikitexto $1.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|usa marcado (\"markup\")]]",
+ "flow-wikitext-editor-help-preview-the-result": "previsualiza el resultado",
+ "flow-wikitext-switch-editor-tooltip": "Cambiar al editor visual",
+ "flow-ve-switch-editor-tool-title": "Cambiar al editor de wikitexto"
+}
diff --git a/Flow/i18n/et.json b/Flow/i18n/et.json
new file mode 100644
index 00000000..454dbe74
--- /dev/null
+++ b/Flow/i18n/et.json
@@ -0,0 +1,199 @@
+{
+ "@metadata": {
+ "authors": [
+ "Pikne",
+ "Lyrixn",
+ "Boxmein"
+ ]
+ },
+ "flow-desc": "Töövoo haldamise süsteem",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|kustutas}} leheküljel [[$6]] [$4 postituse] \"[[$3|$5]]\"",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|taastas}} leheküljel [[$6]] [$4 postituse] \"[[$3|$5]]\"",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|varjas}} leheküljel [[$6]] [$4 postituse] \"[[$3|$5]]\"",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|kustutas}} leheküljel [[$6]] teema \"[[$3|$5]]\"",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|taastas}} leheküljel [[$6]] teema \"[[$3|$5]]\"",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|varjas}} leheküljel [[$6]] teema \"[[$3|$5]]\"",
+ "flow-board-header-browse-topics-link": "Sirvi teemasid",
+ "flow-edit-header-link": "Redigeeri päist",
+ "flow-topic-moderated-reason-prefix": "Põhjus:",
+ "flow-hide-post-content": "$1 peitis selle kommentaari ([$2 ajalugu]).",
+ "flow-hide-title-content": "$1 peitis selle teema.",
+ "flow-lock-title-content": "$1 lukustas selle teema.",
+ "flow-hide-header-content": "{{GENDER:$1|Peitnud}} $2",
+ "flow-delete-post-content": "$1 kustutas selle kommentaari ([$2 ajalugu]).",
+ "flow-delete-title-content": "$1 kustutas selle teema.",
+ "flow-delete-header-content": "{{GENDER:$1|Kustutanud}} $2",
+ "flow-suppress-post-content": "$1 varjas selle kommentaari ([$2 ajalugu]).",
+ "flow-suppress-title-content": "$1 varjas selle teema.",
+ "flow-suppress-header-content": "{{GENDER:$1|Varjanud}} $2",
+ "flow-cancel": "Loobu",
+ "flow-preview": "Eelvaade",
+ "flow-newtopic-content-placeholder": "Postita uus sõnum leheküljele \"$1\"",
+ "flow-newtopic-save": "Lisa teema",
+ "flow-newtopic-start-placeholder": "Alusta uut teemat",
+ "flow-newtopic-first-heading": "Alusta uut teemat lehel $1",
+ "flow-reply-topic-title-placeholder": "Vasta teemale \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Vasta}}",
+ "flow-reply-link": "{{GENDER:$1|Vasta}}",
+ "flow-thank-link-title": "Täna postitajat avalikult",
+ "flow-history-action-suppress-post": "varja",
+ "flow-history-action-delete-post": "kustuta",
+ "flow-history-action-hide-post": "peida",
+ "flow-history-action-unsuppress-post": "tühista varjamine",
+ "flow-history-action-undelete-post": "taasta",
+ "flow-history-action-unhide-post": "tühista peitmine",
+ "flow-history-action-restore-post": "ennista",
+ "flow-history-action-lock-topic": "lukusta",
+ "flow-history-action-unlock-topic": "tühista lukustamine",
+ "flow-post-action-view": "Püsilink",
+ "flow-post-action-suppress-post": "Varja",
+ "flow-post-action-delete-post": "Kustuta",
+ "flow-post-action-hide-post": "Peida",
+ "flow-post-action-edit-post": "Redigeeri",
+ "flow-post-action-edit-post-submit": "Salvesta muudatused",
+ "flow-post-action-unsuppress-post": "Tühista varjamine",
+ "flow-post-action-undelete-post": "Taasta",
+ "flow-post-action-unhide-post": "Tühista varjamine",
+ "flow-post-action-restore-post": "Ennista",
+ "flow-post-action-undo-moderation": "Võta tagasi",
+ "flow-topic-action-view": "Püsilink",
+ "flow-topic-action-edit-title": "Redigeeri pealkirja",
+ "flow-topic-action-history": "Ajalugu",
+ "flow-topic-action-hide-topic": "Peida teema",
+ "flow-topic-action-delete-topic": "Kustuta teema",
+ "flow-topic-action-lock-topic": "Lukusta teema",
+ "flow-topic-action-unlock-topic": "Tühista teema lukustamine",
+ "flow-topic-action-summarize-topic": "Võta kokku",
+ "flow-topic-action-resummarize-topic": "Redigeeri teema kokkuvõtet",
+ "flow-topic-action-suppress-topic": "Varja teema",
+ "flow-topic-action-unhide-topic": "Tühista teema varjamine",
+ "flow-topic-action-undelete-topic": "Taasta teema",
+ "flow-topic-action-unsuppress-topic": "Tühista teema varjamine",
+ "flow-topic-action-restore-topic": "Ennista teema",
+ "flow-topic-action-undo-moderation": "Võta tagasi",
+ "flow-topic-notification-subscribe-title": "See teema on lisatud {{GENDER:$1|sinu}} jälgimisloendisse.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Saad}} teavituse iga selle teemaga seotud tegevuse kohta.",
+ "flow-edit-header-placeholder": "Kirjelda seda arutelukohta",
+ "flow-edit-header-submit": "Salvesta päis",
+ "flow-summarize-topic-submit": "Võta kokku",
+ "flow-lock-topic-submit": "Lukusta teema",
+ "flow-unlock-topic-submit": "Tühista teema lukustamine",
+ "flow-edit-title-submit": "Muuda pealkirja",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|redigeeris}} [$3 kommentaari] teemas \"$4\"",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Redigeeritud}} postitust",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|kirjutas}}] teemasse \"$4\" (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>{{PLURAL:$1|Üks kommentaar|$1 kommentaari}}</strong> lisatud",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|alustas}} teemat \"[$3 $4]\"",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Alustas}} uut teemat",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|pani}} teema \"$5\" uueks pealkirjaks \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|tegi}} päise",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|redigeeris}} päist",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|tegi}} kokkuvõtte teemast \"$3\"",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|redigeeris}} teema \"$3\" kokkuvõtet",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|peitis}} [$4 kommentaari] teemas \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|kustutas}} [$4 kommentaari] teemas \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|varjas}} [$4 kommentaari] teemas \"$6\" (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|ennistas}} [$4 kommentaari] teemas \"$6\" (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|peitis}} [$4 teema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|kustutas}} [$4 teema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|varjas}} [$4 teema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|lukustas}} [$4 teema] \"$6\" (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|ennistas}} [$4 teema] \"$6\" (<em>$5</em>)",
+ "flow-rc-topic-of-board": "$2 – teema \"$1\"",
+ "flow-topic-comments": "{{PLURAL:$1|Üks kommentaar|$1 kommentaari|0=Kommentaare {{GENDER:$2|pole}} veel.}}",
+ "flow-workflow": "töövoog",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 vastas leheküljel \"'''$4'''\".",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 ja veel {{PLURAL:$6|üks kasutaja vastas|$5 kasutajat vastasid}} leheküljel \"'''$3'''\".",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 redigeeris sinu <span class=\"plainlinks\">[$5 postitust]</span> leheküljel \"[[$3|$4]]\".",
+ "flow-notification-edit-bundle": "$1 ja veel {{PLURAL:$6|üks kasutaja redigeeris|$5 kasutajat redigeerisid}} <span class=\"plainlinks\">[$4 postitust]</span> teemas \"$2\" leheküljel \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 alustas leheküljel \"'''$3'''\" uut teemat.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|Üks uus teema|250=Üle 250 uue teema}} leheküljel \"'''<span class=\"plainlinks\">[$3 $2]</span>'''\"",
+ "flow-notification-rename": "$1 pani leheküljel \"[[$5|$6]]\" teema <span class=\"plainlinks\">\"[$2 $3]\"</span> uueks pealkirjaks \"$4\".",
+ "flow-notification-mention": "$1 mainis {{GENDER:$5|sind}} leheküljel \"$4\" teema \"$3\" <span class=\"plainlinks\">[$2 postituses]</span>.",
+ "flow-notification-link-text-view-post": "Vaata postitust",
+ "flow-notification-link-text-view-topic": "Vaata teemat",
+ "flow-notification-reply-email-subject": "\"$2\" leheküljel \"$3\"",
+ "flow-notification-reply-email-batch-body": "$1 vastas teemale \"$2\" leheküljel \"$3\".",
+ "flow-notification-reply-email-batch-bundle-body": "$1 ja veel {{PLURAL:$5|üks kasutaja vastas|$4 kasutajat vastasid}} leheküljel \"$3\" teemale \"$2\".",
+ "flow-notification-mention-email-subject": "$1 mainis {{GENDER:$3|sind}} leheküljel \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 mainis {{GENDER:$4|sind}} leheküljel \"$3\" teema \"$2\" postituses.",
+ "flow-notification-edit-email-subject": "$1 redigeeris postitust",
+ "flow-notification-edit-email-batch-body": "$1 redigeeris postitust teemas \"$2\" leheküljel \"$3\".",
+ "flow-notification-edit-email-batch-bundle-body": "$1 ja veel {{PLURAL:$5|üks kasutaja redigeeris|$4 kasutajat redigeerisid}} postitust leheküljel \"$2\" teemas \"$3\".",
+ "flow-notification-rename-email-subject": "$1 pani sinu teemale uue pealkirja",
+ "flow-notification-rename-email-batch-body": "$1 pani leheküljel \"$4\" sinu teema \"$2\" uueks pealkirjaks \"$3\".",
+ "flow-notification-newtopic-email-subject": "$1 alustas uut teemat leheküljel \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 alustas leheküljel \"$3\" uut teemat \"$2\".",
+ "echo-category-title-flow-discussion": "Voogarutelu",
+ "echo-pref-tooltip-flow-discussion": "Teavita mind, kui voogarutelus esineb minusse puutuvaid tegevusi.",
+ "flow-link-topic": "teema",
+ "flow-moderation-placeholder-suppress-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse varjad.",
+ "flow-moderation-placeholder-delete-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse kustutad.",
+ "flow-moderation-placeholder-hide-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse peidad.",
+ "flow-moderation-placeholder-unsuppress-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse varjamise tühistad.",
+ "flow-moderation-placeholder-undelete-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse taastad.",
+ "flow-moderation-placeholder-unhide-post": "Palun {{GENDER:$3|selgita}}, miks selle postituse peitmise tühistad.",
+ "flow-moderation-confirm-suppress-post": "Varja",
+ "flow-moderation-confirm-delete-post": "Kustuta",
+ "flow-moderation-confirm-hide-post": "Peida",
+ "flow-moderation-confirm-unsuppress-post": "Tühista varjamine",
+ "flow-moderation-confirm-undelete-post": "Taasta",
+ "flow-moderation-confirm-unhide-post": "Tühista peitmine",
+ "flow-moderation-confirm-suppress-topic": "Varja",
+ "flow-moderation-confirm-delete-topic": "Kustuta",
+ "flow-moderation-confirm-hide-topic": "Peida",
+ "flow-moderation-confirm-lock-topic": "Lukusta",
+ "flow-moderation-confirm-unsuppress-topic": "Tühista varjamine",
+ "flow-moderation-confirm-undelete-topic": "Taasta",
+ "flow-moderation-confirm-unhide-topic": "Tühista peitmine",
+ "flow-moderation-confirm-unlock-topic": "Tühista lukustamine",
+ "flow-moderation-placeholder-suppress-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema varjad.",
+ "flow-moderation-placeholder-delete-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema kustutad.",
+ "flow-moderation-placeholder-hide-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema peidad.",
+ "flow-moderation-placeholder-lock-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema lukustad.",
+ "flow-moderation-placeholder-unsuppress-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema varjamise tühistad.",
+ "flow-moderation-placeholder-undelete-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema taastad.",
+ "flow-moderation-placeholder-unhide-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema peitmise tühistad.",
+ "flow-moderation-placeholder-unlock-topic": "Palun {{GENDER:$3|selgita}}, miks selle teema lukustamise tühistad.",
+ "flow-revision-permalink-warning-post-first": "See püsilink viitab selle postituse esimesele versioonile.\nHilisemaid versioone saad vaadata postituse [$4 ajaloo leheküljelt].",
+ "flow-revision-permalink-warning-postsummary-first": "See püsilink viitab selle postituse kokkuvõtte esimesele versioonile.\nHilisemaid versioone saad vaadata postituse [$4 ajaloo leheküljelt].",
+ "right-flow-hide": "Peita voogarutelu teemasid ja postitusi",
+ "right-flow-lock": "Lukustada voogarutelu teemasid",
+ "right-flow-delete": "Kustutada voogarutelu teemasid ja postitusi",
+ "right-flow-edit-post": "Redigeerida voogarutelus teiste postitusi",
+ "right-flow-suppress": "Varjata voogarutelu redaktsioone",
+ "flow-terms-of-use-new-topic": "Kui klõpsad \"{{int:flow-newtopic-save}}\", nõustud selle viki kasutustingimustega.",
+ "flow-terms-of-use-reply": "Kui klõpsad \"{{int:flow-reply-submit}}\", nõustud selle viki kasutustingimustega.",
+ "flow-terms-of-use-edit": "Kui muudatused salvestad, nõustud selle viki kasutustingimustega.",
+ "flow-anon-warning": "Sa pole sisse logitud. Et postitus omistataks sulle nime, mitte IP-aadressi järgi, saad [$1 sisse logida] või [$2 konto luua].",
+ "flow-cancel-warning": "Oled sellesse vormi teksti sisestanud. Kas oled kindel, et tahad sellest tekstist loobuda?",
+ "flow-topic-first-heading": "Teema leheküljel \"$1\"",
+ "flow-topic-html-title": "\"$1\" leheküljelt \"$2\"",
+ "flow-no-more-fwd": "Vanemaid teemasid pole.",
+ "flow-newest-topics": "Uusimad teemad",
+ "flow-recent-topics": "Viimati aktiivsed teemad",
+ "flow-sorting-tooltip-newest": "Loed praegu kõigepealt uusimaid teemasid. Klõpsa, et näha rohkem järjestussätteid.",
+ "flow-sorting-tooltip-recent": "Loed praegu kõigepealt viimati aktiivseid teemasid. Klõpsa, et näha rohkem järjestussätteid.",
+ "flow-toggle-small-topics": "Vaheta vaadet: ainult teemapealkirjad",
+ "flow-toggle-topics": "Vaheta vaadet: teemad ilma postitusteta",
+ "flow-toggle-topics-posts": "Vaheta vaadet: teemad postitustega",
+ "flow-terms-of-use-summarize": "Kui klõpsad \"{{int:flow-summarize-topic-submit}}\", nõustud selle viki kasutustingimustega.",
+ "flow-terms-of-use-lock-topic": "Kui klõpsad \"{{int:flow-lock-topic-submit}}\", nõustud selle viki kasutustingimustega.",
+ "flow-terms-of-use-unlock-topic": "Kui klõpsad \"{{int:flow-unlock-topic-submit}}\", nõustud selle viki kasutustingimustega.",
+ "flow-special-type": "Tüüp",
+ "flow-special-type-post": "Postitus",
+ "flow-special-type-workflow": "Töövoog",
+ "flow-preview-warning": "Näed praegu eelvaadet. Klõpsa nuppu \"{{int:flow-newtopic-save}}\", et postitada, või nuppu \"{{int:flow-preview-return-edit-post}}\", et kirjutamist jätkata.",
+ "flow-preview-return-edit-post": "Redigeeri edasi",
+ "flow-edited": "Redigeeritud:",
+ "flow-edited-by": "Redigeerinud $1",
+ "flow-ve-mention-inspector-title": "Mainimine",
+ "flow-ve-mention-tool-title": "Maini kasutajat",
+ "flow-ve-mention-inspector-invalid-user": "Kasutajanimi \"$1\" pole registreeritud.",
+ "flow-wikitext-editor-help": "Vikitekst $1.",
+ "flow-wikitext-editor-help-and-preview": "Vikitekst $1. Saad iga hetk $2.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|kasutab märgistuskeelt]]",
+ "flow-wikitext-editor-help-preview-the-result": "tulemust eelvaadelda",
+ "flow-wikitext-switch-editor-tooltip": "Vaheta visuaaltoimeti vastu",
+ "flow-ve-switch-editor-tool-title": "Vaheta vikiteksti redaktori vastu"
+}
diff --git a/Flow/i18n/eu.json b/Flow/i18n/eu.json
new file mode 100644
index 00000000..ac890d4a
--- /dev/null
+++ b/Flow/i18n/eu.json
@@ -0,0 +1,34 @@
+{
+ "@metadata": {
+ "authors": [
+ "Subi"
+ ]
+ },
+ "flow-board-header-browse-topics-link": "Arakatu gaiak",
+ "flow-topic-moderated-reason-prefix": "Arrazoia:",
+ "flow-post-actions": "Ekintzak",
+ "flow-topic-actions": "Ekintzak",
+ "flow-cancel": "Utzi",
+ "flow-show-change": "Erakutsi aldaketak",
+ "flow-newtopic-title-placeholder": "Gai berria",
+ "flow-reply-submit": "{{GENDER:$1|Erantzun}}",
+ "flow-reply-link": "{{GENDER:$1|Erantzun}}",
+ "flow-thank-link": "{{GENDER:$1|Eskertu}}",
+ "flow-history-action-delete-post": "ezabatu",
+ "flow-history-action-hide-post": "ezkutatu",
+ "flow-post-action-post-history": "Historia",
+ "flow-post-action-delete-post": "Ezabatu",
+ "flow-post-action-hide-post": "Ezkutatu",
+ "flow-post-action-edit-post": "Aldatu",
+ "flow-post-action-edit-post-submit": "Aldaketak gorde",
+ "flow-post-action-undo-moderation": "Desegin",
+ "flow-topic-action-history": "Historia",
+ "flow-topic-action-hide-topic": "Ezkutatu gaia",
+ "flow-topic-action-delete-topic": "Ezabatu gaia",
+ "flow-topic-action-undo-moderation": "Desegin",
+ "flow-history-day": "Gaur",
+ "flow-moderation-confirm-delete-post": "Ezabatu",
+ "flow-moderation-confirm-hide-post": "Ezkutatu",
+ "flow-moderation-confirm-delete-topic": "Ezabatu",
+ "flow-moderation-confirm-hide-topic": "Ezkutatu"
+}
diff --git a/Flow/i18n/fa.json b/Flow/i18n/fa.json
new file mode 100644
index 00000000..12d39703
--- /dev/null
+++ b/Flow/i18n/fa.json
@@ -0,0 +1,257 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Armin1392",
+ "Ebraminio",
+ "Omidh",
+ "Reza1615",
+ "درفش کاویانی",
+ "Alirezaaa",
+ "Hosseinblue",
+ "Danialbehzadi"
+ ]
+ },
+ "flow-desc": "سامانهٔ مدیریت گردش کار",
+ "flow-talk-taken-over": "این صفحهٔ گفتگو توسط یک [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow board] تصاحب شده‌است.",
+ "log-name-flow": "جریان داشتن فعالیت سیاهه",
+ "logentry-delete-flow-delete-post": "$1 یک [$4 پست] را در [[$3]] {{GENDER:$2|حذف کرد}}",
+ "logentry-delete-flow-restore-post": "$1 یک [$4 ارسال] را در [[$3]] {{GENDER:$2|بازیابی کرد}}",
+ "logentry-suppress-flow-suppress-post": "$1 یک [$4 پست] را در [[$3]] {{GENDER:$2|سرکوب شده}}",
+ "logentry-suppress-flow-restore-post": "$1 یک [$4 پست] را در [[$3]] {{GENDER:$2|حذف کرد}}",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|حذف شده}} یک [$4 tموضوع] در [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|بازگردانده شده}} یک [$4 موضوع] در [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|سرکوب شده}} یک [$4 topic] در [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|حذف شده}} یک [$4 topic] در [[$3]]",
+ "flow-user-moderated": "کاربر کنترل شده",
+ "flow-edit-header-link": "ویرایش سرفصل",
+ "flow-post-moderated-toggle-hide-show": "نمایش نظر {{GENDER:$1|پنهان شده}} توسط $2",
+ "flow-post-moderated-toggle-delete-show": "نمایش نظر {{GENDER:$1|حذف شده}} توسط $2",
+ "flow-post-moderated-toggle-suppress-show": "نمایش نظر {{GENDER:$1|سرکوب شده}} توسط $2",
+ "flow-post-moderated-toggle-hide-hide": "پنهان کردن نظر {{GENDER:$1|پنهان‌شده}} توسط $2",
+ "flow-post-moderated-toggle-delete-hide": "پنهان کردن نظر {{GENDER:$1|حذف‌شده}} توسط $2",
+ "flow-post-moderated-toggle-suppress-hide": "پنهان کردن نظر {{GENDER:$1|سرکوب شده}} توسط $2",
+ "flow-hide-post-content": "این نظر توسط $1 ، {{GENDER:$1|hidden}} بود",
+ "flow-hide-title-content": "این موضوع توسط $1، {{GENDER:$1|hidden}} بود",
+ "flow-hide-header-content": "{{GENDER:$1|Hidden}} توسط $2",
+ "flow-delete-post-content": "این نظر توسط $1، {{GENDER:$1|deleted}} بود",
+ "flow-delete-title-content": "این موضوع توسط $1، {{GENDER:$1|deleted}} بود",
+ "flow-delete-header-content": "{{GENDER:$1|Deleted}} توسط $2",
+ "flow-suppress-post-content": "این نظر توسط $1، {{GENDER:$1|suppressed}} بود",
+ "flow-suppress-title-content": "این موضوع توسط $1، {{GENDER:$1|suppressed}} بود",
+ "flow-suppress-header-content": "{{GENDER:$1|Suppressed}} توسط $2",
+ "flow-suppress-usertext": "<em>نام کاربری سرکوب شده</em>",
+ "flow-post-actions": "اقدامات",
+ "flow-topic-actions": "اقدامات",
+ "flow-cancel": "لغو",
+ "flow-preview": "پیش‌نمایش",
+ "flow-show-change": "نمایش تغییرات",
+ "flow-last-modified-by": "آخرین {{GENDER:$1|modified}} توسط $1",
+ "flow-stub-post-content": "''به دلیل یک خطای فنی، این پست نتوانست بازیابی شود.''",
+ "flow-newtopic-title-placeholder": "موضوع تازه",
+ "flow-newtopic-content-placeholder": "یک پیام تازه به «$1» ارسال کنید",
+ "flow-newtopic-header": "اضافه کردن یک موضوع تازه",
+ "flow-newtopic-save": "افزودن موضوع",
+ "flow-newtopic-start-placeholder": "آغاز موضوع تازه",
+ "flow-newtopic-first-heading": "مبحث تازه‌ای در $1 آغاز کن",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|نظر}} در \"$2\"",
+ "flow-reply-submit": "{{GENDER:$1|پاسخ}}",
+ "flow-reply-link": "{{GENDER:$1|پاسخ}}",
+ "flow-thank-link": "{{GENDER:$1|تشکر}}",
+ "flow-post-edited": "پست {{GENDER:$1|ویرایش شد}} توسط $1 $2",
+ "flow-post-action-view": "پیوند پایدار",
+ "flow-post-action-post-history": "تاریخچه",
+ "flow-post-action-suppress-post": "سرکوب",
+ "flow-post-action-delete-post": "حذف",
+ "flow-post-action-hide-post": "نهفتن",
+ "flow-post-action-edit-post": "ویرایش",
+ "flow-post-action-edit-post-submit": "ذخیره‌کردن تغییرات",
+ "flow-post-action-unsuppress-post": "از مخفی‌بودن در آوردن",
+ "flow-post-action-undelete-post": "احیاء",
+ "flow-post-action-unhide-post": "نمایش",
+ "flow-post-action-restore-post": "بازیابی",
+ "flow-topic-action-view": "پیوند پایدار",
+ "flow-topic-action-watchlist": "فهرست پی‌گیری‌ها",
+ "flow-topic-action-edit-title": "ویرایش عنوان",
+ "flow-topic-action-history": "تاریخچه",
+ "flow-topic-action-hide-topic": "پنهان کردن موضوع",
+ "flow-topic-action-delete-topic": "حذف موضوع",
+ "flow-topic-action-summarize-topic": "خلاصه‌سازی",
+ "flow-topic-action-resummarize-topic": "ویرایش خلاصه موضوع",
+ "flow-topic-action-suppress-topic": "سرکوب موضوع",
+ "flow-topic-action-unhide-topic": "آشکارنمودن موضوع",
+ "flow-topic-action-undelete-topic": "احیاء عنوان",
+ "flow-topic-action-unsuppress-topic": "از مخفی‌بودن در آوردن عنوان",
+ "flow-topic-action-restore-topic": "بازگرداندن موضوع",
+ "flow-topic-action-undo-moderation": "واگردانی",
+ "flow-topic-notification-subscribe-title": "این مبحث به فهرست پیگیری‌تان افزوده شده است.",
+ "flow-topic-notification-subscribe-description": "شما برای همه فعالیت‌ها در این مبحث اطلاعیه دریافت خواهید کرد.",
+ "flow-board-notification-subscribe-title": "شما به این بحث اشتراک دارید!",
+ "flow-board-notification-subscribe-description": "شما به طور خودکار به همه مباحث تازه ایجادشده در این بحث مشترک خواهید شد.",
+ "flow-error-http": "یک خطا هنگام تماس با سرور رخ داد.",
+ "flow-error-other": "یک خطای غیرمنتظره رخ داد.",
+ "flow-error-external": "خطایی رخ داده. <br /> پیغام خطای دریافت شده: $1 بود",
+ "flow-error-edit-restricted": "شما مجاز به ویرایش این پست نیستید.",
+ "flow-error-external-multi": "خطاهایی رخ داده‌اند. <br />$1",
+ "flow-error-missing-content": "پست هیچ محتوایی ندارد. محتوا نیازمند به ذخیرهٔ یک پست است.",
+ "flow-error-missing-title": "موضوع هیچ عنوانی ندارد. عنوان نیازمند به ذخیرهٔ یک موضوع است.",
+ "flow-error-parsoid-failure": "به علت یک پارسوئید ناموفق، قادر به تجزیهٔ محتوا نیست.",
+ "flow-error-missing-replyto": "هیچ \"پاسخی به\" پارامتر عرضه نشد. این پارامتر نیازمند عمل \"پاسخ\" است.",
+ "flow-error-invalid-replyto": "«پاسخ» پارامتر نامعتبر بود. پست تعیین‌شده نتوانست پیدا شود.",
+ "flow-error-delete-failure": "حذف کردن این مورد ناموفق بود.",
+ "flow-error-hide-failure": "پنهان کردن این مورد ناموفق بود.",
+ "flow-error-missing-postId": "هیچ «شناسهٔ پستی» پارامتری عرضه نشد. این پارامتر نیازمند به کنترل یک پست است.",
+ "flow-error-invalid-postId": "\"شناسهٔ پستی\" پارامتر نامعتبر بود. پست تعیین شدهٔ ($1) نتوانست پیدا شود.",
+ "flow-error-restore-failure": "بازگردانی این مورد ناموفق بود.",
+ "flow-error-invalid-moderation-state": "یک ارزش نامعتبر برای وضعیت کنترل، ارائه شد.",
+ "flow-error-invalid-moderation-reason": "لطفاً یک دلیل برای کنترل ارائه دهید.",
+ "flow-error-not-allowed": "مجوزهای ناکافی برای اجرای این عمل.",
+ "flow-error-title-too-long": "عناوین موضوع، محدود به $1 {{PLURAL:$1|byte|bytes}} هستند.",
+ "flow-error-no-existing-workflow": "این جریان کار هنوز وجود ندارد.",
+ "flow-error-not-a-post": "عنوان موضوع نمی‌تواند به عنوان یک پست ذخیره شود.",
+ "flow-error-missing-header-content": "سرفصل هیچ محتوایی ندارد. محتوا نیازمند به ذخیزهٔ یک سرفصل است.",
+ "flow-error-missing-prev-revision-identifier": "معرف بررسی قبلی از گم شده‌است.",
+ "flow-error-prev-revision-mismatch": "چند ثانیه پیش کاربر دیگری این پست را ویرایش کرده‌است. آیا مطمئن هستید که می‌خواهید تغییر اخیر را بازنویسی کنید؟",
+ "flow-error-prev-revision-does-not-exist": "بررسی قبلی نتوانست پیدا شود.",
+ "flow-error-default": "یک خطا رخ داده است.",
+ "flow-error-invalid-input": "ارزش نامعتبر برای بارگذاری جریان محتوا، ارائه شده.",
+ "flow-error-invalid-title": "عنوان صفحهٔ نامعتبر ارائه شده.",
+ "flow-error-fail-load-history": "عدم موفقیت بارگذاری محتوای سابقه.",
+ "flow-error-missing-revision": "بررسی برای بارگذاری محتوای جریان، نتوانست پیدا شود.",
+ "flow-error-fail-commit": "عدم موفقیت ذخیرهٔ محتوای جریان.",
+ "flow-error-insufficient-permission": "مجوز ناکافی برای دسترسی به محتوا.",
+ "flow-error-revision-comparison": "عملکرد متفاوت برای دو بررسی متعلق به پست مشابه، می‌تواند به تنهایی انجام شده باشد.",
+ "flow-error-missing-topic-title": "عنوان موضوع برای جریان کار کنونی، نتوانست پیدا شود.",
+ "flow-error-fail-load-data": "عدم موفقیت در بارگذاری اطلاعات درخواست شده.",
+ "flow-error-invalid-workflow": "جریان کار درخواست شده نتوانست پیدا شود.",
+ "flow-error-process-data": "خطایی هنگام پردازش اطلاعات در درخواست شما رخ داده‌است.",
+ "flow-error-process-wikitext": "خطایی هنگام پردازش تبدیل اچ‌تی‌‌ام‌ال/متن‌ویکی رخ داده‌است.",
+ "flow-error-no-index": "عدم موفقیت در پیدا کردن یک شاخص برای انجام جستجوی اطلاعات.",
+ "flow-error-move": "انتقال یک بحث در حال حاضر پشتیبانی نمی‌شود.",
+ "flow-edit-header-placeholder": "این بحث را توصیف کنید",
+ "flow-edit-header-submit": "ذخیرهٔ سرفصل",
+ "flow-edit-header-submit-overwrite": "بازنویسی سرصفحه",
+ "flow-summarize-topic-submit": "خلاصه‌سازی",
+ "flow-edit-title-submit": "تغییر عنوان",
+ "flow-edit-title-submit-overwrite": "بازنویسی عنوان",
+ "flow-edit-post-submit": "ثبت تغییرات",
+ "flow-edit-post-submit-overwrite": "بازنویسی تغییرات",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|ویرایش شد}} یک [$3 نظر] در $4.",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|نطر داده}}] در $4 (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|نظر|نظرها}}</strong> {{PLURAL:$1|بود|بودند}} اضافه شد.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|ایجاد شد}} موضوع [$3 $4].",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|تغییر یافت}} عنوان موضوع از $5 به [$3 $4].",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|ایجاد شده}} سرفصل صفحه.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|ویرایش شده}} سرفصل صفحه.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|پنهان}} یک [$4 نظر] در $6 (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|حذف شده}} یک [$4 نظر] در $6 (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|سرکوب شده}} یک [$4 نظر] در $6 (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|بازگردانده شده}} یک [$4 نظر] در $6 (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|پنهان شد}} [$4 موضوع] $6 (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|حذف شده}} [$4 موضوع] $6 (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|سرکوب شده}} [$4 موضوع] $6 (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|بازگردانده شده}} [$4 موضوع] $6 (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 در $2",
+ "flow-board-history": "تاریخ \"$1\"",
+ "flow-topic-history": " تاریخچهٔ موضوع \"$1\"",
+ "flow-post-history": "\"نظر توسط {{GENDER:$2|$2}}\" تاریخچهٔ پست",
+ "flow-history-last4": "4 ساعت گذشته",
+ "flow-history-day": "امروز",
+ "flow-history-week": "هفتهٔ گذشته",
+ "flow-history-pages-topic": "بر روی [$1 \"$2\" صفحه] به نظر رسیدن",
+ "flow-history-pages-post": "بر روی [$1 $2] به نظر رسیدن",
+ "flow-topic-comments": "{{PLURAL:$1|نظر $1 |نظرها $1 |0={{GENDER:$2|اولین}} شخصی باشید که نظر می‌گذارد!}}",
+ "flow-comment-restored": "بازگرداندن نظر",
+ "flow-comment-deleted": "نظر حذف شده",
+ "flow-comment-hidden": "پنهان کردن نظر",
+ "flow-comment-moderated": "کنترل نظر",
+ "flow-last-modified": "آخرین تغییریافته دربارهٔ $1",
+ "flow-notification-reply": "$1 {{GENDER:$1|پاسخ داده شد}} به شما <span class=\"plainlinks\">[$5 post]</span> در \"$2\" در \"$4\".",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 و $5 {{PLURAL:$6|نفر دیگر|نفر دیگر}} روی '''$3''' {{GENDER:$1|پاسخ داده‌اند}}.",
+ "flow-notification-edit": "$1 {{GENDER:$1|ویرایش شده}} یک <span class=\"plainlinks\">[$5 post]</span> در \"$2\" در [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 و $5 {{PLURAL:$6|دیگر|دیگران}} {{GENDER:$1|ویرایش شده}} یک <span class=\"plainlinks\">[$4 post]</span> در \"$2\" در \"$3\".",
+ "flow-notification-newtopic": "$1 یک مبحث تازه در '''$3''' {{GENDER:$1|ایجاد کرد}}.",
+ "flow-notification-rename": "$1 {{GENDER:$1|تغیر یافته}} به عنوان <span class=\"plainlinks\">[$2 $3]</span> به \"$4\" در [[$5|$6]].",
+ "flow-notification-mention": "$1 به {{GENDER:$5|شما}} در <span class=\"plainlinks\">[$2 ارسال]</span> {{GENDER:$1|خودش|خودش|خودشان}} در «$3» در «$4» {{GENDER:$1|اشاره کرد}}.",
+ "flow-notification-link-text-view-post": "نمایش ارسال",
+ "flow-notification-link-text-view-topic": "مشاهدهٔ موضوع",
+ "flow-notification-reply-email-subject": "$1 {{GENDER:$1|پاسخ داده}} به پست شما",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|پاسخ داده شده}} به پست شما در $2 در \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 و $4 {{PLURAL:$5|دیگر|دیگران}} {{GENDER:$1|پاسخ داده}} به پست شما در \"$2\" در \"$3\"",
+ "flow-notification-mention-email-subject": "$1 به {{GENDER:$3|شما}} در «$2» {{GENDER:$1|اشاره کرد}}",
+ "flow-notification-mention-email-batch-body": "$1 به {{GENDER:$4|شما}} در ارسال {{GENDER:$1|ش|ش|‌شان}} در «$2» در «$3» {{GENDER:$1|اشاره کرد}}",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|ویرایش شد}} یک پست",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|ویرایش شده}} یک پست در $2 در \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 و $4 {{PLURAL:$5|دیگر|دیگران}} {{GENDER:$1|ویرایش شده}} یک پست در $2 در \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|تغییر نام}}موضوع شما",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|تغییر نام داد}} موضوع شما \"$2\" بر \"$3\" بر \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|ایجاد شده}} یک موضوع تازه در $2",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|ایجاد شد}} یک موضوع جدید با عنوان \"$2\" بر $3",
+ "echo-category-title-flow-discussion": "جریان",
+ "echo-pref-tooltip-flow-discussion": "هنگامی که عملیات مربوط به من رخ می‌دهد، من را در جریان قرار بده.",
+ "flow-link-post": "ارسال",
+ "flow-link-topic": "موضوع",
+ "flow-link-history": "تاریخچه",
+ "flow-moderation-title-suppress-post": "سرکوب ارسال؟",
+ "flow-moderation-title-delete-post": "حذف ارسال؟",
+ "flow-moderation-title-hide-post": "پنهان‌کردن پست؟",
+ "flow-moderation-title-unsuppress-post": "از مخفی‌بودن در آوردن ارسال؟",
+ "flow-moderation-title-undelete-post": "احیاء ارسال",
+ "flow-moderation-title-unhide-post": "آشکارنمودن ارسال؟",
+ "flow-moderation-placeholder-suppress-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این پست را سرکوب می‌کنید.",
+ "flow-moderation-placeholder-delete-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا این پست را حذف می‌کنید.",
+ "flow-moderation-placeholder-hide-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این پست را پنهان می‌کنید.",
+ "flow-moderation-placeholder-unsuppress-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این ارسال از مخفی‌بودن در می‌آورید.",
+ "flow-moderation-placeholder-undelete-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این ارسال را احیاء می‌کنید.",
+ "flow-moderation-placeholder-unhide-post": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این ارسال را از مخفی‌بودن در می‌آورید.",
+ "flow-moderation-confirm-suppress-post": "سرکوب",
+ "flow-moderation-confirm-delete-post": "حذف",
+ "flow-moderation-confirm-hide-post": "نهفتن",
+ "flow-moderation-confirm-unsuppress-post": "از مخفی‌بودن در آوردن",
+ "flow-moderation-confirm-undelete-post": "احیاء کردن",
+ "flow-moderation-confirm-unhide-post": "نمایش",
+ "flow-moderation-confirm-suppress-topic": "سرکوب",
+ "flow-moderation-confirm-delete-topic": "حذف",
+ "flow-moderation-confirm-hide-topic": "نهفتن",
+ "flow-moderation-confirm-unsuppress-topic": "از مخفی‌بودن در آوردن",
+ "flow-moderation-confirm-undelete-topic": "احیاءکردن",
+ "flow-moderation-confirm-unhide-topic": "نمایش",
+ "flow-moderation-confirmation-suppress-post": "پست با موفقیت سرکوب شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این پست.",
+ "flow-moderation-confirmation-delete-post": "پست با موفقیت حذف شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این پست.",
+ "flow-moderation-confirmation-hide-post": "پست با موفقیت پنهان شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این پست.",
+ "flow-moderation-confirmation-unsuppress-post": "شما با موفقیت ارسال بالا را از مخفی‌بودن در آوردید.",
+ "flow-moderation-confirmation-undelete-post": "شما با موفقیت ارسال بالا را احیاء کردید.",
+ "flow-moderation-confirmation-suppress-topic": "موضوع با موفقیت سرکوب شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این موضوع.",
+ "flow-moderation-confirmation-delete-topic": "موضوع با موفقیت حذف شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این موضوع.",
+ "flow-moderation-confirmation-hide-topic": "موضوع با موفقیت پنهان شده‌بود.\n{{GENDER:$2|در نظر بگیرید}} واکنش دادن $1 را در این موضوع.",
+ "flow-moderation-title-suppress-topic": "موضوع سرکوب؟",
+ "flow-moderation-title-delete-topic": "موضوع حذف؟",
+ "flow-moderation-title-hide-topic": "موضوع پنهان؟",
+ "flow-moderation-title-unsuppress-topic": "از مخفی‌بودن در آوردن عنوان؟",
+ "flow-moderation-title-undelete-topic": "احیاء عنوان؟",
+ "flow-moderation-title-unhide-topic": "آشکارنمودن موضوع؟",
+ "flow-moderation-placeholder-suppress-topic": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این موضوع را سرکوب می‌کنید.",
+ "flow-moderation-placeholder-delete-topic": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این موضوع را سرکوب می‌کنید.",
+ "flow-moderation-placeholder-hide-topic": "لطفاً {{GENDER:$3|توضیح دهید}} که چرا شما این موضوع را پنهان می‌کنید.",
+ "flow-topic-permalink-warning": "این موضوع در [$2 $1] شروع شده‌ بود",
+ "flow-topic-permalink-warning-user-board": "این موضوع در [$2 {{GENDER:$1|$1}}'sصفحهٔ] شروع شده بود",
+ "flow-revision-permalink-warning-post": "این یک پیوند دائم برای یک تک نسخه از این ارسال است.\nاین نسخه از $1 است.\nشما می‌توانید [$5 تفاوت‌ها از نسخهٔ قبلی] را مشاهده کنید، یا نسخه‌های دیگری را در [$4 صفحهٔ تاریخچهٔ پست] مشاهده کنید.",
+ "flow-revision-permalink-warning-post-first": "این یک پیوند دائم برای اولین نسخهٔ این پست است.\nشما می‌توانید نسخه‌های بعدی را در [$4 صفحهٔ تاریخچهٔ پست] مشاهده کنید.",
+ "flow-revision-permalink-warning-header": "این یک پیوند دائمی برای یک نسخهٔ تک سرفصل است.\nاین نسخه از $1 است. شما می‌توانید [$3 تفاوت‌ها را از نسخهٔ قبلی] مشاهده کنید، یا نسخه‌های دیگر را در [$2 تابلو صفحهٔ تاریخچه] مشاهده کنید.",
+ "flow-revision-permalink-warning-header-first": "این یک پیوند دائمی برای یک نسخهٔ تک سرفصل است.\nشما می‌توانید نسخه‌های بعدی را در [$2 تابلو صفحهٔ تاریخچه] مشاهده کنید.",
+ "flow-compare-revisions-revision-header": "نسخه توسط {{GENDER:$2|$2}} از $1",
+ "flow-compare-revisions-header-post": "این صفحه {{GENDER:$3|تغییرات}} را بین دو نسخه از یک پست توسط $3 در موضوع \"[$5 $2]\" بر [$4 $1] نمایش می‌دهد.\nشما می‌توانید نسخه‌های دیگری از این پست را در [$6 صفحهٔ تاریخچه] مشاهده کنید.",
+ "flow-compare-revisions-header-header": "این صفحه {{GENDER:$2|تغییرات}} بین دو نسخهٔ سرفصل را در [$3 $1] نشان می‌دهد.\nشما می‌توانید نسخه‌های دیگر سرفصل را در این [$4 صفحهٔ تاریخچه] مشاهده کنید.",
+ "flow-terms-of-use-new-topic": "با کلیک کردن \"{{int:flow-newtopic-save}}\"، شما با شرایط استفاده برای این ویکی موافقت می‌کنید.",
+ "flow-terms-of-use-reply": "با کلیک کردن \"{{int:flow-reply-submit}}\"، شما با شرایط استفاده برای این ویکی موافقت می‌کنید.",
+ "flow-terms-of-use-edit": "با ذخیرهٔ تغییرات شما، شما با شرایط استفاده برای این ویکی موافقت می‌کنید.",
+ "flow-anon-warning": "شما وارد نشده‌ايد.",
+ "flow": "جریان",
+ "flow-special-type": "نوع",
+ "flow-special-type-post": "ارسال",
+ "flow-preview-return-edit-post": "به ویرایش ادامه‌دادن",
+ "flow-anonymous": "ناشناس",
+ "flow-wikitext-editor-help-preview-the-result": "پیش‌نمایش نتیجه",
+ "flow-wikitext-switch-editor-tooltip": "رفتن به ویرایشگر بصری",
+ "flow-ve-switch-editor-tool-title": "رفتن به ویرایشگر متن ویکی"
+}
diff --git a/Flow/i18n/fi.json b/Flow/i18n/fi.json
new file mode 100644
index 00000000..cf7effd8
--- /dev/null
+++ b/Flow/i18n/fi.json
@@ -0,0 +1,135 @@
+{
+ "@metadata": {
+ "authors": [
+ "Elseweyr",
+ "Nike",
+ "Pxos",
+ "Stryn",
+ "MrTapsa"
+ ]
+ },
+ "enableflow": "Ota Flow käyttöön",
+ "flow-desc": "Asianhallintajärjestelmä",
+ "flow-talk-taken-over": "Tämä keskustelusivu käyttää [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow'ta].",
+ "flow-talk-username": "Flow-keskustelusivukäsittelijä",
+ "log-name-flow": "Flow-tapahtumaloki",
+ "flow-board-header-browse-topics-link": "Selaa aiheita",
+ "flow-edit-header-link": "Muokkaa otsikkoa",
+ "flow-post-moderated-toggle-hide-show": "Näytä kommentti, jonka on {{GENDER:$1|piilottanut}} $2",
+ "flow-post-moderated-toggle-hide-hide": "Piilota kommentti, jonka on {{GENDER:$1|piilottanut}} $2",
+ "flow-topic-moderated-reason-prefix": "Syy:",
+ "flow-hide-post-content": "$1 on {{GENDER:$1|piilottanut}} tämän kommentin.",
+ "flow-hide-title-content": "$1 on {{GENDER:$1|piilottanut}} tämän aiheen.",
+ "flow-lock-title-content": "$1 on {{GENDER:$1|lukinnut}} aiheen",
+ "flow-hide-header-content": "{{GENDER:$1|Piilottanut}} $2",
+ "flow-delete-header-content": "{{GENDER:$1|Poistanut}} $2",
+ "flow-suppress-header-content": "{{GENDER:$1|Häivyttänyt}} $2",
+ "flow-suppress-usertext": "<em>Käyttäjänimi häivytetty</em>",
+ "flow-post-actions": "Toiminnot",
+ "flow-topic-actions": "Toiminnot",
+ "flow-cancel": "Peruuta",
+ "flow-preview": "Esikatselu",
+ "flow-show-change": "Näytä muutokset",
+ "flow-last-modified-by": "Viimeksi {{GENDER:$1|muokannut}} $1",
+ "flow-newtopic-title-placeholder": "Uusi aihe",
+ "flow-newtopic-content-placeholder": "Kirjoita uusi viesti sivulle \"$1\"",
+ "flow-newtopic-header": "Lisää uusi aihe",
+ "flow-newtopic-save": "Lisää aihe",
+ "flow-newtopic-start-placeholder": "Aloita uusi aihe",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Kommentoi}} aihetta \"$2\"",
+ "flow-reply-topic-title-placeholder": "Vastaa viestiin \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Vastaa}}",
+ "flow-reply-link": "{{GENDER:$1|Vastaa}}",
+ "flow-thank-link": "{{GENDER:$1|Kiitä}}",
+ "flow-lock-link": "{{GENDER:$1|Lukitse}}",
+ "flow-history-action-lock-topic": "lukitse",
+ "flow-post-action-view": "Ikilinkki",
+ "flow-post-action-post-history": "Historia",
+ "flow-post-action-suppress-post": "Häivytä",
+ "flow-post-action-delete-post": "Poista",
+ "flow-post-action-hide-post": "Piilota",
+ "flow-post-action-edit-post": "Muokkaa",
+ "flow-post-action-unhide-post": "Tuo näkyviin",
+ "flow-post-action-restore-post": "Palauta",
+ "flow-topic-action-view": "Ikilinkki",
+ "flow-topic-action-watchlist": "Tarkkailulista",
+ "flow-topic-action-edit-title": "Muokkaa otsikkoa",
+ "flow-topic-action-history": "Historia",
+ "flow-topic-action-hide-topic": "Piilota aihe",
+ "flow-topic-action-delete-topic": "Poista aihe",
+ "flow-topic-action-lock-topic": "Lukitse aihe",
+ "flow-topic-action-suppress-topic": "Häivytä aihe",
+ "flow-topic-action-restore-topic": "Palauta aihe",
+ "flow-topic-action-undo-moderation": "Kumoa",
+ "flow-topic-notification-subscribe-title": "Tämä aihe on lisätty {{GENDER:$1|sinun}} tarkkailulistallesi.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Saat}} ilmoituksen kaikista tapahtumista tässä aiheessa.",
+ "flow-error-http": "Virhe muodostettaessa yhteyttä palvelimeen.",
+ "flow-error-other": "Tuntematon virhe tapahtui.",
+ "flow-error-external": "On tapahtunut virhe.<br />Vastaanotettu virheilmoitus: $1",
+ "flow-error-edit-restricted": "Sinulla ei ole lupaa muokata tätä viestiä.",
+ "flow-error-not-allowed": "Käyttöoikeutesi eivät riitä tämän toiminnon suorittamiseen",
+ "flow-error-missing-header-content": "Otsikolla ei ole sisältöä. Otsikkoa ei voida tallentaa ilman sisältöä.",
+ "flow-error-default": "Tapahtui virhe.",
+ "flow-edit-header-submit": "Tallenna otsikko",
+ "flow-lock-topic-submit": "Lukitse aihe",
+ "flow-edit-title-submit": "Muuta otsikkoa",
+ "flow-edit-post-submit": "Lähetä muutokset",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|muokkasi}} aiheen \"$4\" [$3 kommenttia].",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|kommentti|kommenttia}}</strong> {{PLURAL:$1|on}} lisätty.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|loi}} otsikon",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|palautti}} aiheen \"$6\" [$4 kommentin] (<em>$5</em>).",
+ "flow-board-history-empty": "Tällä palstalla ei ole historiaa.",
+ "flow-topic-history": "Aiheen \"$1\" historia",
+ "flow-history-last4": "Viimeiset 4 tuntia",
+ "flow-history-day": "Tänään",
+ "flow-history-week": "Viimeinen viikko",
+ "flow-history-pages-topic": "Näkyy sivulla [$1 \"$2\"]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 kommentti|$1 kommenttia|0={{GENDER:$2|Ole ensimmäinen}} kommentoija!}}",
+ "flow-comment-restored": "Palautettu kommentti",
+ "flow-comment-deleted": "Poistettu kommentti",
+ "flow-comment-hidden": "Piilotettu kommentti",
+ "flow-comment-moderated": "Moderoitu kommentti",
+ "flow-notification-link-text-view-post": "Näytä viesti",
+ "flow-notification-link-text-view-topic": "Näytä aihe",
+ "flow-notification-reply-email-subject": "$1 {{GENDER:$1|vastasi}} aiheeseen",
+ "echo-pref-tooltip-flow-discussion": "Ilmoita minulle, kun minuun liittyvää toimintaa tapahtuu Flowissa.",
+ "flow-link-post": "viesti",
+ "flow-link-topic": "aihe",
+ "flow-link-history": "historia",
+ "flow-moderation-title-suppress-post": "Viestin sensurointi",
+ "flow-moderation-title-delete-post": "Viestin poisto",
+ "flow-moderation-title-hide-post": "Viestin piilotus",
+ "flow-moderation-title-undelete-post": "Palautetaanko viesti?",
+ "flow-moderation-title-unhide-post": "Tuo näkyviin viesti?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|Kerro}} miksi häivytät tämän viestin.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Kerro}} miksi piilotat tämän viestin.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|Kerro}} miksi palautat tämän viestin.",
+ "flow-moderation-confirm-suppress-post": "Häivytä",
+ "flow-moderation-confirm-delete-post": "Poista",
+ "flow-moderation-confirm-hide-post": "Piilota",
+ "flow-moderation-confirm-undelete-post": "Palauta",
+ "flow-moderation-confirm-unhide-post": "Tuo näkyviin",
+ "flow-moderation-confirm-suppress-topic": "Häivytä",
+ "flow-moderation-confirm-delete-topic": "Poista",
+ "flow-moderation-confirm-hide-topic": "Piilota",
+ "flow-moderation-confirm-lock-topic": "Lukitse",
+ "flow-moderation-confirm-undelete-topic": "Palauta",
+ "flow-moderation-confirm-unhide-topic": "Tuo näkyviin",
+ "flow-moderation-confirmation-unhide-post": "Olet onnistuneesti tuonut näkyviin edellä olevan viestin.",
+ "flow-moderation-confirmation-hide-topic": "Tämä aihe on piilotettu.",
+ "flow-moderation-confirmation-unhide-topic": "Olet onnistuneesti tuonut näkyviin tämän aiheen.",
+ "flow-moderation-title-suppress-topic": "Häivytä aihe?",
+ "flow-moderation-title-delete-topic": "Poista aihe?",
+ "flow-moderation-title-hide-topic": "Piilota aihe?",
+ "flow-moderation-title-unhide-topic": "Tuo näkyviin aihe?",
+ "flow-moderation-placeholder-undelete-topic": "{{GENDER:$3|Kerro}} miksi palautat tämän ketjun takaisin.",
+ "flow-topic-permalink-warning": "Tämä aihe aloitettiin sivulla [$2 $1]",
+ "flow-compare-revisions-revision-header": "Versio, jonka tehnyt {{GENDER:$2|$2}} $1",
+ "flow-compare-revisions-header-post": "Tämä sivu näyttää {{GENDER:$3|muutokset}} kahden version välillä viestistä käyttäjältä $3 aiheessa \"[$5 $2]\" sivulla [$4 $1].\nVoit nähdä muut versiot tästä aiheesta sen [$6 historiasivulla].",
+ "flow-terms-of-use-new-topic": "Napsauttamalla \"{{int:flow-newtopic-save}}\", hyväksyt tämän wikin käyttöehdot.",
+ "flow-no-more-fwd": "Ei vanhempia aiheita",
+ "flow-newest-topics": "Uusimmat aiheet",
+ "flow-recent-topics": "Viimeksi aktiiviset aiheet",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Näet}} tällä hetkellä uusimmat aiheet ylimpänä. Napsauta saadaksesi lisää lajitteluvaihtoehtoja.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Näet}} tällä hetkellä viimeksi aktiiviset aiheet ylimpänä. Napsauta saadaksesi lisää lajitteluvaihtoehtoja."
+}
diff --git a/Flow/i18n/fr.json b/Flow/i18n/fr.json
new file mode 100644
index 00000000..a04f6431
--- /dev/null
+++ b/Flow/i18n/fr.json
@@ -0,0 +1,515 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ayack",
+ "Ebe123",
+ "Gomoko",
+ "Jean-Frédéric",
+ "Linedwell",
+ "Ltrlg",
+ "Maxim21",
+ "Rmunn",
+ "Sherbrooke",
+ "VIGNERON",
+ "Verdy p",
+ "Orlodrim",
+ "Scoopfinder",
+ "SnowedEarth",
+ "Orikrin1998",
+ "Wyz",
+ "Crochet.david",
+ "Trizek",
+ "Kvardek du",
+ "Arkanosis",
+ "Dereckson",
+ "Macofe",
+ "0x010C",
+ "Sam",
+ "McDutchie",
+ "Nicolapps",
+ "Hercule",
+ "Weft"
+ ]
+ },
+ "enableflow": "Activer Flow",
+ "flow-desc": "Système de gestion du flux de travail",
+ "flow-talk-taken-over": "Cette page de discussion utilise [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestionnaire de la page de Flow",
+ "log-name-flow": "Journal d’activité de Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|a supprimé}} une [$4 publication] sur « [[$3|$5]] » sur [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|a rétabli}} une [$4 publication] sur « [[$3|$5]] » sur [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|a masqué}} une [$4 publication] sur « [[$3|$5]] » sur [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|a supprimé}} un [$4 message] sur « [[$3|$5]] » sur [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|a supprimé}} le sujet « [[$3|$5]] » sur [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|a rétabli}} le sujet « [[$3|$5]] » sur [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|a masqué}} le sujet « [[$3|$5]] » sur [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|a supprimé}} le sujet « [[$3|$5]] » sur [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "Le sujet [[$1|$2]] de [[$3]] a été importé depuis LiquidThreads vers Flow.",
+ "flow-user-moderated": "Utilisateur modéré",
+ "flow-board-header-browse-topics-link": "Parcourir les discussions",
+ "flow-edit-header-link": "Modifier l’entête",
+ "flow-post-moderated-toggle-hide-show": "Afficher le commentaire {{GENDER:$1|caché}} par $2",
+ "flow-post-moderated-toggle-delete-show": "Afficher le commentaire {{GENDER:$1|supprimé}} par $2",
+ "flow-post-moderated-toggle-suppress-show": "Afficher le commentaire {{GENDER:$1|masqué}} par $2",
+ "flow-post-moderated-toggle-hide-hide": "Cacher le commentaire {{GENDER:$1|caché}} par $2",
+ "flow-post-moderated-toggle-delete-hide": "Cacher le commentaire {{GENDER:$1|supprimé}} par $2",
+ "flow-post-moderated-toggle-suppress-hide": "Cacher le commentaire {{GENDER:$1|masqué}} par $2",
+ "flow-topic-moderated-reason-prefix": "Motif :",
+ "flow-hide-post-content": "Ce commentaire a été {{GENDER:$1|caché}} par $1 ([$2 historique])",
+ "flow-hide-title-content": "Le sujet a été {{GENDER:$1|caché}} par $1",
+ "flow-lock-title-content": "Cette discussion a été {{GENDER:$1|verrouillée}} par $1",
+ "flow-hide-header-content": "{{GENDER:$1|Caché}} par $2",
+ "flow-delete-post-content": "Ce commentaire a été {{GENDER:$1|supprimé}} par $1 ([$2 historique])",
+ "flow-delete-title-content": "Le sujet a été {{GENDER:$1|supprimé}} par $1",
+ "flow-delete-header-content": "{{GENDER:$1|Supprimé}} par $2",
+ "flow-suppress-post-content": "Ce commentaire a été {{GENDER:$1|masqué}} par $1 ([$2 historique])",
+ "flow-suppress-title-content": "Le sujet a été {{GENDER:$1|masqué}} par $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Masqué}} par $2",
+ "flow-suppress-usertext": "<em>Nom d’utilisateur masqué</em>",
+ "flow-post-actions": "Actions",
+ "flow-topic-actions": "Actions",
+ "flow-cancel": "Annuler",
+ "flow-preview": "Prévisualiser",
+ "flow-show-change": "Voir les modifications",
+ "flow-last-modified-by": "{{GENDER:$1|Modifié}} en dernier par $1",
+ "flow-stub-post-content": "« En raison d’une erreur technique, ce message n’a pas pu être récupéré. »",
+ "flow-newtopic-title-placeholder": "Nouveau sujet",
+ "flow-newtopic-content-placeholder": "Rédigez ici votre message sur « $1 »",
+ "flow-newtopic-header": "Ajouter un nouveau sujet",
+ "flow-newtopic-save": "Ajouter une discussion",
+ "flow-newtopic-start-placeholder": "Commencer un nouveau sujet",
+ "flow-newtopic-first-heading": "Démarrer un nouveau sujet sur $1",
+ "flow-summarize-topic-placeholder": "Veuillez résumer le sujet de la discussion",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Commenter}} « $2 »",
+ "flow-reply-topic-title-placeholder": "Répondre à « $1 »",
+ "flow-reply-submit": "{{GENDER:$1|Répondre}}",
+ "flow-reply-link": "{{GENDER:$1|Répondre}}",
+ "flow-thank-link": "{{GENDER:$1|Remercier}}",
+ "flow-lock-link": "{{GENDER:$1|Verrouiller}}",
+ "flow-thank-link-title": "Remercier publiquement l’auteur",
+ "flow-history-action-suppress-post": "masquer",
+ "flow-history-action-delete-post": "supprimer",
+ "flow-history-action-hide-post": "masquer",
+ "flow-history-action-unsuppress-post": "annuler le masquage",
+ "flow-history-action-undelete-post": "rétablir",
+ "flow-history-action-unhide-post": "démasquer",
+ "flow-history-action-restore-post": "rétablir",
+ "flow-history-action-lock-topic": "bloquer",
+ "flow-history-action-unlock-topic": "débloquer",
+ "flow-post-interaction-separator": "&nbsp;•&#32;",
+ "flow-post-edited": "Publication {{GENDER:$1|modifiée}} par $1 $2",
+ "flow-post-action-view": "Lien permanent",
+ "flow-post-action-post-history": "Historique",
+ "flow-post-action-suppress-post": "Masquer",
+ "flow-post-action-delete-post": "Supprimer",
+ "flow-post-action-hide-post": "Cacher",
+ "flow-post-action-edit-post": "Modifier",
+ "flow-post-action-edit-post-submit": "Enregistrer les modifications",
+ "flow-post-action-unsuppress-post": "Annuler la masquage",
+ "flow-post-action-undelete-post": "Rétablir",
+ "flow-post-action-unhide-post": "Afficher",
+ "flow-post-action-restore-post": "Restaurer",
+ "flow-post-action-undo-moderation": "Annuler",
+ "flow-topic-action-view": "Lien permanent",
+ "flow-topic-action-watchlist": "Liste de suivi",
+ "flow-topic-action-edit-title": "Modifier le titre",
+ "flow-topic-action-history": "Historique",
+ "flow-topic-action-hide-topic": "Cacher la discussion",
+ "flow-topic-action-delete-topic": "Supprimer la discussion",
+ "flow-topic-action-lock-topic": "Verrouiller la discussion",
+ "flow-topic-action-unlock-topic": "Déverrouiller la discussion",
+ "flow-topic-action-summarize-topic": "Résumer",
+ "flow-topic-action-resummarize-topic": "Modifier le résumé",
+ "flow-topic-action-suppress-topic": "Masquer la discussion",
+ "flow-topic-action-unhide-topic": "Afficher la discussion",
+ "flow-topic-action-undelete-topic": "Rétablir la discussion",
+ "flow-topic-action-unsuppress-topic": "Annuler le masquage de la discussion",
+ "flow-topic-action-restore-topic": "Restaurer la discussion",
+ "flow-topic-action-undo-moderation": "Annuler",
+ "flow-topic-notification-subscribe-title": "Cette discussion a été ajoutée à {{GENDER:$1|votre}} liste de suivi.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Vous}} recevrez des notifications pour toutes les activités sur cette discussion.",
+ "flow-board-notification-subscribe-title": "Vous vous êtes {{GENDER:$1|abonné|abonnée}} à ce forum de discussion !",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Vous}} recevrez une notification quand un nouveau sujet sera créé sur ce forum.",
+ "flow-error-http": "Une erreur s’est produite en communiquant avec le serveur.",
+ "flow-error-other": "Une erreur inattendue s’est produite.",
+ "flow-error-external": "Une erreur s’est produite.<br />Le message d’erreur reçu était : $1",
+ "flow-error-edit-restricted": "Vous n’êtes pas autorisé{{GENDER:||e}} à modifier cette publication.",
+ "flow-error-topic-is-locked": "Ce sujet est verrouillé pour toute autre activité.",
+ "flow-error-lock-moderated-post": "Vous ne pouvez pas verrouiller un message modéré.",
+ "flow-error-external-multi": "Des erreurs se sont produites.<br />$1",
+ "flow-error-missing-content": "Le message n’a aucun contenu. Un contenu est obligatoire pour enregistrer un message.",
+ "flow-error-missing-summary": "Le résumé n’a pas de contenu. Un contenu est nécessaire pour enregistrer un résumé.",
+ "flow-error-missing-title": "La discussion n’a pas de titre. Un titre est obligatoire pour enregistrer une discussion.",
+ "flow-error-parsoid-failure": "Impossible d'analyser le contenu en raison d'une panne de Parsoid.",
+ "flow-error-missing-replyto": "Aucun paramètre « replyTo » n’a été fourni. Ce paramètre est requis pour l’action « répondre ».",
+ "flow-error-invalid-replyto": "Le paramètre « replyTo » n’était pas valide. Le message spécifié n’a pas pu être trouvé.",
+ "flow-error-delete-failure": "Impossible de supprimer cet élément.",
+ "flow-error-hide-failure": "Impossible de cacher cet élément.",
+ "flow-error-missing-postId": "Aucun paramètre « postId » n’a été fourni. Ce paramètre est obligatoire pour manipuler un message.",
+ "flow-error-invalid-postId": "Le paramètre « postId » n’était pas valide. Le message spécifié ($1) n’a pas pu être trouvé.",
+ "flow-error-restore-failure": "Impossible de restaurer cet élément.",
+ "flow-error-invalid-moderation-state": "Une valeur non valide a été fournie pour un paramètre (« moderationState ») de l’API de Flow.",
+ "flow-error-invalid-moderation-reason": "Veuillez indiquer un motif de la modération",
+ "flow-error-not-allowed": "Droits insuffisants pour exécuter cette action",
+ "flow-error-not-allowed-hide": "Cette discussion a été cachée.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Vous ne pouvez pas répondre car cette discussion a été masquée.",
+ "flow-error-not-allowed-delete": "Cette discussion a été supprimée.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Vous ne pouvez pas répondre car cette discussion a été supprimée.",
+ "flow-error-not-allowed-suppress": "Cette discussion a été supprimée.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Vous ne pouvez pas répondre car cette discussion a été supprimée.",
+ "flow-error-not-allowed-hide-extract": "Cette discussion a été cachée. Le journal de la discussion est affiché ci-dessous pour référence.",
+ "flow-error-not-allowed-delete-extract": "Cette discussion a été supprimée. Le journal de suppression de la discussion est affiché ci-dessous pour référence.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Cette discussion a été supprimée. Le journal de suppression est affiché ci-dessous pour référence.",
+ "flow-error-not-allowed-suppress-extract": "Cette discussion a été supprimée. Le journal de suppression est affiché ci-dessous :",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Vous ne pouvez pas répondre car ce sujet a été supprimé. Le journal de suppression pour ce sujet est fourni ci-dessous à titre de référence.",
+ "flow-error-title-too-long": "Les titres des sujets sont limités à $1 {{PLURAL:$1|octet|octets}}.",
+ "flow-error-no-existing-workflow": "Cette discussion n’existe pas encore.",
+ "flow-error-not-a-post": "Le titre du sujet ne peut pas être enregistré comme un message.",
+ "flow-error-missing-header-content": "L’entête n’a pas de contenu. Un contenu est obligatoire pour enregistrer un entête.",
+ "flow-error-missing-prev-revision-identifier": "L’identifiant de révision précédente est absent.",
+ "flow-error-prev-revision-mismatch": "Un autre utilisateur vient de modifier cette publication il y a quelques secondes. Êtes-vous {{GENDER:$3|sûr|sûre}} de vouloir écraser cette modification récente ?",
+ "flow-error-prev-revision-does-not-exist": "Impossible de trouver la révision précédente.",
+ "flow-error-core-topic-deletion": "Pour supprimer un sujet, utiliser le menu … sur le tableau Flow ou [$1 la page du sujet]. Ne pas visiter directement action=delete pour le sujet.",
+ "flow-error-default": "Une erreur s’est produite.",
+ "flow-error-invalid-input": "Une valeur non valide a été fournie lors du chargement du contenu du flux de discussions.",
+ "flow-error-invalid-title": "Un titre de page non valide a été fourni.",
+ "flow-error-fail-load-history": "Échec au chargement du contenu de l’historique.",
+ "flow-error-missing-revision": "Impossible de trouver une révision pour charger le contenu du flux de discussions.",
+ "flow-error-fail-commit": "Échec à l’enregistrement du contenu du flux de discussions.",
+ "flow-error-insufficient-permission": "Permission insuffisante pour accéder au contenu.",
+ "flow-error-revision-comparison": "Une visualisation des différences ne peut être faite que pour deux révisions appartenant à la même publication.",
+ "flow-error-missing-topic-title": "Impossible de trouver le titre du sujet pour le flux de travail actuel.",
+ "flow-error-missing-metadata": "Les métadonnées requises pour cette révision n'ont pas pu être trouvées.",
+ "flow-error-fail-load-data": "Échec au chargement des données demandées.",
+ "flow-error-invalid-workflow": "Impossible de trouver le flux de travail demandé.",
+ "flow-error-process-data": "Une erreur s’est produite lors du traitement des données dans votre demande.",
+ "flow-error-process-wikitext": "Une erreur s’est produite lors du traitement de la conversion HTML/wikitexte.",
+ "flow-error-no-index": "Impossible de trouver un index pour effectuer la recherche de données.",
+ "flow-error-no-render": "L’action spécifiée n’a pas été reconnue.",
+ "flow-error-no-commit": "L’action spécifiée n’a pas pu être enregistrée.",
+ "flow-error-fetch-after-lock": "Une erreur s’est produite en recherchant de nouvelles données. L’opération de verrouillage/déverrouillage s’est toutefois bien passée. Le message d’erreur est : $1",
+ "flow-error-content-too-long": "Le contenu est trop grand. Après expansion, la taille du contenu est limitée à $1 {{PLURAL:$1|octet|octets}}.",
+ "flow-error-move": "Déplacer l'ensemble de la discussion n’est pas possible pour le moment.",
+ "flow-error-invalid-topic-uuid-title": "Mauvais titre",
+ "flow-error-invalid-topic-uuid": "Le titre de la page demandée n’est pas valide. Les pages de l’espace Sujet sont créées automatiquement par Flow.",
+ "flow-error-unknown-workflow-id-title": "Discussion inconnues",
+ "flow-error-unknown-workflow-id": "La discussion demandée n’existe pas.",
+ "flow-edit-header-placeholder": "Décrire l'ensemble de cette discussion",
+ "flow-edit-header-submit": "Enregistrer l’entête",
+ "flow-edit-header-submit-overwrite": "Écraser l’entête",
+ "flow-summarize-topic-submit": "Enregistrer le résumé",
+ "flow-summarize-topic-submit-overwrite": "Écraser le résumé",
+ "flow-lock-topic-submit": "Verrouiller la discussion",
+ "flow-lock-topic-submit-overwrite": "Écraser le résumé du sujet verrouillé",
+ "flow-unlock-topic-submit": "Déverrouiller la discussion",
+ "flow-unlock-topic-submit-overwrite": "Écraser le résumé du sujet déverrouillé",
+ "flow-edit-title-submit": "Changer le titre",
+ "flow-edit-title-submit-overwrite": "Écraser le titre",
+ "flow-edit-post-submit": "Soumettre les modifications",
+ "flow-edit-post-submit-overwrite": "Écraser les modifications",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|a modifié}} un [$3 commentaire] sur « $4 ».",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|A modifié}} un message",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|a ajouté}} un commentaire] sur « $4 » (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|commentaire|commentaires}}</strong> {{PLURAL:$1|a été ajouté|ont été ajoutés}}.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|a créé}} la discussion « [$3 $4] ».",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|A créé}} un nouveau sujet",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|a changé}} le titre de la discussion « $5 » en « [$3 $4] ».",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|a créé}} l’entête.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|a modifié}} l’entête.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|a créé}} un résumé de la discussion sur $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|a modifié}} le résumé de la discussion sur $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|a caché}} un [$4 commentaire] sur « $6 » (<em>$5</em>)..",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|a supprimé}} un [$4 commentaire] sur « $6 » (<em>$5</em>)..",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|a masqué}} un [$4 commentaire] sur « $6 » (<em>$5</em>)..",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|a restauré}} un [$4 commentaire] sur « $6 » (<em>$5</em>)..",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|a caché}} la [$4 discussion] « $6 » (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|a supprimé}} la [$4 discussion] « $6 » (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|a masqué}} la [$4 discussion] « $6 » (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|a verrouillé}} la [$4 discussion] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|a restauré}} la [$4 discussion] « $6 » (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 sur $2",
+ "flow-board-history": "Historique de « $1 »",
+ "flow-board-history-empty": "Cet ensemble de discussions n’a actuellement aucun historique.",
+ "flow-topic-history": "Historique de la discussion « $1 »",
+ "flow-post-history": "Historique du message « Commentaire par {{GENDER:$2|$2}} »",
+ "flow-history-last4": "Dernières 4 heures",
+ "flow-history-day": "Aujourd’hui",
+ "flow-history-week": "Semaine dernière",
+ "flow-history-pages-topic": "Apparaît sur le [$1 forum « $2 »]",
+ "flow-history-pages-post": "Apparaît sur [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 commentaire|$1 commentaires|0={{GENDER:$2|Soyez le premier|Soyez la première}} à laisser un message !}}",
+ "flow-comment-restored": "Message restauré",
+ "flow-comment-deleted": "Message supprimé",
+ "flow-comment-hidden": "Message caché",
+ "flow-comment-moderated": "Message soumis à modération",
+ "flow-last-modified": "Dernière modification : $1",
+ "flow-workflow": "flux de travail",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|a répondu}} sur '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 et $5 {{PLURAL:$6|autre|autres}} ont {{GENDER:$1|répondu}} sur '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 a {{GENDER:$1|modifié}} votre <span class=\"plainlinks\">[$5 message]</span> sur [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 et $5 {{PLURAL:$6|autre|autres}} {{GENDER:$1|ont modifié}} un <span class=\"plainlinks\">[$4 message]</span> sur « $2 », lié à « $3 ».",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|a créé}} une nouvelle discussion sur '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|nouveau sujet|nouveaux sujets}} sur '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|a modifié}} le titre de <span class=\"plainlinks\">[$2 $3]</span> en « $4 » sur [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$5|vous}} {{GENDER:$1|a mentionné|a mentionnée|ont mentionné}} dans {{GENDER:$1|son|son|leur}} <span class=\"plainlinks\">[$2 message]</span> sur « $3 », lié à « $4 »",
+ "flow-notification-link-text-view-post": "Afficher le message",
+ "flow-notification-link-text-view-topic": "Afficher la discussion",
+ "flow-notification-reply-email-subject": "$2 sur $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|a répondu}} à « $2 » sur « $3 »",
+ "flow-notification-reply-email-batch-bundle-body": "$1 et $4 {{PLURAL:$5|autre|autres}} {{GENDER:$1|ont répondu}} à votre note concernant « $2 » sur « $3 »",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|vous}} {{GENDER:$1|a mentionné|a mentionnée}} sur « $2 »",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|vous}} {{GENDER:$1|a mentionné|a mentionnée}} dans {{GENDER:$1|son}} message sur « $2 », lié à « $3 »",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|a modifié}} un message",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|a modifié}} un message sur « $2 », lié à « $3 »",
+ "flow-notification-edit-email-batch-bundle-body": "$1 et $4 {{PLURAL:$5|autre|autres}} {{GENDER:$1|ont modifié}} un message sur « $2 », lié à « $3 »",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|a renommé}} votre discussion",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|a renommé}} votre discussion « $2 » en « $3 » sur « $4 »",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|a créé}} une nouvelle discussion sur « $2 »",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|a créé}} une nouvelle discussion avec le titre « $2 » sur $3",
+ "echo-category-title-flow-discussion": "Flux de discussion",
+ "echo-pref-tooltip-flow-discussion": "M’informer quand des actions me concernant ont lieu dans le flux de discussion.",
+ "flow-link-post": "message",
+ "flow-link-topic": "discussion",
+ "flow-link-history": "historique",
+ "flow-link-post-revision": "version du message",
+ "flow-link-topic-revision": "version de la discussion",
+ "flow-link-header-revision": "version de l’en-tête",
+ "flow-link-summary-revision": "résumé de la révision",
+ "flow-moderation-title-suppress-post": "Masquer le message ?",
+ "flow-moderation-title-delete-post": "Supprimer le message ?",
+ "flow-moderation-title-hide-post": "Cacher le message ?",
+ "flow-moderation-title-unsuppress-post": "Annuler le masquage du message ?",
+ "flow-moderation-title-undelete-post": "Rétablir le message ?",
+ "flow-moderation-title-unhide-post": "Afficher le message ?",
+ "flow-moderation-placeholder-suppress-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous masquez ce message",
+ "flow-moderation-placeholder-delete-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous supprimez ce message.",
+ "flow-moderation-placeholder-hide-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous cachez ce message",
+ "flow-moderation-placeholder-unsuppress-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous annulez le masquage de ce message.",
+ "flow-moderation-placeholder-undelete-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous annulez la suppression de ce message.",
+ "flow-moderation-placeholder-unhide-post": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous affichez ce message.",
+ "flow-moderation-confirm-suppress-post": "Masquer",
+ "flow-moderation-confirm-delete-post": "Supprimer",
+ "flow-moderation-confirm-hide-post": "Cacher",
+ "flow-moderation-confirm-unsuppress-post": "Annuler le masquage",
+ "flow-moderation-confirm-undelete-post": "Rétablir",
+ "flow-moderation-confirm-unhide-post": "Afficher",
+ "flow-moderation-confirm-suppress-topic": "Masquer",
+ "flow-moderation-confirm-delete-topic": "Supprimer",
+ "flow-moderation-confirm-hide-topic": "Cacher",
+ "flow-moderation-confirm-lock-topic": "Verrouiller",
+ "flow-moderation-confirm-unsuppress-topic": "Annuler le masquage",
+ "flow-moderation-confirm-undelete-topic": "Rétablir",
+ "flow-moderation-confirm-unhide-topic": "Afficher",
+ "flow-moderation-confirm-unlock-topic": "Déverrouiller",
+ "flow-moderation-confirmation-suppress-post": "Ce message a été masqué avec succès.\n{{GENDER:$2|Pensez}} à expliquer à $1 les raisons de ce masquage.",
+ "flow-moderation-confirmation-delete-post": "Ce message a été supprimé avec succès. \n{{GENDER:$2|Pensez}} à expliquer à $1 les raisons de cette suppression.",
+ "flow-moderation-confirmation-hide-post": "Ce message a été caché avec succès. \n{{GENDER:$2|Pensez}} à expliquer à $1 les raisons pour lesquelles il a été caché.",
+ "flow-moderation-confirmation-unsuppress-post": "Vous avez bien annulé le masquage du message ci-dessus.",
+ "flow-moderation-confirmation-undelete-post": "Vous avez bien rétabli le message ci-dessus.",
+ "flow-moderation-confirmation-unhide-post": "Vous avez bien affiché le message ci-dessus.",
+ "flow-moderation-confirmation-suppress-topic": "Cette discussion a été masquée.",
+ "flow-moderation-confirmation-delete-topic": "Cette discussion a été supprimée.",
+ "flow-moderation-confirmation-hide-topic": "Cette discussion a été cachée.",
+ "flow-moderation-confirmation-unsuppress-topic": "Vous avez bien annulé le masquage de cette discussion.",
+ "flow-moderation-confirmation-undelete-topic": "Vous avez bien rétabli cette discussion.",
+ "flow-moderation-confirmation-unhide-topic": "Vous avez bien affiché cette discussion.",
+ "flow-moderation-title-suppress-topic": "Masquer la discussion ?",
+ "flow-moderation-title-delete-topic": "Supprimer la discussion ?",
+ "flow-moderation-title-hide-topic": "Cacher la discussion ?",
+ "flow-moderation-title-unsuppress-topic": "Annuler le masquage de la discussion ?",
+ "flow-moderation-title-undelete-topic": "Rétablir la discussion ?",
+ "flow-moderation-title-unhide-topic": "Afficher la discussion ?",
+ "flow-moderation-placeholder-suppress-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous masquez cette discussion.",
+ "flow-moderation-placeholder-delete-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous supprimez cette discussion.",
+ "flow-moderation-placeholder-hide-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous cachez cette discussion.",
+ "flow-moderation-placeholder-lock-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous verrouillez ce sujet.",
+ "flow-moderation-placeholder-unsuppress-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous avez annulé le masquage de cette discussion.",
+ "flow-moderation-placeholder-undelete-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous avez récupéré cette discussion.",
+ "flow-moderation-placeholder-unhide-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous avez affiché cette discussion.",
+ "flow-moderation-placeholder-unlock-topic": "Veuillez {{GENDER:$3|expliquer}} pourquoi vous déverrouillez ce sujet.",
+ "flow-topic-permalink-warning": "Cette discussion a été démarrée sur [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Cette discussion a été démarrée sur la page de discussion de [$2 {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Voici un lien permanent vers une version unique de ce message.\nCette version date de $1.\nVous pouvez voir les [$5 différences depuis la version précédente], ou afficher d’autres versions sur la [$4 page d’historique du message].",
+ "flow-revision-permalink-warning-post-first": "Voici un lien permanent vers la première version de ce message.\nVous pouvez afficher des versions ultérieures depuis la [$4 page d’historique du message].",
+ "flow-revision-permalink-warning-postsummary": "Voici un lien permanent vers une version unique du résumé de ce message. Cette version date de $1.\nVous pouvez voir les [$5 différences avec la version précédente], ou afficher d’autres versions sur la [$4 page d’historique du message].",
+ "flow-revision-permalink-warning-postsummary-first": "Voici un lien permanent vers la première version du résumé du message.\nVous pouvez afficher des versions ultérieures sur la [$4 page d’historique du message].",
+ "flow-revision-permalink-warning-header": "Voici un lien permanent vers une version unique de l’entête.\nCette version date de $1. Vous pouvez voir les [$3 différences avec la version précédente], ou afficher les autres versions sur la [$2 page du tableau historique].",
+ "flow-revision-permalink-warning-header-first": "Voici un lien permanent vers la première version de l’entête.\nVous pouvez afficher les versions ultérieures sur la [$2 page du tableau historique].",
+ "flow-compare-revisions-revision-header": "Version par {{GENDER:$2|$2}} datée du $1",
+ "flow-compare-revisions-header-post": "Cette page affiche les {{GENDER:$3|modifications}} entre deux versions d’un message par $3 dans la discussion « [$5 $2] » sur [$4 $1].\nVous pouvez voir d’autres versions de ce message sur sa [$6 page d’historique].",
+ "flow-compare-revisions-header-postsummary": "Cette page affiche les modifications entre deux versions d’un résumé de message dans le message « [$4 $2] », lié à [$3 $1].\nVous pouvez voir d’autres versions de ce message sur sa [$5 page d’historique].",
+ "flow-compare-revisions-header-header": "Cette page affiche les {{GENDER:$2|modifications}} entre deux versions de l’entête sur [$3 $1].\nVous pouvez voir les autres versions de l’entête sur sa [$4 page d’historique].",
+ "action-flow-create-board": "créer des tableaux Flow n’importe où",
+ "right-flow-create-board": "Créer des tableaux Flow n’importe où",
+ "right-flow-hide": "Cacher les discussions et messages de Flow",
+ "right-flow-lock": "Verrouiller des discussions de Flow",
+ "right-flow-delete": "Supprimer les discussions et messages de Flow",
+ "right-flow-edit-post": "Modifier les messages de Flow par d’autres utilisateurs",
+ "right-flow-suppress": "Masquer les versions de Flow",
+ "flow-terms-of-use-new-topic": "En cliquant sur « {{int:flow-newtopic-save}} », vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-terms-of-use-reply": "En cliquant sur « {{int:flow-reply-submit}} », vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-terms-of-use-edit": "En enregistrant vos modifications, vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-anon-warning": "Vous n’êtes pas connecté. Pour que vos modifications soient associées à votre nom d'utilisateur plutôt qu'à votre adresse IP, vous pouvez [$1 vous connecter] ou [$2 créer un compte].",
+ "flow-cancel-warning": "Vous avez saisi du texte dans ce formulaire. Confirmez-vous l'abandon de la saisie ?",
+ "flow-topic-first-heading": "Discussion sur $1",
+ "flow-topic-html-title": "$1 sur $2",
+ "flow-topic-count": "Discussions ($1)",
+ "flow-load-more": "Charger davantage",
+ "flow-no-more-fwd": "Il n’y a pas de discussions plus anciennes",
+ "flow-add-topic": "Ajouter une discussion",
+ "flow-newest-topics": "Discussions les plus récentes",
+ "flow-recent-topics": "Discussions actives récemment",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Vous}} lisez les discussions les plus récentes d’abord. Cliquez pour plus d’options de tri.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Vous}} lisez actuellement les sujets les plus récemment actifs en premier. Cliquez pour avoir plus d’options de tri.",
+ "flow-toggle-small-topics": "Basculer vers l'affichage des titres uniquement",
+ "flow-toggle-topics": "Basculer vers l'affichage des discussions sans leur détail",
+ "flow-toggle-topics-posts": "Basculer vers l'affichage des discussions et de leur détail",
+ "flow-terms-of-use-summarize": "En cliquant sur « {{int:flow-summarize-topic-submit}} », vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-terms-of-use-lock-topic": "En cliquant sur « {{int:flow-lock-topic-submit}} », vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-terms-of-use-unlock-topic": "En cliquant sur « {{int:flow-unlock-topic-submit}} », vous acceptez les conditions d’utilisation de ce wiki.",
+ "flow-whatlinkshere-post": "à partir d'un [$1 message]",
+ "flow-whatlinkshere-header": "à partir de l'[$1 en-tête]",
+ "flow": "Flow",
+ "flow-special-desc": "Cette page spéciale redirige vers un flux de travail Flow ou une note Flow d’après un UUID.",
+ "flow-special-type": "Type",
+ "flow-special-type-post": "Message",
+ "flow-special-type-workflow": "Flux de travail",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Impossible de trouver un contenu correspondant au type et à l’UUID.",
+ "flow-special-enableflow-legend": "Activer Flow sur une nouvelle page",
+ "flow-special-enableflow-page": "Page sur laquelle activer Flow",
+ "flow-special-enableflow-header": "Entête initial du tableau Flow (wikitexte)",
+ "flow-special-enableflow-board-already-exists": "Il y a déjà un tableau Flow sur [[$1]].",
+ "flow-special-enableflow-page-already-exists": "Il y a déjà une page avec un autre système que Flow sur [[$1]]. Si vous voulez toujours y mettre un tableau Flow, veuillez déplacer la page existante vers une archive, supprimer la redirection, puis utiliser de nouveau Special:EnableFlow. Incluez le nom de l’archive dans l’entête.",
+ "flow-special-enableflow-confirmation": "Vous avez bien créé un tableau Flow sur [[$1]].",
+ "flow-spam-confirmedit-form": "Veuillez confirmer que vous êtes humain en résolvant le captcha ci-dessous : $1",
+ "flow-preview-warning": "Vous visualisez un aperçu. Cliquez sur « {{int:flow-newtopic-save}} » pour le publier, ou cliquez sur « {{int:flow-preview-return-edit-post}} » pour continuer à écrire.",
+ "flow-preview-return-edit-post": "Continuer la modification",
+ "flow-anonymous": "Anonyme",
+ "flow-embedding-unsupported": "Les discussions ne peuvent pas encore être incorporées.",
+ "mw-ui-unsubmitted-confirm": "Vous avez des modifications non enregistrées sur cette page. Êtes-vous sûr de vouloir la quitter et perdre ainsi votre travail ?",
+ "flow-post-undo-hide": "annuler masquage",
+ "flow-post-undo-delete": "annuler suppression",
+ "flow-post-undo-suppress": "annuler la suppression",
+ "flow-topic-undo-hide": "annuler le masquage",
+ "flow-topic-undo-delete": "annuler la suppression",
+ "flow-topic-undo-suppress": "annuler la suppression",
+ "flow-importer-lqt-moved-thread-template": "Ébauche de fil LQT déplacée et convertie pour Flow",
+ "flow-importer-lqt-converted-template": "Page LQT convertie en Flow",
+ "flow-importer-lqt-converted-archive-template": "Archive pour la page LQT convertie",
+ "flow-importer-wt-converted-template": "Page de discussion wikitexte convertie en Flow",
+ "flow-importer-wt-converted-archive-template": "Archive pour la page de discussion wikitexte convertie",
+ "flow-importer-lqt-suppressed-user-template": "Cette révision a été importée de LiquidThreads avec un utilisateur supprimé. Elle a été réassignée à l’utilisateur courant.",
+ "apihelp-flow-description": "Permet d’effectuer des actions sur les pages Flow.",
+ "apihelp-flow-param-submodule": "Le sous-module Flow à invoquer.",
+ "apihelp-flow-param-page": "La page sur laquelle effectuer l’action.",
+ "apihelp-flow-param-render": "Mettre cela à quelque chose pour inclure un rendu spécifique au bloc dans la sortie.",
+ "apihelp-flow-example-1": "Modifier l’entête de « [[Talk:Sandbox]] »",
+ "apihelp-flow+close-open-topic-description": "Rendu obsolète par [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "État dans lequel mettre le sujet, verrouillé ou déverrouillé.",
+ "apihelp-flow+close-open-topic-param-reason": "Motif pour verrouiller ou déverrouiller ce sujet.",
+ "apihelp-flow+edit-header-description": "Modifie l’entête d’un tableau.",
+ "apihelp-flow+edit-header-param-prev_revision": "ID de la révision actuelle de l’entête, pour vérifier les conflits de modification.",
+ "apihelp-flow+edit-header-param-content": "Contenu de l'en-tête.",
+ "apihelp-flow+edit-header-param-format": "Format de l’entête (wikitexte|html)",
+ "apihelp-flow+edit-header-example-1": "Modifier l'en-tête de [[Talk:Sandbox]]",
+ "apihelp-flow+edit-header-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+edit-post-description": "Modifier le contenu d’un message.",
+ "apihelp-flow+edit-post-param-postId": "ID du message.",
+ "apihelp-flow+edit-post-param-prev_revision": "ID de la révision actuelle du message, pour vérifier les conflits de modification.",
+ "apihelp-flow+edit-post-param-content": "Contenu pour le message.",
+ "apihelp-flow+edit-post-param-format": "Format du contenu du message (wikitexte|html)",
+ "apihelp-flow+edit-post-example-1": "Modifier un message dans [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-post-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+edit-title-description": "Modifie un titre de sujet.",
+ "apihelp-flow+edit-title-param-prev_revision": "ID de la révision actuelle du titre, pour vérifier les conflits de modification.",
+ "apihelp-flow+edit-title-param-content": "Contenu pour le titre.",
+ "apihelp-flow+edit-title-example-1": "Modifier le titre de [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+edit-topic-summary-description": "Modifie le contenu du résumé d’un sujet.",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "ID de la révision du résumé de sujet courant, s’il existe, pour vérifier les conflits de modification.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Contenu du résumé.",
+ "apihelp-flow+edit-topic-summary-param-format": "Format du résumé (wikitexte|html)",
+ "apihelp-flow+edit-topic-summary-example-1": "Modifier le résumé de [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+lock-topic-description": "Verrouiller ou déverrouiller un sujet Flow.",
+ "apihelp-flow+lock-topic-param-moderationState": "État dans lequel mettre le sujet, soit verrouillé, soit déverrouillé.",
+ "apihelp-flow+lock-topic-param-reason": "Motif pour verrouiller ou déverrouiller le sujet.",
+ "apihelp-flow+lock-topic-example-1": "Verrouiller [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+moderate-post-description": "Modère un message Flow",
+ "apihelp-flow+moderate-post-param-moderationState": "À quel niveau modérer.",
+ "apihelp-flow+moderate-post-param-reason": "Motif de modération.",
+ "apihelp-flow+moderate-post-param-postId": "ID du message à modérer.",
+ "apihelp-flow+moderate-post-example-1": "Supprimer un message du sujet [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+moderate-topic-description": "Modère un sujet Flow",
+ "apihelp-flow+moderate-topic-param-moderationState": "À quel niveau modérer.",
+ "apihelp-flow+moderate-topic-param-reason": "Raison de la modération.",
+ "apihelp-flow+moderate-topic-example-1": "Supprimer le sujet [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+new-topic-description": "Crée un nouveau sujet Flow sur un flux de travail.",
+ "apihelp-flow+new-topic-param-topic": "Texte du titre du nouveau sujet.",
+ "apihelp-flow+new-topic-param-content": "Contenu pour la réponse initiale du sujet.",
+ "apihelp-flow+new-topic-param-format": "Format de la nouvelle réponse initiale au sujet (wikitexte|html)",
+ "apihelp-flow+new-topic-example-1": "Créer un nouveau sujet sur [[Talk:Sandbox]]",
+ "apihelp-flow+new-topic-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+reply-description": "Répond à un message.",
+ "apihelp-flow+reply-param-replyTo": "ID du message auquel répondre.",
+ "apihelp-flow+reply-param-content": "Contenu pour le nouveau message.",
+ "apihelp-flow+reply-param-format": "Format du nouveau message (wikitexte|html)",
+ "apihelp-flow+reply-example-1": "Répondre à un message sur [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+reply-param-metadataonly": "S’il faut inclure uniquement les métadonnées sur le nouveau contenu, à l’exclusion de tout le reste",
+ "apihelp-flow+view-header-description": "Afficher un entête de tableau.",
+ "apihelp-flow+view-header-param-contentFormat": "Format dans lequel renvoyer le contenu.",
+ "apihelp-flow+view-header-param-revId": "Charger cette révision, au lieu de la plus récente.",
+ "apihelp-flow+view-header-example-1": "Récupérer l’entête de [[Talk:Sandbox]] en wikitexte",
+ "apihelp-flow+view-post-description": "Afficher un message",
+ "apihelp-flow+view-post-param-postId": "ID du message à voir.",
+ "apihelp-flow+view-post-param-contentFormat": "Format dans lequel renvoyer le contenu.",
+ "apihelp-flow+view-post-example-1": "Récupérer le contenu du message sur [[Topic:S2tycnas4hcucw8w]] en wikitexte",
+ "apihelp-flow+view-topic-description": "Afficher un sujet.",
+ "apihelp-flow+view-topic-example-1": "Afficher [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "Afficher un résumé du sujet.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "Format dans lequel renvoyer le contenu.",
+ "apihelp-flow+view-topic-summary-param-revId": "Charger cette révision, plutôt que la plus récente.",
+ "apihelp-flow+view-topic-summary-example-1": "Afficher le résumé pour [[Topic:S2tycnas4hcucw8w]] en wikitexte",
+ "apihelp-flow+view-topiclist-description": "Afficher une liste de sujets.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Direction de tri des sujets.",
+ "apihelp-flow+view-topiclist-param-sortby": "Option de tri des sujets.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Sauvegarder l'option de tri, si elle est définie.",
+ "apihelp-flow+view-topiclist-param-offset-id": "Valeur de décalage (au format UUID) auquel démarrer la récupération des sujets.",
+ "apihelp-flow+view-topiclist-param-offset": "Valeur du décalage auquel démarrer la récupération des sujets.",
+ "apihelp-flow+view-topiclist-param-limit": "Nombre de sujets à récupérer.",
+ "apihelp-flow+view-topiclist-param-render": "Rendre les sujets en HTML.",
+ "apihelp-flow+view-topiclist-example-1": "Lister les sujets sur [[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "Convertir le texte entre wikitexte et HTML.",
+ "apihelp-flow-parsoid-utils-param-from": "Format du contenu à convertir.",
+ "apihelp-flow-parsoid-utils-param-to": "Format dans lequel convertir le contenu.",
+ "apihelp-flow-parsoid-utils-param-content": "Contenu à convertir.",
+ "apihelp-flow-parsoid-utils-param-title": "Titre de la page. Impossible à utiliser avec $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "ID de la page. Impossible à utiliser avec $1title.",
+ "apihelp-flow-parsoid-utils-example-1": "Convertir le wikicode <nowiki>'''lorem''' ''blah''</nowiki> en HTML",
+ "apihelp-query+flowinfo-description": "Obtenir les informations Flow de base sur une page.",
+ "apihelp-query+flowinfo-example-1": "Récupérer les informations Flow sur [[Talk:Sandbox]], [[Accueil]], et [[Talk:Flow]]",
+ "apihelp-flow+undo-edit-header-description": "Récupérer les informations nécessaires pour annuler les modifications d’entête.",
+ "apihelp-flow+undo-edit-header-param-startId": "Id de révision auquel démarrer l’annulation.",
+ "apihelp-flow+undo-edit-header-param-endId": "Id de révision auquel arrêter l’annulation.",
+ "apihelp-flow+undo-edit-header-example-1": "Récupérer l’information sur l’annulation d’une modification d’entête sur [[Talk:Sandbox]]",
+ "apihelp-flow+undo-edit-post-description": "Récupérer l’information nécessaire pour annuler la modification du message.",
+ "apihelp-flow+undo-edit-post-param-postId": "Id du message à annuler.",
+ "apihelp-flow+undo-edit-post-param-startId": "Id de révision auquel commencer l’annulation.",
+ "apihelp-flow+undo-edit-post-param-endId": "Id de révision auquel arrêter l’annulation.",
+ "apihelp-flow+undo-edit-post-example-1": "Récupérer l’information sur l’annulation d’une modification de message dans un sujet spécifique.",
+ "apihelp-flow+undo-edit-topic-summary-description": "Récupérer l’information nécessaire pour annuler les modifications de résumé de sujet.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "Id de révision auquel commencer l’annulation.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "Id de révision auquel arrêter l’annulation.",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "Récupérer les informations sur l’annulation de la modification d’un résumé de sujet dans un sujet spécifique",
+ "flow-edited": "Modifié",
+ "flow-edited-by": "Modifié par $1",
+ "flow-lqt-redirect-reason": "Redirection de message LiquidThreads retiré vers son message converti en Flow",
+ "flow-talk-conversion-move-reason": "Conversion de la discussion en wikicode de $1 vers Flow",
+ "flow-talk-conversion-archive-edit-reason": "Conversion d'une discussion en wikicode vers Flow",
+ "group-flow-bot": "Bots pour Flow",
+ "grouppage-flow-bot": "Projet:Bots pour Flow"
+}
diff --git a/Flow/i18n/fy.json b/Flow/i18n/fy.json
new file mode 100644
index 00000000..585aeb86
--- /dev/null
+++ b/Flow/i18n/fy.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kening Aldgilles",
+ "Robin0van0der0vliet"
+ ]
+ },
+ "flow-cancel": "Annulearje",
+ "flow-post-action-post-history": "Skiednis",
+ "flow-post-action-edit-post": "Bewurkje",
+ "flow-topic-action-history": "Skiednis",
+ "flow-board-history": "\"$1\" skiednis",
+ "flow-history-day": "Hjoed",
+ "flow-link-history": "skiednis",
+ "flow-load-more": "Mear laden"
+}
diff --git a/Flow/i18n/gl.json b/Flow/i18n/gl.json
new file mode 100644
index 00000000..b9beb3fc
--- /dev/null
+++ b/Flow/i18n/gl.json
@@ -0,0 +1,124 @@
+{
+ "@metadata": {
+ "authors": [
+ "Toliño",
+ "Elisardojm"
+ ]
+ },
+ "flow-desc": "Sistema de xestión do fluxo de traballo",
+ "flow-edit-header-link": "Editar a cabeceira",
+ "flow-post-actions": "Accións",
+ "flow-topic-actions": "Accións",
+ "flow-cancel": "Cancelar",
+ "flow-preview": "Vista previa",
+ "flow-show-change": "Mostrar os cambios",
+ "flow-newtopic-title-placeholder": "Novo fío",
+ "flow-newtopic-content-placeholder": "Publicar unha mensaxe nova en \"$1\"",
+ "flow-newtopic-header": "Engadir un novo fío",
+ "flow-newtopic-save": "Nova sección",
+ "flow-newtopic-start-placeholder": "Iniciar un novo fío",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentario}} en \"$2\"",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Agradecer}}",
+ "flow-post-edited": "Mensaxe {{GENDER:$1|editada}} por $1 $2",
+ "flow-post-action-view": "Ligazón permanente",
+ "flow-post-action-post-history": "Historial",
+ "flow-post-action-suppress-post": "Suprimir",
+ "flow-post-action-delete-post": "Borrar",
+ "flow-post-action-hide-post": "Agochar",
+ "flow-post-action-edit-post": "Editar",
+ "flow-post-action-edit-post-submit": "Gardar os cambios",
+ "flow-post-action-undelete-post": "Restaurar",
+ "flow-post-action-unhide-post": "Descubrir",
+ "flow-post-action-restore-post": "Restaurar",
+ "flow-topic-action-view": "Ligazón permanente",
+ "flow-topic-action-watchlist": "Lista de vixilancia",
+ "flow-topic-action-edit-title": "Editar o título",
+ "flow-topic-action-history": "Historial",
+ "flow-topic-action-summarize-topic": "Resumir",
+ "flow-topic-action-resummarize-topic": "Modificar o resumo do tema",
+ "flow-topic-action-undo-moderation": "Desfacer",
+ "flow-error-http": "Produciuse un erro ao contactar co servidor.",
+ "flow-error-other": "Produciuse un erro inesperado.",
+ "flow-error-external": "Produciuse un erro.<br />A mensaxe de erro recibida foi: $1",
+ "flow-error-edit-restricted": "Non lle está permitido editar esta mensaxe.",
+ "flow-error-external-multi": "Producíronse varios erros.<br />$1",
+ "flow-error-missing-content": "A mensaxe non ten contido. O contido é obrigatorio para gardar unha mensaxe.",
+ "flow-error-missing-title": "O fío non ten título. O título é obrigatorio para gardar un fío.",
+ "flow-error-parsoid-failure": "Non é posible analizar o contido debido a un fallo do Parsoid.",
+ "flow-error-missing-replyto": "Non se achegou ningún parámetro de resposta. Este parámetro é obrigatorio para a acción \"responder\".",
+ "flow-error-invalid-replyto": "O parámetro de resposta non é válido. Non se puido atopar a mensaxe especificada.",
+ "flow-error-delete-failure": "Houbo un erro ao borrar este elemento.",
+ "flow-error-hide-failure": "Houbo un erro ao agochar este elemento.",
+ "flow-error-missing-postId": "Non se achegou ningún parámetro de identificación. Este parámetro é obrigatorio para a manipular unha mensaxe.",
+ "flow-error-invalid-postId": "O parámetro de identificación non é válido. Non se puido atopar a mensaxe especificada ($1).",
+ "flow-error-restore-failure": "Houbo un erro ao restaurar este elemento.",
+ "flow-edit-header-submit": "Gardar a cabeceira",
+ "flow-summarize-topic-submit": "Resumir",
+ "flow-edit-title-submit": "Cambiar o título",
+ "flow-edit-post-submit": "Enviar os cambios",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|editou}} un [$3 comentario] en \"$4\"",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|comentou}}] en \"$4\" (<em>$5</em>)",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|creou}} o fío \"[$3 $4]\"",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|cambiou}} o título do fío de \"$5\" a \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|creou}} a cabeceira",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|editou}} a cabeceira",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|agochou}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|borrou}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suprimiu}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restaurou}} un [$4 comentario] en \"$6\" (<em>$5</em>)",
+ "flow-board-history": "Historial de \"$1\"",
+ "flow-topic-history": "Historial do fío \"$1\"",
+ "flow-history-last4": "Últimas 4 horas",
+ "flow-history-day": "Hoxe",
+ "flow-history-week": "Última semana",
+ "flow-topic-comments": "{{PLURAL:$1|$1 comentario|$1 comentarios|0=Sexa {{GENDER:$2|o primeiro|a primeira}} en comentar!}}",
+ "flow-comment-restored": "Comentario restaurado",
+ "flow-comment-deleted": "Comentario borrado",
+ "flow-comment-hidden": "Comentario agochado",
+ "flow-comment-moderated": "Comentario moderado",
+ "flow-last-modified": "Última modificación $1",
+ "flow-workflow": "fluxo de traballo",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 \"$2\"]</span><br />$1 {{GENDER:$1|respondeu}} en \"'''$4'''\".",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 \"$2\"]</span><br />$1 e {{PLURAL:$6|outra persoa|outras $5 persoas}} {{GENDER:$1|responderon}} en \"'''$3'''\".",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|editou}} a súa <span class=\"plainlinks\">[$5 mensaxe]</span> en [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 e {{PLURAL:$6|outra persoa|outras $5 persoas}} {{GENDER:$1|responderon}} á <span class=\"plainlinks\">[$4 mensaxe]</span> de \"$2\" en \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 \"$4\"]</span><br />$1 {{GENDER:$1|creou}} un novo tema en \"'''$3'''\".",
+ "flow-notification-rename": "$1 {{GENDER:$1|cambiou}} o título de \"<span class=\"plainlinks\">[$2 $3]</span>\" a \"$4\" en \"[[$5|$6]]\".",
+ "flow-notification-mention": "$1 {{GENDER:$5|fíxolle}} {{GENDER:$1|unha mención}} na {{GENDER:$1|súa}} <span class=\"plainlinks\">[$2 mensaxe]</span> de \"$3\" en \"$4\".",
+ "flow-notification-link-text-view-post": "Ver a mensaxe",
+ "flow-notification-link-text-view-topic": "Ver o fío",
+ "flow-notification-reply-email-subject": "\"$2\" en \"$3\"",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondeu}} a \"$2\" en \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 e {{PLURAL:$5|outra persoa|outras $4 persoas}} {{GENDER:$1|responderon}} a \"$2\" en \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|fíxolle}} {{GENDER:$1|unha mención}} en \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|fíxolle}} {{GENDER:$1|unha mención}} na {{GENDER:$1|súa}} mensaxe de \"$2\" en \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|editou}} unha mensaxe",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|editou}} unha mensaxe de \"$2\" en \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 e {{PLURAL:$5|outra persoa|outras $4 persoas}} {{GENDER:$1|editaron}} unha mensaxe de \"$2\" en \"$3\".",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|renomeou}} o seu fío",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|renomeou}} o seu fío \"$2\" a \"$3\" en \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|creou}} un novo fío en \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|creou}} un novo fío co título \"$2\" en \"$3\"",
+ "echo-category-title-flow-discussion": "Fluxo",
+ "echo-pref-tooltip-flow-discussion": "Notificádeme cando sucedan accións relacionadas comigo no fluxo.",
+ "flow-link-post": "mensaxe",
+ "flow-link-topic": "fío",
+ "flow-link-history": "historial",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Borrar",
+ "flow-moderation-confirm-hide-post": "Agochar",
+ "flow-moderation-confirm-undelete-post": "Restaurar",
+ "flow-moderation-confirm-unhide-post": "Descubrir",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Borrar",
+ "flow-moderation-confirm-hide-topic": "Agochar",
+ "flow-moderation-confirm-undelete-topic": "Restaurar",
+ "flow-moderation-confirm-unhide-topic": "Descubrir",
+ "flow-terms-of-use-new-topic": "Ao premer no botón \"{{int:flow-newtopic-save}}\", acepta os termos de uso deste wiki.",
+ "flow-terms-of-use-reply": "Ao premer no botón \"{{int:flow-reply-submit}}\", acepta os termos de uso deste wiki.",
+ "flow-terms-of-use-edit": "Ao gardar os seus cambios, acepta os termos de uso deste wiki.",
+ "flow-load-more": "Cargar máis",
+ "flow-special-type": "Tipo"
+}
diff --git a/Flow/i18n/gu.json b/Flow/i18n/gu.json
new file mode 100644
index 00000000..40bf7a3e
--- /dev/null
+++ b/Flow/i18n/gu.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "KartikMistry"
+ ]
+ },
+ "flow-preview": "પૂર્વદર્શન",
+ "flow-newtopic-first-heading": "$1 પર નવો વિષય શરુ કરો",
+ "flow-topic-notification-subscribe-title": "તમે આ વિષયને સબસ્ક્રાઇબ કર્યો છે.",
+ "flow-topic-notification-subscribe-description": "લોકો જ્યારે આ વિષયનો જવાબ આપશે ત્યારે તમને સંદેશો આવશે.",
+ "flow-notification-link-text-view-topic": "વિષય જુઓ"
+}
diff --git a/Flow/i18n/he.json b/Flow/i18n/he.json
new file mode 100644
index 00000000..90111e88
--- /dev/null
+++ b/Flow/i18n/he.json
@@ -0,0 +1,516 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Guycn2",
+ "Lokal Profil",
+ "Orsa",
+ "Danny-w",
+ "YaronSh"
+ ]
+ },
+ "enableflow": "הפעלת זרימה",
+ "flow-desc": "מערכת לניהול זרימת עבודה",
+ "flow-talk-taken-over": "דף השיחה משתמש ב[https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal זרימה].",
+ "flow-talk-username": "מנהל דפי השיחה של זרימה",
+ "log-name-flow": "יומן פעילות זרימה",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|מחק|מחקה}} [$4 רשומה] בנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|שחזר|שחזרה}} [$4 רשומה] בנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|העלים|העלימה}} [$4 רשומה] בנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|מחק|מחקה}} [$4 רשומה] בנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|מחק|מחקה}} את הנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|שחזר|שחזרה}} את הנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|העלים|העלימה}} את הנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|מחק|מחקה}} את הנושא \"[[$3|$5]]\" בלוח [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] בלוח [[$3]] יובא מ־LiquidThreads לזרימה",
+ "flow-user-moderated": "משתמש מפוקח",
+ "flow-board-header-browse-topics-link": "עיון בנושאים",
+ "flow-edit-header-link": "לערוך את התיאור",
+ "flow-post-moderated-toggle-hide-show": "הצגת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|הסתיר|הסתירה}}",
+ "flow-post-moderated-toggle-delete-show": "הצגת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|מחק|מחקה}}",
+ "flow-post-moderated-toggle-suppress-show": "הצגת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|העלים|העלימה}}",
+ "flow-post-moderated-toggle-hide-hide": "הסתרת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|הסתיר|הסתירה}}",
+ "flow-post-moderated-toggle-delete-hide": "הסתרת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|מחק|מחקה}}",
+ "flow-post-moderated-toggle-suppress-hide": "הסתרת התגובה ש{{GRAMMAR:תחילית|$2}} {{GENDER:$1|העלים|העלימה}}",
+ "flow-topic-moderated-reason-prefix": "סיבה:",
+ "flow-hide-post-content": "$1 {{GENDER:$1|הסתיר|הסתירה}} את התגובה הזאת ([$2 היסטוריה])",
+ "flow-hide-title-content": "$1 {{GENDER:$1|הסתיר|הסתירה}} את הנושא הזה",
+ "flow-lock-title-content": "הנושא {{GENDER:$1|ננעל}} על־ידי $1",
+ "flow-hide-header-content": "$2 {{GENDER:$1|הסתיר|הסתירה}} את זה",
+ "flow-delete-post-content": "$1 {{GENDER:$1|מחק|מחקה}} את התגובה הזאת ([$2 היסטוריה])",
+ "flow-delete-title-content": "$1 {{GENDER:$1|מחק|מחקה}} את הנושא הזה",
+ "flow-delete-header-content": "$2 {{GENDER:$1|מחק|מחקה}} את זה",
+ "flow-suppress-post-content": "$1 {{GENDER:$1|העלים|העלימה}} את התגובה הזאת ([$2 היסטוריה])",
+ "flow-suppress-title-content": "$1 {{GENDER:$1|העלים|העלימה}} את הנושא הזה",
+ "flow-suppress-header-content": "$2 {{GENDER:$1|העלים|העלימה}} את זה",
+ "flow-suppress-usertext": "<strong>השם הועלם</strong>",
+ "flow-post-actions": "פעולות",
+ "flow-topic-actions": "פעולות",
+ "flow-cancel": "ביטול",
+ "flow-preview": "תצוגה מקדימה",
+ "flow-show-change": "הצגת שינויים",
+ "flow-last-modified-by": "שוּנה לאחרונה על־ידי $1",
+ "flow-stub-post-content": "'''בשל בעיה טכנית, לא ניתן לאחזר את הרשומה הזאת.'''",
+ "flow-newtopic-title-placeholder": "כותרת חדשה",
+ "flow-newtopic-content-placeholder": "שליחת הודעה חדשה בדף \"$1\"",
+ "flow-newtopic-header": "הוספת נושא חדש",
+ "flow-newtopic-save": "הוספת נושא",
+ "flow-newtopic-start-placeholder": "התחלת נושא חדש",
+ "flow-newtopic-first-heading": "התחלת נושא חדש בדף $1",
+ "flow-summarize-topic-placeholder": "נא לסכם את הדיון",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|הגב|הגיבי|להגיב}} על \"$2\"",
+ "flow-reply-topic-title-placeholder": "תשובה ל\"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|להשיב}}",
+ "flow-reply-link": "{{GENDER:$1|השב|השיבי|להשיב}}",
+ "flow-thank-link": "{{GENDER:$1|תודה}}",
+ "flow-lock-link": "{{GENDER:$1|נעל|נעלה}}",
+ "flow-thank-link-title": "תודה ציבורית לשולח",
+ "flow-history-action-suppress-post": "העלמה",
+ "flow-history-action-delete-post": "מחיקה",
+ "flow-history-action-hide-post": "הסתרה",
+ "flow-history-action-unsuppress-post": "ביטול העלמה",
+ "flow-history-action-undelete-post": "ביטול מחיקה",
+ "flow-history-action-unhide-post": "ביטול הסתרה",
+ "flow-history-action-restore-post": "שחזור",
+ "flow-history-action-lock-topic": "נעילה",
+ "flow-history-action-unlock-topic": "ביטול נעילה",
+ "flow-post-edited": "$1 {{GENDER:$1|ערך|ערכה}} את הרשומה $2",
+ "flow-post-action-view": "קישור קבוע",
+ "flow-post-action-post-history": "היסטוריה",
+ "flow-post-action-suppress-post": "להעלים",
+ "flow-post-action-delete-post": "למחוק",
+ "flow-post-action-hide-post": "להסתיר",
+ "flow-post-action-edit-post": "עריכה",
+ "flow-post-action-edit-post-submit": "שמירת שינויים",
+ "flow-post-action-unsuppress-post": "ביטול העלמה",
+ "flow-post-action-undelete-post": "ביטול מחיקה",
+ "flow-post-action-unhide-post": "ביטול הסתרה",
+ "flow-post-action-restore-post": "שחזור",
+ "flow-post-action-undo-moderation": "ביטול",
+ "flow-topic-action-view": "קישור קבוע",
+ "flow-topic-action-watchlist": "רשימת מעקב",
+ "flow-topic-action-edit-title": "לערוך את הכותרת",
+ "flow-topic-action-history": "היסטוריה",
+ "flow-topic-action-hide-topic": "להסתיר נושא",
+ "flow-topic-action-delete-topic": "למחוק נושא",
+ "flow-topic-action-lock-topic": "נעילת נושא",
+ "flow-topic-action-unlock-topic": "ביטול נעילת נושא",
+ "flow-topic-action-summarize-topic": "לסכם",
+ "flow-topic-action-resummarize-topic": "עריכת סיכום הנושא",
+ "flow-topic-action-suppress-topic": "להעלים נושא",
+ "flow-topic-action-unhide-topic": "ביטול הסתרת נושא",
+ "flow-topic-action-undelete-topic": "ביטול מחיקת נושא",
+ "flow-topic-action-unsuppress-topic": "ביטול העלמת נושא",
+ "flow-topic-action-restore-topic": "לשחזר נושא",
+ "flow-topic-action-undo-moderation": "ביטול",
+ "flow-topic-notification-subscribe-title": "הנושא הזה נוסף לרשימת המעקב {{GENDER:$1|שלך}}.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|תקבל|תקבלי}} הודעות על כל פעילות בנושא הזה.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|עשית}} מינוי ללוח הדיונים הזה!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|תקבל|תקבלי}} הודעה כאשר נושא חדש נוצר בלוח הזה.",
+ "flow-error-http": "אירעה שגיאה בעת יצירת קשר עם השרת.",
+ "flow-error-other": "אירעה שגיאה בלתי־צפויה.",
+ "flow-error-external": "אירעה שגיאה.<br />התקבלה הודעת השגיאה הבאה: $1",
+ "flow-error-edit-restricted": "אין לך הרשאה לערוך את הרשומה הזאת.",
+ "flow-error-topic-is-locked": "הנושא הזה ננעל לכל פעילות נוספת.",
+ "flow-error-lock-moderated-post": "לא ניתן לנעול רשומה מפוקחת.",
+ "flow-error-external-multi": "אירעו שגיאות.<br />\n$1",
+ "flow-error-missing-content": "ברשומה אין תוכן. דרוש תוכן כדי לשמור רשומה",
+ "flow-error-missing-summary": "בתקציר אין תוכן. יש לכתוב תוכן כדי לשמור תקציר.",
+ "flow-error-missing-title": "לנושא אין כותרת. דרושה כותרת כדי לשמור נושא.",
+ "flow-error-parsoid-failure": "לא ניתן לפענח את התוכן עקב כשל בפרסואיד.",
+ "flow-error-missing-replyto": "לא נשלח פרמטר \"replyTo\". הפרמטר הזה דרוש לפעולת \"reply\".",
+ "flow-error-invalid-replyto": "פרמטר \"replyTo\" שנשלח היה בלתי־תקין. לא נמצאה הרשומה שצוינה.",
+ "flow-error-delete-failure": "מחיקת הפריט הזה נכשלה.",
+ "flow-error-hide-failure": "הסתרת הפריט הזה נכשלה.",
+ "flow-error-missing-postId": "לא ניתן פרמטר \"postId\". הפרמטר הזה דרוש כדי לשנות רשומה.",
+ "flow-error-invalid-postId": "פרמטר \"postId\" שנשלח היה בלתי־תקין. הרשומה שצוינה ($1) לא נמצאה.",
+ "flow-error-restore-failure": "שחזור הפריט הזה נכשל.",
+ "flow-error-invalid-moderation-state": "ערך בלתי־תקין לפרמטר ('moderationState') נשלח ל־API של Flow.",
+ "flow-error-invalid-moderation-reason": "נא לתת סיבה להחלת הפיקוח",
+ "flow-error-not-allowed": "אין הרשאות מספיקות לביצוע הפעולה הזאת.",
+ "flow-error-not-allowed-hide": "הנושא הזה הוסתר.",
+ "flow-error-not-allowed-reply-to-hide-topic": "אינך {{GENDER:|יכול|יכולה}} לענות כי הנושא הזה הוסתר.",
+ "flow-error-not-allowed-delete": "הנושא הזה נמחק.",
+ "flow-error-not-allowed-reply-to-delete-topic": "אינך {{GENDER:|יכול|יכולה}} לענות כי הנושא הזה הועלם.",
+ "flow-error-not-allowed-suppress": "הנושא הזה נמחק.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "אינך {{GENDER:|יכול|יכולה}} לענות כי הנושא הזה נמחק.",
+ "flow-error-not-allowed-hide-extract": "הנושא הזה הוסתר. יומן ההסתרה עבור הנושא הזה מסופק להלן לצורך בדיקה.",
+ "flow-error-not-allowed-delete-extract": "הנושא הזה נמחק. יומן המחיקה עבור הנושא הזה מסופק להלן לצורך בדיקה.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "אינך {{GENDER:|יכול|יכולה}} לענות כי הנושא הזה נמחק. יומן המחיקה של הנושא מוצג להלן לעיון.",
+ "flow-error-not-allowed-suppress-extract": "הנושא הזה נמחק. יומן המחיקה עבור הנושא הזה מסופק להלן לעיון.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "אינך {{GENDER:|יכול|יכולה}} להשיב כי הנושא הזה הועלם. יומן ההעלמה מוצג להלן לעיון.",
+ "flow-error-title-too-long": "כותרות של נושאים מוגבלות {{PLURAL:$1|לבית אחד|ל־$1 בתים}}",
+ "flow-error-no-existing-workflow": "הזרימה הזאת עוד לא קיימת.",
+ "flow-error-not-a-post": "לא ניתן לשמור כותרת נושא בתור רשומה.",
+ "flow-error-missing-header-content": "בתיאור אין תוכן. התוכן נחוץ לשם שמירת תיאור.",
+ "flow-error-missing-prev-revision-identifier": "חסר מזהה גרסה קודמת.",
+ "flow-error-prev-revision-mismatch": "משתמש אחר ערך את הרשומה הזאת לפני שניות אחדות. האם {{GENDER:$3|אתה|את}} רוצה לדרוס את את השינוי האחרון?",
+ "flow-error-prev-revision-does-not-exist": "לא נמצאה גרסה קודמת.",
+ "flow-error-core-topic-deletion": "כדי למחוק נושא, יש להשתמש בכפתור ... בלוח הזרימה או ב[$1 דף הנושא]. נא לא להפנות את הדפדפן ישירות ל־action=delete בשביל מחיקת נושא.",
+ "flow-error-default": "אירעה שגיאה.",
+ "flow-error-invalid-input": "ערך בלתי־תקין ניתן ניתן לטעינת תוכן זרימה.",
+ "flow-error-invalid-title": "ניתנה כותרת דף בלתי־תקינה.",
+ "flow-error-fail-load-history": "טעינת תוכן ההיסטוריה נכשלה.",
+ "flow-error-missing-revision": "לא נמצאה גרסה שממנה ייטען תוכן הזרימה.",
+ "flow-error-fail-commit": "שמירת תוכן הזרימה נכשלה.",
+ "flow-error-insufficient-permission": "אין הרשאות מספיקות בכדי לגשת לתוכן.",
+ "flow-error-revision-comparison": "פעולת השוואה יכולה להיעשות רק בין שתי גרסאות של אותה רשומה.",
+ "flow-error-missing-topic-title": "לא נמצאה כותרת נושא עבור הזרימה הנוכחית.",
+ "flow-error-missing-metadata": "לא נמצאו מטא־נתונים עבור הגרסה הזאת.",
+ "flow-error-fail-load-data": "טעינת הנתונים המובקשים נכשלה.",
+ "flow-error-invalid-workflow": "הזרימה המובקשת לא נמצאה.",
+ "flow-error-process-data": "אירעה שגיאה בעת עיבוד הנתונים בבקשה שלך.",
+ "flow-error-process-wikitext": "אירעה שגיאה בעת עיבוד המרה בין HTML לקוד ויקי.",
+ "flow-error-no-index": "מציאת מפתח לביצוע חיפוש נתונים נכשלה.",
+ "flow-error-no-render": "הפעולה שצוינה אינה מוכרת.",
+ "flow-error-no-commit": "לא היה אפשר לשמור את הפעולה שצוינה.",
+ "flow-error-fetch-after-lock": "אירעה שגיאה בעת בקשת נתונים חדשים. עם זאת, פעולת הנעילה או ביטולה הצליחה. הודעת השגיאה הייתה: $1",
+ "flow-error-content-too-long": "התוכן גדול מדי. תוכן אחרי פענוח מוגבל {{PLURAL:$1|לבית אחד|ל־$1 בתים}}.",
+ "flow-error-move": "אין כרגע תמיכה בהעברת לוח דיון.",
+ "flow-error-invalid-topic-uuid-title": "כותרת שגויה",
+ "flow-error-invalid-topic-uuid": "כותרת הדף שביקשת אינה חוקית. דפים במרחב השם \"נושא\" נוצרים באופן אוטומטי.",
+ "flow-error-unknown-workflow-id-title": "נושא לא יודע",
+ "flow-error-unknown-workflow-id": "הנושא המבוקש לא נמצא.",
+ "flow-edit-header-placeholder": "נא לתאר את לוח הדיונים הזה",
+ "flow-edit-header-submit": "שמירת התיאור",
+ "flow-edit-header-submit-overwrite": "דריסת התיאור",
+ "flow-summarize-topic-submit": "לסכם",
+ "flow-summarize-topic-submit-overwrite": "דריסת תקציר",
+ "flow-lock-topic-submit": "נעילת נושא",
+ "flow-lock-topic-submit-overwrite": "דריסת תקציר נעילת נושא",
+ "flow-unlock-topic-submit": "שחרור נושא",
+ "flow-unlock-topic-submit-overwrite": "דריסת תקציר ביטול נעילת נושא",
+ "flow-edit-title-submit": "שינוי כותרת",
+ "flow-edit-title-submit-overwrite": "דריסת הכותרת",
+ "flow-edit-post-submit": "שליחת שינויים",
+ "flow-edit-post-submit-overwrite": "דריסת השינויים",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|ערך|ערכה}} [$3 תגובה] לנושא \"$4\".",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|ערך|ערכה}} רשומה",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|הוסיף|הוסיפה}} תגובה] לנושא \"$4\" (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "{{PLURAL:$1|נוספה <strong>תגובה אחת</strong>|נוספו <strong>$1 תגובות</strong>}}",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|יצר|יצרה}} את הנושא \"[$3 $4]\".",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|יצר|יצרה}} נושא חדש",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|שינה|שינתה}} את כותרת הנושא מ\"$5\" ל\"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|יצר|יצרה}} את התיאור.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|ערך|ערכה}} את התיאור",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|יצר|יצרה}} סיכום של הנושא $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|ערך|ערכה}} את התקציר של הנושא $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|הסתיר|הסתירה}} [$4 תגובה] בנושא \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|מחק|מחקה}} [$4 תגובה] בנושא \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|העלים|העלימה}} [$4 תגובה] בנושא \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|שחזר|שחזרה}} [$4 תגובה] בנושא \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|הסתיר|הסתירה}} את [$4 הנושא] \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|מחק|מחקה}} את [$4 הנושא] \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|העלים|העלימה}} את [$4 הנושא] \"$6‏\" (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|נעל|נעלה}} את [$4 הנושא] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|שחזר|שחזרה}} את [$4 הנושא] \"$6‏\" (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 בלוח $2",
+ "flow-board-history": "ההיסטוריה של \"$1\"",
+ "flow-board-history-empty": "ללוח הזה אין היסטוריה עכשיו.",
+ "flow-topic-history": "היסטוריית הנושא \"$1\"",
+ "flow-post-history": "היסטוריית הרשומה של \"תגובה מאת $2\"",
+ "flow-history-last4": "4 השעות האחרונות",
+ "flow-history-day": "היום",
+ "flow-history-week": "בשבוע שעבר",
+ "flow-history-pages-topic": "מופיע ב[$1 לוח \"$2\"]",
+ "flow-history-pages-post": "מופיע ב[$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|תגובה אחת|$1 תגובות|0={{GENDER:$2|כתוב|כתבי}} את התגובה הראשונה!}}",
+ "flow-comment-restored": "תגובה משוחזרת",
+ "flow-comment-deleted": "תגובה מחוקה",
+ "flow-comment-hidden": "תגובה מוסתרת",
+ "flow-comment-moderated": "תגובה מפוקחת",
+ "flow-last-modified": "שוּנה לאחרונה $1",
+ "flow-workflow": "זרימת עבודה",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|השיב|השיבה}} בדף '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 ועוד {{PLURAL:$6|אדם אחד|$5 אנשים אחרים}} {{GENDER:$1|השיבו}} בדף '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|ערך|ערכה}} <span class=\"plainlinks\">[$5 רשומה]</span> בנושא \"$2\" בדף [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 {{PLURAL:$6|ועוד אדם אחד|ועוד $5 אנשים}} ערכו <span class=\"plainlinks\">[$4 רשומה]</span> שלך בנושא \"$2\" בדף \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|יצר|יצרה}} נושא חדש בדף '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|נושא חדש אחד|$1 נושאים חדשים|250=יותר מ־250 נושאים חדשים}} בדף '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|שינה|שינתה}} את הכותרת של <span class=\"plainlinks\">[$2 $3]</span> אל \"$4\" בדף [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|הזכיר|הזכירה}} {{GENDER:$5|אותך}} ב<span class=\"plainlinks\">[$2 רשומה]</span> {{GENDER:$1|שלו|שלה}} בנושא \"$3\" בדף \"$4\".",
+ "flow-notification-link-text-view-post": "הצגת הרשומה",
+ "flow-notification-link-text-view-topic": "הצגת הנושא",
+ "flow-notification-reply-email-subject": "$2 ב־$3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|השיב|השיבה}} לנושא \"$2\" בדף \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 {{PLURAL:$5|ועוד אדם אחד|ועוד $4 אנשים}} השיבו לרשומה שלך בנושא \"$2\" בדף \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|הזכיר|הזכירה}} {{GENDER:$3|אותך}} ברשומה \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|הזכיר|הזכירה}} {{GENDER:$4|אותך}} ברשומה {{GENDER:$1|שלו|שלה}} בנושא \"$2\" בדף \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|ערך|ערכה}} רשומה",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|ערך|ערכה}} רשומה בנושא \"$2\" בדף \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 {{PLURAL:$5|ועוד אדם אחד|ועוד $4 אנשים}} ערכו רשומה בנושא \"$2\" בדף \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|שינה|שינתה}} את השם של נושא שלך",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|שינה|שינתה}} את השם של הנושא שלך \"$2\" אל \"$3\" בדף \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|יצר|יצרה}} נושא חדש בדף \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|יצר|יצרה}} נושא חדש עם הכותרת \"$2\" ב{{GRAMMAR:תחלילית|$3}}",
+ "echo-category-title-flow-discussion": "זרימה",
+ "echo-pref-tooltip-flow-discussion": "להודיע לי כשיש פעולות שקשורות אליי בזרימה.",
+ "flow-link-post": "רשומה",
+ "flow-link-topic": "נושא",
+ "flow-link-history": "היסטוריה",
+ "flow-link-post-revision": "גרסה רשומה",
+ "flow-link-topic-revision": "גרסה של נושא",
+ "flow-link-header-revision": "גרסה של תיאור",
+ "flow-link-summary-revision": "גרסת סיכום",
+ "flow-moderation-title-suppress-post": "להעלים את הרשומה?",
+ "flow-moderation-title-delete-post": "למחוק את הרשומה?",
+ "flow-moderation-title-hide-post": "להסתיר את הרשומה?",
+ "flow-moderation-title-unsuppress-post": "לבטל את ההעלמה של הרשומה?",
+ "flow-moderation-title-undelete-post": "לבטל את המחיקה של הרשומה?",
+ "flow-moderation-title-unhide-post": "לבטל את ההסתרה של הרשומה?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מעלים|את מעלימה}} את הרשומה הזאת.",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מוחק|את מוחקת}} את הרשומה הזאת.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מסתיר|את מסתירה}} את הרשומה הזאת.",
+ "flow-moderation-placeholder-unsuppress-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה מדועה {{GENDER:$3|אתה מבטל|את מבטלת}} את ההעלמה של הרשומה הזאת.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה מדועה {{GENDER:$3|אתה מבטל|את מבטלת}} את המחיקה של הרשומה הזאת.",
+ "flow-moderation-placeholder-unhide-post": "{{GENDER:$3|הסבר|הסבירי}} בבקשה מדועה {{GENDER:$3|אתה מבטל|את מבטלת}} את ההסתרה של הרשומה הזאת.",
+ "flow-moderation-confirm-suppress-post": "להעלים",
+ "flow-moderation-confirm-delete-post": "למחוק",
+ "flow-moderation-confirm-hide-post": "להסתיר",
+ "flow-moderation-confirm-unsuppress-post": "ביטול העלמה",
+ "flow-moderation-confirm-undelete-post": "ביטול מחיקה",
+ "flow-moderation-confirm-unhide-post": "ביטול הסתרה",
+ "flow-moderation-confirm-suppress-topic": "להעלים",
+ "flow-moderation-confirm-delete-topic": "למחוק",
+ "flow-moderation-confirm-hide-topic": "להסתיר",
+ "flow-moderation-confirm-lock-topic": "נעילה",
+ "flow-moderation-confirm-unsuppress-topic": "ביטול העלמה",
+ "flow-moderation-confirm-undelete-topic": "ביטול מחיקה",
+ "flow-moderation-confirm-unhide-topic": "ביטול הסתרה",
+ "flow-moderation-confirm-unlock-topic": "ביטול נעילה",
+ "flow-moderation-confirmation-suppress-post": "הרשומה הזאת הועלמה בהצלחה.\nאנא {{GENDER:$2|שקול|שקלי}} לתת ל{{GRAMMAR:תחילית|$1}} משוב על הרשומה הזאת.",
+ "flow-moderation-confirmation-delete-post": "הרשומה נמחקה בהצלחה.\nאנא {{GENDER:$2|שקול|שקלי}} לתת ל{{GRAMMAR:תחילית|$1}} משוב על הרשומה הזאת.",
+ "flow-moderation-confirmation-hide-post": "הרשמה הועלמה בהצלחה.\nאנא {{GENDER:$2|שקול|שקלי}} לתת ל{{GRAMMAR:תחילית|$1}} משוב על הרשומה הזאת.",
+ "flow-moderation-confirmation-unsuppress-post": "ביטלת בהצלחה את ההעלמה של הרשומה הזאת.",
+ "flow-moderation-confirmation-undelete-post": "ביטלת בהצלחה את המחיקה של הרשומה הזאת.",
+ "flow-moderation-confirmation-unhide-post": "ביטלת בהצלחה את ההסתרה של הרשומה הזאת.",
+ "flow-moderation-confirmation-suppress-topic": "הנושא הזה הועלם.",
+ "flow-moderation-confirmation-delete-topic": "הנושא הזה נמחק.",
+ "flow-moderation-confirmation-hide-topic": "הנושא הזה הוסתר.",
+ "flow-moderation-confirmation-unsuppress-topic": "ביטלת בהצלחה את ההעלמה של הנושא הזה.",
+ "flow-moderation-confirmation-undelete-topic": "ביטלת בהצלחה את המחיקה של הנושא הזה.",
+ "flow-moderation-confirmation-unhide-topic": "ביטלת בהצלחה את ההסתרה של הנושא הזה.",
+ "flow-moderation-title-suppress-topic": "להעלים את הנושא?",
+ "flow-moderation-title-delete-topic": "למחוק את הנושא?",
+ "flow-moderation-title-hide-topic": "להסתיר את הנושא?",
+ "flow-moderation-title-unsuppress-topic": "לבטל את ההעלמה של הנושא?",
+ "flow-moderation-title-undelete-topic": "לבטל את המחיקה של הנושא?",
+ "flow-moderation-title-unhide-topic": "לבטל את ההסתרה של הנושא?",
+ "flow-moderation-placeholder-suppress-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מעלים|את מעלימה}} את הנושא הזה.",
+ "flow-moderation-placeholder-delete-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מוחק|את מוחקת}} את הרשומה הזאת.",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מסתיר|את מסתירה}} את הנושא הזה.",
+ "flow-moderation-placeholder-lock-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה נועל|את נועלת}} את הנושא הזה.",
+ "flow-moderation-placeholder-unsuppress-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מבטל|את מבטלת}} את ההעלמה של הנושא הזה.",
+ "flow-moderation-placeholder-undelete-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מבטל|את מבטלת}} את המחיקה של הנושא הזה.",
+ "flow-moderation-placeholder-unhide-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מבטל|את מבטלת}} את ההסתרה של הנושא הזה.",
+ "flow-moderation-placeholder-unlock-topic": "{{GENDER:$3|הסבר|הסבירי}} בבקשה למה {{GENDER:$3|אתה מבטל|את מבטלת}} את הנעילה של הנושא הזה.",
+ "flow-topic-permalink-warning": "הנושא הזה התחיל בדף [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "הנושא הזה התחיל ב[$2 לוח של $1]",
+ "flow-revision-permalink-warning-post": "זהו קישור קבוע לגרסה פרטנית של הרשומה הזאת.\nזוהי גרסה מ־$1.\nבאפשרותך לראות את [$5 השינויים מהגרסה הקודמת] או להציג גרסאות אחרות ב[$4 דף ההיסטוריה של הרשומה].",
+ "flow-revision-permalink-warning-post-first": "זהו קישור קבוע לגרסה הראשונה של הרשומה.\nאפשר להציג גרסאות מאוחרות יותר ב[$4 דף ההיסטוריה של הרשומה].",
+ "flow-revision-permalink-warning-postsummary": "זהו קישור קבוע לגרסה בודדת של סיכום הרשומה הזאת. זאת גרסה מ־$1. אפשר לראות [$5 השוואה לגרסה הקודמת], או להציג גרסאות אחרות ב[$4 דף ההיסטוריה של הרשומה].",
+ "flow-revision-permalink-warning-postsummary-first": "זהו קישור קבוע לגרסה הראשונה של סיכום הרשומה הזאת.\nאפשר להציג גרסאות מאוחרות יותר ב[$4 דף ההיסטוריה של הרשומה].",
+ "flow-revision-permalink-warning-header": "זהו קישור קבוע לגרסה בודדה של התיאור.\nהגרסה מתאריך $1. באפשרותך לראות את [$3 ההבדלים מהגרסה הקודמת] או גרסאות אחרות מ[$2 דף ההיסטוריה של הלוח].",
+ "flow-revision-permalink-warning-header-first": "זהו קישור קבוע לגרסה הראשונה של התיאור.\nבאפשרותך לראות גרסאות חדשות יותר ב[$2 דף ההיסטוריה של הלוח].",
+ "flow-compare-revisions-revision-header": "גרסה מאת $2 מ{{GRAMMAR:תחילית|$1}}",
+ "flow-compare-revisions-header-post": "הדף הזה מציג את ההבדלים בין שתי גרסאות של רשומה מאת $3 בנושא \"[$5 $2]\" בלוח [$4 $1].\n\nבאפשרותך לראות גרסאות אחרות של הרשומה הזאת ב[$6 דף ההיסטוריה] שלו.",
+ "flow-compare-revisions-header-postsummary": "הדף הזה מראה את ההבדלים בין שתי גרסאות של סיכום רשומה ברשומה \"[$4 $2]\" בלוח [$3 $1].\nבאפשרותך לראות גרסאות אחרות של הרשומה ב[$5 דף ההיסטוריה שלה].",
+ "flow-compare-revisions-header-header": "הדף הזה מציג את {{GENDER:$2|ההבדלים}} בין שתי גרסאות של התיאור של [$3 $1]. אפשר לראות גרסאות אחרות של התיאור ב[$4 דף ההיסטוריה].",
+ "action-flow-create-board": "ליצור לוחות זרימה בכל מיקום שהוא",
+ "right-flow-create-board": "יצירת לוחות זרימה בכל מקום",
+ "right-flow-hide": "הסתרת נושאים ברשומות בזרימה",
+ "right-flow-lock": "נעילת נושאים בזרימה",
+ "right-flow-delete": "מחיקת נושאים בזרימה",
+ "right-flow-edit-post": "עריכה רשומות זרימה של משתמשים אחרים",
+ "right-flow-suppress": "העלמת גרסאות בזרימה",
+ "flow-terms-of-use-new-topic": "לחיצה על \"{{int:flow-newtopic-save}}\" מהווה את הסכמתך לתנאי השימוש של הוויקי הזה.",
+ "flow-terms-of-use-reply": "לחיצה על \"{{int:flow-reply-submit}}\" מהווה את הסכמתך לתנאי השימוש של הוויקי הזה.",
+ "flow-terms-of-use-edit": "שמירת השינויים מהווה את הסכמתך לתנאי השימוש של הוויקי הזה.",
+ "flow-anon-warning": "לא נכנסת לחשבון. כדי לקבל ייחוס עם שמך ולא עם כתובת ה־IP שלך, אפשר [$1 להיכנס] או [$2 ליצור חשבון].",
+ "flow-cancel-warning": "הזנת טקסט בטופס הזה. האם למחוק אותו?",
+ "flow-topic-first-heading": "נושא בדף $1",
+ "flow-topic-html-title": "$1 בדף $2",
+ "flow-topic-count": "נושאים ($1)",
+ "flow-load-more": "לטעון עוד",
+ "flow-no-more-fwd": "אין נושאים ישנים יותר",
+ "flow-add-topic": "הוספת נושא",
+ "flow-newest-topics": "הנושאים החדשים ביותר",
+ "flow-recent-topics": "נושאים פעילים לאחרונה",
+ "flow-sorting-tooltip-newest": "{{GENDER:|אתה קורא|את קוראת}} עכשיו את הנושאים החדשים ביותר תחילה. נא ללחוץ כאן לאפשרויות מיון נוספות.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|אתה קורא|את קוראת}} עכשיו תחילה את הנושאים שהיו פעילים לאחרונה. {{GENDER:|לחץ|לחצי}} לאפשרויות מיון אחרות.",
+ "flow-toggle-small-topics": "מעבר לתצוגת נושאים קטנה",
+ "flow-toggle-topics": "מעבר לתצוגת נושאים בלבד",
+ "flow-toggle-topics-posts": "מעבר לתצוגת נושאים ורשומות",
+ "flow-terms-of-use-summarize": "לחיצה על \"{{int:flow-summarize-topic-submit}}\" מהווה את הסכמתך לתנאי השימוש של הוויקי הזה.",
+ "flow-terms-of-use-lock-topic": "לחיצה על \"{{int:flow-lock-topic-submit}}\" מהווה את הסכמתך לתנאי השימוש של אתר הוויקי הזה.",
+ "flow-terms-of-use-unlock-topic": "לחיצה על \"{{int:flow-unlock-topic-submit}}\" מהווה את הסכמתך לתנאי השימוש של אתר הוויקי הזה.",
+ "flow-whatlinkshere-post": "מתוך [$1 רשומה]",
+ "flow-whatlinkshere-header": "מתוך [$1 התיאור]",
+ "flow": "זרימה",
+ "flow-special-desc": "הדף המיוחד הזה מפנה לזרימת עבודה של זרימה או לרשומה של זרימה דרך UUID.",
+ "flow-special-type": "סוג",
+ "flow-special-type-post": "רשומה",
+ "flow-special-type-workflow": "זרימת עבודה",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "לא נמצא תוכן שמתאים לסוג ול־UUID.",
+ "flow-special-enableflow-legend": "הפעלת זרימה בדף חדש",
+ "flow-special-enableflow-page": "באילו דפים להפעיל את זרימה",
+ "flow-special-enableflow-header": "תיאור התחלתי בלוח זרימה (קוד ויקי)",
+ "flow-special-enableflow-board-already-exists": "כבר יש לוח זרימה בדף [[$1]].",
+ "flow-special-enableflow-invalid-title": "הדף שנתין הוא לא כותרת דף תקינה",
+ "flow-special-enableflow-page-already-exists": "כבר יש דף ללא זרימה בכותרת [[$1]]. אם ברצונך בכל זאת למקם שם דף זרימה, נא להעביר את הדף הקיים לארכיון, למחוק את ההפניה ואז להשתמש שוב בדף Special:EnableFlow. יש לכלול את שם הארכיון בתיאור",
+ "flow-special-enableflow-confirmation": "מיקמת בהצלחה לוח זרימה בדף [[$1]].",
+ "flow-spam-confirmedit-form": "נא לאשר שאתה אנושי באמצעות פתרון התמונה להלן: $1",
+ "flow-preview-warning": "זוהי תצוגה מקדימה. יש ללחוץ על \"{{int:flow-newtopic-save}}\" כדי לשלוח, או על \"{{int:flow-preview-return-edit-post}}\" כדי להמשיך לכתוב.",
+ "flow-preview-return-edit-post": "להמשיך לערוך",
+ "flow-anonymous": "אלמוני",
+ "flow-embedding-unsupported": "בינתיים אין אפשרות להטמיע דיונים.",
+ "mw-ui-unsubmitted-confirm": "עשית בדף הזה שינויים ולא שלחת אותם. האם ברצונך לצאת מכאן ולעבד את עבודתך?",
+ "flow-post-undo-hide": "ביטול הסתרה",
+ "flow-post-undo-delete": "ביטול מחיקה",
+ "flow-post-undo-suppress": "ביטול העלמה",
+ "flow-topic-undo-hide": "ביטול הסתרה",
+ "flow-topic-undo-delete": "ביטול מחיקה",
+ "flow-topic-undo-suppress": "ביטול העלמה",
+ "flow-importer-lqt-moved-thread-template": "שרשור LQT שהומר לזרימה",
+ "flow-importer-lqt-converted-template": "דף LQT מומר לזרימה",
+ "flow-importer-lqt-converted-archive-template": "ארכיון לדף LQT מאורכב",
+ "flow-importer-wt-converted-template": "דף קוד ויקי מומר לזרימה",
+ "flow-importer-wt-converted-archive-template": "ארכיון לדף קוד ויקי מומר",
+ "flow-importer-lqt-suppressed-user-template": "הגרסה הזאת יובאה מ־LiquidThreads עם משתמש מועלם. היא שויכה מחדש למשתמש הנוכחי.",
+ "apihelp-flow-description": "מאפשר ביצוע פעולות על דפים זרימה.",
+ "apihelp-flow-param-submodule": "איזה תת-מודול של זרימה להפעיל.",
+ "apihelp-flow-param-page": "באיזה דף ברצונך לפעול.",
+ "apihelp-flow-param-render": "כאן צריך להגדיר משהו כדי לכלול עיצוב ייחודי לבלוק בפלט.",
+ "apihelp-flow-example-1": "עריכת התיאור של \"[[Talk:Sandbox]]\"",
+ "apihelp-flow+close-open-topic-description": "הוכרז המיושן לטובת [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "באיזה מצב לשים את הנושא, נעול (locked) או לא נעול (unlocked).",
+ "apihelp-flow+close-open-topic-param-reason": "סיבה לנעילה או פתיחת נעילה של נושא.",
+ "apihelp-flow+edit-header-description": "עריכת תיאור לוח.",
+ "apihelp-flow+edit-header-param-prev_revision": "מזהה הגרסה של גרסת התיאור הנוכחית, כדי לבדוק אם יש התנגשויות עריכה.",
+ "apihelp-flow+edit-header-param-content": "תוכן לכותרת",
+ "apihelp-flow+edit-header-param-format": "תסדיר התיאור (wikitext|html)",
+ "apihelp-flow+edit-header-example-1": "עריכת התיאור של [[Talk:Sandbox]]",
+ "apihelp-flow+edit-header-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, לא כולל שאר הדברים",
+ "apihelp-flow+edit-post-description": "עריכת תוכן של רשומה.",
+ "apihelp-flow+edit-post-param-postId": "מזהה רשומה.",
+ "apihelp-flow+edit-post-param-prev_revision": "מזהה הגרסה של גרסת הרשומה הנוכחית, כדי לבדוק אם יש התנגשויות עריכה.",
+ "apihelp-flow+edit-post-param-content": "תוכן לרשומה.",
+ "apihelp-flow+edit-post-param-format": "תסדיר תוכן הרשומה (wikitext|html)",
+ "apihelp-flow+edit-post-example-1": "עריכת רשומה ב־[[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-post-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+edit-title-description": "עריכת כותרת של נושא",
+ "apihelp-flow+edit-title-param-prev_revision": "מזהה גרסה של גרסת הכותרת הנוכחית, כדי לבדוק התנגשויות עריכה.",
+ "apihelp-flow+edit-title-param-content": "תוכן לכותרת.",
+ "apihelp-flow+edit-title-example-1": "עריכת הכותרת של [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+edit-topic-summary-description": "עריכת תוכן סיכום נושא",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "מזהה גרסה של הגרסה הנוכחית של סיכום הנושא, אם יש כזה, כדי לבדוק התנגשויות עריכה.",
+ "apihelp-flow+edit-topic-summary-param-summary": "תוכן הסיכום.",
+ "apihelp-flow+edit-topic-summary-param-format": "תסדיר הסיכום (wikitext|html)",
+ "apihelp-flow+edit-topic-summary-example-1": "עריכת הסיכום של [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+lock-topic-description": "נעילה או פתיחת נעילה של נושא בזרימה.",
+ "apihelp-flow+lock-topic-param-moderationState": "באיזה מצב לשים את הנושא, נעול (locked) או לא נעול (unlocked).",
+ "apihelp-flow+lock-topic-param-reason": "סיבה לנעילה או פתיחת נעילה של נושא.",
+ "apihelp-flow+lock-topic-example-1": "נעילת [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+moderate-post-description": "פיקוח על רשומת זרימה.",
+ "apihelp-flow+moderate-post-param-moderationState": "באיזו רמה לפקח.",
+ "apihelp-flow+moderate-post-param-reason": "סיבה לפיקוח.",
+ "apihelp-flow+moderate-post-param-postId": "מזהה הרשומה שצריך לפקח עליה.",
+ "apihelp-flow+moderate-post-example-1": "מחיקת רשומה בנושה [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+moderate-topic-description": "פיקוח על נושא בזרימה.",
+ "apihelp-flow+moderate-topic-param-moderationState": "באיזו רמה לפקח.",
+ "apihelp-flow+moderate-topic-param-reason": "סיבה לפיקוח.",
+ "apihelp-flow+moderate-topic-example-1": "מחיקת הנושה [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+new-topic-description": "יצירת נושא זרימה חדש בזרם עבודה נתון.",
+ "apihelp-flow+new-topic-param-topic": "טקסט לכותרת הנושא החדש.",
+ "apihelp-flow+new-topic-param-content": "תוכן לתגובה ההתחלתית בנושא.",
+ "apihelp-flow+new-topic-param-format": "תסדיר לתגובה ההתחלתית בנושא החדש (wikitext|html)",
+ "apihelp-flow+new-topic-example-1": "יצירת נושא חדש ב־[[Talk:Sandbox]]",
+ "apihelp-flow+new-topic-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+reply-description": "תגובות לרשומה.",
+ "apihelp-flow+reply-param-replyTo": "המזהה של הרשומה שצריך להשיב עליה.",
+ "apihelp-flow+reply-param-content": "תוכן לרשומה החדשה.",
+ "apihelp-flow+reply-param-format": "תסדיר לרשומה החדשה (wikitext|html)",
+ "apihelp-flow+reply-example-1": "תגובה לרשימה ב־[[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+reply-param-metadataonly": "האם לכלול רק מטא־נתונים על התוכן החדש, ולא לכלול שום דבר אחר",
+ "apihelp-flow+view-header-description": "הצגת תיאור הלוח.",
+ "apihelp-flow+view-header-param-contentFormat": "באיזה תסדיר להחזיר את התוכן.",
+ "apihelp-flow+view-header-param-revId": "טעינת הגרסה הזאת, ולא האחרונה.",
+ "apihelp-flow+view-header-example-1": "אחזור התיאור של [[Talk:Sandbox]] בתור קוד ויקי",
+ "apihelp-flow+view-post-description": "הצגת רשומה.",
+ "apihelp-flow+view-post-param-postId": "מזהה הרשומה שרצים להציג.",
+ "apihelp-flow+view-post-param-contentFormat": "באיזה תסדיר להחזיר את התוכן.",
+ "apihelp-flow+view-post-example-1": "אחזור תוכן הרשומה ב־[[Topic:S2tycnas4hcucw8w]] בתור קוד ויקי",
+ "apihelp-flow+view-topic-description": "תצוגת נושא.",
+ "apihelp-flow+view-topic-example-1": "תצוגת [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "הצגת סיכום נושא.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "באיזה תסדיר להחזיר את התוכן.",
+ "apihelp-flow+view-topic-summary-param-revId": "טעינת הגרסה הזאת, ולא האחרונה.",
+ "apihelp-flow+view-topic-summary-example-1": "הצגת הסיכום של [[Topic:S2tycnas4hcucw8w]] בתור קוד ויקי",
+ "apihelp-flow+view-topiclist-description": "הצגת רשימת נושאים.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "באיזה סדר למיין את הנושאים.",
+ "apihelp-flow+view-topiclist-param-sortby": "אפשרות הסיכום של הנושאים.",
+ "apihelp-flow+view-topiclist-param-savesortby": "שמירת האפשרות \"sortby\", אם היא מוגדרת.",
+ "apihelp-flow+view-topiclist-param-offset-id": "ערך ההיסט (בתסדיר UUID) שבו יתחיל אחזור הנושאים.",
+ "apihelp-flow+view-topiclist-param-offset": "ערך ההיסט שבו יתחיל אחזור הנושאים.",
+ "apihelp-flow+view-topiclist-param-limit": "כמה נושאים לאחזר.",
+ "apihelp-flow+view-topiclist-param-render": "לתצג את הנושאים ב־HTML.",
+ "apihelp-flow+view-topiclist-example-1": "לקבל רשימת נושאים ב־[[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "להמיר טקסט בין קוד ויקי לבין HTML.",
+ "apihelp-flow-parsoid-utils-param-from": "מאיזה תסדיר להמיר את התוכן.",
+ "apihelp-flow-parsoid-utils-param-to": "לאיזה תסדיר להמיר את התוכן.",
+ "apihelp-flow-parsoid-utils-param-content": "איזה תוכן להמיר.",
+ "apihelp-flow-parsoid-utils-param-title": "כותרת הדף. לא ניתן לשימוש יחד עם $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "מזהה הדף. לא יכול לשמש יחד עם $1pageid.",
+ "apihelp-flow-parsoid-utils-example-1": "המרת קוד הוויקי <nowiki>'''lorem''' ''blah''</nowiki> ל־HTML",
+ "apihelp-query+flowinfo-description": "קבלת מידע זרימה בסיסי על דף.",
+ "apihelp-query+flowinfo-example-1": "אחזור מידע זרימה על [[Talk:Sandbox]]‏, [[Main Page]], ו־[[Talk:Flow]]",
+ "apihelp-flow+undo-edit-header-description": "אחזור המידע הנחוץ לביטול עריכות של התיאור.",
+ "apihelp-flow+undo-edit-header-param-startId": "באיזה מזהה גרסה להתחיל את הביטול.",
+ "apihelp-flow+undo-edit-header-param-endId": "באיזה מזהה גרסה לסיים את הביטול.",
+ "apihelp-flow+undo-edit-header-example-1": "אחזור מידע על ביטול עריכת תיאור ב־[[Talk:Sandbox]]",
+ "apihelp-flow+undo-edit-post-description": "אחזור המידע הנחוץ לביטול עריכת רשומה.",
+ "apihelp-flow+undo-edit-post-param-postId": "מזהה של הרשומה שצריך לבטל.",
+ "apihelp-flow+undo-edit-post-param-startId": "באיזה מזהה גרסה להתחיל את הביטול.",
+ "apihelp-flow+undo-edit-post-param-endId": "באיזה מזהה גרסה לסיים את הביטול.",
+ "apihelp-flow+undo-edit-post-example-1": "אחזור מידע על ביטול עריכת רשומה בנושא מסוים.",
+ "apihelp-flow+undo-edit-topic-summary-description": "אחזור מידע נחוץ לביטול עריכות הסיכום.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "באיזה מזהה גרסה להתחיל את הביטול.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "באיזה מזהה גרסה לסיים את הביטול.",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "אחזור מידע על ביטול עריכת סיכום נושא בנושא מסוים",
+ "flow-edited": "נערכה",
+ "flow-edited-by": "נערכה על־ידי $1",
+ "flow-lqt-redirect-reason": "הפניה של רשומת LiquidThreads מיושנת לרשומת זרימה מומרת",
+ "flow-talk-conversion-move-reason": "המרה של שיחה בקוד מקור לזרימה מהדף $1",
+ "flow-talk-conversion-archive-edit-reason": "המרה משיחה בקוד וויקי לזרימה",
+ "flow-previous-diff": "→ עריכה ישנה יותר",
+ "flow-next-diff": "עריכה חדשה יותר ←",
+ "flow-undo": "ביטול",
+ "flow-undo-latest-revision": "גרסה אחרונה",
+ "flow-undo-your-text": "הטקסט שלך",
+ "flow-undo-edit-header": "עריכת התיאור",
+ "flow-undo-edit-topic-summary": "עריכת סיכום הנושא",
+ "flow-undo-edit-post": "עריכת רשומה",
+ "flow-undo-edit-content": "ניתן לבטל את העריכה. נא לבדוק את השוואת הגרסאות למטה כדי לוודא שזה מה שרצית לעשות, ואז לשמור את השינויים למטה כדי לסיים את ביטול העריכה.",
+ "flow-undo-edit-failure": "לא היה אפשר לבטל את העריכה עקב התנגשות עם עריכות שנעשו בינתיים.",
+ "group-flow-bot": "בוטי זרימה",
+ "group-flow-bot-member": "בוט זרימה",
+ "grouppage-flow-bot": "Project:בוטי זרימה",
+ "flow-ve-mention-context-item-label": "אזכור",
+ "flow-ve-mention-inspector-title": "אזכור",
+ "flow-ve-mention-inspector-remove-label": "הסרה",
+ "flow-ve-mention-tool-title": "אזכור משתמש",
+ "flow-ve-mention-template": "ping",
+ "flow-ve-mention-inspector-invalid-user": "שם המשתמש \"$1\" אינו רשום.",
+ "flow-wikitext-editor-help": "קוד ויקי $1.",
+ "flow-wikitext-editor-help-and-preview": "קוד ויקי $1 ובאפשרותך $2 בכל זמן.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|משתמש בשפת עיצוב]]",
+ "flow-wikitext-editor-help-preview-the-result": "לראות תצוגה מקדימה של התוצאה",
+ "flow-wikitext-switch-editor-tooltip": "מעבר לעורך החזותי",
+ "flow-ve-switch-editor-tool-title": "מעבר לעורך קוד ויקי"
+}
diff --git a/Flow/i18n/hi.json b/Flow/i18n/hi.json
new file mode 100644
index 00000000..cade3233
--- /dev/null
+++ b/Flow/i18n/hi.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vivek Rai",
+ "संजीव कुमार",
+ "Phoenix303"
+ ]
+ },
+ "enableflow": "प्रवाह को सक्षम करें",
+ "flow-newtopic-first-heading": "$1 पर नया विषय आरम्भ करें",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|हटाया हुआ}} एक [ $4 टिप्पणी] पर $6 (<em> $5 </em>)।",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|हटाया हुआ}} [ $4 विषय] $6 (<em> $5 </em>)।",
+ "flow-notification-reply-email-subject": "$3 पर $2",
+ "flow-link-summary-revision": "सारांश संशोधन",
+ "action-flow-create-board": "किसी भी स्थान पर प्रवाह-पट्ट बनायें",
+ "flow-special-enableflow-header": "प्रवाह-पट्ट की प्रारम्भिक शीर्षणी (विकिपाठ)",
+ "flow-undo": "पूर्ववत करें",
+ "flow-undo-latest-revision": "सद्य अवतरण",
+ "flow-undo-your-text": "आपका पाठ"
+}
diff --git a/Flow/i18n/hr.json b/Flow/i18n/hr.json
new file mode 100644
index 00000000..851fcbb2
--- /dev/null
+++ b/Flow/i18n/hr.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "MaGa"
+ ]
+ },
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|Vas je spomenuo|Vas je spomenula}} na projektu $2"
+}
diff --git a/Flow/i18n/hu.json b/Flow/i18n/hu.json
new file mode 100644
index 00000000..aaba4e4f
--- /dev/null
+++ b/Flow/i18n/hu.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Csega"
+ ]
+ },
+ "flow-post-action-restore-post": "Visszaállítás",
+ "flow-anonymous": "Névtelen"
+}
diff --git a/Flow/i18n/hy.json b/Flow/i18n/hy.json
new file mode 100644
index 00000000..ff43aba0
--- /dev/null
+++ b/Flow/i18n/hy.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "M hamlet",
+ "Vadgt",
+ "Xelgen"
+ ]
+ },
+ "flow-preview": "Նախադիտել",
+ "flow-notification-edit": "$1 մասնակիցը {{GENDER:$1|խմբագրեց}} <span class=\"plainlinks\">[$5 գրառումը]</span> [[$3|$4]] էջի «$2» թեմայում։",
+ "flow-notification-rename": "$1՝ {{GENDER:$1|փոխեց}} վերնագրիրը [$2 $3]-ի \"$4\"-ում [[$5|$6]]-ի վրա:"
+}
diff --git a/Flow/i18n/ia.json b/Flow/i18n/ia.json
new file mode 100644
index 00000000..17fb813d
--- /dev/null
+++ b/Flow/i18n/ia.json
@@ -0,0 +1,101 @@
+{
+ "@metadata": {
+ "authors": [
+ "McDutchie"
+ ]
+ },
+ "flow-desc": "Systema de gestion de fluxo de travalio",
+ "log-name-flow": "Registro de activitate de fluxo",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|deleva}} un [$4 message] in [[$3]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restaurava}} un [$4 message] in [[$3]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|deleva}} un [$4 message] in [[$3]]",
+ "flow-user-moderated": "Usator moderate",
+ "flow-edit-header-link": "Modificar titulo",
+ "flow-suppress-usertext": "<em>Nomine de usator supprimite</em>",
+ "flow-post-actions": "Actiones",
+ "flow-topic-actions": "Actiones",
+ "flow-cancel": "Cancellar",
+ "flow-preview": "Previsualisar",
+ "flow-newtopic-title-placeholder": "Nove topico",
+ "flow-newtopic-content-placeholder": "Adde detalios si tu vole",
+ "flow-newtopic-header": "Adder un nove topico",
+ "flow-newtopic-save": "Adder topico",
+ "flow-newtopic-start-placeholder": "Initiar un nove discussion",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Commentar}} \"$2\"",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Regratiar}}",
+ "flow-post-edited": "Entrata {{GENDER:$1|modificate}} per $1 $2",
+ "flow-post-action-view": "Permaligamine",
+ "flow-post-action-post-history": "Historia de messages",
+ "flow-post-action-suppress-post": "Supprimer",
+ "flow-post-action-delete-post": "Deler",
+ "flow-post-action-hide-post": "Celar",
+ "flow-post-action-edit-post": "Modificar entrata",
+ "flow-post-action-restore-post": "Restaurar entrata",
+ "flow-topic-action-view": "Permaligamine",
+ "flow-topic-action-watchlist": "Observatorio",
+ "flow-topic-action-edit-title": "Modificar titulo",
+ "flow-topic-action-history": "Historia del topico",
+ "flow-topic-action-hide-topic": "Celar topico",
+ "flow-topic-action-delete-topic": "Deler topico",
+ "flow-topic-action-suppress-topic": "Supprimer topico",
+ "flow-topic-action-restore-topic": "Restaurar topico",
+ "flow-error-http": "Un error occurreva durante le communication con le servitor.",
+ "flow-error-other": "Un error inexpectate ha occurrite.",
+ "flow-error-external": "Un error ha occurrite.<br />Le message de error recipite es: $1",
+ "flow-error-edit-restricted": "Tu non es autorisate a modificar iste entrata.",
+ "flow-error-external-multi": "Errores ha occurrite.<br />$1",
+ "flow-error-missing-content": "Le message non ha contento. Contento es necessari pro salveguardar un message.",
+ "flow-error-missing-title": "Le topico non ha titulo. Le titulo es necessari pro salveguardar un topico.",
+ "flow-error-parsoid-failure": "Impossibile interpretar le contento a causa de un fallimento de Parsoid.",
+ "flow-error-missing-replyto": "Nulle parametro \"replyTo\" ha essite fornite. Iste parametro es necessari pro le action \"responder\".",
+ "flow-error-invalid-replyto": "Le parametro \"replyTo\" es invalide. Le entrata specificate non pote esser trovate.",
+ "flow-error-delete-failure": "Le deletion de iste elemento ha fallite.",
+ "flow-error-hide-failure": "Le celamento de iste elemento ha fallite.",
+ "flow-error-missing-postId": "Nulle parametro \"postId\" ha essite specificate. Iste parametro es necessari pro manipular un entrata.",
+ "flow-error-invalid-postId": "Le parametro \"postId\" es invalide. Le entrata specificate ($1) non poteva esser trovate.",
+ "flow-error-restore-failure": "Le restauration de iste elemento ha fallite.",
+ "flow-error-invalid-moderation-state": "Un valor invalide ha essite fornite pro moderationState",
+ "flow-error-invalid-moderation-reason": "Per favor da un motivo pro le moderation",
+ "flow-error-not-allowed": "Permissiones insufficiente pro exequer iste action",
+ "flow-error-invalid-topic-uuid-title": "Titulo invalide",
+ "flow-error-invalid-topic-uuid": "Le titulo de pagina requestate non es valide. Paginas in le spatio de nomines Topic es create automaticamente per Flow.",
+ "flow-error-unknown-workflow-id-title": "Topico incognite",
+ "flow-error-unknown-workflow-id": "Le topico requestate non existe.",
+ "flow-edit-header-submit": "Salveguardar titulo",
+ "flow-edit-title-submit": "Cambiar titulo",
+ "flow-edit-post-submit": "Submitter modificationes",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|modificava}} un [$3 commento].",
+ "flow-rev-message-reply": "$1 {{GENDER:$2|addeva}} un [$3 commento].",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|commento|commentos}}</strong> ha essite addite.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|creava}} le topico [$3 $4].",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|cambiava}} le titulo del topico de $5 in [$3 $4].",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|creava}} le titulo del tabuliero.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|modificava}} le titulo del tabuliero.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|celava}} un [$4 commento] (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|deleva}} un [$4 commento] (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|supprimeva}} un [$4 commento] (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restaurava}} un [$4 commento] (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|celava}} le [$4 topico] (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|deleva}} le [$4 topico] (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|supprimeva}} le [$4 topico] (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|restaurava}} le [$4 topico] (<em>$5</em>).",
+ "flow-topic-history": "Historia del topico \"$1\"",
+ "flow-comment-restored": "Commento restaurate",
+ "flow-comment-deleted": "Commento delite",
+ "flow-comment-hidden": "Commento celate",
+ "flow-comment-moderated": "Commento moderate",
+ "flow-last-modified": "Ultime modification circa $1",
+ "flow-notification-reply": "$1 {{GENDER:$1|respondeva}} a <span class=\"plainlinks\">[$5 $2]</span> sur \"$4\".",
+ "flow-notification-reply-bundle": "$1 e $5 {{PLURAL:$6|altere|alteres}} {{GENDER:$1|respondeva}} a <span class=\"plainlinks\">[$4 $2]</span> sur \"$3\".",
+ "flow-notification-edit": "$1 {{GENDER:$1|modificava}} un [$5 message] in $2 sur [[$3|$4]].",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|creava}} un nove topico super '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|cambiava}} le titulo de [$2 $3] a \"$4\" super [[$5|$6]].",
+ "flow-notification-link-text-view-post": "Vider message",
+ "flow-notification-reply-email-subject": "$1 {{GENDER:$1|respondeva}} a un topico",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondeva}} a \"$2\" sur \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 e $4 {{PLURAL:$5|altere|alteres}} {{GENDER:$1|respondeva}} a \"$2\" sur \"$3\"",
+ "echo-category-title-flow-discussion": "Fluxo",
+ "echo-pref-tooltip-flow-discussion": "Notificar me quando actiones concernente me occurre in Fluxo."
+}
diff --git a/Flow/i18n/id.json b/Flow/i18n/id.json
new file mode 100644
index 00000000..6765e4bf
--- /dev/null
+++ b/Flow/i18n/id.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ahdan",
+ "William Surya Permana"
+ ]
+ },
+ "flow-topic-moderated-reason-prefix": "Alasan:",
+ "flow-topic-action-undo-moderation": "Batal",
+ "echo-pref-tooltip-flow-discussion": "Beritahu saya saat ada tindakan yang berhubungan dengan saya terjadi di Flow.",
+ "flow-moderation-confirmation-suppress-topic": "Topik ini telah dipadamkan",
+ "flow-moderation-confirmation-delete-topic": "Topik ini telah dihapus",
+ "flow-moderation-confirmation-hide-topic": "Topik ini telah disembunyikan"
+}
diff --git a/Flow/i18n/it.json b/Flow/i18n/it.json
new file mode 100644
index 00000000..ee0b9d20
--- /dev/null
+++ b/Flow/i18n/it.json
@@ -0,0 +1,359 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Beta16",
+ "Gianfranco",
+ "Maria victoria",
+ "Rosh",
+ "FRacco",
+ "Macofe",
+ "Giuseppe Forte"
+ ]
+ },
+ "enableflow": "Attiva Flow",
+ "flow-desc": "Sistema di gestione del flusso di lavoro",
+ "flow-talk-taken-over": "Questa pagina di discussione sta utilizzando [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestore pagine di discussione Flow",
+ "log-name-flow": "Attività sui flussi",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|ha cancellato}} un [$4 messaggio] su \"[[$3|$5]]\" su [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|ha ripristinato}} un [$4 messaggio] su \"[[$3|$5]]\" su [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|ha soppresso}} un [$4 messaggio] su \"[[$3|$5]]\" su [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|ha cancellato}} un [$4 messaggio] su \"[[$3|$5]]\" su [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|ha cancellato}} l'argomento \"[[$3|$5]]\" su [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|ha ripristinato}} l'argomento \"[[$3|$5]]\" su [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|ha soppresso}} l'argomento \"[[$3|$5]]\" su [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|ha cancellato}} l'argomento \"[[$3|$5]]\" su [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] su [[$3]] è stato importato da LiquidThreads a Flow",
+ "flow-user-moderated": "Utente moderato",
+ "flow-edit-header-link": "Modifica intestazione",
+ "flow-post-moderated-toggle-hide-show": "Mostra commenti {{GENDER:$1|nascosti}} da $2",
+ "flow-post-moderated-toggle-delete-show": "Mostra commenti {{GENDER:$1|cancellati}} da $2",
+ "flow-post-moderated-toggle-suppress-show": "Mostra commenti {{GENDER:$1|soppressi}} da $2",
+ "flow-post-moderated-toggle-hide-hide": "Nascondi commenti {{GENDER:$1|nascosti}} da $2",
+ "flow-post-moderated-toggle-delete-hide": "Nascondi commento {{GENDER:$1|cancellato}} da $2",
+ "flow-post-moderated-toggle-suppress-hide": "Nascondi commenti {{GENDER:$1|soppressi}} da $2",
+ "flow-topic-moderated-reason-prefix": "Motivo:",
+ "flow-hide-post-content": "Questo commento è stato {{GENDER:$1|nascosto}} da $1 ([$2 cronologia])",
+ "flow-hide-title-content": "Questo argomento è stato {{GENDER:$1|nascosto}} da $1",
+ "flow-lock-title-content": "Questo argomento è stato {{GENDER:$1|bloccato}} da $1",
+ "flow-hide-header-content": "{{GENDER:$1|Nascosto}} da $2",
+ "flow-delete-post-content": "Questo commento è stato {{GENDER:$1|cancellato}} da $1 ([$2 cronologia])",
+ "flow-delete-title-content": "Questo argomento è stato {{GENDER:$1|cancellato}} da $1",
+ "flow-delete-header-content": "{{GENDER:$1|Cancellato}} da $2",
+ "flow-suppress-post-content": "Questo commento è stato {{GENDER:$1|soppresso}} da $1 ([$2 cronologia])",
+ "flow-suppress-title-content": "Questo argomento è stato {{GENDER:$1|soppresso}} da $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Soppresso}} da $2",
+ "flow-suppress-usertext": "<em>Nome utente soppresso</em>",
+ "flow-post-actions": "Azioni",
+ "flow-topic-actions": "Azioni",
+ "flow-cancel": "Annulla",
+ "flow-preview": "Anteprima",
+ "flow-show-change": "Mostra modifiche",
+ "flow-last-modified-by": "Ultima {{GENDER:$1|modifica}} di $1",
+ "flow-stub-post-content": "''A causa di un errore tecnico, questo messaggio non può essere recuperato.''",
+ "flow-newtopic-title-placeholder": "Nuovo argomento",
+ "flow-newtopic-content-placeholder": "Scrivi un nuovo messaggio su \"$1\"",
+ "flow-newtopic-header": "Aggiungi un nuovo argomento",
+ "flow-newtopic-save": "Aggiungi argomento",
+ "flow-newtopic-start-placeholder": "Inizia un nuovo argomento",
+ "flow-newtopic-first-heading": "Inizia un nuovo argomento su $1",
+ "flow-summarize-topic-placeholder": "Riassumi questo argomento",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Commento}} su \"$2\"",
+ "flow-reply-topic-title-placeholder": "Risposta a \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Rispondi}}",
+ "flow-reply-link": "{{GENDER:$1|Rispondi}}",
+ "flow-thank-link": "{{GENDER:$1|Ringrazia}}",
+ "flow-lock-link": "{{GENDER:$1|Blocca}}",
+ "flow-history-action-suppress-post": "sopprimi",
+ "flow-history-action-delete-post": "cancella",
+ "flow-history-action-hide-post": "nascondi",
+ "flow-history-action-unsuppress-post": "annulla soppressione",
+ "flow-history-action-undelete-post": "ripristina",
+ "flow-history-action-unhide-post": "rendi visibile",
+ "flow-history-action-restore-post": "ripristina",
+ "flow-history-action-lock-topic": "blocca",
+ "flow-history-action-unlock-topic": "sblocca",
+ "flow-post-edited": "Messaggio {{GENDER:$1|modificato}} da $1 $2",
+ "flow-post-action-view": "Link permanente",
+ "flow-post-action-post-history": "Cronologia",
+ "flow-post-action-suppress-post": "Sopprimi",
+ "flow-post-action-delete-post": "Cancella",
+ "flow-post-action-hide-post": "Nascondi",
+ "flow-post-action-edit-post": "Modifica",
+ "flow-post-action-edit-post-submit": "Salva modifiche",
+ "flow-post-action-unsuppress-post": "Annulla soppressione",
+ "flow-post-action-undelete-post": "Ripristina",
+ "flow-post-action-unhide-post": "Rendi visibile",
+ "flow-post-action-restore-post": "Ripristina",
+ "flow-post-action-undo-moderation": "Annulla",
+ "flow-topic-action-view": "Link permanente",
+ "flow-topic-action-watchlist": "Osservati speciali",
+ "flow-topic-action-edit-title": "Modifica titolo",
+ "flow-topic-action-history": "Cronologia",
+ "flow-topic-action-hide-topic": "Nascondi argomento",
+ "flow-topic-action-delete-topic": "Cancella argomento",
+ "flow-topic-action-lock-topic": "Blocca argomento",
+ "flow-topic-action-unlock-topic": "Sblocca argomento",
+ "flow-topic-action-summarize-topic": "Riassumi",
+ "flow-topic-action-resummarize-topic": "Modifica riassunto dell'argomento",
+ "flow-topic-action-suppress-topic": "Sopprimi argomento",
+ "flow-topic-action-unhide-topic": "Rendi visibile argomento",
+ "flow-topic-action-undelete-topic": "Ripristina argomento",
+ "flow-topic-action-unsuppress-topic": "Annulla soppressione argomento",
+ "flow-topic-action-restore-topic": "Ripristina argomento",
+ "flow-topic-action-undo-moderation": "Annulla",
+ "flow-topic-notification-subscribe-title": "Questo argomento è stato aggiunto alla {{GENDER:$1|propria}} lista degli osservati speciali.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Riceverai}} notifiche per tutte le attività su questo argomento.",
+ "flow-board-notification-subscribe-title": "Ti sei {{GENDER:$1|iscritto|iscritta}} a questa bacheca di discussione!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Riceverai}} una notifica quando un nuovo argomento verrà creato su questa bacheca.",
+ "flow-error-http": "Si è verificato un errore durante la comunicazione con il server.",
+ "flow-error-other": "Si è verificato un errore imprevisto.",
+ "flow-error-external": "Si è verificato un errore.<br />Il messaggio di errore ricevuto è: $1",
+ "flow-error-edit-restricted": "Non è consentito modificare questo messaggio.",
+ "flow-error-topic-is-locked": "Questo argomento è bloccato per qualsiasi ulteriore attività.",
+ "flow-error-lock-moderated-post": "Non puoi bloccare un messaggio moderato.",
+ "flow-error-external-multi": "Si sono verificati errori.<br />$1",
+ "flow-error-missing-content": "Il tuo messaggio non ha contenuto. Un minimo di contenuto è necessario per poter salvare un messaggio.",
+ "flow-error-missing-summary": "Il riassunto non ha contenuto. Un minimo di contenuto è necessario per poter salvare un riassunto.",
+ "flow-error-missing-title": "L'argomento non ha titolo. Serve un titolo per salvare un argomento.",
+ "flow-error-parsoid-failure": "Impossibile analizzare il contenuto a causa di un errore di Parsoid.",
+ "flow-error-missing-replyto": "Non è stato indicato un parametro \"rispondi_a\". Questo parametro è richiesto per la funzione \"rispondi\".",
+ "flow-error-invalid-replyto": "Il parametro \"rispondi_a\" non era valido. Il messaggio indicato non è stato trovato.",
+ "flow-error-delete-failure": "La cancellazione di questo elemento non è riuscita.",
+ "flow-error-hide-failure": "Il tentativo di nascondere questo elemento non è riuscito.",
+ "flow-error-missing-postId": "Non è stato fornito alcun parametro \"ID_messaggio\". Questo parametro è necessario per poter elaborare un messaggio.",
+ "flow-error-invalid-postId": "Il parametro \"ID_messaggio\" non era valido. Il messaggio indicato ($1) non è stato trovato.",
+ "flow-error-restore-failure": "Il ripristino di questo elemento non è riuscito.",
+ "flow-error-invalid-moderation-state": "È stato fornito un valore non valido per il parametro ('moderationState') alle API Flow.",
+ "flow-error-invalid-moderation-reason": "Fornisci una motivazione per la moderazione",
+ "flow-error-not-allowed": "Autorizzazioni insufficienti per eseguire questa azione",
+ "flow-error-not-allowed-hide": "Questo argomento è stato nascosto.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Non puoi rispondere perché questo argomento è stato nascosto.",
+ "flow-error-not-allowed-delete": "Questo argomento è stato cancellato.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Non puoi rispondere perché questo argomento è stato cancellato.",
+ "flow-error-not-allowed-suppress": "Questo argomento è stato cancellato.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Non puoi rispondere perché questo argomento è stato cancellato.",
+ "flow-error-not-allowed-delete-extract": "Questo argomento è stato cancellato. Il registro delle cancellazioni per l'argomento è disponibile qui sotto per riferimento.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Non puoi rispondere perché questo argomento è stato cancellato. Il registro delle cancellazioni per l'argomento è disponibile qui sotto per riferimento.",
+ "flow-error-not-allowed-suppress-extract": "Questo argomento è stato cancellato. Il registro delle cancellazioni per l'argomento è disponibile qui sotto per riferimento.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Non puoi rispondere perché questo argomento è stato soppresso. Il registro delle soppressioni per l'argomento è disponibile qui sotto per riferimento.",
+ "flow-error-title-too-long": "I titoli degli argomenti sono limitati a $1 {{PLURAL:$1|byte}}.",
+ "flow-error-no-existing-workflow": "Questo flusso di lavoro non esiste ancora.",
+ "flow-error-not-a-post": "Il titolo di un argomento non può essere salvato come un messaggio.",
+ "flow-error-missing-header-content": "L'intestazione non ha contenuto. Un minimo di contenuto è necessario per poter salvare un'intestazione.",
+ "flow-error-missing-prev-revision-identifier": "L'Identificatore della versione precedente è mancante.",
+ "flow-error-prev-revision-mismatch": "Un altro utente ha modificato questo messaggio pochi secondi fa. Sei {{GENDER:$3|sicuro|sicura}} di voler sovrascrivere la recente modifica?",
+ "flow-error-prev-revision-does-not-exist": "Impossibile trovare la versione precedente.",
+ "flow-error-default": "Si è verificato un errore.",
+ "flow-error-invalid-input": "È stato fornito un valore non valido per il caricamento dei contenuti Flow.",
+ "flow-error-invalid-title": "È stato fornito un titolo di pagina non valido.",
+ "flow-error-fail-load-history": "Impossibile caricare la cronologia.",
+ "flow-error-missing-revision": "Non è possibile trovare una versione per il caricamento dei contenuti Flow.",
+ "flow-error-fail-commit": "Impossibile salvare il contenuto del flusso.",
+ "flow-error-insufficient-permission": "Autorizzazioni insufficienti per accedere al contenuto.",
+ "flow-error-revision-comparison": "Le differenze possono essere visualizzate solo per due versioni dello stesso messaggio.",
+ "flow-error-missing-topic-title": "Impossibile trovare il titolo dell'argomento per il flusso di lavoro attuale.",
+ "flow-error-fail-load-data": "Impossibile caricare i dati richiesti.",
+ "flow-error-invalid-workflow": "Impossibile trovare il flusso di lavoro richiesto.",
+ "flow-error-process-data": "Si è verificato un errore durante l'elaborazione dei dati nella tua richiesta.",
+ "flow-error-process-wikitext": "Si è verificato un errore durante il processo di conversione HTML/wikitesto.",
+ "flow-error-no-index": "Impossibile trovare un indice per eseguire la ricerca di dati.",
+ "flow-error-no-render": "L'azione indicata non è stata riconosciuta.",
+ "flow-error-no-commit": "L'azione specificata non può essere salvata.",
+ "flow-error-fetch-after-lock": "Si è verificato un errore durante la richiesta di nuovi dati. L'operazione di blocco/sblocco è comunque andata a buon fine. Il messaggio di errore è: $1",
+ "flow-error-content-too-long": "Il contenuto è troppo grande. Il contenuto, dopo l'espansione, deve essere minore di $1 {{PLURAL:$1|byte}}.",
+ "flow-error-move": "Lo spostamento di una bacheca di discussione non è attualmente supportato.",
+ "flow-error-invalid-topic-uuid-title": "Titolo non corretto",
+ "flow-error-unknown-workflow-id-title": "Argomento sconosciuto",
+ "flow-error-unknown-workflow-id": "L'argomento richiesto non esiste.",
+ "flow-edit-header-placeholder": "Descrivi questa bacheca di discussione",
+ "flow-edit-header-submit": "Salva intestazione",
+ "flow-edit-header-submit-overwrite": "Sovrascrivi intestazione",
+ "flow-summarize-topic-submit": "Riassumi",
+ "flow-summarize-topic-submit-overwrite": "Sovrascrivi riassunto",
+ "flow-lock-topic-submit": "Blocca argomento",
+ "flow-unlock-topic-submit": "Sblocca argomento",
+ "flow-edit-title-submit": "Cambia titolo",
+ "flow-edit-title-submit-overwrite": "Sovrascrivi titolo",
+ "flow-edit-post-submit": "Invia modifiche",
+ "flow-edit-post-submit-overwrite": "Sovrascrivi modifiche",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|ha modificato}} un [$3 commento] su \"$4\".",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Modificato}} un messaggio",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|ha commentato}}] su \"$4\" (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|commento|commenti}}</strong> {{PLURAL:$1|è stato aggiunto|sono stati aggiunti}}.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|ha creato}} l'argomento \"[$3 $4]\".",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Creato}} nuovo argomento",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|ha modificato}} il titolo dell'argomento da \"$5\" a \"[$3 $4]\".",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|ha creato}} l'intestazione.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|ha modificato}} l'intestazione.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|ha creato}} la sintesi dell'argomento $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|ha modificato}} la sintesi dell'argomento $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|ha nascosto}} un [$4 commento] su \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|ha cancellato}} un [$4 commento] su \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|ha soppresso}} un [$4 commento] su \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|ha ripristinato}} un [$4 commento] su \"$6\" (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|ha nascosto}} [$4 l'argomento] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|ha cancellato}} [$4 l'argomento] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|ha soppresso}} [$4 l'argomento] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|ha bloccato}} [$4 l'argomento] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|ha ripristinato}} [$4 l'argomento] \"$6\" (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 su $2",
+ "flow-board-history": "Cronologia di \"$1\"",
+ "flow-board-history-empty": "Questa bacheca attualmente non ha cronologia.",
+ "flow-topic-history": "Cronologia dell'argomento \"$1\"",
+ "flow-post-history": "Cronologia del commento di {{GENDER:$2|$2}}",
+ "flow-history-last4": "Ultime 4 ore",
+ "flow-history-day": "Oggi",
+ "flow-history-week": "Ultima settimana",
+ "flow-history-pages-topic": "Apparso sulla [$1 bacheca \"$2\"]",
+ "flow-history-pages-post": "Apparso su [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 commento|$1 commenti|0=Sii {{GENDER:$2|il primo|la prima}} a commentare!}}",
+ "flow-comment-restored": "Commento ripristinato",
+ "flow-comment-deleted": "Commento cancellato",
+ "flow-comment-hidden": "Commento nascosto",
+ "flow-comment-moderated": "Commento moderato",
+ "flow-last-modified": "Ultima modifica $1",
+ "flow-workflow": "flusso di lavoro",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|ha risposto}} su '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 e {{PLURAL:$6|un altro|altri $5}} {{GENDER:$1|hanno risposto}} su '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|ha modificato}} il tuo <span class=\"plainlinks\">[$5 messaggio]</span> su [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 e {{PLURAL:$6|un altro|altri $5}} utenti {{GENDER:$1|hanno modificato}} un <span class=\"plainlinks\">[$4 messaggio]</span> in \"$2\" su \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 ha {{GENDER:$1|creato}} un nuovo argomento su '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|nuovo argomento|nuovi argomenti}} su '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 ha {{GENDER:$1|cambiato}} il titolo di <span class=\"plainlinks\">[$2 $3]</span> in \"$4\" su [[$5|$6]]",
+ "flow-notification-mention": "$1 {{GENDER:$5|ti}} {{GENDER:$1|ha menzionato}} nel suo <span class=\"plainlinks\">[$2 messaggio]</span> in \"$3\" su \"$4\".",
+ "flow-notification-link-text-view-post": "Vedi messaggio",
+ "flow-notification-link-text-view-topic": "Vedi argomento",
+ "flow-notification-reply-email-subject": "$2 su $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|ha risposto}} a \"$2\" su \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 e {{PLURAL:$5|un altro|altri $4}} {{GENDER:$1|hanno risposto}} a \"$2\" su \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|ti}} {{GENDER:$1|ha menzionato}} su \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|ti}} {{GENDER:$1|ha menzionato}} nel suo messaggio in \"$2\" su \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|ha modificato}} un messaggio",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|ha modificato}} un messaggio in \"$2\" su \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 e {{PLURAL:$5|un altro|altri $4}} utenti {{GENDER:$1|hanno modificato}} un messaggio in \"$2\" su \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|ha rinominato}} il tuo argomento",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|ha rinominato}} l'argomento \"$2\" in \"$3\" su \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|ha creato}} un nuovo argomento su \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|ha creato}} un nuovo argomento \"$2\" su $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Avvisami quando vengono eseguite azioni connesse a me su Flow.",
+ "flow-link-post": "messaggio",
+ "flow-link-topic": "argomento",
+ "flow-link-history": "cronologia",
+ "flow-link-post-revision": "versione messaggio",
+ "flow-link-topic-revision": "versione argomento",
+ "flow-link-header-revision": "Versione intestazione",
+ "flow-moderation-title-suppress-post": "Sopprimere il messaggio?",
+ "flow-moderation-title-delete-post": "Cancellare il messaggio?",
+ "flow-moderation-title-hide-post": "Nascondere il messaggio?",
+ "flow-moderation-title-unsuppress-post": "Annullare soppressione del messaggio?",
+ "flow-moderation-title-undelete-post": "Ripristinare il messaggio?",
+ "flow-moderation-title-unhide-post": "Rendi visibile il messaggio?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|Spiega}} perché stai sopprimendo questo messaggio.",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Spiega}} perché stai cancellando questo messaggio.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Spiega}} perché stai nascondendo questo messaggio.",
+ "flow-moderation-placeholder-unsuppress-post": "{{GENDER:$3|Spiega}} perché stai annullando la soppressione di questo messaggio.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|Spiega}} perché stai ripristinando questo messaggio.",
+ "flow-moderation-placeholder-unhide-post": "{{GENDER:$3|Spiega}} perché stai rendendo visibile questo messaggio.",
+ "flow-moderation-confirm-suppress-post": "Sopprimi",
+ "flow-moderation-confirm-delete-post": "Cancella",
+ "flow-moderation-confirm-hide-post": "Nascondi",
+ "flow-moderation-confirm-unsuppress-post": "Annulla soppressione",
+ "flow-moderation-confirm-undelete-post": "Ripristina",
+ "flow-moderation-confirm-unhide-post": "Rendi visibile",
+ "flow-moderation-confirm-suppress-topic": "Sopprimi",
+ "flow-moderation-confirm-delete-topic": "Cancella",
+ "flow-moderation-confirm-hide-topic": "Nascondi",
+ "flow-moderation-confirm-lock-topic": "Blocca",
+ "flow-moderation-confirm-unsuppress-topic": "Annulla soppressione",
+ "flow-moderation-confirm-undelete-topic": "Ripristina",
+ "flow-moderation-confirm-unhide-topic": "Rendi visibile",
+ "flow-moderation-confirm-unlock-topic": "Sblocca",
+ "flow-moderation-confirmation-suppress-post": "Il messaggio è stato soppresso con successo.\n{{GENDER:$2|Scrivi}} a $1 riguardo a questo messaggio.",
+ "flow-moderation-confirmation-delete-post": "Il messaggio è stato cancellato con successo.\n{{GENDER:$2|Scrivi}} a $1 riguardo a questo messaggio.",
+ "flow-moderation-confirmation-hide-post": "Il messaggio è stato nascosto con successo.\n{{GENDER:$2|Scrivi}} a $1 riguardo a questo messaggio.",
+ "flow-moderation-confirmation-unsuppress-post": "Hai annullato la soppressione con successo per il messaggio precedente.",
+ "flow-moderation-confirmation-undelete-post": "Hai ripristinato con successo il messaggio precedente.",
+ "flow-moderation-confirmation-unhide-post": "Hai reso visibile con successo il messaggio precedente.",
+ "flow-moderation-confirmation-suppress-topic": "Questo argomento è stato soppresso.",
+ "flow-moderation-confirmation-delete-topic": "Questo argomento è stato cancellato.",
+ "flow-moderation-confirmation-hide-topic": "Questo argomento è stato nascosto.",
+ "flow-moderation-confirmation-unsuppress-topic": "Hai annullato la soppressione con successo per questo argomento.",
+ "flow-moderation-confirmation-undelete-topic": "Hai ripristinato con successo questo argomento.",
+ "flow-moderation-confirmation-unhide-topic": "Hai reso visibile con successo questo argomento.",
+ "flow-moderation-title-suppress-topic": "Sopprimere l'argomento?",
+ "flow-moderation-title-delete-topic": "Cancellare l'argomento?",
+ "flow-moderation-title-hide-topic": "Nascondere l'argomento?",
+ "flow-moderation-title-unsuppress-topic": "Annullare soppressione dell'argomento?",
+ "flow-moderation-title-undelete-topic": "Ripristinare l'argomento?",
+ "flow-moderation-title-unhide-topic": "Rendi visibile l'argomento?",
+ "flow-moderation-placeholder-suppress-topic": "{{GENDER:$3|Spiega}} perché stai sopprimendo questo argomento.",
+ "flow-moderation-placeholder-delete-topic": "{{GENDER:$3|Spiega}} perché stai cancellando questo argomento.",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|Spiega}} perché stai nascondendo questo argomento.",
+ "flow-moderation-placeholder-unsuppress-topic": "{{GENDER:$3|Spiega}} perché stai annullando la soppressione di questo argomento.",
+ "flow-moderation-placeholder-undelete-topic": "{{GENDER:$3|Spiega}} perché stai ripristinando questo argomento.",
+ "flow-moderation-placeholder-unhide-topic": "{{GENDER:$3|Spiega}} perché stai rendendo visibile questo argomento.",
+ "flow-topic-permalink-warning": "L'argomento è iniziato su [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "L'argomento è iniziato sulla [$2 bacheca di {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Questo è un collegamento permanente ad una singola versione di questo messaggio.\nQuesta versione è del $1.\nPuoi vedere le [$5 differenze dalla versione precedente] o le altre versioni nella [$4 cronologia della pagina].",
+ "flow-revision-permalink-warning-post-first": "Questo è un collegamento permanente alla prima versione di questo messaggio.\nPuoi vedere le versioni successive nella [$4 cronologia della pagina].",
+ "flow-revision-permalink-warning-postsummary": "Questo è un collegamento permanente ad una singola versione del riassunto di questo messaggio.\nQuesta versione è del $1.\nPuoi vedere le [$5 differenze dalla versione precedente] o le altre versioni nella [$4 cronologia della pagina].",
+ "flow-revision-permalink-warning-postsummary-first": "Questo è un collegamento permanente alla prima versione del riassunto di questo messaggio.\nPuoi vedere le versioni successive nella [$4 cronologia della pagina].",
+ "flow-revision-permalink-warning-header": "Questo è un collegamento permanente ad una singola versione dell'intestazione.\nQuesta versione è del $1.\nPuoi vedere le [$3 differenze dalla versione precedente] o le altre versioni nella [$2 cronologia della pagina].",
+ "flow-revision-permalink-warning-header-first": "Questo è un collegamento permanente alla prima versione dell'intestazione.\nPuoi vedere le versioni successive nella [$2 cronologia della pagina].",
+ "flow-compare-revisions-revision-header": "Versione di {{GENDER:$2|$2}} del $1",
+ "flow-compare-revisions-header-post": "Questa pagina mostra le {{GENDER:$3|modifiche}} tra due versioni del messaggio di $3, nell'argomento \"[$5 $2]\" su [$4 $1].\nPuoi vedere le altre versioni nella [$6 cronologia della pagina].",
+ "flow-compare-revisions-header-postsummary": "Questa pagina mostra le modifiche tra due versioni del riassunto del messaggio, nell'argomento \"[$4 $2]\" su [$3 $1].\nPuoi vedere le altre versioni nella [$5 cronologia della pagina].",
+ "flow-compare-revisions-header-header": "Questa pagina mostra le {{GENDER:$2|modifiche}} tra due versioni dell'intestazione su [$3 $1].\nPuoi vedere le altre versioni nella [$4 cronologia della pagina].",
+ "right-flow-hide": "Nasconde messaggi e argomenti Flow",
+ "right-flow-lock": "Blocca argomenti Flow",
+ "right-flow-delete": "Cancella messaggi e argomenti Flow",
+ "right-flow-edit-post": "Modifica messaggi di altri utenti Flow",
+ "right-flow-suppress": "Sopprime versioni Flow",
+ "flow-terms-of-use-new-topic": "Cliccando su \"{{int:flow-newtopic-save}}\", accetti le condizioni d'uso per questo wiki.",
+ "flow-terms-of-use-reply": "Cliccando su \"{{int:flow-reply-submit}}\", accetti le condizioni d'uso per questo wiki.",
+ "flow-terms-of-use-edit": "Salvando le modifiche, accetti le condizioni d'uso per questo wiki.",
+ "flow-anon-warning": "Non hai effettuato l'accesso. Per ricevere l'attribuzione con il tuo nome, anziché con l'indirizzo IP, puoi [$1 accedere] o [$2 creare un'utenza].",
+ "flow-cancel-warning": "Hai inserito del testo in questo modulo. Sei sicuro di volerlo ignorare?",
+ "flow-topic-first-heading": "Argomento su $1",
+ "flow-topic-html-title": "$1 su $2",
+ "flow-topic-count": "Argomenti ($1)",
+ "flow-load-more": "Caricane ancora",
+ "flow-no-more-fwd": "Non ci sono vecchi argomenti.",
+ "flow-add-topic": "Aggiungi argomento",
+ "flow-newest-topics": "Argomenti più recenti",
+ "flow-recent-topics": "Ultimi argomenti attivi",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Stai}} attualmente leggendo gli argomenti più recenti. Fai clic per ulteriori opzioni di ordinamento.",
+ "flow-toggle-small-topics": "Passa alla vista ridotta",
+ "flow-toggle-topics": "Passa alla vista degli argomenti",
+ "flow-toggle-topics-posts": "Passa alla vista degli argomenti e dei messaggi",
+ "flow-terms-of-use-summarize": "Cliccando su \"{{int:flow-summarize-topic-submit}}\", accetti le condizioni d'uso per questo wiki.",
+ "flow-terms-of-use-lock-topic": "Cliccando su \"{{int:flow-lock-topic-submit}}\", accetti le condizioni d'uso per questo wiki.",
+ "flow-terms-of-use-unlock-topic": "Cliccando su \"{{int:flow-unlock-topic-submit}}\", accetti le condizioni d'uso per questo wiki.",
+ "flow-whatlinkshere-post": "da un [$1 messaggio]",
+ "flow-whatlinkshere-header": "dall'[$1 intestazione]",
+ "flow": "Flusso",
+ "flow-special-desc": "Questa pagina speciale reindirizza a un messaggio o a un flusso di lavoro Flow dato un UUID.",
+ "flow-special-type": "Tipo",
+ "flow-special-type-post": "Messaggio",
+ "flow-special-type-workflow": "Flusso di lavoro",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Impossibile trovare il contenuto corrispondente il tipo e l'UUID.",
+ "flow-spam-confirmedit-form": "Conferma che sei una persona risolvendo il captcha qui sotto: $1",
+ "flow-preview-warning": "Stai vedendo un'anteprima. Clicca \"{{int:flow-newtopic-save}}\" per completare l'invio, o clicca su \"{{int:flow-preview-return-edit-post}}\" per continuare a scrivere.",
+ "flow-preview-return-edit-post": "Continua a modificare",
+ "flow-anonymous": "Anonimo",
+ "flow-embedding-unsupported": "Gli argomenti non possono essere incorporati, ancora.",
+ "mw-ui-unsubmitted-confirm": "Ci sono modifiche non salvate su questa pagina. Sei sicuro di vole uscire e perderle?",
+ "apihelp-flow+undo-edit-header-description": "Recupera le informazioni necessarie per annullare le modifiche dell'intestazione.",
+ "apihelp-flow+undo-edit-post-description": "Recuperare le informazioni necessarie per annullare la post-modifica.",
+ "flow-undo": "annulla",
+ "flow-undo-latest-revision": "Ultima revisione",
+ "flow-undo-your-text": "Il tuo testo",
+ "flow-undo-edit-header": "Modificare l'intestazione",
+ "flow-undo-edit-post": "Modificare un post",
+ "flow-undo-edit-content": "Questa modifica può essere annullata.\nControlla le differenze mostrate sotto fra le due versioni per verificare che il contenuto corrisponda a quanto desiderato, e quindi salvare le modifiche per completare la procedura di annullamento.",
+ "flow-undo-edit-failure": "Impossibile annullare la modifica a causa di un conflitto con modifiche intermedie."
+}
diff --git a/Flow/i18n/ja.json b/Flow/i18n/ja.json
new file mode 100644
index 00000000..426e0e7f
--- /dev/null
+++ b/Flow/i18n/ja.json
@@ -0,0 +1,342 @@
+{
+ "@metadata": {
+ "authors": [
+ "Fryed-peach",
+ "Kanon und wikipedia",
+ "Shirayuki",
+ "Whym",
+ "SkyDaisy9"
+ ]
+ },
+ "flow-desc": "ワークフロー管理システム",
+ "flow-talk-taken-over": "このトークページでは [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow] を使用しています。",
+ "log-name-flow": "Flow活動記録",
+ "logentry-delete-flow-delete-post": "$1 が [[$3]] の[$4 投稿]を{{GENDER:$2|削除}}",
+ "logentry-delete-flow-restore-post": "$1 が [[$3]] の[$4 投稿]を{{GENDER:$2|復元}}",
+ "logentry-suppress-flow-suppress-post": "$1 が [[$3]] の[$4 投稿]を{{GENDER:$2|秘匿}}",
+ "logentry-suppress-flow-restore-post": "$1 が [[$3]] の[$4 投稿]を{{GENDER:$2|削除}}",
+ "logentry-delete-flow-delete-topic": "$1 が [[$3]] の[$4 話題]を{{GENDER:$2|削除}}",
+ "logentry-delete-flow-restore-topic": "$1 が [[$3]] の[$4 話題]を{{GENDER:$2|復元}}",
+ "logentry-suppress-flow-suppress-topic": "$1 が [[$3]] の[$4 話題]を{{GENDER:$2|秘匿}}",
+ "logentry-suppress-flow-restore-topic": "$1 が [[$3]] の[$4 話題]を{{GENDER:$2|削除}}",
+ "flow-edit-header-link": "ヘッダーを編集",
+ "flow-post-moderated-toggle-hide-show": "$2 が{{GENDER:$1|非表示にした}}コメントを表示",
+ "flow-post-moderated-toggle-delete-show": "$2 が{{GENDER:$1|削除した}}コメントを表示",
+ "flow-post-moderated-toggle-hide-hide": "$2 が{{GENDER:$1|非表示にした}}コメントを非表示",
+ "flow-post-moderated-toggle-delete-hide": "$2 が{{GENDER:$1|削除した}}コメントを非表示",
+ "flow-topic-moderated-reason-prefix": "理由:",
+ "flow-hide-post-content": "このコメントは $1 によって{{GENDER:$1|非表示にされました}}",
+ "flow-hide-title-content": "この話題は $1 によって{{GENDER:$1|非表示にされました}}",
+ "flow-hide-header-content": "$2 が{{GENDER:$1|非表示にしました}}",
+ "flow-delete-post-content": "このコメントは $1 によって{{GENDER:$1|削除されました}}",
+ "flow-delete-title-content": "この話題は $1 によって{{GENDER:$1|削除されました}}",
+ "flow-delete-header-content": "$2 が{{GENDER:$1|削除しました}}",
+ "flow-suppress-post-content": "このコメントは $1 によって{{GENDER:$1|秘匿されました}}",
+ "flow-suppress-title-content": "この話題は $1 によって{{GENDER:$1|秘匿されました}}",
+ "flow-suppress-header-content": "$2 が{{GENDER:$1|秘匿しました}}",
+ "flow-suppress-usertext": "<em>利用者名は秘匿されています</em>",
+ "flow-post-actions": "操作",
+ "flow-topic-actions": "操作",
+ "flow-cancel": "キャンセル",
+ "flow-preview": "プレビュー",
+ "flow-show-change": "差分を表示",
+ "flow-last-modified-by": "最終{{GENDER:$1|更新}}者: $1",
+ "flow-stub-post-content": "''技術的な問題が発生したため、この投稿を取得できませんでした。''",
+ "flow-newtopic-title-placeholder": "新しい話題",
+ "flow-newtopic-content-placeholder": "「$1」に新規メッセージを投稿",
+ "flow-newtopic-header": "新しい話題の追加",
+ "flow-newtopic-save": "話題を追加",
+ "flow-newtopic-start-placeholder": "新しい話題の作成",
+ "flow-reply-topic-placeholder": "「$2」に{{GENDER:$1|コメントする}}",
+ "flow-reply-topic-title-placeholder": "「$1」への返信",
+ "flow-reply-submit": "{{GENDER:$1|返信}}",
+ "flow-reply-link": "{{GENDER:$1|返信}}",
+ "flow-thank-link": "{{GENDER:$1|感謝}}",
+ "flow-lock-link": "{{GENDER:$1|ロック}}",
+ "flow-post-edited": "$1 が $2 に{{GENDER:$1|編集した}}投稿",
+ "flow-post-action-view": "固定リンク",
+ "flow-post-action-post-history": "履歴",
+ "flow-post-action-suppress-post": "秘匿",
+ "flow-post-action-delete-post": "削除",
+ "flow-post-action-hide-post": "非表示にする",
+ "flow-post-action-edit-post": "編集",
+ "flow-post-action-edit-post-submit": "変更を保存",
+ "flow-post-action-unsuppress-post": "秘匿を解除",
+ "flow-post-action-undelete-post": "削除を解除",
+ "flow-post-action-unhide-post": "非表示を解除",
+ "flow-post-action-restore-post": "復元",
+ "flow-topic-action-view": "固定リンク",
+ "flow-topic-action-watchlist": "ウォッチリスト",
+ "flow-topic-action-edit-title": "題名を編集",
+ "flow-topic-action-history": "履歴",
+ "flow-topic-action-hide-topic": "話題を非表示にする",
+ "flow-topic-action-delete-topic": "話題を削除",
+ "flow-topic-action-lock-topic": "話題をロック",
+ "flow-topic-action-unlock-topic": "話題をロック解除",
+ "flow-topic-action-summarize-topic": "要約",
+ "flow-topic-action-resummarize-topic": "要約を編集",
+ "flow-topic-action-suppress-topic": "話題を秘匿",
+ "flow-topic-action-unhide-topic": "話題の非表示を解除",
+ "flow-topic-action-undelete-topic": "話題の削除を解除",
+ "flow-topic-action-unsuppress-topic": "話題の秘匿を解除",
+ "flow-topic-action-restore-topic": "話題を復元",
+ "flow-topic-action-undo-moderation": "取り消す",
+ "flow-topic-notification-subscribe-title": "この話題を{{GENDER:$1|}}ウォッチリストに追加しました。",
+ "flow-topic-notification-subscribe-description": "この話題に関する全ての活動の通知が{{GENDER:$1|あなた}}に届きます。",
+ "flow-board-notification-subscribe-title": "",
+ "flow-board-notification-subscribe-description": "この掲示板で新しい話題が立ち上がった時には{{GENDER:$1|あなた}}に通知が届きます。",
+ "flow-error-http": "サーバーとの通信中にエラーが発生しました。",
+ "flow-error-other": "予期しないエラーが発生しました。",
+ "flow-error-external": "エラーが発生しました。<br />受信したエラーメッセージ: $1",
+ "flow-error-edit-restricted": "あなたはこの投稿を編集を許可されていません。",
+ "flow-error-external-multi": "複数のエラーが発生しました。<br /> $1",
+ "flow-error-missing-content": "投稿の本文がありません。投稿を保存するには本文が必要です。",
+ "flow-error-missing-summary": "要約の内容がありません。要約を保存するには内容が必要です。",
+ "flow-error-missing-title": "話題の題名がありません。話題を保存するには題名が必要です。",
+ "flow-error-parsoid-failure": "Parsoid でエラーが発生したため、本文を構文解析できませんでした。",
+ "flow-error-missing-replyto": "「返信先」のパラメーターを指定していません。「返信」するには、このパラメーターが必要です。",
+ "flow-error-invalid-replyto": "「返信先」のパラメーターが無効です。指定した投稿が見つかりませんでした。",
+ "flow-error-delete-failure": "この項目を削除できませんでした。",
+ "flow-error-hide-failure": "この項目を非表示にできませんでした。",
+ "flow-error-missing-postId": "「投稿 ID」のパラメーターを指定していません。投稿を操作するには、このパラメーターが必要です。",
+ "flow-error-invalid-postId": "「投稿 ID」のパラメーターが無効です。指定した投稿 ($1) が見つかりませんでした。",
+ "flow-error-restore-failure": "この項目を復元できませんでした。",
+ "flow-error-invalid-moderation-state": "moderationState に指定した値は無効です。",
+ "flow-error-not-allowed": "この操作を実行するのに十分な権限がありません。",
+ "flow-error-title-too-long": "話題の題名は $1 {{PLURAL:$1|バイト}}までに制限されています。",
+ "flow-error-no-existing-workflow": "このワークフローはまだ存在しません。",
+ "flow-error-not-a-post": "話題の題名は投稿としては保存できません。",
+ "flow-error-missing-header-content": "ヘッダーの本文がありません。ヘッダーを保存するには本文が必要です。",
+ "flow-error-missing-prev-revision-identifier": "以前の版の ID がありません。",
+ "flow-error-prev-revision-mismatch": "数秒前に別の利用者がこの投稿を編集しました。{{GENDER:$3|}}この最新の変更に本当に上書きしますか?",
+ "flow-error-prev-revision-does-not-exist": "過去の版が見つかりませんでした。",
+ "flow-error-default": "エラーが発生しました。",
+ "flow-error-invalid-input": "Flow の本文の読み込みについて無効な値を指定しました。",
+ "flow-error-invalid-title": "無効なページ名を指定しました。",
+ "flow-error-fail-load-history": "履歴の内容を読み込めませんでした。",
+ "flow-error-missing-revision": "Flow の本文を読み込むための版が見つかりませんでした。",
+ "flow-error-fail-commit": "Flow の本文を保存できませんでした。",
+ "flow-error-insufficient-permission": "その内容にアクセスするのに十分な権限がありません。",
+ "flow-error-revision-comparison": "差分の操作は、2 つの版が同一の投稿に属する場合のみ実行できます。",
+ "flow-error-missing-topic-title": "現在のワークフローについて話題の題名が見つかりませんでした。",
+ "flow-error-fail-load-data": "要求したデータを読み込めませんでした。",
+ "flow-error-invalid-workflow": "要求したワークフローが見つかりませんでした。",
+ "flow-error-process-data": "要求されたデータを処理する際にエラーが発生しました。",
+ "flow-error-process-wikitext": "HTML/ウィキテキスト変換を処理する際にエラーが発生しました。",
+ "flow-error-no-index": "データ検索を実行するためのインデックスが見つかりませんでした。",
+ "flow-error-content-too-long": "本文が長すぎます。本文の展開後のサイズが $1 {{PLURAL:$1|バイト}}までに制限されています。",
+ "flow-error-move": "現在、議論掲示板の移動には対応していません。",
+ "flow-edit-header-placeholder": "この議論掲示板について説明してください",
+ "flow-edit-header-submit": "ヘッダーを保存",
+ "flow-edit-header-submit-overwrite": "ヘッダーを上書き",
+ "flow-summarize-topic-submit": "要約",
+ "flow-edit-title-submit": "題名を変更",
+ "flow-edit-title-submit-overwrite": "題名を上書き",
+ "flow-edit-post-submit": "変更を保存",
+ "flow-edit-post-submit-overwrite": "変更を上書き",
+ "flow-rev-message-edit-post": "$1 が「$4」の[$3 コメント]を{{GENDER:$2|編集}}",
+ "flow-rev-message-edit-post-recentchanges-summary": "投稿を{{GENDER:$2|編集}}",
+ "flow-rev-message-reply": "$1 が「$4」に[$3 {{GENDER:$2|コメントを追加}}] (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|件のコメント}}</strong>が追加{{PLURAL:$1|されました}}",
+ "flow-rev-message-new-post": "$1 が話題「[$3 $4]」を{{GENDER:$2|作成}}",
+ "flow-rev-message-new-post-recentchanges-summary": "話題を新規{{GENDER:$2|作成}}",
+ "flow-rev-message-edit-title": "$1 が話題の題名を「$5」から「[$3 $4]」に{{GENDER:$2|変更}}",
+ "flow-rev-message-create-header": "$1 がヘッダーを{{GENDER:$2|作成}}",
+ "flow-rev-message-edit-header": "$1 がヘッダーを{{GENDER:$2|編集}}",
+ "flow-rev-message-create-topic-summary": "$1 が $3 で話題の要約を{{GENDER:$2|作成}}",
+ "flow-rev-message-edit-topic-summary": "$1 が $3 で話題の要約を{{GENDER:$2|編集}}",
+ "flow-rev-message-hid-post": "$1 が「$6」の[$4 コメント]を{{GENDER:$2|非表示化}} (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 が「$6」の[$4 コメント]を{{GENDER:$2|削除}} (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 が「$6」の[$4 コメント]を{{GENDER:$2|秘匿}} (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 が「$6」の[$4 コメント]を{{GENDER:$2|復元}} (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 が[$4 話題]「$6」を{{GENDER:$2|非表示化}} (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 が[$4 話題]「$6」を{{GENDER:$2|削除}} (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 が[$4 話題]「$6」を{{GENDER:$2|秘匿}} (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 が[$4 話題]「$6」を{{GENDER:$2|復元}} (<em>$5</em>)",
+ "flow-board-history": "「$1」の履歴",
+ "flow-board-history-empty": "現在、この掲示板には履歴がありません。",
+ "flow-topic-history": "話題「$1」の履歴",
+ "flow-post-history": "「{{GENDER:$2|$2}} によるコメント」投稿履歴",
+ "flow-history-last4": "過去 4 時間",
+ "flow-history-day": "今日",
+ "flow-history-week": "過去 1 週間",
+ "flow-history-pages-topic": "[$1 掲示板「$2」]に出現",
+ "flow-history-pages-post": "[$1 $2]に出現",
+ "flow-topic-comments": "{{PLURAL:$1|$1 件のコメント|0=最初のコメントを{{GENDER:$2|書きましょう}}!}}",
+ "flow-comment-restored": "コメントを復元",
+ "flow-comment-deleted": "コメントを削除",
+ "flow-comment-hidden": "コメントを非表示",
+ "flow-last-modified": "最終更新 $1",
+ "flow-workflow": "ワークフロー",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 が '''$4''' で{{GENDER:$1|返信しました}}。",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 と他 $5 {{PLURAL:$6|人}}が '''$3''' で{{GENDER:$1|返信しました}}。",
+ "flow-notification-edit": "$1 が [[$3|$4]] の「$2」での<span class=\"plainlinks\">[$5 投稿]</span>を{{GENDER:$1|編集しました}}。",
+ "flow-notification-edit-bundle": "$1 と他 $5 {{PLURAL:$6|人}}が「$3」の「$2」での<span class=\"plainlinks\">[$4 投稿]</span>を{{GENDER:$1|編集しました}}。",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 が '''$3''' で 新しい話題を{{GENDER:$1|作成しました}}。",
+ "flow-notification-rename": "$1 が [[$5|$6]] で <span class=\"plainlinks\">[$2 $3]</span> のページ名を「$4」に{{GENDER:$1|変更しました}}。",
+ "flow-notification-mention": "$1 が「$4」の「$3」での{{GENDER:$1|自身の}}<span class=\"plainlinks\">[$2 投稿]</span>で{{GENDER:$5|あなた}}に{{GENDER:$1|言及しました}}。",
+ "flow-notification-link-text-view-post": "投稿を閲覧",
+ "flow-notification-link-text-view-topic": "話題を閲覧",
+ "flow-notification-reply-email-subject": "$3 上の $2",
+ "flow-notification-reply-email-batch-body": "$1 が「$3」の「$2」に{{GENDER:$1|返信しました}}",
+ "flow-notification-reply-email-batch-bundle-body": "$1 と他 $4 {{PLURAL:$5|人}}が「$3」の「$2」に{{PLURAL:$1|返信しました}}",
+ "flow-notification-mention-email-subject": "$1 が「$2」で{{GENDER:$3|あなた}}に{{GENDER:$1|言及しました}}",
+ "flow-notification-mention-email-batch-body": "$1 が「$3」の「$2」での{{GENDER:$1|自身の}}投稿で{{GENDER:$4|あなた}}に{{GENDER:$1|言及しました}}",
+ "flow-notification-edit-email-subject": "$1 が投稿を{{GENDER:$1|編集しました}}",
+ "flow-notification-edit-email-batch-body": "$1 が「$3」の「$2」で投稿を{{GENDER:$1|編集しました}}",
+ "flow-notification-edit-email-batch-bundle-body": "$1 と他 $4 {{PLURAL:$5|人}}が「$3」の「$2」での投稿を{{GENDER:$1|編集しました}}",
+ "flow-notification-rename-email-subject": "$1 があなたの話題の{{GENDER:$1|題名を変更しました}}",
+ "flow-notification-rename-email-batch-body": "$1 が「$4」のあなたの話題「$2」の題名を「$3」に{{GENDER:$1|変更しました}}",
+ "flow-notification-newtopic-email-subject": "$1 が「$2」に新しい話題を{{GENDER:$1|作成しました}}",
+ "flow-notification-newtopic-email-batch-body": "$1 が $3 で新しい話題「$2」を{{GENDER:$1|作成しました}}",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Flow で私に関連する操作がなされたときに通知する。",
+ "flow-link-post": "投稿",
+ "flow-link-topic": "話題",
+ "flow-link-history": "履歴",
+ "flow-link-post-revision": "投稿の版",
+ "flow-link-topic-revision": "話題の版",
+ "flow-link-header-revision": "ヘッダーの版",
+ "flow-moderation-title-suppress-post": "投稿を秘匿しますか?",
+ "flow-moderation-title-delete-post": "投稿を削除しますか?",
+ "flow-moderation-title-hide-post": "投稿を非表示にしますか?",
+ "flow-moderation-placeholder-suppress-post": "この投稿を秘匿する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-delete-post": "この投稿を削除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-hide-post": "この投稿を非表示にする理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-unsuppress-post": "この投稿の秘匿を解除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-undelete-post": "この投稿を復元する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-unhide-post": "この投稿の非表示を解除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-confirm-suppress-post": "秘匿",
+ "flow-moderation-confirm-delete-post": "削除",
+ "flow-moderation-confirm-hide-post": "非表示にする",
+ "flow-moderation-confirm-unsuppress-post": "秘匿を解除",
+ "flow-moderation-confirm-undelete-post": "復元",
+ "flow-moderation-confirm-unhide-post": "非表示を解除",
+ "flow-moderation-confirm-suppress-topic": "秘匿",
+ "flow-moderation-confirm-delete-topic": "削除",
+ "flow-moderation-confirm-hide-topic": "非表示にする",
+ "flow-moderation-confirm-unsuppress-topic": "秘匿を解除",
+ "flow-moderation-confirm-undelete-topic": "復元",
+ "flow-moderation-confirm-unhide-topic": "非表示を解除",
+ "flow-moderation-confirmation-suppress-topic": "この話題を秘匿しました。",
+ "flow-moderation-confirmation-delete-topic": "この話題を削除しました。",
+ "flow-moderation-confirmation-hide-topic": "この話題を非表示にしました。",
+ "flow-moderation-confirmation-unsuppress-topic": "この話題の秘匿を解除しました。",
+ "flow-moderation-confirmation-undelete-topic": "この話題を復元しました。",
+ "flow-moderation-confirmation-unhide-topic": "この話題の非表示を解除しました。",
+ "flow-moderation-title-suppress-topic": "話題を秘匿しますか?",
+ "flow-moderation-title-delete-topic": "話題を削除しますか?",
+ "flow-moderation-title-hide-topic": "話題を非表示にしますか?",
+ "flow-moderation-placeholder-suppress-topic": "この話題を秘匿する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-delete-topic": "この話題を削除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-hide-topic": "この話題を非表示にする理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-unsuppress-topic": "この話題の秘匿を解除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-undelete-topic": "この話題を復元する理由を{{GENDER:$3|説明}}してください。",
+ "flow-moderation-placeholder-unhide-topic": "この話題の非表示を解除する理由を{{GENDER:$3|説明}}してください。",
+ "flow-topic-permalink-warning": "この話題は [$2 $1] で開始されました",
+ "flow-topic-permalink-warning-user-board": "この話題は [$2 {{GENDER:$1|$1}} の掲示板]で開始されました",
+ "flow-revision-permalink-warning-post": "これはこの投稿の特定の版への固定リンクです。\nこの版は $1 時点のものです。\n[$5 以前の版との差分]や、[$4 投稿の履歴ページ]でその他の版を閲覧することもできます。",
+ "flow-revision-permalink-warning-post-first": "これはこの投稿の初版への固定リンクです。\n[$4 投稿の履歴ページ]で以降の版を閲覧できます。",
+ "flow-revision-permalink-warning-header": "これはヘッダーの特定の版への固定リンクです。\nこの版は $1 時点のものです。[$3 以前の版との差分]や、[$2 掲示板の履歴ページ]でその他の版を閲覧することもできます。",
+ "flow-revision-permalink-warning-header-first": "これはヘッダーの初版への固定リンクです。\n[$2 掲示板の履歴ページ]で以降の版を閲覧できます。",
+ "flow-compare-revisions-revision-header": "$1における {{GENDER:$2|$2}} による版",
+ "flow-compare-revisions-header-post": "このページでは、[$4 $1] の話題「[$5 $2]」での $3 の投稿の 2 つの版の{{GENDER:$3|差分}}を表示しています。\nこの投稿の[$6 履歴ページ]でその他の版を閲覧できます。",
+ "flow-compare-revisions-header-postsummary": "このページでは、[$3 $1] の投稿「[$4 $2]」の要約の 2 つの版の差分を表示しています。\nこの要約の[$5 履歴ページ]でその他の版を閲覧できます。",
+ "flow-compare-revisions-header-header": "このページでは、[$3 $1] のヘッダーの 2 つの版の{{GENDER:$2|差分}}を表示しています。\nこのヘッダーの[$4 履歴ページ]でその他の版を閲覧できます。",
+ "flow-topic-collapsed-one-line": "縮小表示",
+ "flow-topic-collapsed-full": "折りたたみ表示",
+ "flow-topic-complete": "全体表示",
+ "right-flow-delete": "Flow の話題や投稿を削除",
+ "right-flow-edit-post": "他の利用者の Flow の投稿を編集",
+ "right-flow-suppress": "Flow の版を秘匿",
+ "flow-terms-of-use-new-topic": "「{{int:flow-newtopic-save}}」をクリックすると、このウィキの利用規約に同意したと見なされます。",
+ "flow-terms-of-use-reply": "「{{int:flow-reply-submit}}」をクリックすると、このウィキの利用規約に同意したと見なされます。",
+ "flow-terms-of-use-edit": "変更内容を保存すると、このウィキの利用規約に同意したと見なされます。",
+ "flow-anon-warning": "ログインしていません。IP アドレスではなく名前が帰属として表示されるようにするには、[$1 ログイン]または[$2 アカウント作成]をしてください。",
+ "flow-topic-html-title": "$1 - $2",
+ "flow-load-more": "続きを表示",
+ "flow-recent-topics": "最近活発な話題",
+ "flow-toggle-topics": "話題のみの表示に切り替える",
+ "flow-toggle-topics-posts": "話題と投稿の表示に切り替える",
+ "flow-terms-of-use-summarize": "「{{int:flow-summarize-topic-submit}}」をクリックすると、このウィキの利用規約に同意したと見なされます。",
+ "flow-whatlinkshere-post": "[$1 投稿]から",
+ "flow-whatlinkshere-header": "[$1 ヘッダー]から",
+ "flow": "Flow",
+ "flow-special-type": "種類",
+ "flow-special-type-post": "投稿",
+ "flow-special-type-workflow": "ワークフロー",
+ "flow-special-uuid": "UUID",
+ "flow-preview-return-edit-post": "編集を続行",
+ "flow-anonymous": "匿名",
+ "apihelp-flow-description": "Flow ページに対して操作を行います。",
+ "apihelp-flow-param-submodule": "実行する Flow サブモジュールです。",
+ "apihelp-flow-param-page": "操作を行うページです。",
+ "apihelp-flow-param-render": "出力にブロック固有のレンダリングを含めるには、これに何らかの値を設定してください。",
+ "apihelp-flow-example-1": "[[Talk:Sandbox]] のヘッダーを編集",
+ "apihelp-flow+close-open-topic-param-moderationState": "変更後の話題の状態であり、locked または unlocked です。",
+ "apihelp-flow+close-open-topic-param-reason": "話題をロックまたはロック解除する理由です。",
+ "apihelp-flow+edit-header-description": "話題のヘッダーを編集します。",
+ "apihelp-flow+edit-header-param-prev_revision": "現在のヘッダーの版 ID であり、編集競合を確認するためのものです。",
+ "apihelp-flow+edit-header-param-content": "ヘッダーの内容です。",
+ "apihelp-flow+edit-header-example-1": "[[Talk:Sandbox]] のヘッダーを編集",
+ "apihelp-flow+edit-post-description": "投稿の本文を編集します。",
+ "apihelp-flow+edit-post-param-postId": "投稿 ID です。",
+ "apihelp-flow+edit-post-param-prev_revision": "現在の投稿の版 ID であり、編集競合を確認するためのものです。",
+ "apihelp-flow+edit-post-param-content": "投稿の本文です。",
+ "apihelp-flow+edit-post-example-1": "[[Topic:S2tycnas4hcucw8w]] の投稿を編集します。",
+ "apihelp-flow+edit-title-description": "話題の題名を編集します。",
+ "apihelp-flow+edit-title-param-prev_revision": "現在のヘッダーの版 ID であり、編集競合を確認するためのものです。",
+ "apihelp-flow+edit-title-param-content": "題名の内容です。",
+ "apihelp-flow+edit-title-example-1": "[[Topic:S2tycnas4hcucw8w]] の題名を編集",
+ "apihelp-flow+edit-topic-summary-description": "話題の要約の内容を編集します。",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "現在の話題の要約の版 ID がある場合は指定します。編集競合を確認するためのものです。",
+ "apihelp-flow+edit-topic-summary-param-summary": "要約の内容です。",
+ "apihelp-flow+edit-topic-summary-example-1": "[[Topic:S2tycnas4hcucw8w]] の要約を編集",
+ "apihelp-flow+lock-topic-description": "Flow の話題をロックまたはロック解除します。",
+ "apihelp-flow+lock-topic-param-moderationState": "変更後の話題の状態であり、locked または unlocked です。",
+ "apihelp-flow+lock-topic-param-reason": "話題をロックまたはロック解除する理由です。",
+ "apihelp-flow+lock-topic-example-1": "[[Topic:S2tycnas4hcucw8w]] をロック",
+ "apihelp-flow+moderate-post-example-1": "話題 [[Topic:S2tycnas4hcucw8w]] の投稿を削除",
+ "apihelp-flow+moderate-topic-example-1": "話題 [[Topic:S2tycnas4hcucw8w]] を削除",
+ "apihelp-flow+new-topic-description": "指定したワークフローに Flow の話題を新規作成します。",
+ "apihelp-flow+new-topic-param-topic": "新しい話題のヘッダーのテキストです。",
+ "apihelp-flow+new-topic-param-content": "新しい話題の本文です。",
+ "apihelp-flow+new-topic-example-1": "[[Talk:Sandbox]] に話題を新規作成",
+ "apihelp-flow+reply-description": "投稿に返信します。",
+ "apihelp-flow+reply-param-replyTo": "返信先の投稿の ID です。",
+ "apihelp-flow+reply-param-content": "新しい話題の本文です。",
+ "apihelp-flow+reply-example-1": "[[Topic:S2tycnas4hcucw8w]] の投稿に返信",
+ "apihelp-flow+view-header-description": "掲示板のヘッダーを閲覧します。",
+ "apihelp-flow+view-header-param-contentFormat": "返す本文の形式です。",
+ "apihelp-flow+view-header-param-revId": "最新版の代わりに、この版を読み込みます。",
+ "apihelp-flow+view-header-example-1": "[[Talk:Sandbox]] のヘッダーをウィキテキスト形式で取得",
+ "apihelp-flow+view-post-description": "投稿を閲覧します。",
+ "apihelp-flow+view-post-param-postId": "閲覧する投稿の ID です。",
+ "apihelp-flow+view-post-param-contentFormat": "返す本文の形式です。",
+ "apihelp-flow+view-post-example-1": "[[Topic:S2tycnas4hcucw8w]] の投稿の本文をウィキテキスト形式で取得",
+ "apihelp-flow+view-topic-description": "話題を閲覧します。",
+ "apihelp-flow+view-topic-example-1": "[[Topic:S2tycnas4hcucw8w]] を閲覧",
+ "apihelp-flow+view-topic-summary-description": "話題の要約を閲覧します。",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "返す本文の形式です。",
+ "apihelp-flow+view-topic-summary-param-revId": "最新版の代わりに、この版を読み込みます。",
+ "apihelp-flow+view-topic-summary-example-1": "[[Topic:S2tycnas4hcucw8w]] の要約をウィキテキスト形式で閲覧",
+ "apihelp-flow+view-topiclist-description": "話題の一覧を閲覧します。",
+ "apihelp-flow+view-topiclist-param-offset-dir": "話題の並び順指定です。",
+ "apihelp-flow+view-topiclist-param-sortby": "話題の並べ替えのオプションです。",
+ "apihelp-flow+view-topiclist-param-savesortby": "設定すると、並べ替えのオプションを保存します。",
+ "apihelp-flow+view-topiclist-param-offset-id": "話題の取得を開始するオフセット値 (UUID 形式) です。",
+ "apihelp-flow+view-topiclist-param-offset": "話題の取得を開始するオフセット値です。",
+ "apihelp-flow+view-topiclist-param-limit": "取得する話題の件数です。",
+ "apihelp-flow+view-topiclist-param-render": "話題を HTML 形式でレンダリングします。",
+ "apihelp-flow+view-topiclist-example-1": "[[Talk:Sandbox]] の話題を列挙",
+ "apihelp-flow-parsoid-utils-description": "テキストをウィキテキスト形式と HTML 形式の間で相互変換します。",
+ "apihelp-flow-parsoid-utils-param-from": "変換元の形式です。",
+ "apihelp-flow-parsoid-utils-param-to": "変換先の形式です。",
+ "apihelp-flow-parsoid-utils-param-content": "変換する内容です。",
+ "apihelp-flow-parsoid-utils-param-title": "ページ名です。$1pageid とは共存できません。",
+ "apihelp-flow-parsoid-utils-param-pageid": "ページの ID です。$1title とは共存できません。",
+ "apihelp-flow-parsoid-utils-example-1": "ウィキテキスト <nowiki>'''lorem''' ''blah''</nowiki> を HTML 形式に変換",
+ "apihelp-query+flowinfo-description": "ページの Flow の基礎的な情報を取得します。",
+ "apihelp-query+flowinfo-example-1": "[[Talk:Sandbox]]、[[Main Page]]、[[Talk:Flow]] の Flow の情報を取得",
+ "flow-edited-by": "$1 が編集しました"
+}
diff --git a/Flow/i18n/jam.json b/Flow/i18n/jam.json
new file mode 100644
index 00000000..e2fe22cf
--- /dev/null
+++ b/Flow/i18n/jam.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chabi1"
+ ]
+ },
+ "flow-error-invalid-input": "No fain no rivijan fi luod Fluo kantent.",
+ "flow-error-missing-revision": "Piej yuuz jupliket agiument ina templet kaal",
+ "flow-error-fail-commit": "Fluo kantent no sieb."
+}
diff --git a/Flow/i18n/jbo.json b/Flow/i18n/jbo.json
new file mode 100644
index 00000000..1486e4a5
--- /dev/null
+++ b/Flow/i18n/jbo.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gleki"
+ ]
+ },
+ "log-name-flow": "flecu fasnu citri",
+ "flow-post-actions": "loi se zukte",
+ "flow-topic-actions": "loi se zukte",
+ "flow-newtopic-save": "jmina la'e se casnu",
+ "flow-post-action-delete-post": "daspo",
+ "flow-post-action-hide-post": "cancygau",
+ "flow-post-action-edit-post": "stika lo se mrilu",
+ "echo-category-title-flow-discussion": "lo flecu",
+ "flow-link-topic": "lo se casnu",
+ "flow-link-history": "lo citri"
+}
diff --git a/Flow/i18n/ka.json b/Flow/i18n/ka.json
new file mode 100644
index 00000000..a33188d6
--- /dev/null
+++ b/Flow/i18n/ka.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "David1010",
+ "MIKHEIL"
+ ]
+ },
+ "flow-post-action-hide-post": "დამალვა",
+ "flow-moderation-confirm-delete-post": "წაშლა",
+ "flow-moderation-confirm-hide-post": "დამალვა",
+ "flow-moderation-confirm-delete-topic": "წაშლა",
+ "flow-moderation-confirm-hide-topic": "დამალვა",
+ "flow-importer-lqt-converted-archive-template": "არქივი კონვერტირებული LQT გვერდისთვის"
+}
diff --git a/Flow/i18n/km.json b/Flow/i18n/km.json
new file mode 100644
index 00000000..d5b1ce92
--- /dev/null
+++ b/Flow/i18n/km.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sovichet"
+ ]
+ },
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|បាន​បង្កើត}}​ប្រធានបទ​សង្ខេប​នៅ $3។"
+}
diff --git a/Flow/i18n/kn.json b/Flow/i18n/kn.json
new file mode 100644
index 00000000..04fd18de
--- /dev/null
+++ b/Flow/i18n/kn.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "VASANTH S.N."
+ ]
+ },
+ "flow-topic-action-resummarize-topic": "ಸಂಪಾದನೆಯ ತಾತ್ಪರ್ಯ"
+}
diff --git a/Flow/i18n/ko.json b/Flow/i18n/ko.json
new file mode 100644
index 00000000..872bddfd
--- /dev/null
+++ b/Flow/i18n/ko.json
@@ -0,0 +1,305 @@
+{
+ "@metadata": {
+ "authors": [
+ "Clockoon",
+ "Daisy2002",
+ "Hym411",
+ "Jskang",
+ "Priviet",
+ "Yjs5497",
+ "아라",
+ "관인생략",
+ "Keysuck",
+ "Revi",
+ "Gusdud25",
+ "Infinity",
+ "SeoJeongHo"
+ ]
+ },
+ "flow-desc": "워크플로우 관리 시스템",
+ "flow-talk-taken-over": "이 토론 문서는 [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal 플로우]를 사용합니다.",
+ "flow-talk-username": "플로우 토론 문서 관리",
+ "log-name-flow": "플로우 활동 기록",
+ "logentry-delete-flow-delete-post": "$1 사용자가 [[$3]] 문서의 [$4 게시물]을 {{GENDER:$2|삭제했습니다}}",
+ "logentry-delete-flow-restore-post": "$1 사용자가 [[$3]] 문서의 [$4 게시물]을 {{GENDER:$2|되살렸습니다}}",
+ "logentry-suppress-flow-suppress-post": "$1 사용자가 [[$3]] 문서의 [$4 게시물]을 {{GENDER:$2|숨겼습니다}}",
+ "logentry-suppress-flow-restore-post": "$1 사용자가 [[$3]] 문서의 [$4 게시물]을 {{GENDER:$2|삭제했습니다}}",
+ "logentry-delete-flow-delete-topic": "$1 사용자가 [[$3]] 문서의 [$4 주제]를 {{GENDER:$2|삭제했습니다}}",
+ "logentry-delete-flow-restore-topic": "$1 사용자가 [[$3]] 문서의 [$4 주제]를 {{GENDER:$2|복원했습니다}}",
+ "logentry-suppress-flow-suppress-topic": "$1 사용자가 [[$3]] 문서의 [$4 주제]를 {{GENDER:$2|숨겼습니다}}",
+ "logentry-suppress-flow-restore-topic": "$1 사용자가 [[$3]] 문서의 [$4 주제]를 {{GENDER:$2|삭제했습니다}}",
+ "flow-user-moderated": "중재된 사용자",
+ "flow-edit-header-link": "머리말 고치기",
+ "flow-post-moderated-toggle-hide-show": "$2 사용자가 {{GENDER:$1|표시 안 함으로 설정한}} 댓글 보이기",
+ "flow-post-moderated-toggle-delete-show": "$2 사용자가 {{GENDER:$1|삭제한}} 댓글 보이기",
+ "flow-post-moderated-toggle-suppress-show": "$2 사용자가 {{GENDER:$1|표시하지 않도록 한}} 덧글 보기",
+ "flow-post-moderated-toggle-hide-hide": "$2 사용자가 {{GENDER:$1|표시 안 함으로 설정한}} 댓글 숨기기",
+ "flow-post-moderated-toggle-delete-hide": "$2 사용자가 {{GENDER:$1|삭제한}} 댓글 숨기기",
+ "flow-post-moderated-toggle-suppress-hide": "$2 사용자가 {{GENDER:$1|표시하지 않도록 한}} 덧글 숨기기",
+ "flow-topic-moderated-reason-prefix": "이유:",
+ "flow-hide-post-content": "이 덧글은 $1 사용자에 의해 {{GENDER:$1|숨겨졌}}습니다",
+ "flow-hide-title-content": "이 주제는 $1 사용자에 의해 {{GENDER:$1|숨겨졌}}습니다",
+ "flow-hide-header-content": "$2 사용자가 {{GENDER:$1|숨김}}",
+ "flow-delete-post-content": "이 덧글은 $1 사용자에 의해 {{GENDER:$1|삭제}}되었습니다",
+ "flow-delete-title-content": "이 주제는 $1 사용자에 의해 {{GENDER:$1|삭제}}되었습니다",
+ "flow-delete-header-content": "$2 사용자가 {{GENDER:$1|삭제함}}",
+ "flow-suppress-post-content": "이 덧글은 $1 사용자가 {{GENDER:$1|표시하지 않도록}} 했습니다",
+ "flow-suppress-title-content": "이 주제는 $1 사용자가 {{GENDER:$1|표시하지 않도록}} 했습니다",
+ "flow-suppress-header-content": "$2 사용자가 {{GENDER:$1|표시하지 않도록 함}}",
+ "flow-suppress-usertext": "<em>사용자 이름을 표시하지 않음</em>",
+ "flow-post-actions": "동작",
+ "flow-topic-actions": "동작",
+ "flow-cancel": "취소",
+ "flow-preview": "미리 보기",
+ "flow-show-change": "차이 보기",
+ "flow-last-modified-by": "$1 사용자가 마지막으로 {{GENDER:$1|수정함}}",
+ "flow-stub-post-content": "\"기술적인 오류로 인하여 이 게시물을 가져올 수 없었습니다.\"",
+ "flow-newtopic-title-placeholder": "새 주제",
+ "flow-newtopic-content-placeholder": "\"$1\"에 새 메시지 남기기",
+ "flow-newtopic-header": "새 항목 추가",
+ "flow-newtopic-save": "새 항목",
+ "flow-newtopic-start-placeholder": "새 주제",
+ "flow-newtopic-first-heading": "$1에 새 주제 시작하기",
+ "flow-summarize-topic-placeholder": "이 토론을 요약해 주세요",
+ "flow-reply-topic-placeholder": "$1의 \"$2\"에 대한 의견",
+ "flow-reply-topic-title-placeholder": "\"$1\"에 답변하기",
+ "flow-reply-submit": "{{GENDER:$1|답변}}",
+ "flow-reply-link": "{{GENDER:$1|답변}}",
+ "flow-thank-link": "{{GENDER:$1|감사합니다}}",
+ "flow-post-edited": "$1 사용자가 $2에 게시물을 {{GENDER:$1|편집했습니다}}",
+ "flow-post-action-view": "고유링크",
+ "flow-post-action-post-history": "역사",
+ "flow-post-action-suppress-post": "표시 안 함",
+ "flow-post-action-delete-post": "삭제",
+ "flow-post-action-hide-post": "숨기기",
+ "flow-post-action-edit-post": "편집",
+ "flow-post-action-unsuppress-post": "숨김 해제",
+ "flow-post-action-undelete-post": "삭제 취소",
+ "flow-post-action-unhide-post": "숨기기 취소",
+ "flow-post-action-restore-post": "복구",
+ "flow-topic-action-view": "고유링크",
+ "flow-topic-action-watchlist": "주시문서 목록",
+ "flow-topic-action-edit-title": "제목 편집",
+ "flow-topic-action-history": "역사",
+ "flow-topic-action-hide-topic": "주제 숨기기",
+ "flow-topic-action-delete-topic": "주제 삭제",
+ "flow-topic-action-summarize-topic": "요약",
+ "flow-topic-action-resummarize-topic": "주제 요약 편집",
+ "flow-topic-action-suppress-topic": "주제 숨겨놓기",
+ "flow-topic-action-unhide-topic": "주제 숨기기",
+ "flow-topic-action-undelete-topic": "주제 삭제 취소",
+ "flow-topic-action-unsuppress-topic": "주제 보여주기",
+ "flow-topic-action-restore-topic": "항목 복원",
+ "flow-error-http": "서버와 만나는 동안 오류가 발생했습니다.",
+ "flow-error-other": "예기치 않은 오류가 발생했습니다.",
+ "flow-error-external": "오류가 발생했습니다.<br />받은 오류 메시지는: $1",
+ "flow-error-edit-restricted": "이 문서의 편집을 허용하지 않습니다.",
+ "flow-error-external-multi": "오류가 발생했습니다.<br />$1",
+ "flow-error-missing-content": "게시물에 내용이 없습니다. 게시물을 저장하려면 내용이 있어야 합니다.",
+ "flow-error-missing-summary": "편집 요약에 내용이 없습니다. 요약을 저장하려면 내용이 있어야 합니다.",
+ "flow-error-missing-title": "주제에 제목이 없습니다. 주제를 저장하려면 제목이 있어야 합니다.",
+ "flow-error-parsoid-failure": "Parsoid 오류로 인해 내용을 구문 분석할 수 없습니다.",
+ "flow-error-missing-replyto": "\"ReplyTo\" 매개변수는 지원되지 않습니다. 이 매개변수는 \"답변\" 명령에 대해 필요합니다.",
+ "flow-error-invalid-replyto": "\"replyTo\" 변수가 잘못되었습니다. 지정한 게시물을 찾을 수 없습니다.",
+ "flow-error-delete-failure": "이 항목을 삭제하는 데 실패했습니다.",
+ "flow-error-hide-failure": "이 항목을 표시하지 않음으로 설정하지 못하였습니다.",
+ "flow-error-missing-postId": "\"postId\" 매개변수를 지원하지 않습니다. 이 매개변수는 게시물을 조작하여야 합니다.",
+ "flow-error-invalid-postId": "\"postId\" 변수가 잘못되었습니다. 지정한 게시물($1)을 찾을 수 없습니다.",
+ "flow-error-restore-failure": "이 항목을 복원하는 데 실패했습니다.",
+ "flow-error-invalid-moderation-state": "유효하지 않은 값이 조정상태에 입력되었습니다.",
+ "flow-error-invalid-moderation-reason": "조정의 이유를 알려주세요.",
+ "flow-error-not-allowed": "이 명령을 실행할 권한이 부족합니다.",
+ "flow-error-title-too-long": "주제 제목은 $1 {{PLURAL:$1|바이트}}로 제한됩니다.",
+ "flow-error-no-existing-workflow": "이 워크플로우는 아직 존재하지 않습니다.",
+ "flow-error-not-a-post": "주제 제목은 기여로 저장할 수 없습니다.",
+ "flow-error-missing-header-content": "머릿글에 내용이 없습니다. 머릿글을 저장하려면 내용이 있어야 합니다.",
+ "flow-error-missing-prev-revision-identifier": "이전 판 식별자가 없습니다.",
+ "flow-error-prev-revision-mismatch": "다른 사용자가 이 게시물을 조금 전에 편집했습니다. 최근 바뀜을 덮어쓰시겠습니까?",
+ "flow-error-prev-revision-does-not-exist": "이전 판을 찾을 수 없습니다.",
+ "flow-error-default": "오류가 발생했습니다.",
+ "flow-error-invalid-input": "유효하지 않은 값은 플로우 콘텐츠를 불러오기 위해 입력됩니다.",
+ "flow-error-invalid-title": "유효하지 않은 문서 제목을 입력했습니다.",
+ "flow-error-fail-load-history": "역사 내용을 불러오는 데 실패했습니다.",
+ "flow-error-missing-revision": "플로우 내용을 불러오기 위한 판을 찾을 수 없습니다.",
+ "flow-error-fail-commit": "플로우 내용을 저장하는 데 실패했습니다.",
+ "flow-error-insufficient-permission": "내용에 접근하기 위한 권한이 부족합니다.",
+ "flow-error-revision-comparison": "차이 보기 명령은 같은 게시물의 두 개 판에 대해서만 이루어집니다.",
+ "flow-error-missing-topic-title": "현재 워크플로우에 대한 주제 제목을 찾을 수 없습니다.",
+ "flow-error-fail-load-data": "요청한 데이터를 불러오는 데 실패했습니다.",
+ "flow-error-invalid-workflow": "요청한 워크플로우를 찾을 수 없습니다.",
+ "flow-error-process-data": "당신의 요청 데이터를 처리하는 도중 오류가 발생했습니다.",
+ "flow-error-process-wikitext": "HTML/위키텍스트 대화를 처리하는 도중 오류가 발생했습니다.",
+ "flow-error-no-index": "데이터 검색을 수행하기 위한 인덱스를 찾는 데 실패했습니다.",
+ "flow-edit-header-submit": "머릿글을 저장",
+ "flow-edit-header-submit-overwrite": "머릿글을 덮어쓰기",
+ "flow-summarize-topic-submit": "요약",
+ "flow-summarize-topic-submit-overwrite": "요약 덮어쓰기",
+ "flow-edit-title-submit": "제목 바꾸기",
+ "flow-edit-title-submit-overwrite": "제목 덮어쓰기",
+ "flow-edit-post-submit": "변경된 내용을 제출합니다",
+ "flow-edit-post-submit-overwrite": "바뀜 덮어쓰기",
+ "flow-rev-message-edit-post": "$1 사용자가 \"$4\"의 [$3 덧글]을 {{GENDER:$2|편집했습니다}}.",
+ "flow-rev-message-reply": "$1 사용자가 \"$4\"에 [$3 {{GENDER:$2|덧글을 남겼습니다}}]. (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|개의 덧글}}</strong>이 추가{{PLURAL:$1|되었습니다}}.",
+ "flow-rev-message-new-post": "$1 사용자가 \"[$3 $4]\" 주제를 {{GENDER:$2|만들었습니다}}.",
+ "flow-rev-message-edit-title": "$1 사용자가 \"$5\"에서 \"[$3 $4]\"(으)로 주제의 제목을 {{GENDER:$2|바꾸었습니다}}.",
+ "flow-rev-message-create-header": "$1 사용자가 머릿글을 {{GENDER:$2|만들었습니다}}",
+ "flow-rev-message-edit-header": "$1 사용자가 머릿글을 {{GENDER:$2|편집했습니다}}",
+ "flow-rev-message-create-topic-summary": "$1 사용자가 $3 주제의 주제 요약을 {{GENDER:$2|만들었습니다}}",
+ "flow-rev-message-edit-topic-summary": "$1 사용자가 $3 의 주제 요약을 {{GENDER:$2|편집했습니다}}",
+ "flow-rev-message-hid-post": "$1 사용자가 \"$6\"에서 [$4 덧글]을 {{GENDER:$2|숨겼습니다}} (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 사용자가 \"$6\"에서 [$4 덧글]을 {{GENDER:$2|삭제했습니다}} (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 사용자가 \"$6\"에서 [$4 덧글]을 {{GENDER:$2|표시 안하도록 했습니다}} (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 사용자가 \"$6\"에서 [$4 덧글]을 {{GENDER:$2|되살렸습니다}} (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 사용자가 \"$6\"의 [$4 주제]를 {{GENDER:$2|숨겼습니다}} (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 사용자가 \"$6\"의 [$4 주제]를 {{GENDER:$2|삭제했습니다}} (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 사용자가 \"$6\"의 [$4 주제]를 {{GENDER:$2|표시 안하도록 했습니다}} (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 사용자가 \"$6\"의 [$4 주제]를 {{GENDER:$2|되살렸습니다}} (<em>$5</em>)",
+ "flow-board-history": "\"$1\" 역사",
+ "flow-board-history-empty": "이 판에는 현재 역사가 없습니다.",
+ "flow-topic-history": "\"$1\" 주제 역사",
+ "flow-post-history": "\"{{GENDER:$2|$2}}가 쓴 덧글\"의 역사",
+ "flow-history-last4": "지난 4시간",
+ "flow-history-day": "오늘",
+ "flow-history-week": "지난 주",
+ "flow-history-pages-topic": "[$1 \"$2\" 게시판]에 나타납니다",
+ "flow-history-pages-post": "[$1 $2]에 나타납니다",
+ "flow-topic-comments": "{{PLURAL:$1|덧글 $1개|0=첫 덧글을 {{GENDER:$2|남기세요}}!}}",
+ "flow-comment-restored": "복원된 덧글",
+ "flow-comment-deleted": "삭제된 덧글",
+ "flow-comment-hidden": "표시 안 함으로 설정된 댓글",
+ "flow-comment-moderated": "검토 의견",
+ "flow-last-modified": "$1에 대한 마지막 수정",
+ "flow-workflow": "워크플로우",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 사용자가 '''$4'''에 {{GENDER:$1|답변을 남겼습니다}}.",
+ "flow-notification-reply-bundle": "$1 사용자와 $5 {{PLURAL:$6|그 외 사용자}}가 당신이 \"$3\"에 남긴 <span class=\"plainlinks\">[$4 게시물] $2</span>에 {{GENDER:$1|답변을 남겼습니다}}.",
+ "flow-notification-edit": "$1 [[$3|$4]]에 남긴 <span class=\"plainlinks\">[$5 게시물]</span> $2을 {{GENDER:$1|편집했습니다}}.",
+ "flow-notification-edit-bundle": "$1 사용자와 $5 {{PLURAL:$6|그 외 사용자}}가 당신이 \"$3\"에 남긴 <span class=\"plainlinks\">[$4 게시물]</span> $2을 {{GENDER:$1|편집했습니다}}.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 사용자가 '''$3'''에 새 주제를 {{GENDER:$1|만들었습니다}}.",
+ "flow-notification-rename": "$1 사용자가 [[$5|$6]]의 <span class=\"plainlinks\">[$2 $3]</span>의 제목을 \"$4\"으로 {{GENDER:$1|바꿨습니다}}.",
+ "flow-notification-mention": "$1 사용자가 \"$4\"의 \"$3\" {{GENDER:$1|}} <span class=\"plainlinks\">[$2 게시물]</span>에서 {{GENDER:$1|언급했습니다}}.",
+ "flow-notification-link-text-view-post": "게시물 보기",
+ "flow-notification-link-text-view-topic": "주제 보기",
+ "flow-notification-reply-email-subject": "$3의 $2",
+ "flow-notification-reply-email-batch-body": "$1 사용자가 \"$3\"에 있는 \"$2\" 주제의 당신의 게시물에 {{GENDER:$1|답변을 남겼습니다}}.",
+ "flow-notification-reply-email-batch-bundle-body": "$1 사용자 외 $4명의 {{PLURAL:$5|사용자}}가 \"$3\"에 남긴 \"$2\" 주제에 {{GENDER:$1|답변을 남겼습니다}}.",
+ "flow-notification-mention-email-subject": "$1 사용자가 \"$2\"에 {{GENDER:$3|당신}}을 {{GENDER:$1|언급했습니다}}.",
+ "flow-notification-mention-email-batch-body": "$1 사용자가 {{GENDER:$4|당신}}을 \"$3\"에 있는 {{GENDER:$1|그의}} \"$2\" 게시물에 {{GENDER:$1|언급했습니다}}.",
+ "flow-notification-edit-email-subject": "$1 사용자가 게시물을 {{GENDER:$1|편집했습니다}}",
+ "flow-notification-edit-email-batch-body": "$1 사용자가 \"$3\"에 있는 \"$2\"의 게시물을 {{GENDER:$1|편집했습니다}}",
+ "flow-notification-edit-email-batch-bundle-body": "$1 사용자와 $4 {{PLURAL:$5|그 외 사용자}}가 당신이 \"$3\"에 있는 \"$2\" [$4 게시물]을 {{GENDER:$1|편집했습니다}}.",
+ "flow-notification-rename-email-subject": "$1 이 당신의 주제를 바꾸었습니다.",
+ "flow-notification-rename-email-batch-body": "$1 사용자가 \"$4\"에 있는 당신의 \"$2\" 주제를 \"$3\"으로 {{GENDER:$1|이름을 바꿨습니다}}",
+ "flow-notification-newtopic-email-subject": "$1 사용자가 \"$2\"의 새 주제를 {{GENDER:$1|만들었습니다}}",
+ "flow-notification-newtopic-email-batch-body": "$1 사용자가 $3의 \"$2\" 문서와 새 주제를 {{GENDER:$1|만들었습니다}}",
+ "echo-category-title-flow-discussion": "플로우",
+ "echo-pref-tooltip-flow-discussion": "플로우에 나와 관련된 명령을 알림",
+ "flow-link-post": "게시물",
+ "flow-link-topic": "주제",
+ "flow-link-history": "역사",
+ "flow-link-post-revision": "게시물의 판",
+ "flow-link-topic-revision": "주제의 판",
+ "flow-link-header-revision": "헤더 판",
+ "flow-moderation-title-suppress-post": "게시물을 숨기시겠습니까?",
+ "flow-moderation-title-delete-post": "게시물을 삭제하시겠습니까?",
+ "flow-moderation-title-hide-post": "게시물을 표시 안 함으로 설정하시겠습니까?",
+ "flow-moderation-title-unsuppress-post": "게시물을 숨김 해제하겠습니까?",
+ "flow-moderation-title-undelete-post": "게시물을 복원하시겠습니까?",
+ "flow-moderation-title-unhide-post": "게시물을 표시하시겠습니까?",
+ "flow-moderation-placeholder-suppress-post": "게시물을 숨기시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-delete-post": "게시물을 삭제하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-hide-post": "게시물을 표시 안 함으로 설정하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-unsuppress-post": "게시물을 숨김 해제하는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-undelete-post": "게시믈을 복원하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-unhide-post": "게시물을 숨김 해제하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-confirm-suppress-post": "표시 안 함",
+ "flow-moderation-confirm-delete-post": "삭제",
+ "flow-moderation-confirm-hide-post": "숨기기",
+ "flow-moderation-confirm-unsuppress-post": "숨김 해제",
+ "flow-moderation-confirm-undelete-post": "삭제 취소",
+ "flow-moderation-confirm-unhide-post": "숨기기 취소",
+ "flow-moderation-confirm-suppress-topic": "표시 안 함",
+ "flow-moderation-confirm-delete-topic": "삭제",
+ "flow-moderation-confirm-hide-topic": "숨기기",
+ "flow-moderation-confirm-unsuppress-topic": "숨김 해제",
+ "flow-moderation-confirm-undelete-topic": "삭제 취소",
+ "flow-moderation-confirm-unhide-topic": "숨기기 취소",
+ "flow-moderation-confirmation-suppress-post": "게시물 숨김에 성공하였습니다. 이 게시물에 대한 피드백을 $1 사용자에게 주는 것을 {{GENDER:$2|고려해주세요}}.",
+ "flow-moderation-confirmation-delete-post": "게시물 삭제에 성공하였습니다. 이 게시물에 대한 피드백을 $1 사용자에게 주는 것을 {{GENDER:$2|고려해주세요}}.",
+ "flow-moderation-confirmation-hide-post": "게시물을 표시 안 함으로 설정하는 데 성공하였습니다. 이 게시물에 대한 피드백을 $1 사용자에게 주는 것을 {{GENDER:$2|고려해주세요}}.",
+ "flow-moderation-confirmation-unsuppress-post": "위의 게시물을 성공적으로 보여주기했습니다.",
+ "flow-moderation-confirmation-undelete-post": "위의 게시물을 성공적으로 보여주기했습니다.",
+ "flow-moderation-confirmation-unhide-post": "위의 게시물을 성공적으로 표시했습니다.",
+ "flow-moderation-confirmation-suppress-topic": "주제 숨김에 성공했습니다.",
+ "flow-moderation-confirmation-delete-topic": "주제 삭제에 성공하였습니다.",
+ "flow-moderation-confirmation-hide-topic": "주제를 표시 안 함으로 설정하는 데 성공하였습니다.",
+ "flow-moderation-confirmation-unsuppress-topic": "이 게시물을 성공적으로 숨김 해제했습니다.",
+ "flow-moderation-confirmation-undelete-topic": "이 게시물을 성공적으로 복원했습니다.",
+ "flow-moderation-confirmation-unhide-topic": "이 게시물을 성공적으로 표시했습니다.",
+ "flow-moderation-title-suppress-topic": "주제를 숨기겠습니까?",
+ "flow-moderation-title-delete-topic": "주제를 삭제하겠습니까?",
+ "flow-moderation-title-hide-topic": "주제를 숨기겠습니까?",
+ "flow-moderation-title-unsuppress-topic": "주제를 숨김 해제하시겠습니까?",
+ "flow-moderation-title-undelete-topic": "주제를 복원하시겠습니까?",
+ "flow-moderation-title-unhide-topic": "주제를 보이시겠습니까?",
+ "flow-moderation-placeholder-suppress-topic": "이 주제를 숨기시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-delete-topic": "이 주제를 삭제하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-hide-topic": "이 주제를 표시 안 함으로 설정하시는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-unsuppress-topic": "이 주제를 숨김 해제하는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-undelete-topic": "이 주제를 복원하는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-moderation-placeholder-unhide-topic": "이 주제를 보이는 이유를 {{GENDER:$3|설명해주세요}}.",
+ "flow-topic-permalink-warning": "이 주제는 [$2 $1]에 시작됐습니다",
+ "flow-topic-permalink-warning-user-board": "이 주제는 [$2 {{GENDER:$1|$1}}의 게시판]에서 시작됐습니다",
+ "flow-revision-permalink-warning-post": "이 게시물의 하나의 판에 대한 영구 링크 입니다.\n이 판은 $1에서 가져왔습니다.\n[$5 이전 판]나 [$4 게시물 역사 문서]의 다른 판과의 차이를 볼 수 있습니다.",
+ "flow-revision-permalink-warning-post-first": "이 게시물의 첫 번째 판으로 연결된 영구 링크입니다.\n[$4 게시물 역사 문서]에서 이후의 판을 볼 수 있습니다.",
+ "flow-revision-permalink-warning-postsummary": "이 요약에 대한 고유링크입니다. 이 버전은 $1 에서 가져왔습니다.\n[$5 이전 버전과의 차이]를 보거나, [$4 역사]에서 다른 버전을 볼 수 있습니다.",
+ "flow-revision-permalink-warning-postsummary-first": "이 요약의 첫 판에 대한 고유링크입니다. [$4 역사]에서 이후의 버전을 볼 수 있습니다.",
+ "flow-revision-permalink-warning-header": "이 링크는 헤더에 대한 고유 링크입니다.\n이 판은 $1에서 가져왔습니다. [$3 이전 판과의 차이]를 보거나, [$2 역사]에서 다른 판을 볼 수 있습니다.",
+ "flow-revision-permalink-warning-header-first": "이것은 헤더의 첫 판에 대한 고유링크입니다. 이후 버전은 [$2 역사 문서]에서 볼 수 있습니다.",
+ "flow-compare-revisions-revision-header": "$1에 {{GENDER:$2|$2}} 사용자가 작성한 판",
+ "flow-compare-revisions-header-post": "이 문서는 $3 사용자가 [$4 $1]의 \"[$5 $2]\" 주제의 게시물의 두 판 사이의 {{GENDER:$3|차이}}를 보여줍니다.\n[$6 역사 문서]에서 이 게시물의 다른 판을 볼 수 있습니다.",
+ "flow-compare-revisions-header-postsummary": "이 문서는 [$3 $1]의 \"[$4 $2]\" 주제의 게시글 요약의 두 판 사이의 차이를 보여줍니다.\n[$5 역사]에서 이 게시물의 다른 판을 볼 수 있습니다.",
+ "flow-compare-revisions-header-header": "이 문서는 [$3 $1] 헤더의 {{GENDER:$2|차이}}를 보여주고 있습니다. [$4 역사 문서]에서 다른 판을 볼 수 있습니다.",
+ "right-flow-hide": "플로우 주제와 게시물 숨기기",
+ "right-flow-delete": "플로우 주제와 문서 삭제",
+ "right-flow-edit-post": "다른 사용자의 플로우 게시글을 편집",
+ "right-flow-suppress": "플로우 판을 숨김",
+ "flow-terms-of-use-new-topic": "\"{{int:flow-newtopic-save}}\"을 클릭하면 이 위키의 이용 약관에 동의한 것이 됩니다.",
+ "flow-terms-of-use-reply": "\"{{int:flow-newtopic-save}}\"을 클릭하면 이 위키의 이용 약관에 동의한 것이 됩니다.",
+ "flow-terms-of-use-edit": "바뀐 내용을 저장하면 이 위키의 이용 약관에 동의한 것이 됩니다.",
+ "flow-anon-warning": "로그인하고 있지 않습니다. IP 주소 대신에 당신의 이름으로 표시되려면, [$1 로그인]하거나 [$2 계정을 만들] 수 있습니다.",
+ "flow-topic-first-heading": "$1의 토론 주제",
+ "flow-topic-html-title": "$2의 $1",
+ "flow-load-more": "더 불러오기",
+ "flow-terms-of-use-summarize": "\"{{int:flow-summarize-topic-submit}}\"을 누름으로써, 당신은 이 위키의 이용 약관에 동의하는 것입니다.",
+ "flow-whatlinkshere-post": "[$1 게시글]에서",
+ "flow-whatlinkshere-header": "[$1 머리말]에서",
+ "flow": "플로우",
+ "flow-special-desc": "이 특수문서는 UUID를 플로우 워크플로우 혹은 플로우 문서로 넘겨줍니다.",
+ "flow-special-type": "종류",
+ "flow-special-type-post": "게시글",
+ "flow-special-type-workflow": "워크플로우",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "종류와 UUID에 맞는 콘텐츠를 찾을 수 없습니다.",
+ "flow-special-enableflow-invalid-title": "제공되는 페이지는 유효한 페이지 제목이 아닙니다.",
+ "apihelp-flow+undo-edit-post-description": "포스트 편집을 취소하기 위해 필요한 정보를 검색합니다.",
+ "apihelp-flow+undo-edit-post-param-postId": "포스트 아이디는 취소할수 있습니다.",
+ "apihelp-flow+undo-edit-post-param-endId": "끝으로 아이디 수정이 취소되었습니다.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "계정 아이디 취소를 시작합니다.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "끝으로 아이디 수정이 취소되었습니다.",
+ "flow-undo": "취소",
+ "flow-undo-latest-revision": "최근 개정",
+ "flow-undo-your-text": "당신의 텍스트",
+ "flow-undo-edit-header": "헤더 편집",
+ "flow-undo-edit-topic-summary": "주제 요약 편집",
+ "flow-undo-edit-post": "포스트 수정",
+ "flow-undo-edit-content": "편집은 취소할수 있습니다. 당신이 무엇을 하려는지 확인 하고 편집을 취소 하려면 아래의 변경 사항을 확인 하시기 바랍니다.",
+ "flow-undo-edit-failure": "중간 충돌 편집으로 인해 편집을 취소 할수 없습니다.",
+ "group-flow-bot": "플로 봇",
+ "group-flow-bot-member": "플로 봇",
+ "grouppage-flow-bot": "Project:플로 봇",
+ "flow-wikitext-editor-help": "위키텍스트 $1",
+ "flow-wikitext-editor-help-preview-the-result": "결과를 미리보기",
+ "flow-wikitext-switch-editor-tooltip": "시각 편집기로 변환",
+ "flow-ve-switch-editor-tool-title": "위키 텍스트 에디터로 변경"
+}
diff --git a/Flow/i18n/ksh.json b/Flow/i18n/ksh.json
new file mode 100644
index 00000000..ea9bf535
--- /dev/null
+++ b/Flow/i18n/ksh.json
@@ -0,0 +1,123 @@
+{
+ "@metadata": {
+ "authors": [
+ "Purodha"
+ ]
+ },
+ "flow-suppress-usertext": "Däm Metmaacher singe Nahme es ongerdrök",
+ "flow-cancel": "Ophühre",
+ "flow-preview": "{{int:preview}}",
+ "flow-show-change": "Änderunge aanzeije",
+ "flow-last-modified-by": "Zeläz verändert {{GENDER:$1|vum|vum|vumm Metmaacher|vun dä|vum}} „$1“",
+ "flow-history-action-suppress-post": "ongerdrökke",
+ "flow-history-action-delete-post": "fottschmiiße",
+ "flow-history-action-hide-post": "verschteijsche",
+ "flow-history-action-unsuppress-post": "nimmih ongerdrökke",
+ "flow-history-action-undelete-post": "zerökholle",
+ "flow-history-action-unhide-post": "nit mih verschteijsche",
+ "flow-history-action-restore-post": "zeröckholle",
+ "flow-post-action-view": "Dohrhafte Lengk",
+ "flow-post-action-suppress-post": "Ongerdröke",
+ "flow-post-action-delete-post": "Fottschmiiße",
+ "flow-post-action-hide-post": "Verschteihsche",
+ "flow-post-action-edit-post": "Beärbeide",
+ "flow-post-action-edit-post-submit": "Lohß jonn!",
+ "flow-post-action-unsuppress-post": "Nimmih ongerdroke",
+ "flow-post-action-undelete-post": "Zerökholle",
+ "flow-post-action-unhide-post": "Nit mih verschteische",
+ "flow-post-action-undo-moderation": "Retuhr nämme",
+ "flow-topic-action-view": "Dohrhafte Lengk",
+ "flow-topic-action-watchlist": "Oppaßleß",
+ "flow-topic-action-edit-title": "De Övverschreff ändere",
+ "flow-topic-action-history": "Väsjohne",
+ "flow-topic-action-summarize-topic": "Zosammefaßße",
+ "flow-topic-action-resummarize-topic": "De Zosammefaßßong ändere",
+ "flow-topic-action-undo-moderation": "Zeröknämme",
+ "flow-error-not-allowed-reply-to-hide-topic": "Do kanns nit antwoote, weil heh dä topic verschtoche woode es.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Do kanns nit antwoote, weil heh dä topic fottjeschmeße wohd.",
+ "flow-error-not-allowed-suppress": "Heh dä topic wohd fottjeschmeße.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Do kanns nit antwoote, weil heh dä topic fottjeschmeße wohd.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Do kanns nit antwoote, weil heh dä topic fottjeschmeße wohd. Heh küdd ene Ußzoch uss_em Logbohch vum Fottschmiiße doh för.",
+ "flow-error-not-allowed-suppress-extract": "Heh dä topic wohd fottjeschmeße. Heh küdd ene Ußzoch uss_em Logbohch vum Fottschmiiße doh för.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Do kanns nit antwoote, weil heh dä topic ongerdök woode es. Heh küdd ene Ußzoch uss_em Logbohch vum Ongerdöke doh för.",
+ "flow-error-invalid-topic-uuid-title": "Kapodde Övverschreff",
+ "flow-edit-header-submit": "Lohß jonn!",
+ "flow-edit-header-submit-overwrite": "De Zosammefaßßong övverschrihve",
+ "flow-summarize-topic-submit": "Zosammefaßße",
+ "flow-summarize-topic-submit-overwrite": "De Zosammefaßßong övverschrihve",
+ "flow-edit-title-submit": "Lohß jonn!",
+ "flow-edit-title-submit-overwrite": "Lohß jonn!",
+ "flow-rc-topic-of-board": "$1 op $2",
+ "flow-history-last4": "Läzde vier Woche",
+ "flow-history-day": "Hück",
+ "flow-history-week": "Läzde Woch",
+ "flow-history-pages-post": "Douch op op [$1 $2]",
+ "flow-notification-reply-email-subject": "$2 op $3",
+ "flow-notification-reply-email-batch-body": "{{GENDER:$1|Dä|Dat|Dä Metmaacher|De|Dat}} $1 hädd op „$2“ op dä Sigg „$3“ jeantwoot.",
+ "flow-notification-reply-email-batch-bundle-body": "{{GENDER:$1|Dä|Dat|Dä Metmaacher|De|Dat}} $1 un {{PLURAL:$5|eine andere hädd|$4 andere han|keine söns hädd}} op „$2“ op dä Sigg „$3“ jeantwoot.",
+ "flow-notification-mention-email-subject": "{{GENDER:$1|Dä|Dat|Dä Metmaacher|De|Dat}} $1 hät Desch op „$2“ jenannt.{{GENDER:$3|}}",
+ "flow-notification-mention-email-batch-body": "{{GENDER:$1|Dä|Dat|Dä Metmaacher|De|Dat}} $1 hät Desch en {{GENDER:$2|däm singem|däm singem|dämm singem|dä iehrem|däm singem}} Beijdrahch op „$2“ op dä Sigg „$3“ jenannt.",
+ "flow-link-history": "Ällder Väsjohne",
+ "flow-moderation-confirm-suppress-post": "Ongerdröke",
+ "flow-moderation-confirm-delete-post": "Fottschmiiße",
+ "flow-moderation-confirm-hide-post": "Verschteihsche",
+ "flow-moderation-confirm-unsuppress-post": "Nimmih ongerdroke",
+ "flow-moderation-confirm-undelete-post": "Wider zerök holle",
+ "flow-moderation-confirm-unhide-post": "Nit mih verschteihsche",
+ "flow-moderation-confirm-suppress-topic": "Ongerdröke",
+ "flow-moderation-confirm-delete-topic": "Fottschmiiße",
+ "flow-moderation-confirm-hide-topic": "Verschteihsche",
+ "flow-moderation-confirm-lock-topic": "Schpärr",
+ "flow-moderation-confirm-unsuppress-topic": "Nimmih ongerdröke",
+ "flow-moderation-confirm-undelete-topic": "Wider zerök holle",
+ "flow-moderation-confirm-unhide-topic": "Nit mih verschteihsche",
+ "flow-moderation-confirm-unlock-topic": "Sperr ophävve",
+ "flow-compare-revisions-revision-header": "De Väsjohn {{GENDER:$2|vum|vum|vumm Metmaacher|vun dä|vum}} „$1“ vum $2\n<!-- https://translatewiki.net/wiki/Thread:Support/About_MediaWiki:Flow-compare-revisions-revision-header/en\n-->",
+ "flow-topic-html-title": "$1 op $2",
+ "flow-load-more": "Mih lahde",
+ "flow-special-type": "Zoot",
+ "flow-special-enableflow-invalid-title": "The provided page is not a valid page title\n<!-- \nhttps://translatewiki.net/wiki/Thread:Support/About_MediaWiki:Flow-special-enableflow-invalid-title/en\n-->",
+ "flow-preview-warning": "Do sühs en onjeseschte Ußjahb vöraf. Donn op „{{int:flow-newtopic-save}}“ kleke zom Veröffentlesche, udder „{{int:flow-preview-return-edit-post}}“, öm mem Schrihve wigger ze maache.",
+ "flow-preview-return-edit-post": "Blihv beim Ändere",
+ "flow-anonymous": "Nahmeloßß",
+ "flow-post-undo-hide": "et Verschteijsche ophävve",
+ "flow-post-undo-delete": "et Fottschmiiße ophävve",
+ "flow-post-undo-suppress": "de Ongerdrökong ophävve",
+ "flow-topic-undo-hide": "et Verschteijsche ophävve",
+ "flow-topic-undo-delete": "et Fottschmiiße ophävve",
+ "flow-topic-undo-suppress": "de Ongerdrökong ophävve",
+ "flow-importer-lqt-suppressed-user-template": "He di empottehrte Väsjohn uß LiquidThreads es vun enem ongerdrökte Metmaacher. Se wohd däm aktoälle Metmaacher zohjeschlonn.",
+ "apihelp-flow-param-page": "De Sigg, fö di jät ze donn es.",
+ "apihelp-flow+edit-header-param-content": "Der Ennhalld för de Övverschreff.",
+ "apihelp-flow+edit-header-param-format": "Et Fommaht vum Kopp, Wikkitäx udder <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>?",
+ "apihelp-flow+edit-post-param-format": "Et Fommaht vum Enhalld vum neue post, Wikkitäx udder <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>?\n<!-- \nhttps://translatewiki.net/wiki/Thread:Support/About_MediaWiki:Apihelp-flow%2Breply-param-format/en\n-->",
+ "apihelp-flow+edit-title-param-prev_revision": "De Kännong för de aktoölle Väsjohn vun dä Övverschreff, öm noh dubbelte Beärbeidonge ze söhke.",
+ "apihelp-flow+edit-title-param-content": "Der Ennhalld för de Övverschreff.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Der Inhalt for et Resümeh.",
+ "apihelp-flow+edit-topic-summary-param-format": "Et Fommaht vun dä Zosammefaßong, Wikkitäx udder <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>?\n<!-- https://translatewiki.net/wiki/Thread:Support/About_MediaWiki:Apihelp-flow%2Bedit-topic-summary-param-format/en_and_MediaWiki:Apihelp-flow%2Bedit-title-param-format/en\n-->",
+ "apihelp-flow+new-topic-param-format": "Et Fommaht vum neue topic singe eezde Antwoot, Wikkitäx udder <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>?",
+ "apihelp-flow+reply-param-content": "Dä Enhald för dä neuje Beidraach.",
+ "apihelp-flow+reply-param-format": "Et Fommaht vum neue post, Wikkitäx udder <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>?\n<!-- \nhttps://translatewiki.net/wiki/Thread:Support/About_MediaWiki:Apihelp-flow%2Breply-param-format/en\n-->",
+ "apihelp-flow-parsoid-utils-description": "Donn Täx zwesche Wikkitäx un <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> wandelle.",
+ "apihelp-flow-parsoid-utils-param-content": "Der Enhalld zom Ömwandelle.",
+ "apihelp-flow+undo-edit-header-description": "Holl nühdejje Enfommazjuhne, öm de Änderong aan däm Kopp retuhr ze nämme.",
+ "apihelp-flow+undo-edit-header-param-startId": "De Kännong vun dä Väsohn, woh et Zeröcknämme aanfange sull.",
+ "apihelp-flow+undo-edit-header-param-endId": "De Kännong vun dä Väsohn, woh et Zeröcknämme ophühre sull.",
+ "apihelp-flow+undo-edit-post-description": "Holl nühdejje Enfommazjuhne, öm de Änderong aan em Beijdrahch retuhr ze nämme.",
+ "apihelp-flow+undo-edit-post-param-postId": "De Kännong vum Beijdrahch zom zerök nämme.",
+ "apihelp-flow+undo-edit-post-param-startId": "De Kännong vun dä Väsohn, woh et Zeröcknämme aanfange sull.",
+ "apihelp-flow+undo-edit-post-param-endId": "De Kännong vun dä Väsohn, woh et Zeröcknämme ophühre sull.",
+ "apihelp-flow+undo-edit-post-example-1": "Holl Enfommazjuhne, öm en Änderong aan ene beschtemmte topic retuhr ze nämme.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "De Kännong vun dä Väsohn, woh et Zeröcknämme aanfange sull.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "De Kännong vun dä Väsohn, woh et Zeröcknämme ophühre sull.",
+ "flow-edited": "Be'ärbeidt",
+ "flow-edited-by": "Be'ärbeidt {{GENDER:$1|vum|vum|vumm Metmaacher|vun dä|vum}} „$1“",
+ "flow-previous-diff": "←&nbsp;De Änderong dovör",
+ "flow-next-diff": "De Änderong donoh&nbsp;→",
+ "flow-undo": "zeröck nämme",
+ "flow-undo-latest-revision": "De neuste Väsjohn",
+ "flow-undo-your-text": "Dinge Täx",
+ "flow-undo-edit-header": "Övverschreff",
+ "flow-undo-edit-content": "De Änderong künnte mer zerök nämme. Beloor Der de Ungerscheide un dann donn di Sigg avspeichere, wann De dengks, et es en Oodenong esu.",
+ "flow-undo-edit-failure": "Dat kunnt mer nit zerök nämme, weil et enzwesche ald widder beärbeidt wood."
+}
diff --git a/Flow/i18n/ku-latn.json b/Flow/i18n/ku-latn.json
new file mode 100644
index 00000000..0ed872a7
--- /dev/null
+++ b/Flow/i18n/ku-latn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bikarhêner"
+ ]
+ },
+ "flow-undo-your-text": "Nivîsara te",
+ "flow-undo-edit-header": "Bikeyskirina sernavê"
+}
diff --git a/Flow/i18n/lb.json b/Flow/i18n/lb.json
new file mode 100644
index 00000000..784de5a4
--- /dev/null
+++ b/Flow/i18n/lb.json
@@ -0,0 +1,215 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robby",
+ "Soued031"
+ ]
+ },
+ "enableflow": "Flow aktivéieren",
+ "flow-desc": "Workflow-Management-System",
+ "flow-talk-taken-over": "Dës Diskussiounssäit benotzt [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|huet}} eng [$4 Matdeelung] iwwer \"[[$3|$5]]\" op [[$6]] geläscht",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|huet}} d'Thema \"[[$3|$5]]\" op [[$6]] geläscht",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|huet}} d'Thema \"[[$3|$5]]\" iwwer [[$6]] restauréiert",
+ "flow-board-header-browse-topics-link": "Themen duerchsichen",
+ "flow-edit-header-link": "Iwwerschrëft änneren",
+ "flow-post-moderated-toggle-hide-show": "Bemierkung weisen déi {{GENDER:$1|vum|vun der}} $2 verstoppt gouf",
+ "flow-post-moderated-toggle-delete-show": "Bemierkung weisen {{GENDER:$1|geläscht}} vum $2",
+ "flow-post-moderated-toggle-hide-hide": "Bemierkung verstoppen {{GENDER:$1|verstoppt}} vum $2",
+ "flow-post-moderated-toggle-delete-hide": "Bemierkung verstoppen déi vum $2 {{GENDER:$1|geläscht}} gouf",
+ "flow-topic-moderated-reason-prefix": "Grond:",
+ "flow-hide-post-content": "Dës Bemierkung gouf vum $1 {{GENDER:$1|verstoppt}} ([$2 Versiounen])",
+ "flow-hide-title-content": "Dëst Thema gouf vum $1 {{GENDER:$1|verstoppt}}",
+ "flow-hide-header-content": "{{GENDER:$1|Verstoppt}} vum $2",
+ "flow-delete-post-content": "Dës Bemierkung gouf vum $1 {{GENDER:$1|geläscht}} ([$2 Versiounen])",
+ "flow-delete-title-content": "Dëst Thema gouf vum $1 {{GENDER:$1|Geläscht}}",
+ "flow-delete-header-content": "{{GENDER:$1|Geläscht}} vum $2",
+ "flow-post-actions": "Aktiounen",
+ "flow-topic-actions": "Aktiounen",
+ "flow-cancel": "Ofbriechen",
+ "flow-preview": "Kucken ouni ze späicheren",
+ "flow-show-change": "Ännerunge weisen",
+ "flow-last-modified-by": "Fir d'lescht {{GENDER:$1|geännert}} vum $1",
+ "flow-stub-post-content": "\"Duerch en technesche Feeler konnt dës Matdeelung net ofgeruff ginn.\"",
+ "flow-newtopic-title-placeholder": "Neit Thema",
+ "flow-newtopic-content-placeholder": "En neie Message iwwer \"$1\" schécken",
+ "flow-newtopic-header": "En neit Thema derbäisetzen",
+ "flow-newtopic-save": "Thema derbäisetzen",
+ "flow-newtopic-start-placeholder": "En neit Thema ufänken",
+ "flow-newtopic-first-heading": "En neit Thema op $1 ufänken",
+ "flow-summarize-topic-placeholder": "Resuméiert dës Diskussioun w.e.g.",
+ "flow-reply-topic-placeholder": "\"$2\" {{GENDER:$1|kommentéieren}}",
+ "flow-reply-topic-title-placeholder": "Op \"$1\" äntwerten",
+ "flow-reply-submit": "{{GENDER:$1|Äntwerten}}",
+ "flow-reply-link": "{{GENDER:$1|Äntwerten}}",
+ "flow-thank-link": "{{GENDER:$1|Merci soen}}",
+ "flow-lock-link": "{{GENDER:$1|Spären}}",
+ "flow-history-action-delete-post": "läschen",
+ "flow-history-action-hide-post": "verstoppen",
+ "flow-post-action-view": "Permanentlink",
+ "flow-post-action-post-history": "Versiounen",
+ "flow-post-action-delete-post": "Läschen",
+ "flow-post-action-hide-post": "Verstoppen",
+ "flow-post-action-edit-post": "Änneren",
+ "flow-post-action-edit-post-submit": "Ännerunge späicheren",
+ "flow-post-action-restore-post": "Restauréieren",
+ "flow-topic-action-watchlist": "Iwwerwaachungslëscht",
+ "flow-topic-action-edit-title": "Titel änneren",
+ "flow-topic-action-history": "Versiounen",
+ "flow-topic-action-hide-topic": "Thema verstoppen",
+ "flow-topic-action-delete-topic": "Thema läschen",
+ "flow-topic-action-lock-topic": "Thema spären",
+ "flow-topic-action-summarize-topic": "Resuméieren",
+ "flow-topic-action-resummarize-topic": "De Resumé vum Thema änneren",
+ "flow-topic-action-restore-topic": "Thema restauréieren",
+ "flow-topic-action-undo-moderation": "Réckgängeg maachen",
+ "flow-topic-notification-subscribe-title": "Dëst Thema gouf op {{GENDER:$1|Är}} Iwwerwaachungslëscht dobäigesat.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Dir}} gitt vun allen Aktivitéiten zu dësem Thema informéiert.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Dir}} sidd an dësen Diskussiouns-Board ageschriwwen!",
+ "flow-error-other": "En onerwaarte Feeler ass geschitt.",
+ "flow-error-external": "Et ass e Feeler geschitt.<br />De Feelermessage war:$1</ small>",
+ "flow-error-edit-restricted": "Dir däerft dës Matdeelung net änneren.",
+ "flow-error-external-multi": "Et si Feeler geschitt.<br />$1",
+ "flow-error-missing-summary": "De Resumé huet keen Inhalt. Den Inhalt ass obligatoresch fir e Resumé ze späicheren.",
+ "flow-error-missing-title": "D'Thema huet keen Titel. Den Titel ass obligatoresch fir een Thema ze späicheren.",
+ "flow-error-delete-failure": "D'Läsche vun dësem Element huet net funktionéiert.",
+ "flow-error-hide-failure": "Verstoppe vun dësem Element huet net funktionéiert.",
+ "flow-error-restore-failure": "D'Restauréiere vun dësem Element huet net funktionéiert.",
+ "flow-error-not-allowed": "Net genuch Rechter fir dës Aktioun ze maachen",
+ "flow-error-not-allowed-reply-to-hide-topic": "Dir kënnt net äntwerte well dëst Thema verstoppt gouf.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Dir kënnt net äntwerte well dëst Thema geläscht gouf.",
+ "flow-error-not-allowed-suppress": "Dëst Thema gouf geläscht.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Dir kënnt net äntwerte well dëst Thema geläscht gouf.",
+ "flow-error-not-a-post": "Den Titel vum Thema kann net als Matdeelung gespäichert ginn.",
+ "flow-error-missing-header-content": "D'Iwwerschrëft huet keen Inhalt. Den Inhalt ass obligatoresch fir eng Iwwerschrëft ze späicheren.",
+ "flow-error-prev-revision-mismatch": "En anere Benotzer huet dës Matdeelung virun e puer Sekonne geännert. Sidd {{GENDER:$3|Dir}} sécher datt Dir déi rezent Ännerung iwwerschreiwe wëllt?",
+ "flow-error-prev-revision-does-not-exist": "Déi vireg Versioun konnt net fonnt ginn.",
+ "flow-error-default": "Et ass e Feeler geschitt.",
+ "flow-error-invalid-title": "En net valabelen Säitentitel gouf uginn.",
+ "flow-error-insufficient-permission": "Net genuch Rechter fir op den Inhalt zouzegräifen.",
+ "flow-error-no-render": "Déi spzifizéiert Aktioun gouf net erkannt.",
+ "flow-error-no-commit": "Déi spezifiséiert Aktioun konnt net gespäichert ginn",
+ "flow-error-move": "D'Réckel vun engem Diskussiouns-Forum gëtt elo net ënnerstëtzt",
+ "flow-error-invalid-topic-uuid-title": "Schlechten Titel",
+ "flow-error-unknown-workflow-id-title": "Onbekannt Thema",
+ "flow-edit-header-placeholder": "Dësen Diskussiouns-Board beschreiwen",
+ "flow-edit-header-submit": "Iwwerschrëft späicheren",
+ "flow-edit-header-submit-overwrite": "Iwwerschrëft iwwerschreiwen",
+ "flow-summarize-topic-submit": "Resuméieren",
+ "flow-summarize-topic-submit-overwrite": "Resumé iwwerschreiwen",
+ "flow-lock-topic-submit": "Thema spären",
+ "flow-edit-title-submit": "Titel änneren",
+ "flow-edit-title-submit-overwrite": "Titel iwwerschreiwen",
+ "flow-edit-post-submit": "Ännerunge späicheren",
+ "flow-edit-post-submit-overwrite": "Ännerungen iwwerschreiwen",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|huet}} eng [$3 Bemierkung] iwwer \"$4\" geännert.",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|huet}} eng Bemierkung] iwwer \"$4\" (<em>$5</em>) derbäigesat.",
+ "flow-rev-message-reply-bundle": "<strong>{{PLURAL:$1|Eng Bemierkung gouf|$1 Bemierkunge goufen}} derbäigesat</strong>.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|huet}} d'Thema \"[$3 $4]\" ugeluecht.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|huet}} d'Iwwerschrëft ugeluecht.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|huet}} d'Iwwerschrëft geännert.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|huet}} eng [$4 Bemierkung] iwwer ''$6'' (<em>$5</em>) verstoppt.",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|huet}} eng [$4 Bemierkung] iwwer \"$6\" (<em>$5</em>) geläscht.",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|huet}} eng [$4 Bemierkung] iwwer ''$6'' (<em>$5</em>) restauréiert.",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|huet}} d'[Thema $4] \"$6\" (<em>$5</em>) geläscht.",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|huet}} d'[Thema $4] \"$6\" (<em>$5</em>) restauréiert.",
+ "flow-rc-topic-of-board": "$1 op $2",
+ "flow-board-history": "Versioune vun \"$1\"",
+ "flow-topic-history": "Versioune vum Thema \"$1\"",
+ "flow-history-last4": "Lescht 4 Stonnen",
+ "flow-history-day": "Haut",
+ "flow-history-week": "Lescht Woch",
+ "flow-topic-comments": "{{PLURAL:$1|Eng Bemierkung|$1 Bemierkungen|0=Sidd {{GENDER:$2|deen éischten deen|déi éischt déi}} eng Bemierkung mécht!}}",
+ "flow-comment-restored": "Restauréiert Bemierkung",
+ "flow-comment-deleted": "Geläscht Bemierkung",
+ "flow-comment-hidden": "Verstoppte Bemierkung",
+ "flow-comment-moderated": "Moderéiert Bemierkung",
+ "flow-last-modified": "Fir d'lescht geännert ongeféier $1",
+ "flow-workflow": "workflow",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|huet}} een neit Thema iwwer '''$3''' ugeluecht.",
+ "flow-notification-rename": "$1 {{GENDER:$1|huet}} den Titel vu(n) span class=\"plainlinks\">[$2 $3]</span> op \"$4\" op [[$5|$6]] geännert.",
+ "flow-notification-link-text-view-topic": "Thema weisen",
+ "flow-notification-reply-email-subject": "$2 op $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|huet}} op Är Matdeelung iwwer \"$2\" op \"$3\" geäntwert",
+ "flow-notification-mention-email-subject": "$1 huet {{GENDER:$3|Iech}} op \"$2\" {{GENDER:$1|ernimmt}}",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|huet}} eng Matdeelung geännert",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|huet}} eng Matdeelung vu(n) \"$2\" iwwer \"$3\" geännert",
+ "flow-notification-rename-email-subject": "$1 huet Ärt Thema {{GENDER:$1|ëmbenannt}}",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|huet}} een neit Thema iwwer \"$2\" ugeluecht",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Mech informéiere wann Aktiounen déi mech betreffen a geschéien.",
+ "flow-link-topic": "Thema",
+ "flow-link-history": "Versiounen",
+ "flow-link-post-revision": "Versioun vun der Matdeelung",
+ "flow-link-topic-revision": "Versioun vum Thema",
+ "flow-link-header-revision": "Versioun vun der Iwwerschrëft",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Erklärt}} w.e.g. firwat datt Dir dës Matdeelung läscht.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Erklärt}} w.e.g. firwat datt Dir dës Matdeelung verstoppt.",
+ "flow-moderation-confirm-delete-post": "Läschen",
+ "flow-moderation-confirm-hide-post": "Verstoppen",
+ "flow-moderation-confirm-undelete-post": "Restauréieren",
+ "flow-moderation-confirm-delete-topic": "Läschen",
+ "flow-moderation-confirm-hide-topic": "Verstoppen",
+ "flow-moderation-confirm-undelete-topic": "Restauréieren",
+ "flow-moderation-confirmation-suppress-topic": "Dëst Thema gouf ewechgeholl.",
+ "flow-moderation-confirmation-delete-topic": "Dëst Thema gouf geläscht.",
+ "flow-moderation-confirmation-hide-topic": "Dëst Thema gouf verstoppt.",
+ "flow-moderation-title-delete-topic": "Thema läschen?",
+ "flow-moderation-title-hide-topic": "Thema verstoppen?",
+ "flow-moderation-placeholder-suppress-topic": "{{GENDER:$3|Erkläert}} w.e.g. fir wat datt Dir dëst Thema läscht.",
+ "flow-moderation-placeholder-delete-topic": "{{GENDER:$3|Erklärt}} w.e.g. firwat datt Dir dëst Thema läscht.",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|Erklärt}} w.e.g. firwat datt Dir dëst Thema verstoppt.",
+ "flow-topic-permalink-warning": "Dëse Sujet gouf op [$2 $1] ugefaang",
+ "flow-compare-revisions-revision-header": "Versioun vum {{GENDER:$2|$2}} vum $1",
+ "flow-terms-of-use-new-topic": "Wann Dir op \"{{int:flow-newtopic-save}}\" klickt, da sidd Dir mat de Benotzungsbedingunge vun dëser Wiki d'accord.",
+ "flow-terms-of-use-reply": "Wann Dir op \"{{int:flow-reply-submit}}\" klickt, da sidd Dir mat de Benotzungsbedingunge vun dëser Wiki d'accord.",
+ "flow-terms-of-use-edit": "Duerch Späichere vun Ären Ännerunge sidd Dir mat de Konditioune fir d'Benotze vun dëser Wiki d'Accord.",
+ "flow-anon-warning": "Dir sidd net ageloggt. Fir a Plaz vun enger Verbindung mat Ärer IP-Adress eng Verbindung mat Ärem Numm ze kréie musst Dir Iech [$1 aloggen] oder [$2 e Benotzerkont opmaachen].",
+ "flow-topic-count": "Themen ($1)",
+ "flow-load-more": "Méi lueden",
+ "flow-add-topic": "Thema derbäisetzen",
+ "flow-newest-topics": "Neisten Themen",
+ "flow-recent-topics": "Rezent aktiv Themen",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Dir}} liest elo déi neist Theme fir d'éischt. Klickt fir méi Zortéierungsoptiounen.",
+ "flow-whatlinkshere-post": "vun engem [$1 Message]",
+ "flow-special-type": "Typ",
+ "flow-special-uuid": "UUID",
+ "flow-preview-return-edit-post": "Virufuere mat Änneren",
+ "flow-anonymous": "Anonym",
+ "mw-ui-unsubmitted-confirm": "Dir hutt net gespäichert Ännerungen op dëser Säit. Sidd Dir sécher datt Dir vun dëser Säit wëllt erofgoen an Är Aarbecht verléieren?",
+ "flow-post-undo-hide": "verstoppe réckgängeg maachen",
+ "flow-post-undo-delete": "läsche réckgängeg maachen",
+ "flow-topic-undo-hide": "verstoppe réckgängeg maachen",
+ "flow-topic-undo-delete": "läsche réckgängeg maachen",
+ "apihelp-flow+edit-header-param-content": "Inhalt fir d'Iwwerschrëft.",
+ "apihelp-flow+edit-header-param-format": "Format vun der Iwwerschrëft (wikitext|html)",
+ "apihelp-flow+edit-title-param-content": "Inhalt fir den Titel.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Inhalt fir de Resumé.",
+ "apihelp-flow+edit-topic-summary-param-format": "Format vum Resumé (wikitext|html)",
+ "apihelp-flow+edit-topic-summary-example-1": "Ännert de Resumé vu(n) [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-reason": "Grond fir Moderatioun.",
+ "apihelp-flow+view-topic-description": "En Thema weisen.",
+ "apihelp-flow-parsoid-utils-param-to": "Format an deen den Inhalt ëmgewandelt soll ginn.",
+ "flow-edited": "Geännert",
+ "flow-edited-by": "Geännert vum $1",
+ "flow-previous-diff": "← Méi al Ännerung",
+ "flow-next-diff": "Méi nei Ännerung →",
+ "flow-undo": "réckgängeg maachen",
+ "flow-undo-latest-revision": "Aktuell Versioun",
+ "flow-undo-your-text": "Ären Text",
+ "flow-undo-edit-header": "Iwwerschrëft änneren",
+ "flow-undo-edit-topic-summary": "De Resumé vum Thema änneren",
+ "group-flow-bot": "Flow-Botten",
+ "group-flow-bot-member": "Flow-Bot",
+ "grouppage-flow-bot": "Project:Flow-Botten",
+ "flow-ve-mention-context-item-label": "Ernimmen",
+ "flow-ve-mention-inspector-title": "Ernimmen",
+ "flow-ve-mention-inspector-remove-label": "Ewechhuelen",
+ "flow-ve-mention-tool-title": "E Benotzer ernimmen",
+ "flow-ve-mention-template": "ping",
+ "flow-ve-mention-inspector-invalid-user": "De Benotzernumm '$1' ass net registréiert.",
+ "flow-wikitext-editor-help": "Wikitext $1.",
+ "flow-wikitext-editor-help-preview-the-result": "D'Resultat kucken ouni ze späicheren",
+ "flow-wikitext-switch-editor-tooltip": "Op de VisualEditor wiesselen",
+ "flow-ve-switch-editor-tool-title": "Op de Wikitext-Editeur wiesselen"
+}
diff --git a/Flow/i18n/lki.json b/Flow/i18n/lki.json
new file mode 100644
index 00000000..752f4b9a
--- /dev/null
+++ b/Flow/i18n/lki.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hosseinblue"
+ ]
+ },
+ "flow-moderation-confirmation-delete-topic": "ئئ تاپیکۀ پاک بیۀسا"
+}
diff --git a/Flow/i18n/lrc.json b/Flow/i18n/lrc.json
new file mode 100644
index 00000000..471c003d
--- /dev/null
+++ b/Flow/i18n/lrc.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mogoeilor"
+ ]
+ },
+ "flow-show-change": "آلشتيانه نشون بيئه"
+}
diff --git a/Flow/i18n/lt.json b/Flow/i18n/lt.json
new file mode 100644
index 00000000..a1885da1
--- /dev/null
+++ b/Flow/i18n/lt.json
@@ -0,0 +1,21 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robotukas11",
+ "Pofka"
+ ]
+ },
+ "flow-error-external": "Įvyko klaida.<br>Gautas klaidos pranešimas: $1",
+ "flow-error-not-allowed-reply-to-hide-topic": "Jūs negalite atsakyti, nes ši tema buvo paslėpta.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Jūs negalite atsakyti, nes ši tema buvo ištrinta.",
+ "flow-error-not-allowed-suppress": "Ši tema buvo ištrinta.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Jūs negalite atsakyti, nes ši tema buvo ištrinta.",
+ "flow-link-summary-revision": "santraukos peržiūra",
+ "apihelp-flow+edit-header-param-format": "Antraštės formatas (wikitext|html)",
+ "apihelp-flow+reply-param-format": "Naujo pranešimo formatas (wikitext|html)",
+ "flow-undo": "atšaukti",
+ "flow-undo-latest-revision": "Dabartinė versija",
+ "flow-undo-your-text": "Jūsų tekstas",
+ "flow-undo-edit-topic-summary": "Temos santraukos redagavimas",
+ "flow-undo-edit-post": "Pranešimo redagavimas"
+}
diff --git a/Flow/i18n/lv.json b/Flow/i18n/lv.json
new file mode 100644
index 00000000..c346d54f
--- /dev/null
+++ b/Flow/i18n/lv.json
@@ -0,0 +1,29 @@
+{
+ "@metadata": {
+ "authors": [
+ "Papuass"
+ ]
+ },
+ "flow-edit-header-link": "Labot galveni",
+ "flow-newtopic-start-placeholder": "Sākt jaunu tēmu",
+ "flow-reply-submit": "{{GENDER:$1|Atbildēt}}",
+ "flow-reply-link": "{{GENDER:$1|Atbildēt}}",
+ "flow-thank-link": "{{GENDER:$1|Pateikties}}",
+ "flow-topic-action-view": "Pastāvīgā saite",
+ "flow-edit-header-submit": "Saglabāt galveni",
+ "flow-rev-message-edit-post": "Labot ieraksta saturu",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|izveidoja}} galveni",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|izmainīja}} galveni",
+ "flow-rev-message-deleted-post": "Dzēsts ieraksts",
+ "flow-rev-message-suppressed-post": "Cenzēts ieraksts",
+ "flow-notification-reply": "$1 {{GENDER:$1|atbildēja}} par <span class=\"plainlinks\">[$5 $2]</span> lapā \"$4\".",
+ "flow-notification-reply-bundle": "$1 un $5 {{PLURAL:$6|citi|cits|citi}} {{GENDER:$1|atbildēja}} par <span class=\"plainlinks\">[$4 $2]</span> lapā \"$3\".",
+ "flow-notification-reply-email-subject": "$1 {{GENDER:$1|atbildēja}} par tēmu",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|atbildēja}} par \"$2\" lapā \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 un $4 {{PLURAL:$5|citi|cits|citi}} {{GENDER:$1|atbildēja}} par \"$2\" lapā \"$3\"",
+ "flow-link-topic": "tēma",
+ "flow-link-history": "vēsture",
+ "flow-load-more": "Ielādēt vairāk",
+ "flow-undo": "atcelt",
+ "flow-ve-mention-inspector-remove-label": "Noņemt"
+}
diff --git a/Flow/i18n/lzh.json b/Flow/i18n/lzh.json
new file mode 100644
index 00000000..5c7de194
--- /dev/null
+++ b/Flow/i18n/lzh.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jason924tw",
+ "StephDC",
+ "灰太狼Wolffy55"
+ ]
+ },
+ "flow-topic-moderated-reason-prefix": "因:",
+ "flow-post-action-post-history": "誌",
+ "flow-topic-action-history": "誌",
+ "flow-topic-action-undo-moderation": "撤",
+ "flow-error-invalid-moderation-state": "一参数('moderationState')无效值已提API焉。",
+ "flow-board-history": "「$1」誌",
+ "flow-link-history": "誌",
+ "flow-topic-html-title": "$1於$2"
+}
diff --git a/Flow/i18n/mg.json b/Flow/i18n/mg.json
new file mode 100644
index 00000000..689567bc
--- /dev/null
+++ b/Flow/i18n/mg.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jagwar"
+ ]
+ },
+ "flow-edit-post-submit-overwrite": "Hanitsaka ny fiovana"
+}
diff --git a/Flow/i18n/mk.json b/Flow/i18n/mk.json
new file mode 100644
index 00000000..9a8e000e
--- /dev/null
+++ b/Flow/i18n/mk.json
@@ -0,0 +1,512 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Bjankuloski06"
+ ]
+ },
+ "enableflow": "Вклучи го Тек",
+ "flow-desc": "Систем за раководење со работниот тек",
+ "flow-talk-taken-over": "Оваа страница за разговор користи [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Тек].",
+ "flow-talk-username": "Раководител на страница за разговор со Тек",
+ "log-name-flow": "Дневник на активности во текот",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|избриша}} [$4 објава] на „[[$3|$5]]“ на [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|поврати}} [$4 објава] на „[[$3|$5]]“ на [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|притаи}} [$4 објава] на „[[$3|$5]]“ на [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|избриша}} [$4 објава] на „[[$3|$5]]“ на [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|ја избриша}} темата „[[$3|$5]]“ на [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|ја избриша}} темата „[[$3|$5]]“ на [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|ја избриша}} темата „[[$3|$5]]“ на [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|ја избриша}} темата „[[$3|$5]]“ на [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] на [[$3]] е увезена во Flow од LiquidThreads",
+ "flow-user-moderated": "Модериран корисник",
+ "flow-board-header-browse-topics-link": "Прегледај теми",
+ "flow-edit-header-link": "Измени наслов",
+ "flow-post-moderated-toggle-hide-show": "Прикажи го коментарот што го {{GENDER:$1|скри}} $2",
+ "flow-post-moderated-toggle-delete-show": "Прикажи го коментарот што го {{GENDER:$1|избриша}} $2",
+ "flow-post-moderated-toggle-suppress-show": "Прикажи го коментарот што го {{GENDER:$1|притаи}} $2",
+ "flow-post-moderated-toggle-hide-hide": "Скриј го коментарот што го {{GENDER:$1|скри}} $2",
+ "flow-post-moderated-toggle-delete-hide": "Скриј го коментарот што го {{GENDER:$1|избриша}} $2",
+ "flow-post-moderated-toggle-suppress-hide": "Скриј го коментарот што го {{GENDER:$1|притаи}} $2",
+ "flow-topic-moderated-reason-prefix": "Причина:",
+ "flow-hide-post-content": "Коментаров е {{GENDER:$1|скриен}} од $1 ([$2 историја])",
+ "flow-hide-title-content": "Темава е {{GENDER:$1|скриена}} од $1",
+ "flow-lock-title-content": "Темава е {{GENDER:$1|заклучена}} од $1",
+ "flow-hide-header-content": "{{GENDER:$1|Скриено}} од $2",
+ "flow-delete-post-content": "Коментаров е {{GENDER:$1|избришан}} од $1 ([$2 историја])",
+ "flow-delete-title-content": "Темава е {{GENDER:$1|избришана}} од $1",
+ "flow-delete-header-content": "{{GENDER:$1|Избришано}} од $2",
+ "flow-suppress-post-content": "Коментаров е {{GENDER:$1|притаен}} од $1 ([$2 историја])",
+ "flow-suppress-title-content": "Темава е {{GENDER:$1|притаена}} од $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Притаено}} од $2",
+ "flow-suppress-usertext": "<em>Корисничкото име е притаено</em>",
+ "flow-post-actions": "Дејства",
+ "flow-topic-actions": "Дејства",
+ "flow-cancel": "Откажи",
+ "flow-preview": "Преглед",
+ "flow-show-change": "Прикажи промени",
+ "flow-last-modified-by": "Последно {{GENDER:$1|изменето}} од $1",
+ "flow-stub-post-content": "''Објавата не може да се добие поради техничка грешка.''",
+ "flow-newtopic-title-placeholder": "Нова тема",
+ "flow-newtopic-content-placeholder": "Напишете нова порака на „$1“",
+ "flow-newtopic-header": "Додај нова тема",
+ "flow-newtopic-save": "Додај тема",
+ "flow-newtopic-start-placeholder": "Почнете нова тема",
+ "flow-newtopic-first-heading": "Започнете нова тема на $1",
+ "flow-summarize-topic-placeholder": "Дајте краток опис на разговорот",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Коментирај}} на „$2“",
+ "flow-reply-topic-title-placeholder": "Одговори на „$1“",
+ "flow-reply-submit": "{{GENDER:$1|Одговори}}",
+ "flow-reply-link": "{{GENDER:$1|Одговори}}",
+ "flow-thank-link": "{{GENDER:$1|Заблагодари се}}",
+ "flow-lock-link": "{{GENDER:$1|Заклучи}}",
+ "flow-thank-link-title": "Јавно заблагодари му се на објавувачот",
+ "flow-history-action-suppress-post": "притај",
+ "flow-history-action-delete-post": "избриши",
+ "flow-history-action-hide-post": "скриј",
+ "flow-history-action-unsuppress-post": "отпритаи",
+ "flow-history-action-undelete-post": "обнови",
+ "flow-history-action-unhide-post": "откриј",
+ "flow-history-action-restore-post": "врати",
+ "flow-history-action-lock-topic": "заклучи",
+ "flow-history-action-unlock-topic": "отклучи",
+ "flow-post-edited": "$1 {{GENDER:$1|измени}} објава во $2",
+ "flow-post-action-view": "Постојана врска",
+ "flow-post-action-post-history": "Историја",
+ "flow-post-action-suppress-post": "Притај",
+ "flow-post-action-delete-post": "Избриши",
+ "flow-post-action-hide-post": "Скриј",
+ "flow-post-action-edit-post": "Уреди ја пораката",
+ "flow-post-action-edit-post-submit": "Зачувај промени",
+ "flow-post-action-unsuppress-post": "Отпритаи",
+ "flow-post-action-undelete-post": "Обнови",
+ "flow-post-action-unhide-post": "Откриј",
+ "flow-post-action-restore-post": "Поврати",
+ "flow-post-action-undo-moderation": "Откажи",
+ "flow-topic-action-view": "Постојана врска",
+ "flow-topic-action-watchlist": "Набљудувања",
+ "flow-topic-action-edit-title": "Уреди наслов",
+ "flow-topic-action-history": "Историја",
+ "flow-topic-action-hide-topic": "Скриј тема",
+ "flow-topic-action-delete-topic": "Избриши тема",
+ "flow-topic-action-lock-topic": "Заклучи теми",
+ "flow-topic-action-unlock-topic": "Отклучи тема",
+ "flow-topic-action-summarize-topic": "Дај краток опис",
+ "flow-topic-action-resummarize-topic": "Уреди опис на темата",
+ "flow-topic-action-suppress-topic": "Притај тема",
+ "flow-topic-action-unhide-topic": "Откриј тема",
+ "flow-topic-action-undelete-topic": "Обнови тема",
+ "flow-topic-action-unsuppress-topic": "Отпритај тема",
+ "flow-topic-action-restore-topic": "Поврати тема",
+ "flow-topic-action-undo-moderation": "Врати",
+ "flow-topic-notification-subscribe-title": "Темава е додадена во {{GENDER:$1|набљудуваните}}.",
+ "flow-topic-notification-subscribe-description": "Ќе {{GENDER:$1|добивате}} известувања за сите активности на темава.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Претплатени}} сте на оваа дискусиона табла!",
+ "flow-board-notification-subscribe-description": "Ќе {{GENDER:$1|бидете}} автоматски известувани за сите нови теми создадени на оваа табла.",
+ "flow-error-http": "Се јави грешка при поврзувањето со опслужувачот.",
+ "flow-error-other": "Се појави неочекувана грешка.",
+ "flow-error-external": "Се појави грешка.<br />Објаснувањето гласи: $1",
+ "flow-error-edit-restricted": "Не ви е дозволено да ја менувате објавата.",
+ "flow-error-topic-is-locked": "Темава е заклучена за понатамошни активности.",
+ "flow-error-lock-moderated-post": "Не можете да заклучите модерирана објава.",
+ "flow-error-external-multi": "Наидов на грешки.<br />$1",
+ "flow-error-missing-content": "Пораката нема содржина. За да се зачува, мора да има содржина.",
+ "flow-error-missing-summary": "Описот нема содржина. Ви треба содржина за да можете да го зачувате.",
+ "flow-error-missing-title": "Темата нема наслов. Се бара наслов за да може да се зачува темата.",
+ "flow-error-parsoid-failure": "Не можам да ја расчленам содржината поради проблем со Parsoid.",
+ "flow-error-missing-replyto": "Нема зададено параметар „replyTo“. Овој параметар е потребен за да може да се даде одговор.",
+ "flow-error-invalid-replyto": "Параметарот на „replyTo“ е неважечки. Не можев да ја најдам укажаната порака.",
+ "flow-error-delete-failure": "Бришењето на ставката не успеа.",
+ "flow-error-hide-failure": "Не успеав да ја скријам ставката.",
+ "flow-error-missing-postId": "Нема зададено параметар „postId“. Овој параметар е потребен за работа со пораката.",
+ "flow-error-invalid-postId": "Параметарот на „postId“ е неважечки. Не можев да ја најдам укажаната порака ($1).",
+ "flow-error-restore-failure": "Повраќањето на ставката не успеа.",
+ "flow-error-invalid-moderation-state": "На извршникот на Тек му е укажана неважечка вредност за параметар („moderationState“) .",
+ "flow-error-invalid-moderation-reason": "Наведете причина за модерирањето",
+ "flow-error-not-allowed": "Немате дозвола за да го извршите ова дејство",
+ "flow-error-not-allowed-hide": "Темата е скриена.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Не можете да одговорите бидејќи темава е скриена.",
+ "flow-error-not-allowed-delete": "Темата е избришана.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Не можете да одговорите бидејќи темава е избришана.",
+ "flow-error-not-allowed-suppress": "Темава е избришана.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Не можете да одговорите бидејќи темава е избришана.",
+ "flow-error-not-allowed-hide-extract": "Темава е скриена. Подолу е приложен записникот на скривања за темата.",
+ "flow-error-not-allowed-delete-extract": "Овој курс е избришан. Подолу е приложен записникот на бришења.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Не можете да одговорите бидејќи темава е избришана. Подолу е приложен записникот на бришења.",
+ "flow-error-not-allowed-suppress-extract": "Овој курс е избришан. Подолу е приложен записникот на бришења.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Не можете да одговорите бидејќи темава е притаена. Подолу е приложен записникот на притајувања.",
+ "flow-error-title-too-long": "Насловот на темата може да има највеќе {{PLURAL:$1|еден бајт|$1 бајти}}.",
+ "flow-error-no-existing-workflow": "Овој работен тек сè уште не постои.",
+ "flow-error-not-a-post": "Насловот на темата не може да се зачува како објава.",
+ "flow-error-missing-header-content": "Заглавието нема содржина. Ви треба содржина за да можете да го зачувате.",
+ "flow-error-missing-prev-revision-identifier": "Недостасува назнака на претходната преработка.",
+ "flow-error-prev-revision-mismatch": "Објавава ја измени друг корисник пред неколку секунди. Дали {{GENDER:$3|сте}} сигурни дека сакате да презапишете врз оваа последна промена?",
+ "flow-error-prev-revision-does-not-exist": "Не можев да ја надам претходната преработка.",
+ "flow-error-core-topic-deletion": "За да избришете тема, послужете се со менито ... на таблата на Тек или на [$1 страницата на темата]. Не го посетувајте action=delete за темата непосредно.",
+ "flow-error-default": "Се појави грешка.",
+ "flow-error-invalid-input": "Укажана е неважечка вредност за вчитување на содржините на текот.",
+ "flow-error-invalid-title": "Укажан е неважечки наслов на страницата.",
+ "flow-error-fail-load-history": "Не успеав да ја вчитам содржината на историјата.",
+ "flow-error-missing-revision": "Не можев да ја пронајдам преработката од која би ја вчитал содржината на текот.",
+ "flow-error-fail-commit": "Не успеав да ја зачувам содржината на текот.",
+ "flow-error-insufficient-permission": "Немате доволно дозволи за пристап до содржината.",
+ "flow-error-revision-comparison": "Операцијата за разлика може да се врши само кога две преработки припаѓаат на иста објава.",
+ "flow-error-missing-topic-title": "Не можев да го најдам насловот на темата во тековниот работен тек.",
+ "flow-error-missing-metadata": "Не можев да ги најдам потребните метаподатоци за оваа преработка.",
+ "flow-error-fail-load-data": "Не успеав да ги вчитам побараните податоци.",
+ "flow-error-invalid-workflow": "Не успеав да го најдам бараниот работен тек.",
+ "flow-error-process-data": "Се појави грешка при обработката на податоците во вашето барање.",
+ "flow-error-process-wikitext": "Се појави грешка при обработката на претворањето на HTML/викитекстот.",
+ "flow-error-no-index": "Не успеав да најдам индекс за пребарување на податоците.",
+ "flow-error-no-render": "Не го препознав укажаното дејство.",
+ "flow-error-no-commit": "Не можев да го зачувам укажаното дејство.",
+ "flow-error-fetch-after-lock": "Наидов на грешка при барањето на новите податоци. Но отворањето/затворањето успеа. Известувањето за грешката гласи: $1",
+ "flow-error-content-too-long": "Содржината е преголема. По проширувањето, содржината е ограничена на {{PLURAL:$1|1 бајт|$1 бајт}}.",
+ "flow-error-move": "Засега не може да се преместуваат дискусиони табли.",
+ "flow-error-invalid-topic-uuid-title": "Неисправен наслов",
+ "flow-error-invalid-topic-uuid": "Побараниот наслов е неважечки. Страниците во именскиот простор „Тема“ се создаваат автоматски од страна на Тек.",
+ "flow-error-unknown-workflow-id-title": "Непозната тема",
+ "flow-error-unknown-workflow-id": "Бараната тема не постои.",
+ "flow-edit-header-placeholder": "Опиши ја оваа дискусиона табла",
+ "flow-edit-header-submit": "Зачувај заглавие",
+ "flow-edit-header-submit-overwrite": "Презапиши врз заглавието",
+ "flow-summarize-topic-submit": "Дај краток опис",
+ "flow-summarize-topic-submit-overwrite": "Дај нов опис",
+ "flow-lock-topic-submit": "Заклучи тема",
+ "flow-lock-topic-submit-overwrite": "Презапиши врз описот на заклучувањето",
+ "flow-unlock-topic-submit": "Отклучи тема",
+ "flow-unlock-topic-submit-overwrite": "Презапиши врз описот на отклучувањето",
+ "flow-edit-title-submit": "Измени наслов",
+ "flow-edit-title-submit-overwrite": "Презапиши врз насловот",
+ "flow-edit-post-submit": "Спроведи измени",
+ "flow-edit-post-submit-overwrite": "Презапиши врз промените",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|измени}} [$3 коментар] на „$4“.",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Изменета}} објава",
+ "flow-rev-message-reply": "$1 {{GENDER:$2|доидаде}} [$3 коментар] на „$4“ (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "{{PLURAL:$1|Додаден|Додадени}} <strong>{{PLURAL:$1|еден коментар|$1 коментари}}</strong>.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|ја создаде}} темата „[$3 $4]“.",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Создадена}} нова тема",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|го смени}} насловот на темата од „$5“ во „[$3 $4]“.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|го создаде}} заглавието.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|го измени}} заглавието.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|создаде}} опис на темата $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|го измени}} описот на темата $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|скри}} [$4 коментар] на на „$6“ (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|избриша}} [$4 коментар] на „$6“ (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|притаи}} [$4 коментар] на „$6“ (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|поврати}} [$4 коментар] на „$6“ (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|ја скри}} [$4 темата] на „$6“ (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|ја избриша}} [$4 темата] „$6“ (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|ја притаи}} [$4 темата] „$6“ (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 ја {{GENDER:$2|заклучи}} [$4 темата] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|ја поврати}} [$4 темата] „$6“ (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 на $2",
+ "flow-board-history": "Историја на „$1“",
+ "flow-board-history-empty": "Таблата засега нема историја.",
+ "flow-topic-history": "Историја на темата „$1“",
+ "flow-post-history": "Историја на објавите — Коментар од {{GENDER:$2|$2}}",
+ "flow-history-last4": "Последниве 4 часа",
+ "flow-history-day": "Денес",
+ "flow-history-week": "Минатата седмица",
+ "flow-history-pages-topic": "Фигурира на [$1 таблата „$2“]",
+ "flow-history-pages-post": "Фигурира на [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 коментар|$1 коментари|0={{GENDER:$2|Бидете први}} со коментар!}}",
+ "flow-comment-restored": "Повратен коментар",
+ "flow-comment-deleted": "Избришан коментар",
+ "flow-comment-hidden": "Скриен коментар",
+ "flow-comment-moderated": "Модериран коментар",
+ "flow-last-modified": "Последна измена: $1",
+ "flow-workflow": "работен тек",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|одговори}} на '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 и {{PLURAL:$6|еден друг одговори|$5 други одговорија}} {{GENDER:$1|на}} '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|ја измени}} вашата <span class=\"plainlinks\">[$5 објава]</span> на [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 и $5 {{PLURAL:$6|уште еден друг|уште $5 други}} {{GENDER:$1|изменија}} <span class=\"plainlinks\">[$4 post]</span> во „$2“ на „$3“.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|создаде}} нова тема на '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|Една нова|250=Преку 250 нови}} {{PLURAL:$1|тема|теми}} на '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 го {{GENDER:$1|смени}} насловот на <span class=\"plainlinks\">[$2 $3]</span> во „$4“ на [[$5|$6]]",
+ "flow-notification-mention": "$1 {{GENDER:$5|вр}} спомна во {{GENDER:$1|неговата|нејзината|неговата}} <span class=\"plainlinks\">[$2 објава]</span> во „$3“ на „$4“",
+ "flow-notification-link-text-view-post": "Погл. објавата",
+ "flow-notification-link-text-view-topic": "Погл. темата",
+ "flow-notification-reply-email-subject": "$2 на $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|одговори}} на „$2“ на „$3“",
+ "flow-notification-reply-email-batch-bundle-body": "$1 и уште {{PLURAL:$5|еден друг|$4 други}} {{GENDER:$1|одговорија}} на „$2“ на „$3“",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$3|ве}} {{GENDER:$1|спомна}} на „$2“",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|ве}} спомна во {{GENDER:$1|неговата|нејзината|неговата}} објава во „$2“ на „$3“",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|измени}} објава",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|измени}} објава во „$2“ на „$3“",
+ "flow-notification-edit-email-batch-bundle-body": "$1 и {{PLURAL:$5|уште еден друг|уште $4 други}} {{GENDER:$1|ја изменија}} вашата објава во „$2“ на „$3“",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|ја преименуваше}} вашата тема",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|ја преименуваше}} вашата тема „$2“ во „$3“ на „$4“",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|создаде}} нова тема на „$2“",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|создаде}} нова тема со наслов „$2“ на $3",
+ "echo-category-title-flow-discussion": "Тек",
+ "echo-pref-tooltip-flow-discussion": "Извести ме кога во Тек ќе се случат дејства поврзани со мене.",
+ "flow-link-post": "објава",
+ "flow-link-topic": "тема",
+ "flow-link-history": "историја",
+ "flow-link-post-revision": "преработка на објавата",
+ "flow-link-topic-revision": "преработка на темата",
+ "flow-link-header-revision": "преработка на заглавието",
+ "flow-link-summary-revision": "преработка на описот",
+ "flow-moderation-title-suppress-post": "Да ја притаам објавата?",
+ "flow-moderation-title-delete-post": "Да ја избришам објавата?",
+ "flow-moderation-title-hide-post": "Да ја скријам објавата?",
+ "flow-moderation-title-unsuppress-post": "Да ја отпритаам приатената тема?",
+ "flow-moderation-title-undelete-post": "Да ја обновам избришаната објава?",
+ "flow-moderation-title-unhide-post": "Да ја откријам скриената објава?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|Објаснете}} зошто ја притајувате објавава.",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Објаснете}} зошто ја бришете објавава.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Објаснете}} зошто ја скривате објавава.",
+ "flow-moderation-placeholder-unsuppress-post": "{{GENDER:$3|Објаснете}} зошто ја отпритајувате објавава.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|Објаснете}} зошто ја обновувате објавава.",
+ "flow-moderation-placeholder-unhide-post": "{{GENDER:$3|Објаснете}} зошто ја откривате објавава.",
+ "flow-moderation-confirm-suppress-post": "Притај",
+ "flow-moderation-confirm-delete-post": "Избриши",
+ "flow-moderation-confirm-hide-post": "Скриј",
+ "flow-moderation-confirm-unsuppress-post": "Отпритаи",
+ "flow-moderation-confirm-undelete-post": "Обнови",
+ "flow-moderation-confirm-unhide-post": "Откриј",
+ "flow-moderation-confirm-suppress-topic": "Притај",
+ "flow-moderation-confirm-delete-topic": "Избриши",
+ "flow-moderation-confirm-hide-topic": "Скриј",
+ "flow-moderation-confirm-lock-topic": "Заклучи",
+ "flow-moderation-confirm-unsuppress-topic": "Отпритаи",
+ "flow-moderation-confirm-undelete-topic": "Обнови",
+ "flow-moderation-confirm-unhide-topic": "Откриј",
+ "flow-moderation-confirm-unlock-topic": "Отклучи",
+ "flow-moderation-confirmation-suppress-post": "Објавата е успешно притаена. {{GENDER:$2|Ви препорачуваме}} на корисникот $1 да му дадете образложение и/или совет за објавата.",
+ "flow-moderation-confirmation-delete-post": "Објавата е успешно избришана. {{GENDER:$2|Ви препорачуваме}} на корисникот $1 да му дадете образложение и/или совет за објавата.",
+ "flow-moderation-confirmation-hide-post": "Објавата е успешно скриена. {{GENDER:$2|Ви препорачуваме}} на корисникот $1 да му дадете образложение и/или совет за објавата.",
+ "flow-moderation-confirmation-unsuppress-post": "Успешно ја отпритаивте објавата.",
+ "flow-moderation-confirmation-undelete-post": "Успешно ја обновивте објавата.",
+ "flow-moderation-confirmation-unhide-post": "Успешно ја откривте објавата.",
+ "flow-moderation-confirmation-suppress-topic": "Темава е притаена.",
+ "flow-moderation-confirmation-delete-topic": "Темава е избришана.",
+ "flow-moderation-confirmation-hide-topic": "Темава е скриена.",
+ "flow-moderation-confirmation-unsuppress-topic": "Успешно ја отпритаивте темата.",
+ "flow-moderation-confirmation-undelete-topic": "Успешно ја обновивте темата.",
+ "flow-moderation-confirmation-unhide-topic": "Успешно ја откривте темата.",
+ "flow-moderation-title-suppress-topic": "Да ја притаам темата?",
+ "flow-moderation-title-delete-topic": "Да ја избришам темата?",
+ "flow-moderation-title-hide-topic": "Да ја скријам темата?",
+ "flow-moderation-title-unsuppress-topic": "Да ја отпритаам притаената тема?",
+ "flow-moderation-title-undelete-topic": "Да ја обновам избришаната тема?",
+ "flow-moderation-title-unhide-topic": "Да ја обновам избришаната тема?",
+ "flow-moderation-placeholder-suppress-topic": "{{GENDER:$3|Објаснете}} зошто ја притајувате темава.",
+ "flow-moderation-placeholder-delete-topic": "{{GENDER:$3|Објаснете}} зошто ја бришете темава.",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|Објаснете}} зошто ја скривате темава.",
+ "flow-moderation-placeholder-lock-topic": "{{GENDER:$3|Образложете}} зошто ја заклучувате темава.",
+ "flow-moderation-placeholder-unsuppress-topic": "{{GENDER:$3|Објаснете}} зошто ја отпритајувате темава.",
+ "flow-moderation-placeholder-undelete-topic": "{{GENDER:$3|Објаснете}} зошто ја обновувате темава.",
+ "flow-moderation-placeholder-unhide-topic": "{{GENDER:$3|Објаснете}} зошто ја откривате темава.",
+ "flow-moderation-placeholder-unlock-topic": "{{GENDER:$3|Образложете}} зошто ја отклучувате темава.",
+ "flow-topic-permalink-warning": "Темата е започната на [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Темата е започната на [$2 таблата на {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Ова е постојана врска со една верзија на објавава.\nОваа верзија е од $1.\nМожете да ги погледате [$5 разликите од претходната верзија], или пак другите верзии во [$4 историјата на објавата].",
+ "flow-revision-permalink-warning-post-first": "Ова е постојана врска до една верзија на објавава.\nМожете да ги погледате подоцнежните верзии во [$4 историјата на објавата].",
+ "flow-revision-permalink-warning-postsummary": "Ова е постојана врска до една верзија на описот на објавава. Верзијата е од $1.\nМожете да ги погледате [$5 разликите од претходната верзија], или пак другите верзии во [$4 историјата на објавата].",
+ "flow-revision-permalink-warning-postsummary-first": "Ова е постојана врска до првата верзија на описот на објавава. Можете да ги погледате подоцнежните верзии во [$4 историјата на објавата].",
+ "flow-revision-permalink-warning-header": "Ова е постојана врска до една верзија на заглавието.\nОваа верзиаја е од $1. Можете да ги погледате [$3 разликите од претходната верзија], или пак другите верзии во [$2 историјата].",
+ "flow-revision-permalink-warning-header-first": "Ова е постојана врска до првата верзија на заглавието.\nМожете да ги погледате подоцнежните верзии во [$2 историјата].",
+ "flow-compare-revisions-revision-header": "Верзија на {{GENDER:$2|$2}} од $1",
+ "flow-compare-revisions-header-post": "На страницава се прикажани {{GENDER:$3|разликите}} помеѓу две верзии на објава на $3 во темата „[$5 $2]“ на [$4 $1].\nМожете да ги погледате другите верзии на објавата во [$6 нејзината историја].",
+ "flow-compare-revisions-header-postsummary": "На страницава се прикажани разликите помеѓу две верзии на опис на објавата „[$4 $2]“ на [$3 $1].\nМожете да ги погледате другите верзии на објавата во [$5 нејзината историја].",
+ "flow-compare-revisions-header-header": "На страницава се прикажани {{GENDER:$2|промените}} помеѓу две верзии на заглавието на [$3 $1].\nДругите верзии на заглавието можете да ги видите во [$4 неговата историја].",
+ "action-flow-create-board": "создавање на табли со Тек на било кое место",
+ "right-flow-create-board": "Создавање на табли со Тек на било кое место",
+ "right-flow-hide": "Скривање на теми и објави во „Тек“",
+ "right-flow-lock": "Заклучување на теми во „Тек“",
+ "right-flow-delete": "Бришење на теми и објави во „Тек“",
+ "right-flow-edit-post": "Менување на објави на други корисници во „Тек“",
+ "right-flow-suppress": "Скривање на преработки во „Тек“",
+ "flow-terms-of-use-new-topic": "Стискајќи на „{{int:flow-newtopic-save}}“, се согласувате со условите на употреба на ова вики.",
+ "flow-terms-of-use-reply": "Стискајќи на „{{int:flow-reply-submit}}“, се согласувате со условите на употреба на ова вики.",
+ "flow-terms-of-use-edit": "Зачувувајќи ги промените, се согласувате со условите на употреба на ова вики.",
+ "flow-anon-warning": "Не сте најавени. За да бидете наведени како уредник по име наместо по IP-адреса, [$1 најавете се] или [$2 направете сметка].",
+ "flow-cancel-warning": "Внесовте текст во образецов. Дали сте сигурни дека сакате да го отфрлите?",
+ "flow-topic-first-heading": "Тема на $1",
+ "flow-topic-html-title": "$1 на $2",
+ "flow-topic-count": "Теми ($1)",
+ "flow-load-more": "Вчитај уште",
+ "flow-no-more-fwd": "Нема постари теми",
+ "flow-add-topic": "Додај тема",
+ "flow-newest-topics": "Најнови теми",
+ "flow-recent-topics": "Скорешни активни теми",
+ "flow-sorting-tooltip-newest": "Моментално ги {{GENDER:|разголедувате}} најпрвин најновите теми. Стиснете за повеќе можности за подредување.",
+ "flow-sorting-tooltip-recent": "Моментално ги {{GENDER:|читате}} најпрвин најновите теми. Стиснете за повеќе можности за подредување.",
+ "flow-toggle-small-topics": "Прејди на мал преглед на темите",
+ "flow-toggle-topics": "Прејди на преглед на само теми",
+ "flow-toggle-topics-posts": "Прејди на преглед на теми и објави",
+ "flow-terms-of-use-summarize": "Стискајќи на „{{int:flow-summarize-topic-submit}}“, се согласувате со условите на употреба на ова вики.",
+ "flow-terms-of-use-lock-topic": "Стискајќи на „{{int:flow-lock-topic-submit}}“, се согласувате со условите на употреба на ова вики.",
+ "flow-terms-of-use-unlock-topic": "Стискајќи на „{{int:flow-unlock-topic-submit}}“, се согласувате со условите на употреба на ова вики.",
+ "flow-whatlinkshere-post": "од [$1 објава]",
+ "flow-whatlinkshere-header": "од [$1 заглавието]",
+ "flow": "Тек",
+ "flow-special-desc": "Оваа службена страница пренасочува кон работниот тек или објава на „Тек“ со зададен UUID.",
+ "flow-special-type": "Тип",
+ "flow-special-type-post": "Објава",
+ "flow-special-type-workflow": "Работен тек",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Не можев да пронајдам содржини што одговараат на дадениот тип и UUID.",
+ "flow-special-enableflow-legend": "Вклучи го Тек на нова страница",
+ "flow-special-enableflow-page": "На која страница да се вклучи Тек",
+ "flow-special-enableflow-header": "Првично заглавие на таблата со Тек (викитекст)",
+ "flow-special-enableflow-board-already-exists": "Веќе има табла со Тек на [[$1]].",
+ "flow-special-enableflow-invalid-title": "Укажаната страница не претставува важечки наслов на страница",
+ "flow-special-enableflow-page-already-exists": "Веќе има страница без Тек на [[$1]]. Ако сепак сакате да има табла и таму, тогаш преместете ја постоечката страница во архивата, избришете го пренасочувањаето, па повторно послужете се Special:EnableFlow. Во заглавието ставете го името на архивата",
+ "flow-special-enableflow-confirmation": "Успешно создадовте табла со Тек на [[$1]].",
+ "flow-spam-confirmedit-form": "Поврдете дека не сте робот, внесувајќи го прикажаното во полето: $1",
+ "flow-preview-warning": "Гледате преглед. Стиснете на „{{int:flow-newtopic-save}}“ за да го објавите напишаното, или на „{{int:flow-preview-return-edit-post}}“ за да продолжите со пишување.",
+ "flow-preview-return-edit-post": "Продолжи со уредување",
+ "flow-anonymous": "Анонимен",
+ "flow-embedding-unsupported": "Дискусиите засега не може да се вметнуваат.",
+ "mw-ui-unsubmitted-confirm": "Имате неподнесени промени на страницата. Дали сигурно сакате да ја напуштите, губејќи го напишаното?",
+ "flow-post-undo-hide": "откажи го скривањето",
+ "flow-post-undo-delete": "откажи го бришењето",
+ "flow-post-undo-suppress": "откажи го притајувањето",
+ "flow-topic-undo-hide": "откажи го скривањето",
+ "flow-topic-undo-delete": "откажи го бришењето",
+ "flow-topic-undo-suppress": "откажи го притајувањето",
+ "flow-importer-lqt-moved-thread-template": "LQT премести нишка-никулец претворена во Тек",
+ "flow-importer-lqt-converted-template": "LQT-страница пертворена во Тек",
+ "flow-importer-lqt-converted-archive-template": "Архив за претворената LQT-страница",
+ "flow-importer-wt-converted-template": "Бикитекстуална страница за разговор што треба да се претвори во Тек",
+ "flow-importer-wt-converted-archive-template": "Архива за претворени викитекстуални страници за разговор",
+ "flow-importer-lqt-suppressed-user-template": "Оваа преработка е увезена од LiquidThreads со притаен корисник. Сега е му доделена на тековниот корисник.",
+ "apihelp-flow-description": "Овозможува вршење дејства брз страници со Тек.",
+ "apihelp-flow-param-submodule": "Подмодулот на Тек што треба да се повика.",
+ "apihelp-flow-param-page": "Врз која страница да се изврши дејството.",
+ "apihelp-flow-param-render": "Задајте му нешто на ова за да вклучите испис на дадениот блок во изводот.",
+ "apihelp-flow-example-1": "Уреди го заглавието на „[[Talk:Sandbox]]“",
+ "apihelp-flow+close-open-topic-description": "Застарено и заменето со [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "Која состојба да ѝ се зададе на темата: заклучена или отклучена.",
+ "apihelp-flow+close-open-topic-param-reason": "Причина за заклучување или отклучување на темата.",
+ "apihelp-flow+edit-header-description": "Уредува заглавие на таблата.",
+ "apihelp-flow+edit-header-param-prev_revision": "Назнака на тековната преработка на заглавието, за проверка на можни спротиставености во уредувањето.",
+ "apihelp-flow+edit-header-param-content": "Содржина на заглавието.",
+ "apihelp-flow+edit-header-param-format": "Формат на заглавието (викитекст|HTML)",
+ "apihelp-flow+edit-header-example-1": "Уреди го заглавието на [[Talk:Sandbox]]",
+ "apihelp-flow+edit-header-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+edit-post-description": "Уреди ја содржината на објавата.",
+ "apihelp-flow+edit-post-param-postId": "Назнака на објавата.",
+ "apihelp-flow+edit-post-param-prev_revision": "Назнака на тековната преработка на објавата, за проверка на можни спротиставености во уредувањето.",
+ "apihelp-flow+edit-post-param-content": "Содржина на објавата.",
+ "apihelp-flow+edit-post-param-format": "Формат на содржината на објавата (викитекст|HTML)",
+ "apihelp-flow+edit-post-example-1": "Уреди објава во [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-post-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+edit-title-description": "Уредува наслов на објава.",
+ "apihelp-flow+edit-title-param-prev_revision": "Назнака на тековната преработка на насловот, за проверка на можни спротиставености во уредувањето.",
+ "apihelp-flow+edit-title-param-content": "Содржина на насловот.",
+ "apihelp-flow+edit-title-example-1": "Уреди го насловот на [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+edit-topic-summary-description": "Уреди ја содржината на краткиот преглед на темата.",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "Назнака на тековната преработка на краткиот преглед на темата, за проверка на можни спротиставености во уредувањето.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Содржина на краткиот преглед.",
+ "apihelp-flow+edit-topic-summary-param-format": "Формат на описот (викитекст|HTML)",
+ "apihelp-flow+edit-topic-summary-example-1": "Уреди го краткиот преглед на [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+lock-topic-description": "Заклучи или отклучи тема со Тек.",
+ "apihelp-flow+lock-topic-param-moderationState": "Која состојба да ѝ се зададе на темата: заклучена или отклучена.",
+ "apihelp-flow+lock-topic-param-reason": "Причина за заклучување или отклучување на темата.",
+ "apihelp-flow+lock-topic-example-1": "Заклучи ја [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+moderate-post-description": "Модерирај објава со Тек.",
+ "apihelp-flow+moderate-post-param-moderationState": "На кое ниво да се модерира.",
+ "apihelp-flow+moderate-post-param-reason": "Причина за модерирањето.",
+ "apihelp-flow+moderate-post-param-postId": "Назнака на објавата што ќе се модерира.",
+ "apihelp-flow+moderate-post-example-1": "Избриши објава на темата [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+moderate-topic-description": "Модерира тема со Тек.",
+ "apihelp-flow+moderate-topic-param-moderationState": "На кое ниво да се модерира.",
+ "apihelp-flow+moderate-topic-param-reason": "Причина за модерирањето.",
+ "apihelp-flow+moderate-topic-example-1": "Избриши ја темата [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+new-topic-description": "Создава нова тема со Тек за дадениот работен тек.",
+ "apihelp-flow+new-topic-param-topic": "Текст на насловот на новата тема.",
+ "apihelp-flow+new-topic-param-content": "Содржина на првичниот одговор на темата.",
+ "apihelp-flow+new-topic-param-format": "Формат на првичниот одговор на темата (викитекст|HTML)",
+ "apihelp-flow+new-topic-example-1": "Создај нова тема на [[Talk:Sandbox]]",
+ "apihelp-flow+new-topic-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+reply-description": "Одговара на објава.",
+ "apihelp-flow+reply-param-replyTo": "Назнака на објавата на која се одговара.",
+ "apihelp-flow+reply-param-content": "Содржина на новата објава.",
+ "apihelp-flow+reply-param-format": "Формат на новата објава (викитекст|HTML)",
+ "apihelp-flow+reply-example-1": "Одговори на објава на [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+reply-param-metadataonly": "Дали да се вклучат само метаподатоци за нова содржина, изземајќи сè друго",
+ "apihelp-flow+view-header-description": "Погл. заглавие на таблата.",
+ "apihelp-flow+view-header-param-contentFormat": "Во кој формат да се даде содржината.",
+ "apihelp-flow+view-header-param-revId": "Вчитај ја оваа преработка наместо најновата.",
+ "apihelp-flow+view-header-example-1": "Дај го заглавието на [[Talk:Sandbox]] како викитекст",
+ "apihelp-flow+view-post-description": "Погл. објава.",
+ "apihelp-flow+view-post-param-postId": "Назнака на објавата што треба да се погледа.",
+ "apihelp-flow+view-post-param-contentFormat": "Во кој формат да се даде содржината.",
+ "apihelp-flow+view-post-example-1": "Дај ја содржината на објава на [[Topic:S2tycnas4hcucw8w]] како викитекст",
+ "apihelp-flow+view-topic-description": "Погл. тема.",
+ "apihelp-flow+view-topic-example-1": "Погл. [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "Погл. опис на темата.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "Во кој формат да се даде содржината.",
+ "apihelp-flow+view-topic-summary-param-revId": "Вчитај ја оваа преработка наместо најновата.",
+ "apihelp-flow+view-topic-summary-example-1": "Погл. описот на [[Topic:S2tycnas4hcucw8w]] како викитекст",
+ "apihelp-flow+view-topiclist-description": "Погл. список на теми.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Насока на подредување на темите.",
+ "apihelp-flow+view-topiclist-param-sortby": "Подреденост на темата.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Зачувај ја подреденоста, ако е зададена.",
+ "apihelp-flow+view-topiclist-param-offset-id": "Вредност на отстапката (во форматот UUID) од каде ќе се почне со давање на темите.",
+ "apihelp-flow+view-topiclist-param-offset": "Вредност на отстапката од која ќе се почне со давање на теми.",
+ "apihelp-flow+view-topiclist-param-limit": "Колку теми да се дадат.",
+ "apihelp-flow+view-topiclist-param-render": "Испиши ги темите во HTML.",
+ "apihelp-flow+view-topiclist-example-1": "Испиши ги темите во [[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "Претвори го текстот од викитекст во HMTL и обратно.",
+ "apihelp-flow-parsoid-utils-param-from": "Од кој формат да се претвори содржината.",
+ "apihelp-flow-parsoid-utils-param-to": "Во кој формат да се претвори содржината.",
+ "apihelp-flow-parsoid-utils-param-content": "Содржина за претворање.",
+ "apihelp-flow-parsoid-utils-param-title": "Наслов на страницата. Не може да се користи заедно со $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "Назнака на страницата. Не може да се користи заедно со $1title.",
+ "apihelp-flow-parsoid-utils-example-1": "Претвори го викитекстот <nowiki>'''пример''' ''бла''</nowiki> во HTML",
+ "apihelp-query+flowinfo-description": "Дај основни информации за текот на една страница.",
+ "apihelp-query+flowinfo-example-1": "Дај информации за текот на [[Talk:Sandbox]], [[Main Page|Главната страница]] и [[Talk:Flow]]",
+ "apihelp-flow+undo-edit-header-description": "Испис на информации неопходни за отповикување на уредување на заглавие.",
+ "apihelp-flow+undo-edit-header-param-startId": "Назнака на преработката од која ќе се почне отповикувањето.",
+ "apihelp-flow+undo-edit-header-param-endId": "Назнака на преработката со која ќе се заврши отповикувањето.",
+ "apihelp-flow+undo-edit-header-example-1": "Испиши информации за отповикување на уредување на заглавие во [[Talk:Sandbox]]",
+ "apihelp-flow+undo-edit-post-description": "Испис на информации неопходни за отповикување на уредување на објава.",
+ "apihelp-flow+undo-edit-post-param-postId": "Назнака на објавата што треба да се отповика.",
+ "apihelp-flow+undo-edit-post-param-startId": "Назнака на преработката од која ќе се почне отповикувањето.",
+ "apihelp-flow+undo-edit-post-param-endId": "Назнака на преработката со која ќе се заврши отповикувањето.",
+ "apihelp-flow+undo-edit-post-example-1": "Испиши информации за отповикување на уредување на објава на дадена тема",
+ "apihelp-flow+undo-edit-topic-summary-description": "Испис на информации неопходни за отповикување на уредување во опис на тема.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "Назнака на преработката од која ќе се почне отповикувањето.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "Назнака на преработката со која ќе се заврши отповикувањето.",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "Испиши информации за отповикување на уредување на опис на дадена тема",
+ "flow-edited": "Изменета",
+ "flow-edited-by": "Изменил: $1",
+ "flow-lqt-redirect-reason": "Пренасочување на повлечена објава во LiquidThreads кон нејзината претворана објава во Тек",
+ "flow-talk-conversion-move-reason": "Претворање на викитекстуален разговор во Тек од $1",
+ "flow-talk-conversion-archive-edit-reason": "Претворање на викитекстуален развговор во Тек",
+ "flow-previous-diff": "← Постаро уредување",
+ "flow-next-diff": "Поново уредување →",
+ "flow-undo": "отповикај",
+ "flow-undo-latest-revision": "Последна преработка",
+ "flow-undo-your-text": "Вашиот текст",
+ "flow-undo-edit-header": "Уредување на заглавјето",
+ "flow-undo-edit-topic-summary": "Уредување на описот на темата",
+ "flow-undo-edit-post": "Уредување на објавата",
+ "flow-undo-edit-content": "Уредувањето може да се отповика.\nВе молиме споредете ги промените со претходната верзија за да проверите дали тоа е сигурно она што сакате да го направите, а потоа зачувајте ги промените за да го завршите отповикувањето на претходното уредување.",
+ "flow-undo-edit-failure": "Уредувањето не можеше да се отповика заради меѓувремени спротиставени уредувања.",
+ "group-flow-bot": "Ботови за „Тек“",
+ "group-flow-bot-member": "Бот на „Тек“",
+ "grouppage-flow-bot": "Project:Ботови за Тек",
+ "flow-ve-mention-context-item-label": "Спомнување",
+ "flow-ve-mention-inspector-title": "Спомнување",
+ "flow-ve-mention-inspector-remove-label": "Отстрани",
+ "flow-ve-mention-tool-title": "Спомни корисник",
+ "flow-ve-mention-template": "пинг",
+ "flow-ve-mention-inspector-invalid-user": "Корисничкото име „$1“ не е регистрирано.",
+ "flow-wikitext-editor-help": "Викитекстот $1.",
+ "flow-wikitext-editor-help-and-preview": "Викитекстот $1 и можете да го $2 во секое време.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|користи означување]]",
+ "flow-wikitext-editor-help-preview-the-result": "прегледате исходот",
+ "flow-wikitext-switch-editor-tooltip": "Префрли на ВизуеленУредник",
+ "flow-ve-switch-editor-tool-title": "Префрли на викитекст"
+}
diff --git a/Flow/i18n/ml.json b/Flow/i18n/ml.json
new file mode 100644
index 00000000..8768e811
--- /dev/null
+++ b/Flow/i18n/ml.json
@@ -0,0 +1,210 @@
+{
+ "@metadata": {
+ "authors": [
+ "Praveenp",
+ "Suresh.balasubra",
+ "Hamnashafeer"
+ ]
+ },
+ "enableflow": "ഫ്ലോ സജ്ജമാക്കുക",
+ "flow-desc": "പ്രവൃത്തി കൈകാര്യ സൗകര്യം",
+ "flow-talk-taken-over": "ഈ സംവാദത്താൾ [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal ഫ്ലോ] ഉപയോഗിക്കുന്നു.",
+ "flow-talk-username": "ഫ്ലോ സംവാദത്താൾ കൈകാര്യോപകരണം",
+ "log-name-flow": "ഫ്ലോ പ്രവർത്തനരേഖ",
+ "logentry-delete-flow-delete-post": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 കുറിപ്പ്] $1 {{GENDER:$2|മായ്ച്ചു}}",
+ "logentry-delete-flow-restore-post": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള താളിലെ ഒരു [$4 കുറിപ്പ്] $1 {{GENDER:$2|പുനഃസ്ഥാപിച്ചു}}",
+ "logentry-suppress-flow-suppress-post": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 കുറിപ്പ്] $1 {{GENDER:$2|ഒതുക്കി}}",
+ "logentry-suppress-flow-restore-post": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 കുറിപ്പ്] $1 {{GENDER:$2|മായ്ച്ചു}}",
+ "logentry-delete-flow-delete-topic": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 വിഷയം] $1 {{GENDER:$2|മായ്ച്ചു}}",
+ "logentry-delete-flow-restore-topic": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 വിഷയം] $1 {{GENDER:$2|പുനഃസ്ഥാപിച്ചു}}",
+ "logentry-suppress-flow-suppress-topic": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 വിഷയം] $1 {{GENDER:$2|ഒതുക്കി}}",
+ "logentry-suppress-flow-restore-topic": "[[$6]] താളിലെ \"[[$3|$5]]\" എന്നതിലുള്ള ഒരു [$4 വിഷയം] $1 {{GENDER:$2|മായ്ച്ചു}}",
+ "logentry-import-lqt-to-flow-topic": "[[$3]] എന്നതിലെ [[$1|$2]] തലക്കെട്ട് ചരടിൽ കോർത്ത സംവദത്തിൽ നിന്നും ഫ്ലോയിലേക്ക് ഇറക്കുമതി ചെയ്തിരിക്കുന്നു",
+ "flow-user-moderated": "നിയന്ത്രിത ഉപയോക്താവ്",
+ "flow-board-header-browse-topics-link": "വിഷയങ്ങൾ ബ്രൗസ് ചെയ്യുക",
+ "flow-edit-header-link": "തലക്കെട്ട് തിരുത്തുക",
+ "flow-post-moderated-toggle-hide-show": "$2 {{GENDER:$1|മറച്ച}} അഭിപ്രായം പ്രദർശിപ്പിക്കുക",
+ "flow-post-moderated-toggle-delete-show": "$2 {{GENDER:$1|മായ്ച്ച}} അഭിപ്രായം പ്രദർശിപ്പിക്കുക",
+ "flow-post-moderated-toggle-suppress-show": "$2 {{GENDER:$1|ഒതുക്കിയ}} അഭിപ്രായം പ്രദർശിപ്പിക്കുക",
+ "flow-post-moderated-toggle-hide-hide": "$2 {{GENDER:$1|മറച്ച}} അഭിപ്രായം മറയ്ക്കുക",
+ "flow-post-moderated-toggle-delete-hide": "$2 {{GENDER:$1|മായ്ച്ച}} അഭിപ്രായം മറയ്ക്കുക",
+ "flow-post-moderated-toggle-suppress-hide": "$2 {{GENDER:$1|ഒതുക്കിയ}} അഭിപ്രായം മറയ്ക്കുക",
+ "flow-topic-moderated-reason-prefix": "കാരണം:",
+ "flow-hide-post-content": "ഈ അഭിപ്രായം $1 {{GENDER:$1|മറച്ചതാണ്}} ([$2 നാൾവഴി])",
+ "flow-hide-title-content": "ഈ വിഷയം $1 {{GENDER:$1|മറച്ചതാണ്}}",
+ "flow-lock-title-content": "ഈ വിഷയം {{GENDER:$1|അടച്ചത്}} $1 ആണ്",
+ "flow-hide-header-content": "$2 {{GENDER:$1|മറച്ചതാണ്}}",
+ "flow-delete-post-content": "ഈ അഭിപ്രായം $1 {{GENDER:$1|മായ്ച്ചതാണ്}} ([$2 നാൾവഴി])",
+ "flow-delete-title-content": "ഈ വിഷയം $1 {{GENDER:$1|മായ്ച്ചതാണ്}}",
+ "flow-delete-header-content": "$2 {{GENDER:$1|മായ്ച്ചതാണ്}}",
+ "flow-suppress-post-content": "ഈ അഭിപ്രായം $1 {{GENDER:$1|ഒതുക്കിയതാണ്}} ([$2 നാൾവഴി])",
+ "flow-suppress-title-content": "ഈ വിഷയം $1 {{GENDER:$1|ഒതുക്കിയതാണ്}}",
+ "flow-suppress-header-content": "$2 {{GENDER:$1|ഒതുക്കിയതാണ്}}",
+ "flow-suppress-usertext": "<em>ഉപയോക്തൃനാമം ഒതുക്കിയിരിക്കുന്നു</em>",
+ "flow-post-actions": "പ്രവൃത്തികൾ",
+ "flow-topic-actions": "പ്രവൃത്തികൾ",
+ "flow-cancel": "റദ്ദാക്കുക",
+ "flow-preview": "എങ്ങനെയുണ്ടെന്നു കാണുക",
+ "flow-show-change": "മാറ്റങ്ങൾ കാണിക്കുക",
+ "flow-last-modified-by": "അവസാനം {{GENDER:$1|പുതുക്കിയത്}} $1 ആണ്",
+ "flow-stub-post-content": "\"ഒരു സാങ്കേതിക പിഴവുണ്ടായതിനാൽ, ഈ കുറിപ്പ് എടുക്കാനായില്ല.\"",
+ "flow-newtopic-title-placeholder": "പുതിയ വിഷയം",
+ "flow-newtopic-content-placeholder": "\"$1\" എന്നതിലേക്ക് പുതിയ സന്ദേശം ചേർക്കുക",
+ "flow-newtopic-header": "പുതിയ വിഷയം ചേർക്കുക",
+ "flow-newtopic-save": "വിഷയം ചേർക്കുക",
+ "flow-newtopic-start-placeholder": "പുതിയ വിഷയം തുടങ്ങുക",
+ "flow-newtopic-first-heading": "$1 നെ കുറിച്ചു പുതിയ വിഷയം തുടങ്ങുക.",
+ "flow-summarize-topic-placeholder": "ദയവായി ഈ ചർച്ച സംഗ്രഹിക്കുക",
+ "flow-reply-topic-placeholder": "\"$2\" എന്ന വിഷയത്തിൽ അഭിപ്രായം {{GENDER:$1|ചേർക്കുക}}",
+ "flow-reply-topic-title-placeholder": "\"$1\" എന്നതിനു മറുപടിയിടുക",
+ "flow-reply-submit": "{{GENDER:$1|മറുപടി}}",
+ "flow-reply-link": "{{GENDER:$1|മറുപടി}}",
+ "flow-thank-link": "{{GENDER:$1|നന്ദി രേഖപ്പെടുത്തുക}}",
+ "flow-lock-link": "{{GENDER:$1|അടയ്ക്കുക}}",
+ "flow-thank-link-title": "കുറിപ്പിട്ടയാൾക്ക് പരസ്യമായി നന്ദി രേഖപ്പെടുത്തുക",
+ "flow-history-action-suppress-post": "ഒതുക്കുക",
+ "flow-history-action-delete-post": "മായ്ക്കുക",
+ "flow-history-action-hide-post": "മറയ്ക്കുക",
+ "flow-history-action-unsuppress-post": "ഒതുക്കൽ ഒഴിവാക്കുക",
+ "flow-history-action-undelete-post": "പുനഃസ്ഥാപിക്കുക",
+ "flow-history-action-unhide-post": "മറയ്ക്കൽ ഒഴിവാക്കുക",
+ "flow-history-action-restore-post": "പുനഃസ്ഥാപിക്കുക",
+ "flow-history-action-lock-topic": "തടയുക",
+ "flow-history-action-unlock-topic": "സ്വതന്ത്രമാക്കുക",
+ "flow-post-edited": "കുറിപ്പ് $2-ന് $1 {{GENDER:$1|തിരുത്തി}}",
+ "flow-post-action-view": "സ്ഥിരം കണ്ണി",
+ "flow-post-action-post-history": "നാൾവഴി",
+ "flow-post-action-suppress-post": "ഒതുക്കുക",
+ "flow-post-action-delete-post": "മായ്ക്കുക",
+ "flow-post-action-hide-post": "മറയ്ക്കുക",
+ "flow-post-action-edit-post": "തിരുത്തുക",
+ "flow-post-action-edit-post-submit": "മാറ്റങ്ങൾ സേവ് ചെയ്യുക",
+ "flow-post-action-unsuppress-post": "ഒതുക്കൽ ഒഴിവാക്കുക",
+ "flow-post-action-undelete-post": "പുനഃസ്ഥാപിക്കുക",
+ "flow-post-action-unhide-post": "മറയ്ക്കൽ ഒഴിവാക്കുക",
+ "flow-post-action-restore-post": "പുനഃസ്ഥാപിക്കുക",
+ "flow-post-action-undo-moderation": "തിരസ്ക്കരിക്കുക",
+ "flow-topic-action-view": "സ്ഥിരം കണ്ണി",
+ "flow-topic-action-watchlist": "ശ്രദ്ധിക്കുന്നവ",
+ "flow-topic-action-edit-title": "തലക്കെട്ട് തിരുത്തുക",
+ "flow-topic-action-history": "നാൾവഴി",
+ "flow-topic-action-hide-topic": "വിഷയം മറയ്ക്കുക",
+ "flow-topic-action-delete-topic": "വിഷയം മായ്ക്കുക",
+ "flow-topic-action-lock-topic": "വിഷയം അടയ്ക്കുക",
+ "flow-topic-action-unlock-topic": "വിഷയം അടയ്ക്കൽ ഒഴിവാക്കുക",
+ "flow-topic-action-summarize-topic": "ചുരുക്കിയെഴുതുക",
+ "flow-topic-action-resummarize-topic": "തിരുത്തിന്റെ ചുരുക്കം",
+ "flow-topic-action-suppress-topic": "വിഷയം ഒതുക്കുക",
+ "flow-topic-action-unhide-topic": "വിഷയം മറച്ചത് ഒഴിവാക്കുക",
+ "flow-topic-action-undelete-topic": "വിഷയം പുനഃസ്ഥാപിക്കുക",
+ "flow-topic-action-unsuppress-topic": "വിഷയം ഒതുക്കിയത് ഒഴിവാക്കുക",
+ "flow-topic-action-restore-topic": "വിഷയം പുനഃസ്ഥാപിക്കുക",
+ "flow-topic-action-undo-moderation": "തിരസ്ക്കരിക്കുക",
+ "flow-topic-notification-subscribe-title": "ഈ വിഷയം {{GENDER:$1|താങ്കൾ}} ശ്രദ്ധിക്കുന്നവയുടെ പട്ടികയിലേക്ക് ചേർത്തിരിക്കുന്നു.",
+ "flow-topic-notification-subscribe-description": "ഈ വിഷയത്തിലെ എല്ലാ പ്രവൃത്തികൾക്കും {{GENDER:$1|താങ്കൾക്ക്}} അറിയിപ്പുകൾ ലഭിക്കുന്നതാണ്.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|താങ്കൾ}} ഈ ചർച്ചാ ബോർഡിന് വരിചേർന്നിരിക്കുന്നു!",
+ "flow-board-notification-subscribe-description": "ഈ ബോർഡിൽ ഒരു പുതിയ വിഷയം വരുമ്പോൾ {{GENDER:$1|താങ്കൾക്ക്}} അറിയിപ്പ് ലഭിക്കുന്നതാണ്.",
+ "flow-error-http": "സെർവറുമായി ബന്ധപ്പെടുന്നതിനിടെ ഒരു പിഴവുണ്ടായി.",
+ "flow-error-other": "അപ്രതീക്ഷിതമായ പിഴവ് ഉണ്ടായി.",
+ "flow-error-external": "പിഴവുണ്ടായി.<br />പിഴവ് സംബന്ധിച്ച് ലഭിച്ച സന്ദേശം: $1",
+ "flow-error-edit-restricted": "ഈ കുറിപ്പ് തിരുത്താൻ താങ്കൾക്ക് അനുവാദമില്ല.",
+ "flow-error-topic-is-locked": "മറ്റ് പ്രവൃത്തികളിൽ നിന്നും ഈ വിഷയം ബന്ധിച്ചിരിക്കുന്നു.",
+ "flow-error-external-multi": "പിഴവുകൾ നേരിടേണ്ടി വന്നു.<br />$1",
+ "flow-error-missing-content": "കുറിപ്പിൽ ഉള്ളടക്കമൊന്നുമില്ല. കുറിപ്പ് സേവ് ചെയ്യാനായി ഉള്ളടക്കം വേണം.",
+ "flow-error-missing-summary": "സംഗ്രഹത്തിൽ ഉള്ളടക്കമൊന്നുമില്ല. സംഗ്രഹം സേവ് ചെയ്യാനായി ഉള്ളടക്കം വേണം.",
+ "flow-error-missing-title": "വിഷയത്തിനു തലക്കെട്ട് നൽകിയിട്ടില്ല. വിഷയം സേവ് ചെയ്യുന്നതിനായി തലക്കെട്ട് നൽകിയിരിക്കണം.",
+ "flow-error-parsoid-failure": "പാഴ്സോയ്ഡ് പരാജയപ്പെട്ടതിനാൽ ഉള്ളടക്കം പാഴ്സ് ചെയ്യാനായില്ല.",
+ "flow-error-missing-replyto": "\"ഇദ്ദേഹത്തിനുമറുപടിയിടുക\" എന്ന ചരം നൽകിയിട്ടില്ല. \"മറുപടിയിടുക\" എന്ന പ്രവൃത്തി ചെയ്യുന്നതിനു ഈ ചരം ആവശ്യമാണ്.",
+ "flow-error-invalid-replyto": "\"ഇദ്ദേഹത്തിനുമറുപടിയിടുക\" എന്ന ചരം അസാധുവാണ്. ആവശ്യപ്പെട്ട കുറിപ്പ് കണ്ടെത്താനായില്ല.",
+ "flow-error-delete-failure": "ഈ ഇനം മായ്ക്കൽ പരാജയപ്പെട്ടു.",
+ "flow-error-hide-failure": "ഈ ഇനം മറയ്ക്കൽ പരാജയപ്പെട്ടു.",
+ "flow-error-missing-postId": "\"postId\" എന്ന ചരം നൽകിയിട്ടില്ല. കുറിപ്പ് കൈകാര്യം ചെയ്യുന്നതിനായി ഈ ചരം ആവശ്യമാണ്.",
+ "flow-error-invalid-postId": "\"postId\" എന്ന ചരം അസാധുവാണ്. ആവശ്യപ്പെട്ട കുറിപ്പ് ($1) കണ്ടെത്താനായില്ല.",
+ "flow-error-restore-failure": "ഈ ഇനം പുനഃസ്ഥാപിക്കൽ പരാജയപ്പെട്ടു.",
+ "flow-error-invalid-moderation-state": "നിയന്ത്രണസ്ഥിതിയ്ക്ക് അസാധുവായ വിലയാണ് നൽകിയിരിക്കുന്നത്.",
+ "flow-error-invalid-moderation-reason": "മയപ്പെടുത്തുന്നതിനുള്ള കാരണം ദയവായി നൽകുക.",
+ "flow-error-not-allowed": "ഈ പ്രവൃത്തി ചെയ്യുന്നതിനാവശ്യമായ അനുമതികൾ ഇല്ല.",
+ "flow-error-title-too-long": "വിഷയത്തിന്റെ തലക്കെട്ടുകൾ {{PLURAL:$1|ഒരു ബൈറ്റ്|$1 ബൈറ്റുകൾ}} മാത്രമേ പാടുള്ളു.",
+ "flow-error-no-existing-workflow": "ഈ വർക്ക്‌ഫ്ലോ ഇതുവരെ നിലവിലില്ല.",
+ "flow-error-prev-revision-does-not-exist": "പഴയ നാൾപ്പതിപ്പ് കണ്ടെത്താനായില്ല.",
+ "flow-error-default": "ഒരു പിഴവുണ്ടായി.",
+ "flow-edit-header-submit": "ശീർഷകം സേവ് ചെയ്യുക",
+ "flow-edit-header-submit-overwrite": "ശീർഷകം മാറ്റിയെഴുതുക",
+ "flow-summarize-topic-submit": "ചുരുക്കിയെഴുതുക",
+ "flow-summarize-topic-submit-overwrite": "സംഗ്രഹം വീണ്ടും നൽകുക",
+ "flow-edit-title-submit": "തലക്കെട്ട് മാറ്റുക",
+ "flow-edit-title-submit-overwrite": "തലക്കെട്ട് മാറ്റിയെഴുതുക",
+ "flow-edit-post-submit": "മാറ്റങ്ങൾ സമർപ്പിക്കുക",
+ "flow-edit-post-submit-overwrite": "മാറ്റങ്ങൾ മാറ്റിയെഴുതുക",
+ "flow-board-history": "\"$1\" എന്നതിന്റെ നാൾവഴി",
+ "flow-topic-history": "\"$1\" എന്ന വിഷയത്തിന്റെ നാൾവഴി",
+ "flow-post-history": "കുറിപ്പിന്റെ നാൾവഴിയിൽ \"{{GENDER:$2|$2}} ഇട്ട കുറിപ്പ്\"",
+ "flow-history-last4": "അവസാനത്തെ 4 മണിക്കൂറുകൾ",
+ "flow-history-day": "ഇന്ന്",
+ "flow-history-week": "കഴിഞ്ഞയാഴ്ച്ച",
+ "flow-history-pages-post": "[$1 $2] എന്നതിൽ പ്രത്യക്ഷപ്പെടുന്നു",
+ "flow-comment-restored": "പുനഃസ്ഥാപിച്ച അഭിപ്രായം",
+ "flow-comment-deleted": "മായ്ച്ച അഭിപ്രായം",
+ "flow-comment-hidden": "മറയ്ക്കപ്പെട്ട അഭിപ്രായം",
+ "flow-comment-moderated": "നിയന്ത്രിച്ചിട്ടുള്ള അഭിപ്രായം",
+ "flow-last-modified": "അവസാനം പുതുക്കിയത് $1",
+ "flow-notification-link-text-view-post": "കുറിപ്പ് കാണുക",
+ "flow-notification-link-text-view-topic": "വിഷയം കാണുക",
+ "flow-notification-mention-email-subject": "\"$2\" എന്നതിൽ $1 താങ്കളെ {{GENDER:$1|പരാമർശിച്ചിരിക്കുന്നു}}",
+ "echo-category-title-flow-discussion": "ഫ്ലോ",
+ "flow-link-post": "കുറിപ്പ്",
+ "flow-link-topic": "വിഷയം",
+ "flow-link-history": "നാൾവഴി",
+ "flow-moderation-title-suppress-post": "കുറിപ്പ് ഒതുക്കണോ?",
+ "flow-moderation-title-delete-post": "കുറിപ്പ് മായ്ക്കണോ?",
+ "flow-moderation-title-hide-post": "കുറിപ്പ് മറയ്ക്കണോ?",
+ "flow-moderation-title-unsuppress-post": "കുറിപ്പ് മറച്ചത് ഒഴിവാക്കണോ?",
+ "flow-moderation-title-undelete-post": "കുറിപ്പ് പുനഃസ്ഥാപിക്കണോ?",
+ "flow-moderation-title-unhide-post": "കുറിപ്പ് മറച്ചത് ഒഴിവാക്കണോ?",
+ "flow-moderation-confirm-suppress-post": "ഒതുക്കുക",
+ "flow-moderation-confirm-delete-post": "മായ്ക്കുക",
+ "flow-moderation-confirm-hide-post": "മറയ്ക്കുക",
+ "flow-moderation-confirm-unsuppress-post": "ഒതുക്കൽ ഒഴിവാക്കുക",
+ "flow-moderation-confirm-undelete-post": "പുനഃസ്ഥാപിക്കുക",
+ "flow-moderation-confirm-unhide-post": "മറയ്ക്കൽ ഒഴിവാക്കുക",
+ "flow-moderation-confirm-suppress-topic": "ഒതുക്കുക",
+ "flow-moderation-confirm-delete-topic": "മായ്ക്കുക",
+ "flow-moderation-confirm-hide-topic": "മറയ്ക്കുക",
+ "flow-moderation-confirm-lock-topic": "പൂട്ടുക",
+ "flow-moderation-confirm-unsuppress-topic": "ഒതുക്കൽ ഒഴിവാക്കുക",
+ "flow-moderation-confirm-undelete-topic": "പുനഃസ്ഥാപിക്കുക",
+ "flow-moderation-confirm-unhide-topic": "മറയ്ക്കൽ ഒഴിവാക്കുക",
+ "flow-moderation-confirm-unlock-topic": "പൂട്ടഴിക്കുക",
+ "flow-moderation-confirmation-unsuppress-topic": "ഈ വിഷയം ഒതുക്കിയിരുന്നത് താങ്കൾ വിജയകരമായി ഒഴിവാക്കി.",
+ "flow-moderation-confirmation-undelete-topic": "ഈ വിഷയം താങ്കൾ വിജയകരമായി പുനഃസ്ഥാപിച്ചിരിക്കുന്നു.",
+ "flow-moderation-confirmation-unhide-topic": "ഈ വിഷയം മറച്ചിരുന്നത് താങ്കൾ വിജയകരമായി ഒഴിവാക്കി.",
+ "flow-moderation-title-suppress-topic": "വിഷയം ഒതുക്കണോ?",
+ "flow-moderation-title-delete-topic": "വിഷയം മായ്ക്കണോ?",
+ "flow-moderation-title-hide-topic": "വിഷയം മറയ്ക്കണോ?",
+ "flow-moderation-title-unsuppress-topic": "വിഷയം ഒതുക്കിയത് ഒഴിവാക്കണോ?",
+ "flow-moderation-title-undelete-topic": "വിഷയം പുനഃസ്ഥാപിക്കണോ?",
+ "flow-moderation-title-unhide-topic": "വിഷയം മറച്ചത് ഒഴിവാക്കണോ?",
+ "flow-moderation-placeholder-suppress-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയം ഒതുക്കേണ്ടതെന്ന് {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-moderation-placeholder-delete-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയം മായ്ക്കുന്നതെന്ന് {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-moderation-placeholder-hide-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയം മറയ്ക്കുന്നതെന്ന് {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-moderation-placeholder-unsuppress-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയത്തിന്റെ ഒതുക്കൽ ഒഴിവാക്കേണ്ടതെന്ന് ദയവായി {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-moderation-placeholder-undelete-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയം പുനഃസ്ഥാപിക്കേണ്ടതെന്ന് ദയവായി {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-moderation-placeholder-unhide-topic": "എന്തുകൊണ്ടാണ് ഈ വിഷയത്തിന്റെ മറയ്ക്കൽ ഒഴിവാക്കേണ്ടതെന്ന് ദയവായി {{GENDER:$3|വിശദീകരിക്കുക}}.",
+ "flow-topic-permalink-warning": "ഈ വിഷയം തുടങ്ങിയത് [$2 $1] എന്നതിൽ ആണ്",
+ "flow-compare-revisions-revision-header": "{{GENDER:$2|$2}}, $1-നു ചെയ്ത പതിപ്പ്",
+ "flow-terms-of-use-new-topic": "\"{{int:flow-newtopic-save}}\" ഞെക്കുമ്പോൾ, ഈ വിക്കിയിലെ ഉപയോഗനിബന്ധനകൾ താങ്കൾ സമ്മതിക്കുന്നുണ്ട്.",
+ "flow-terms-of-use-reply": "\"{{int:flow-reply-submit}}\" ഞെക്കുമ്പോൾ, ഈ വിക്കിയിലെ ഉപയോഗനിബന്ധനകൾ താങ്കൾ സമ്മതിക്കുന്നുണ്ട്.",
+ "flow-terms-of-use-edit": "താങ്കൾ വരുത്തിയ മാറ്റങ്ങൾ സേവ് ചെയ്യുമ്പോൾ, ഈ വിക്കിയിലെ ഉപയോഗനിബന്ധനകൾ താങ്കൾ സമ്മതിക്കുന്നുണ്ട്.",
+ "flow-topic-first-heading": "$1 എന്നതിലെ വിഷയം",
+ "flow-topic-html-title": "$2 എന്നതിലെ $1",
+ "flow-topic-count": "വിഷയങ്ങൾ ($1)",
+ "flow-load-more": "കൂടുതൽ എടുക്കുക",
+ "flow-no-more-fwd": "പഴയ വിഷയങ്ങൾ ഒന്നുമില്ല",
+ "flow-add-topic": "വിഷയം ചേർക്കുക",
+ "flow-newest-topics": "ഏറ്റവും പുതിയ വിഷയങ്ങൾ",
+ "flow-recent-topics": "സമീപകാലത്ത് സജീവമായ വിഷയങ്ങൾ",
+ "flow": "പ്രവാഹം",
+ "flow-special-type": "തരം",
+ "flow-special-type-post": "കുറിപ്പ്",
+ "flow-anonymous": "അജ്ഞാതം"
+}
diff --git a/Flow/i18n/mn.json b/Flow/i18n/mn.json
new file mode 100644
index 00000000..20954a29
--- /dev/null
+++ b/Flow/i18n/mn.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Wisdom"
+ ]
+ },
+ "flow-newtopic-content-placeholder": "\"$1\" -руу шинэ мэдээ нийтлэх",
+ "flow-newtopic-first-heading": "$1 дээр шинэ сэдэв эхлүүлэх",
+ "flow-topic-notification-subscribe-title": "Энэ сэдэв өөрийн тань watchlist -д орлоо.",
+ "flow-topic-notification-subscribe-description": "Энэ сэдвийн дагуух бүх үйл явдлын тоймыг хүлээх авах болно.",
+ "flow-board-notification-subscribe-title": "Энэ хэлэлцүүлгийн самбарт feed хийсэн байна!",
+ "flow-board-notification-subscribe-description": "Энэ самбарт шинээр үүссэн бүх сэдвийг автоматаар хүлээн авах болно.",
+ "flow-error-move": "Хэлэлцүүлгийн самбарыг шилжүүлэх боломжгүй. одоогоор."
+}
diff --git a/Flow/i18n/mr.json b/Flow/i18n/mr.json
new file mode 100644
index 00000000..a3c16cb0
--- /dev/null
+++ b/Flow/i18n/mr.json
@@ -0,0 +1,26 @@
+{
+ "@metadata": {
+ "authors": [
+ "V.narsikar",
+ "संतोष दहिवळ"
+ ]
+ },
+ "flow-newtopic-title-placeholder": "संदेशाचा विषय",
+ "flow-post-action-post-history": "इतिहास",
+ "flow-post-action-edit-post": "संपादन",
+ "flow-topic-action-history": "इतिहास",
+ "flow-error-external": "आपले उत्तर जतन करण्यात त्रूटी घडली.<br />मिळालेला त्रूटी संदेश असा होता: $1",
+ "flow-error-external-multi": "आपले उत्तर जतन करण्यात त्रूटी आढळल्या.आपले उत्तर जतन झाले नाही.<br />$1",
+ "flow-error-missing-title": "विषयास मथळा नाही.एखाद्या विषयास जतन करावयाचे तर मथळा हवा.",
+ "flow-error-prev-revision-mismatch": "काही सेकंदांपूर्वी दुसऱ्या सदस्याने हे टपालन संपादन केले आहे.अलीकडील बदलांवर आपणास उपरीलेखन(ओव्हरराईट) करावयाचे हे नक्की काय?",
+ "flow-error-prev-revision-does-not-exist": "मागील आवृत्ती शोधता आली नाही.",
+ "flow-edit-header-submit-overwrite": "शीर्षाचे उपरीलेखन करा",
+ "flow-edit-title-submit-overwrite": "शीर्षकाचे उपरीलेखन करा",
+ "flow-edit-post-submit-overwrite": "बदलांवर उपरीलेखन करा",
+ "flow-notification-mention-email-subject": "$1 ने \"$2\"वर आपला {{GENDER:$1|उल्लेख केला}}",
+ "flow-notification-newtopic-email-subject": "$1 ने \"$2\" वर एक नविन विषय {{GENDER:$1|तयार केला}}",
+ "flow-terms-of-use-new-topic": "\"{{int:flow-newtopic-save}}\" टिचकण्याने,आपण या विकिच्या वापरण्याच्या अटी मान्य करीत आहात.",
+ "flow-terms-of-use-reply": "\"{{int:flow-reply-submit}}\" टिचकण्याने, आपण या विकिच्या 'वापरण्याच्या अटी' मान्य करीत आहात.",
+ "flow-terms-of-use-edit": "आपले बदल 'जतन करण्याने',आपण या विकिच्या 'वापरण्याच्या अटी' मान्य करीत आहात.",
+ "flow-anon-warning": "आपण सनोंद प्रवेशित नाहीत."
+}
diff --git a/Flow/i18n/ms.json b/Flow/i18n/ms.json
new file mode 100644
index 00000000..2178940f
--- /dev/null
+++ b/Flow/i18n/ms.json
@@ -0,0 +1,53 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anakmalaysia"
+ ]
+ },
+ "flow-post-moderated-toggle-hide-show": "Paparkan komen yang {{GENDER:$1|disembunyikan}} oleh $2",
+ "flow-post-moderated-toggle-delete-show": "Paparkan komen yang {{GENDER:$1|dihapuskan}} oleh $2",
+ "flow-post-moderated-toggle-suppress-show": "Paparkan komen yang {{GENDER:$1|disekat}} oleh $2",
+ "flow-post-moderated-toggle-hide-hide": "Sembunyikan komen yang {{GENDER:$1|disembunyikan}} oleh $2",
+ "flow-post-moderated-toggle-delete-hide": "Sembunyikan komen yang {{GENDER:$1|dihapuskan}} oleh $2",
+ "flow-post-moderated-toggle-suppress-hide": "Sembunyikan komen yang {{GENDER:$1|disekat}} oleh $2",
+ "flow-topic-moderated-reason-prefix": "Sebab:",
+ "flow-stub-post-content": "''Disebabkan ralat teknikal, kiriman ini tidak dapat diperoleh.''",
+ "flow-post-action-post-history": "Sejarah",
+ "flow-post-action-edit-post": "Sunting",
+ "flow-topic-action-history": "Sejarah",
+ "flow-topic-action-unlock-topic": "Buka kunci topik",
+ "flow-topic-action-undo-moderation": "Nyahbuat",
+ "flow-topic-notification-subscribe-title": "Topik ini telah disertakan dalam senarai pantau {{GENDER:$1|anda}}.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Anda}} telah melanggan papan perbincangan ini!",
+ "flow-error-prev-revision-mismatch": "Seorang lagi pengguna baru sahaja menyunting pos ini. Adakah {{GENDER:$3|anda}} benar-benar mahu menulis ganti suntingan terbaru ini?",
+ "flow-error-invalid-input": "Nilai tidak sah diberikan untuk memuatkan kandungan Flow.",
+ "flow-error-missing-revision": "Semakan tidak dapat dicari untuk memuatkan kandungan Flow.",
+ "flow-error-fail-commit": "Kandungan Flow gagal disimpan.",
+ "flow-error-content-too-long": "Kandungan terlalu besar. Kandungan selepas peluasan terhad kepada $1 bait.",
+ "flow-notification-reply": "$1 telah {{GENDER:$1|membalas}} <span class=\"plainlinks\">[$5 kiriman]</span> anda di \"$2\" pada \"$4\".",
+ "flow-notification-reply-bundle": "$1 dan $5 {{PLURAL:$6|orang lain}} telah {{GENDER:$1|membalas}} <span class=\"plainlinks\">[$4 kiriman]</span> anda di \"$2\" pada \"$3\".",
+ "flow-notification-edit": "$1 telah {{GENDER:$1|menyunting}} suatu <span class=\"plainlinks\">[$5 kiriman]</span> di \"$2\" pada [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 dan $5 {{PLURAL:$6|orang lain}} telah {{GENDER:$1|menyunting}} sepucuk <span class=\"plainlinks\">[$4 kiriman]</span> di \"$2\" pada \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 telah {{GENDER:$1|membuka}} topik baru di '''$3'''.",
+ "flow-notification-rename": "$1 telah {{GENDER:$1|menukar}} tajuk <span class=\"plainlinks\">[$2 $3]</span> kepada \"$4\" pada [[$5|$6]].",
+ "flow-notification-mention": "$1 telah {{GENDER:$1|menyebut}} nama anda di <span class=\"plainlinks\">[$2 kirimannya]</span> di \"$3\" pada \"$4\".",
+ "flow-notification-reply-email-subject": "$2 di $3",
+ "flow-notification-reply-email-batch-body": "$1 telah {{GENDER:$1|membalas}} kiriman anda di \"$2\" pada \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 dan $4 {{PLURAL:$5|orang lain}} telah {{GENDER:$1|membalas}} kiriman anda di \"$2\" pada \"$3\"",
+ "flow-notification-mention-email-subject": "$1 telah {{GENDER:$1|menyebut}} nama anda di \"$2\"",
+ "flow-notification-edit-email-batch-body": "$1 telah {{GENDER:$1|menyunting}} suatu kiriman di \"$2\" pada \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 dan $4 {{PLURAL:$5|orang lain}} telah {{GENDER:$1|menyunting}} suatu kiriman di \"$2\" pada \"$3\"",
+ "flow-notification-newtopic-email-subject": "$1 telah {{GENDER:$1|membuka}} topik baru di \"$2\"",
+ "flow-moderation-confirmation-suppress-topic": "Topik ini telah disekat.",
+ "flow-moderation-confirmation-delete-topic": "Tred ini telah dihapuskan.",
+ "flow-moderation-confirmation-hide-topic": "Tred ini telah disembunyikan.",
+ "flow-topic-collapsed-one-line": "Paparan kecil",
+ "flow-topic-collapsed-full": "Paparan terlipat",
+ "flow-topic-complete": "Paparan penuh",
+ "flow-topic-html-title": "$1 di $2",
+ "flow-importer-wt-converted-template": "Laman perbincangan Wikiteks ditukarkan ke Flow",
+ "flow-importer-wt-converted-archive-template": "Arkib untuk laman perbincangan wikiteks yang ditukarkan",
+ "apihelp-flow+reply-param-content": "Kandungan untuk pos baru.",
+ "flow-talk-conversion-move-reason": "Penukaran perbincangan Wikiteks ke Flow dari $1",
+ "flow-talk-conversion-archive-edit-reason": "Penukaran perbincangan Wikiteks ke Flow"
+}
diff --git a/Flow/i18n/mt.json b/Flow/i18n/mt.json
new file mode 100644
index 00000000..d4bcf539
--- /dev/null
+++ b/Flow/i18n/mt.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Leli Forte"
+ ]
+ },
+ "flow-newtopic-content-placeholder": "Ikteb messaġġ ġdid fuq \"$1\"",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|ħalaq|ħalqet}} suġġett ġdid fuq '''$3'''."
+}
diff --git a/Flow/i18n/my.json b/Flow/i18n/my.json
new file mode 100644
index 00000000..b910cee2
--- /dev/null
+++ b/Flow/i18n/my.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sanlinnaing"
+ ]
+ },
+ "flow-edited-by": "$1 က ပြင်ထားသည်။"
+}
diff --git a/Flow/i18n/myv.json b/Flow/i18n/myv.json
new file mode 100644
index 00000000..60fa9ef6
--- /dev/null
+++ b/Flow/i18n/myv.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Botuzhaleny-sodamo"
+ ]
+ },
+ "flow-show-change": "Невтемс мезе полавтозь"
+}
diff --git a/Flow/i18n/nap.json b/Flow/i18n/nap.json
new file mode 100644
index 00000000..aed7f44f
--- /dev/null
+++ b/Flow/i18n/nap.json
@@ -0,0 +1,22 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chelin",
+ "C.R."
+ ]
+ },
+ "flow-show-change": "Vere 'e cagnamiente",
+ "flow-newtopic-content-placeholder": "Scrivite na mmasciata nova a \"$1\"",
+ "flow-post-action-post-history": "Cronologgia",
+ "flow-post-action-edit-post": "Càgna",
+ "flow-topic-notification-subscribe-title": "Chist'argomento è stat'azzeccato dint'a l'elenco 'e paggene cuntrullate {{GENDER:$1|vuoste}}.",
+ "flow-board-notification-subscribe-title": "Mo' te sì {{GENDER:$1|scritto|scritta}} dint'a sta bacheca 'e discussione!",
+ "flow-error-other": "All'intrasatta s'è verificato n'errore.",
+ "flow-error-move": "Rimpizzanno na bacheca 'e discussione ca nun è cchiù supportata.",
+ "flow-edit-header-placeholder": "Descrive sta bacheca 'e discussione",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|rispunnette}} ncopp'a '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 e $5 {{PLURAL:$6|ato|ati}} {{GENDER:$1|rispunnettero}} ncopp'a '''$3'''.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|criaje}} nu tema nuovo ncopp'a '''$3'''.",
+ "flow-notification-reply-email-subject": "$2 ncopp'a $3",
+ "flow-topic-first-heading": "Chiàcchiera ncopp'â $1"
+}
diff --git a/Flow/i18n/nb.json b/Flow/i18n/nb.json
new file mode 100644
index 00000000..cae0b915
--- /dev/null
+++ b/Flow/i18n/nb.json
@@ -0,0 +1,323 @@
+{
+ "@metadata": {
+ "authors": [
+ "Danmichaelo",
+ "Jeblad",
+ "Laaknor"
+ ]
+ },
+ "flow-desc": "Styringssystem for arbeidsflyt",
+ "flow-talk-taken-over": "Denne diskusjonssiden bruker [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Flow-diskusjonssideforvalter",
+ "log-name-flow": "Aktivitetslogg for Flyt",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|slettet}} et [$4 innlegg] på [[$3]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|gjenopprettet}} et [$4 innlegg] på [[$3]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|sensurerte}} et [$4 innlegg] på [[$3]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|slettet}} et [$4 innlegg] på [[$3]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|slettet}} et [$4 innlegg] på [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|gjenopprettet}} et [$4 innlegg] på [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|sensurerte}} et [$4 innlegg] på [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|slettet}} et [$4 innlegg] på [[$3]]",
+ "flow-user-moderated": "Moderert bruker",
+ "flow-edit-header-link": "Rediger overskrift",
+ "flow-post-moderated-toggle-hide-show": "Vis kommentar {{GENDER:$1|skjult}} av $2",
+ "flow-post-moderated-toggle-delete-show": "Vis kommentar {{GENDER:$1|slettet}} av $2",
+ "flow-post-moderated-toggle-suppress-show": "Vis kommentar {{GENDER:$1|sensurert}} av $2",
+ "flow-post-moderated-toggle-hide-hide": "Skjul kommentar {{GENDER:$1|skjult}} av $2",
+ "flow-post-moderated-toggle-delete-hide": "Skjul kommentar {{GENDER:$1|slettet}} av $2",
+ "flow-post-moderated-toggle-suppress-hide": "Skjul kommentar {{GENDER:$1|sensurert}} av $2",
+ "flow-topic-moderated-reason-prefix": "Begrunnelse:",
+ "flow-hide-post-content": "Denne kommentaren ble {{GENDER:$1|skjult}} av $1",
+ "flow-hide-title-content": "Denne diskusjonen ble {{GENDER:$1|skjult}} av $1",
+ "flow-lock-title-content": "Denne diskusjonen ble {{GENDER:$1|låst}} av $1",
+ "flow-hide-header-content": "{{GENDER:$1|Skjult}} av $2",
+ "flow-delete-post-content": "Denne kommentaren ble {{GENDER:$1|slettet}} av $1",
+ "flow-delete-title-content": "Denne diskusjonen ble {{GENDER:$1|slettet}} av $1",
+ "flow-delete-header-content": "{{GENDER:$1|Slettet}} av $2",
+ "flow-suppress-post-content": "Denne kommentaren ble {{GENDER:$1|sensurert}} av $1",
+ "flow-suppress-title-content": "Denne diskusjonen ble {{GENDER:$1|dempet}} av $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Sensurert}} av $2",
+ "flow-suppress-usertext": "<em>Brukernavn sensurert</em>",
+ "flow-post-actions": "Handlinger",
+ "flow-topic-actions": "Handlinger",
+ "flow-cancel": "Avbryt",
+ "flow-preview": "Forhåndsvis",
+ "flow-show-change": "Vis endringer",
+ "flow-last-modified-by": "Sist {{GENDER:$1|endret}} av $1",
+ "flow-stub-post-content": "«På grunn av en teknisk feil kunne ikke dette innlegget hentes.»",
+ "flow-newtopic-title-placeholder": "Ny diskusjon",
+ "flow-newtopic-content-placeholder": "Skriv en melding på «$1»",
+ "flow-newtopic-header": "Start en ny diskusjon",
+ "flow-newtopic-save": "Lagre",
+ "flow-newtopic-start-placeholder": "Start en ny diskusjon",
+ "flow-newtopic-first-heading": "Start et nytt innlegg på $1",
+ "flow-summarize-topic-placeholder": "Sammenfatt denne diskusjonen",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Kommentér}} «$2»",
+ "flow-reply-topic-title-placeholder": "Svar på «$1»",
+ "flow-reply-submit": "{{GENDER:$1|Svar}}",
+ "flow-reply-link": "{{GENDER:$1|Svar}}",
+ "flow-thank-link": "{{GENDER:$1|Takk}}",
+ "flow-lock-link": "{{GENDER:$1|Lås}}",
+ "flow-history-action-delete-post": "slett",
+ "flow-history-action-hide-post": "skjul",
+ "flow-history-action-undelete-post": "gjenopprett",
+ "flow-history-action-unhide-post": "vis igjen",
+ "flow-history-action-restore-post": "gjenopprett",
+ "flow-history-action-lock-topic": "lås",
+ "flow-history-action-unlock-topic": "opphev låsing",
+ "flow-post-edited": "Innlegg {{GENDER:$1|redigert}} av $1 $2",
+ "flow-post-action-view": "Permanent lenke",
+ "flow-post-action-post-history": "Historikk",
+ "flow-post-action-suppress-post": "Sensurer",
+ "flow-post-action-delete-post": "Slett",
+ "flow-post-action-hide-post": "Skjul",
+ "flow-post-action-edit-post": "Rediger",
+ "flow-post-action-edit-post-submit": "Lagre endringer",
+ "flow-post-action-unsuppress-post": "Avdemp",
+ "flow-post-action-undelete-post": "Gjenopprett",
+ "flow-post-action-unhide-post": "Vis",
+ "flow-post-action-restore-post": "Gjenopprett",
+ "flow-post-action-undo-moderation": "Angre",
+ "flow-topic-action-view": "Permalenke",
+ "flow-topic-action-watchlist": "Overvåkningsliste",
+ "flow-topic-action-edit-title": "Rediger tittel",
+ "flow-topic-action-history": "Historikk",
+ "flow-topic-action-hide-topic": "Skjul diskusjonen",
+ "flow-topic-action-delete-topic": "Slett diskusjonen",
+ "flow-topic-action-lock-topic": "Lås diskusjon",
+ "flow-topic-action-unlock-topic": "Lås opp diskusjon",
+ "flow-topic-action-summarize-topic": "Sammenfatt",
+ "flow-topic-action-resummarize-topic": "Rediger sammenfatning",
+ "flow-topic-action-suppress-topic": "Sensurer diskusjonen",
+ "flow-topic-action-unhide-topic": "Vis diskusjonen",
+ "flow-topic-action-undelete-topic": "Gjenopprett diskusjonen",
+ "flow-topic-action-unsuppress-topic": "Ikke sensurer diskusjonen lenger",
+ "flow-topic-action-restore-topic": "Gjenopprett diskusjonen",
+ "flow-topic-action-undo-moderation": "Angre",
+ "flow-topic-notification-subscribe-title": "Diskusjonen har blitt lagt til i overvåkningslisten {{GENDER:$1|din}}.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Du}} vil motta varsler for all aktivitet knyttet til denne diskusjonen.",
+ "flow-error-http": "Det oppsto en feil ved kontakt med serveren.",
+ "flow-error-other": "Det oppsto en ukjent feil.",
+ "flow-error-external": "Det oppsto en feil.<br />Feilmeldingen var: $1",
+ "flow-error-edit-restricted": "Du har ikke tilgang til å redigere dette innlegget.",
+ "flow-error-external-multi": "Feil oppsto under lagring av meldingen.<br />$1",
+ "flow-error-missing-content": "Innlegget har ikke noe innhold. Innhold kreves for å lagre et innlegg.",
+ "flow-error-missing-summary": "Sammendraget har ikke noe innhold. Innhold kreves for å lagre et sammendrag.",
+ "flow-error-missing-title": "Meldingen har ingen tittel. En tittel kreves for å lagre en diskusjon.",
+ "flow-error-parsoid-failure": "Innholdet kunne ikke parseres pga. et Parsord-problem.",
+ "flow-error-missing-replyto": "Ingen \"replyTo\"-parameter ble sendt inn. Parameteren er påkrevd for \"reply\"-handlingen.",
+ "flow-error-invalid-replyto": "Parameteren \"replyTo\" var ugyldig. Det angitte innlegget ble ikke funnet.",
+ "flow-error-delete-failure": "Sletting av dette innlegget feilet.",
+ "flow-error-hide-failure": "Skjuling av dette innlegget feilet.",
+ "flow-error-missing-postId": "Ingen \"postId\"-parameter ble sendt inn. Parameteren er påkrevd for å redigere et innlegg.",
+ "flow-error-invalid-postId": "Parameteren «postId» var ugyldig. Det angitte innlegget ($1) ble ikke funnet.",
+ "flow-error-restore-failure": "Gjenoppretting av dette innlegget feilet.",
+ "flow-error-invalid-moderation-state": "En ugyldig verdi ble gitt for en parameter ('moderationState') ble gitt til Flow-APIet.",
+ "flow-error-invalid-moderation-reason": "Vennligst oppgi en grunn for modereringen",
+ "flow-error-not-allowed": "Manglende rettigheter til å utføre denne handlingen",
+ "flow-error-title-too-long": "Diskusjonstitler er begrenset til {{PLURAL:$1|én byte|$1 bytes}}.",
+ "flow-error-no-existing-workflow": "Denne arbeidsflyten finnes ikke enda.",
+ "flow-error-not-a-post": "Diskusjonstittel kan ikke bli lagret som et innlegg.",
+ "flow-error-missing-header-content": "Overskriften har ikke noe innhold. Innhold kreves for å lagre en overskrift.",
+ "flow-error-missing-prev-revision-identifier": "Foregående revisjons-identifikator mangler.",
+ "flow-error-prev-revision-mismatch": "En annen bruker redigerte innlegget for et par sekunder siden. Er {{GENDER:$3|du}} sikker på at du ønsker å overskrive de siste endringene?",
+ "flow-error-prev-revision-does-not-exist": "Kunne ikke finne foregående revisjon.",
+ "flow-error-default": "En feil oppsto.",
+ "flow-error-invalid-input": "Ugyldig verdi ble brukt for å laste flytinnholdet.",
+ "flow-error-invalid-title": "Ugyldig sidetittel ble brukt.",
+ "flow-error-fail-load-history": "Innholdet i historikken kunne ikke lastes inn.",
+ "flow-error-missing-revision": "Kunne ikke finne en revisjon for å laste flytens innhold.",
+ "flow-error-fail-commit": "Feilet under lagring av flytens innhold.",
+ "flow-error-insufficient-permission": "Mangelfulle rettigheter for å aksessere innholdet.",
+ "flow-error-revision-comparison": "Diff-operasjoner kan bare bli gjort mellom revisjoner som tilhører det samme innlegget.",
+ "flow-error-missing-topic-title": "Finner ikke diskusjonstittel i gjeldende arbeidsflyt.",
+ "flow-error-fail-load-data": "Feilet under lasting av forespurte data.",
+ "flow-error-invalid-workflow": "Kunne ikke finne forespurt arbeidsflyt.",
+ "flow-error-process-data": "En feil har oppstått under behandling av data i din forespørsel.",
+ "flow-error-process-wikitext": "En feil har oppstått under prosessering av HTML/wikitext omforming.",
+ "flow-error-no-index": "Feilet å finne en indeks for å utføre datasøk.",
+ "flow-error-no-render": "Handlingen ble ikke gjenkjent",
+ "flow-error-no-commit": "Handlingen kunne ikke utføres.",
+ "flow-edit-header-placeholder": "Beskriv denne diskusjonssiden",
+ "flow-edit-header-submit": "Lagre overskrift",
+ "flow-edit-header-submit-overwrite": "Overskriv overskrift",
+ "flow-summarize-topic-submit": "Sammenfatt",
+ "flow-summarize-topic-submit-overwrite": "Overskriv sammendrag",
+ "flow-lock-topic-submit": "Lås diskusjon",
+ "flow-edit-title-submit": "Endre tittel",
+ "flow-edit-title-submit-overwrite": "Overskriv tittel",
+ "flow-edit-post-submit": "Send inn endringer",
+ "flow-edit-post-submit-overwrite": "Overskriv endringer",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|redigerte}} en [$3 kommentar] på «$4».",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|kommenterte}}] «$4»(<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|kommentar|kommentarer}}</strong> {{PLURAL:$1|ble}} lagt til.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|startet}} diskusjonen «[$3 $4]».",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|endret}} diskusjonstittelen fra «$5» til «[$3 $4]».",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|opprettet}} overskriften.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|redigerte}} overskriften.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|opprettet}} diskusjonssammendrag for $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|redigerte}} diskusjonssammendraget for $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|skjulte}} en [$4 kommentar] til «$6» (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|slettet}} en [$4 kommentar] til «$6» (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|sensurerte}} en [$4 kommentar] til «$6» (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|gjenopprettet}} en [$4 kommentar] til «$6» (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|skjulte}} [$4 diskusjonen] «$6» (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|slettet}} [$4 diskusjonen] «$6» (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|sensurerte}} [$4 diskusjonen] «$6» (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|gjenopprettet}} [$4 diskusjonen] «$6» (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 på $2",
+ "flow-board-history": "Historikk for «$1»",
+ "flow-board-history-empty": "Dette brettet har for øyeblikket ingen historikk.",
+ "flow-topic-history": "Historikk for diskusjonen «$1»",
+ "flow-post-history": "«Kommentar av {{GENDER:$2|$2}}» innleggshistorikk",
+ "flow-history-last4": "Siste 4 timer",
+ "flow-history-day": "I dag",
+ "flow-history-week": "Forrige uke",
+ "flow-history-pages-topic": "Forekommer på [$1 «$2»-brettet]",
+ "flow-history-pages-post": "Forekommer på [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|Kommentér ($1)|Kommentarer ($1)|0={{GENDER:$2|Bli den første}} til å kommentere!}}",
+ "flow-comment-restored": "Gjenopprettet kommentar",
+ "flow-comment-deleted": "Slettet kommentar",
+ "flow-comment-hidden": "Skjult kommentar",
+ "flow-comment-moderated": "Moderert melding",
+ "flow-last-modified": "Sist endret for rundt $1",
+ "flow-workflow": "arbeidsflyt",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|svarte}} på '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 og $5 {{PLURAL:$6|annen|andre}} {{GENDER:$1|svarte}} på '''$3'''.",
+ "flow-notification-edit": "$1 {{GENDER:$1|redigerte}} en <span class=\"plainlinks\">[$5 melding]</span> i «$2» på [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 og $5 {{PLURAL:$6|annen|andre}} {{GENDER:$1|redigerte}} et <span class=\"plainlinks\">[$4 innlegg]</span> under «$2» på «$3».",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|startet}} en ny diskusjon på '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|endret}} tittelen på <span class=\"plainlinks\">[$2 $3]</span> til «$4» på [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|nevnte}} {{GENDER:$5|deg}} i <span class=\"plainlinks\">[$2 innlegget]</span> {{GENDER:$1|hans|hennes|sitt}} under «$3» på «$4»",
+ "flow-notification-link-text-view-post": "Vis innlegg",
+ "flow-notification-link-text-view-topic": "Vis diskusjon",
+ "flow-notification-reply-email-subject": "$2 på $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|svarte}} på «$2» på «$3»",
+ "flow-notification-reply-email-batch-bundle-body": "$1 og $4 {{PLURAL:$5|annen|andre}} {{GENDER:$1|svarte}} på «$2» på «$3»",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|nevnte}} {{GENDER:$3|deg}} på «$2»",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|nevnte}} {{GENDER:$4|deg}} i innlegget {{GENDER:$1|hans|hennes|sitt}} i «$2» på «$3»",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|redigerte}} et innlegg",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|redigerte}} innlegget i «$2» på «$3»",
+ "flow-notification-edit-email-batch-bundle-body": "$1 og $4 {{PLURAL:$5|annen|andre}} {{GENDER:$1|redigerte}} innlegget ditt «$2» på «$3»",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|endret navn}} på diskusjonen din",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|endret navn}} for diskusjonen din på «$4» fra «$2» til «$3»",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|opprettet}} en ny diskusjon på «$2»",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|opprettet}} en ny diskusjon på «$3» med tittelen «$2»",
+ "echo-category-title-flow-discussion": "Flyt",
+ "echo-pref-tooltip-flow-discussion": "Underrett meg når handlinger relatert til meg skjer i Flyt",
+ "flow-link-post": "innlegg",
+ "flow-link-topic": "diskusjon",
+ "flow-link-history": "historikk",
+ "flow-link-post-revision": "innleggsrevisjon",
+ "flow-link-topic-revision": "diskusjonsrevisjon",
+ "flow-link-header-revision": "overskriftrevisjon",
+ "flow-moderation-title-suppress-post": "Sensurer innlegg?",
+ "flow-moderation-title-delete-post": "Slett innlegg?",
+ "flow-moderation-title-hide-post": "Skjul innlegg?",
+ "flow-moderation-title-unsuppress-post": "Avdemp innlegg?",
+ "flow-moderation-title-undelete-post": "Gjenopprett innlegg?",
+ "flow-moderation-title-unhide-post": "Vis innlegg?",
+ "flow-moderation-placeholder-suppress-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du sensurerer dette innlegget.",
+ "flow-moderation-placeholder-delete-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du sletter dette innlegget.",
+ "flow-moderation-placeholder-hide-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du skjuler dette innlegget.",
+ "flow-moderation-placeholder-unsuppress-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du avdemper dette innlegget.",
+ "flow-moderation-placeholder-undelete-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du gjenoppretter dette innlegget.",
+ "flow-moderation-placeholder-unhide-post": "Vennligst {{GENDER:$3|forklar}} hvorfor du viser dette innlegget.",
+ "flow-moderation-confirm-suppress-post": "Sensurer",
+ "flow-moderation-confirm-delete-post": "Slett",
+ "flow-moderation-confirm-hide-post": "Skjul",
+ "flow-moderation-confirm-unsuppress-post": "Avdemp",
+ "flow-moderation-confirm-undelete-post": "Gjenopprett",
+ "flow-moderation-confirm-unhide-post": "Vis",
+ "flow-moderation-confirm-suppress-topic": "Sensurer",
+ "flow-moderation-confirm-delete-topic": "Slett",
+ "flow-moderation-confirm-hide-topic": "Skjul",
+ "flow-moderation-confirm-lock-topic": "Lås",
+ "flow-moderation-confirm-unsuppress-topic": "Avdemp",
+ "flow-moderation-confirm-undelete-topic": "Gjenopprett",
+ "flow-moderation-confirm-unhide-topic": "Vis",
+ "flow-moderation-confirm-unlock-topic": "Opphev låsing",
+ "flow-moderation-confirmation-suppress-post": "Innlegget ble sensurert.\n{{GENDER:$2|Vurder}} å gi $1 tilbakemelding på innlegget.",
+ "flow-moderation-confirmation-delete-post": "Sletting av innlegget er fullført.\n{{GENDER:$2|Vurder}} å gi $1 tilbakemelding på innlegget.",
+ "flow-moderation-confirmation-hide-post": "Skjuling av innlegget er fullført.\n{{GENDER:$2|Vurder}} å gi $1 tilbakemelding på dette innlegget.",
+ "flow-moderation-confirmation-unsuppress-post": "Du fullførte avdemping av ovenstående innlegg.",
+ "flow-moderation-confirmation-undelete-post": "Du fullførte gjenoppretting av ovenstående innlegg.",
+ "flow-moderation-confirmation-unhide-post": "Du fullførte fremvisning av ovenstående innlegg.",
+ "flow-moderation-confirmation-suppress-topic": "Diskusjonen har blitt sensurert.",
+ "flow-moderation-confirmation-delete-topic": "Diskusjonen har blitt slettet.",
+ "flow-moderation-confirmation-hide-topic": "Diskusjonen har blitt skjult.",
+ "flow-moderation-confirmation-unsuppress-topic": "Diskusjonen er ikke lenger sensurert.",
+ "flow-moderation-confirmation-undelete-topic": "Diskusjonen har blitt gjenopprettet.",
+ "flow-moderation-confirmation-unhide-topic": "Diskusjonen er ikke lenger skjult.",
+ "flow-moderation-title-suppress-topic": "Sensurer diskusjon?",
+ "flow-moderation-title-delete-topic": "Slett diskusjon?",
+ "flow-moderation-title-hide-topic": "Skjul diskusjon?",
+ "flow-moderation-title-unsuppress-topic": "Avslutt sensurering av diskusjonen?",
+ "flow-moderation-title-undelete-topic": "Gjenopprett diskusjonen?",
+ "flow-moderation-title-unhide-topic": "Avslutt skjuling av diskusjonen?",
+ "flow-moderation-placeholder-suppress-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du sensurerer denne diskusjonen.",
+ "flow-moderation-placeholder-delete-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du sletter denne diskusjonen.",
+ "flow-moderation-placeholder-hide-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du skjuler denne diskusjonen.",
+ "flow-moderation-placeholder-lock-topic": "{{GENDER:$3|Forklar}} hvorfor du opphever låsingen av dette emnet.",
+ "flow-moderation-placeholder-unsuppress-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du avslutter sensurering av denne diskusjonen.",
+ "flow-moderation-placeholder-undelete-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du gjenoppretter denne diskusjonen.",
+ "flow-moderation-placeholder-unhide-topic": "Vennligst {{GENDER:$3|forklar}} hvorfor du avslutter skjuling av denne diskusjonen.",
+ "flow-topic-permalink-warning": "Denne diskusjonen startet på [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Denne diskusjonen startet på [$2 {{GENDER:$1|$1}}s diskusjonsside]",
+ "flow-revision-permalink-warning-post": "Dette er en permanent lenke til en enkelt versjon av dette innlegget.\nDenne versjonen er fra $1.\nDu kan se [$5 forskjellene fra foregående versjon], eller se andre versjoner i [$4 innleggshistorikken].",
+ "flow-revision-permalink-warning-post-first": "Dette er en permanent lenke til første versjon av dette innlegget.\nDu kan se senere versjoner i [$4 innleggshistorikken].",
+ "flow-revision-permalink-warning-postsummary": "Dette er en permanent lenke til en enkelt versjon av sammenfatningen for dette innlegget. Denne versjonen er fra $1. Du kan se [$5 endringer fra forrige versjon], eller vise andre versjoner i [$4 innleggets historikk].",
+ "flow-revision-permalink-warning-postsummary-first": "Dette er en permanent lenke til den første versjonen av dette innleggets sammenfatning.\nDu kan vise senere versjoner i [$4 innleggets historikk].",
+ "flow-revision-permalink-warning-header": "Dette er en permanent lenke til en enkelt versjon av denne overskriften.\nDenne versjonen er fra $1.\nDu kan se [$3 forskjellene fra foregående versjon], eller se andre versjoner i [$2 overskriftshistorikken].",
+ "flow-revision-permalink-warning-header-first": "Dette er en permanent lenke til første versjon av denne overskriften.\nDu kan se senere versjoner i [$2 overskriftshistorikken].",
+ "flow-compare-revisions-revision-header": "Versjon av {{GENDER:$2|$2}} fra $1",
+ "flow-compare-revisions-header-post": "Denne siden viser {{GENDER:$3|endringer}} mellom to versjoner av et innlegg av $3 i diskusjonen «[$5 $2]» på [$4 $1].\nDu kan se andre versjoner av dette innlegget i [$6 innleggshistorikken].",
+ "flow-compare-revisions-header-postsummary": "Denne siden viser endringer mellom to versjoner av en sammenfatning av innlegget «[$4 $2]» på [$3 $1].\nDu kan vise andre versjoner av innlegget i [$5 historikken].",
+ "flow-compare-revisions-header-header": "Denne siden viser {{GENDER:$2|endringer}} mellom to versjoner av overskriften [$3 $1].\nDu kan se andre versjoner av denne overskriften i [$4 innleggshistorikken].",
+ "right-flow-hide": "Skjul Flow-diskusjoner og innlegg",
+ "right-flow-lock": "Lås Flow-diskusjoner",
+ "right-flow-delete": "Slett Flow-diskusjoner og innlegg",
+ "right-flow-edit-post": "Rediger innlegg av andre brukere",
+ "right-flow-suppress": "Sensurer revisjoner",
+ "flow-terms-of-use-new-topic": "Ved å klikke «{{int:flow-newtopic-save}}» aksepterer du bruksbetingelsene for denne wikien.",
+ "flow-terms-of-use-reply": "Ved å klikke «{{int:flow-reply-submit}}» aksepterer du bruksbetingelsene for denne wikien.",
+ "flow-terms-of-use-edit": "Ved å lagre endringer dine aksepterer du bruksbetingelsene for denne wikien.",
+ "flow-anon-warning": "Du er ikke logget inn. For å bli kreditert med ditt navn istedenfor din IP-adresse, kan du [$1 logge inn] eller [$2 opprette en konto].",
+ "flow-cancel-warning": "Du har skrevet inn tekst i skjemaet. Er du sikker på at du vil forkaste den?",
+ "flow-topic-first-heading": "Diskusjon på $1",
+ "flow-topic-html-title": "$1 på $2",
+ "flow-topic-count": "Diskusjoner ($1)",
+ "flow-load-more": "Last inn mer",
+ "flow-no-more-fwd": "Det finnes ingen eldre diskusjoner",
+ "flow-add-topic": "Ny diskusjon",
+ "flow-newest-topics": "Nyeste diskusjoner",
+ "flow-recent-topics": "Diskusjoner med nylig aktivitet",
+ "flow-sorting-tooltip-newest": "De nyeste diskusjonene vises først. Klikk for flere sorteringsmuligheter.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Du}} leser nå det nyeste aktive emnet. Klikk for flere sorteringsmuligheter.",
+ "flow-toggle-small-topics": "Bytt til kompakt visning",
+ "flow-toggle-topics": "Bytt til visning av kun emner",
+ "flow-toggle-topics-posts": "Bytt til visning av emner og innlegg",
+ "flow-terms-of-use-summarize": "Ved å klikke på \"{{int:flow-summarize-topic-submit}}\" godtar du vilkårene for denne wikien.",
+ "flow-whatlinkshere-post": "fra et [$1 innlegg]",
+ "flow-whatlinkshere-header": "fra [$1 overskriften]",
+ "flow": "Flow",
+ "flow-special-desc": "Denne spesialsiden omdirigerer til en Flow-arbeidsflyt eller et Flow-innlegg gitt en UUID.",
+ "flow-special-type": "Type",
+ "flow-special-type-post": "Innlegg",
+ "flow-special-type-workflow": "Arbeidsflyt",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Kunne ikke finne innhold som matchet typen og UUID-en.",
+ "flow-spam-confirmedit-form": "Vennligst bekreft at du er et menneske ved å løse captcha-en: $1",
+ "flow-preview-warning": "Dette er en forhåndsvisning. Send inn skjemaet for å publisere, eller trykk «{{int:flow-preview-return-edit-post}}» for å fortsette å skrive.",
+ "flow-preview-return-edit-post": "Fortsett å redigere",
+ "flow-anonymous": "Anonym",
+ "flow-embedding-unsupported": "Diskusjoner kan ikke bygges inn enda.",
+ "mw-ui-unsubmitted-confirm": "Du har ulagrede endringer på denne siden. Er du sikker på at du vil navigere vekk og miste arbeidet ditt?",
+ "flow-post-undo-hide": "angre skjuling",
+ "flow-post-undo-delete": "angre sletting",
+ "flow-post-undo-suppress": "angre undertrykking",
+ "flow-topic-undo-hide": "angre skjuling",
+ "flow-topic-undo-delete": "angre sletting",
+ "flow-topic-undo-suppress": "angre undertrykking",
+ "apihelp-flow+edit-post-param-postId": "Innleggs-ID."
+}
diff --git a/Flow/i18n/nds-nl.json b/Flow/i18n/nds-nl.json
new file mode 100644
index 00000000..9780733a
--- /dev/null
+++ b/Flow/i18n/nds-nl.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Servien"
+ ]
+ },
+ "flow-last-modified": "Ongeveer $1 veur t lest bewarkt"
+}
diff --git a/Flow/i18n/ne.json b/Flow/i18n/ne.json
new file mode 100644
index 00000000..4f2741ba
--- /dev/null
+++ b/Flow/i18n/ne.json
@@ -0,0 +1,18 @@
+{
+ "@metadata": {
+ "authors": [
+ "सरोज कुमार ढकाल",
+ "Ganesh Paudel"
+ ]
+ },
+ "flow-newtopic-title-placeholder": "नयाँ विषय",
+ "flow-post-action-suppress-post": "दबाउने",
+ "flow-post-action-delete-post": "हटाउने",
+ "flow-post-action-hide-post": "लुकाउनुहोस्",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|टिप्पणी|टिप्पणीहरू}}</strong> {{PLURAL:$1|थपिएको|थपिएका}} थिए ।",
+ "flow-workflow": "कार्य बहाव",
+ "flow-moderation-confirm-suppress-post": "दबाउने",
+ "flow-moderation-confirm-delete-post": "मेट्ने",
+ "flow-moderation-confirm-hide-post": "लुकाउनुहोस्",
+ "flow-undo-your-text": "तपाईँको पाठ"
+}
diff --git a/Flow/i18n/nl.json b/Flow/i18n/nl.json
new file mode 100644
index 00000000..6f8b3fbc
--- /dev/null
+++ b/Flow/i18n/nl.json
@@ -0,0 +1,340 @@
+{
+ "@metadata": {
+ "authors": [
+ "Arent",
+ "AvatarTeam",
+ "Breghtje",
+ "Effeietsanders",
+ "Krinkle",
+ "Niknetniko",
+ "SPQRobin",
+ "Siebrand",
+ "Sjoerddebruin",
+ "Southparkfan",
+ "TBloemink",
+ "Arg",
+ "Romaine",
+ "Mar(c)",
+ "Mirolith",
+ "MedShot"
+ ]
+ },
+ "flow-desc": "Workflowmanagementsysteem",
+ "flow-talk-taken-over": "Deze overlegpagina gebruikt [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Overlegpaginabeheer voor Flow",
+ "log-name-flow": "Logboek Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|heeft}} een [$4 bericht] verwijderd van [[$3]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|heeft}} een [$4 bericht] teruggeplaatst op [[$3]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|heeft}} een [$4 bericht] onderdrukt op [[$3]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|heeft}} een [$4 bericht] verwijderd van [[$3]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|heeft}} een [$4 onderwerp] verwijderd op [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|heeft}} een [$4 onderwerp] teruggeplaatst op [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|heeft}} een [$4 onderwerp] onderdrukt op [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|heeft}} een [$4 onderwerp] verwijderd op [[$3]]",
+ "flow-user-moderated": "Gemodereerde gebruiker",
+ "flow-edit-header-link": "Koptekst bewerken",
+ "flow-post-moderated-toggle-hide-show": "Reactie {{GENDER:$1|verborgen}} door $2 zichtbaar maken",
+ "flow-post-moderated-toggle-delete-show": "Reactie {{GENDER:$1|verwijderd}} door $2 zichtbaar maken",
+ "flow-post-moderated-toggle-suppress-show": "Reactie {{GENDER:$1|onderdrukt}} door $2 zichtbaar maken",
+ "flow-post-moderated-toggle-hide-hide": "Reactie {{GENDER:$1|verborgen}} door $2 verbergen",
+ "flow-post-moderated-toggle-delete-hide": "Reactie {{GENDER:$1|verwijderd}} door $2 verbergen",
+ "flow-post-moderated-toggle-suppress-hide": "Reactie {{GENDER:$1|onderdrukt}} door $2 verbergen",
+ "flow-topic-moderated-reason-prefix": "Reden:",
+ "flow-hide-post-content": "Deze opmerking is {{GENDER:$1|verborgen}} door $1",
+ "flow-hide-title-content": "Dit onderwerp is {{GENDER:$1|verborgen}} door $1",
+ "flow-lock-title-content": "Dit onderwerp is {{GENDER:$1|vergrendeld}} door $1",
+ "flow-hide-header-content": "{{GENDER:$1|Verborgen}} door $2",
+ "flow-delete-post-content": "Deze opmerking is {{GENDER:$1|verwijderd}} door $1",
+ "flow-delete-title-content": "Dit onderwerp is {{GENDER:$1|verwijderd}} door $1",
+ "flow-delete-header-content": "{{GENDER:$1|Verwijderd}} door $2",
+ "flow-suppress-post-content": "Deze opmerking is {{GENDER:$1|onderdrukt}} door $1",
+ "flow-suppress-title-content": "Dit onderwerp is {{GENDER:$1|onderdrukt}} door $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Onderdrukt}} door $2",
+ "flow-suppress-usertext": "<em>Gebruikersnaam onderdrukt</em>",
+ "flow-post-actions": "Handelingen",
+ "flow-topic-actions": "Handelingen",
+ "flow-cancel": "Annuleren",
+ "flow-preview": "Voorvertoning",
+ "flow-show-change": "Laat wijzigingen zien",
+ "flow-last-modified-by": "Laatst {{GENDER:$1|bewerkt}} door $1",
+ "flow-stub-post-content": "''Dit bericht kan niet worden opgehaald door een technische fout.''",
+ "flow-newtopic-title-placeholder": "Nieuw onderwerp",
+ "flow-newtopic-content-placeholder": "Nieuw bericht plaatsen op \"$1\"",
+ "flow-newtopic-header": "Nieuw onderwerp toevoegen",
+ "flow-newtopic-save": "Onderwerp toevoegen",
+ "flow-newtopic-start-placeholder": "Nieuw onderwerp",
+ "flow-newtopic-first-heading": "Start een nieuw onderwerp over $1",
+ "flow-summarize-topic-placeholder": "Vat het overleg samen",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Reageren}} op \"$2\"",
+ "flow-reply-topic-title-placeholder": "Reageren op \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Reageren}}",
+ "flow-reply-link": "{{GENDER:$1|Reageren}}",
+ "flow-thank-link": "{{GENDER:$1|Bedanken}}",
+ "flow-lock-link": "{{GENDER:$1|Vergrendelen}}",
+ "flow-post-edited": "Bericht $2 {{GENDER:$1|bewerkt}} door $1",
+ "flow-post-action-view": "Permanente koppeling",
+ "flow-post-action-post-history": "Geschiedenis",
+ "flow-post-action-suppress-post": "Onderdrukken",
+ "flow-post-action-delete-post": "Verwijderen",
+ "flow-post-action-hide-post": "Verbergen",
+ "flow-post-action-edit-post": "Bewerken",
+ "flow-post-action-edit-post-submit": "Wijzigingen opslaan",
+ "flow-post-action-unsuppress-post": "Niet langer onderdrukken",
+ "flow-post-action-undelete-post": "Terugplaatsen",
+ "flow-post-action-unhide-post": "Zichtbaar maken",
+ "flow-post-action-restore-post": "Terugplaatsen",
+ "flow-topic-action-view": "Permanente koppeling",
+ "flow-topic-action-watchlist": "Volglijst",
+ "flow-topic-action-edit-title": "Titel wijzigen",
+ "flow-topic-action-history": "Geschiedenis",
+ "flow-topic-action-hide-topic": "Onderwerp verbergen",
+ "flow-topic-action-delete-topic": "Onderwerp verwijderen",
+ "flow-topic-action-lock-topic": "Onderwerp vergrendelen",
+ "flow-topic-action-unlock-topic": "Onderwerp ontgrendelen",
+ "flow-topic-action-summarize-topic": "Samenvatten",
+ "flow-topic-action-resummarize-topic": "Samenvatting bewerken",
+ "flow-topic-action-suppress-topic": "Onderwerp onderdrukken",
+ "flow-topic-action-unhide-topic": "Onderwerp zichtbaar maken",
+ "flow-topic-action-undelete-topic": "Onderwerp terugplaatsen",
+ "flow-topic-action-unsuppress-topic": "Onderwerp niet langer onderdrukken",
+ "flow-topic-action-restore-topic": "Onderwerp terugplaatsen",
+ "flow-topic-action-undo-moderation": "Ongedaan maken",
+ "flow-topic-notification-subscribe-title": "Dit onderwerp is aan {{GENDER:$1|uw}} volglijst toegevoegd.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|U}} ontvangt meldingen over alle activiteiten aangaande dit onderwerp.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|U}} bent geabonneerd op dit discussiebord.",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|U}} krijgt een melding als er een nieuw onderwerp wordt gemaakt op dit bord.",
+ "flow-error-http": "Er is een fout opgetreden in het contact met de server.",
+ "flow-error-other": "Er is een onverwachte fout opgetreden.",
+ "flow-error-external": "Er is een fout opgetreden.<br />De foutmelding is: $1",
+ "flow-error-edit-restricted": "U mag dit bericht niet bewerken.",
+ "flow-error-topic-is-locked": "Dit onderwerp is gesloten voor alle verdere activiteiten.",
+ "flow-error-lock-moderated-post": "U kunt een gemodereerd bericht niet vergrendelen.",
+ "flow-error-external-multi": "Er zijn fouten opgetreden.<br />$1",
+ "flow-error-missing-content": "Het bericht heeft geen inhoud. Inhoud is vereist voor het opslaan van een bericht.",
+ "flow-error-missing-summary": "De samenvatting is leeg. U moet tekst invoeren om een samenvatting op te kunnen slaan.",
+ "flow-error-missing-title": "Onderwerp heeft geen titel. Een titel is vereist voor het opslaan van een onderwerp.",
+ "flow-error-parsoid-failure": "Verwerken is niet mogelijk vanwege een fout in Parsoid.",
+ "flow-error-missing-replyto": "Er is geen parameter \"replyTo\" opgegeven. Deze parameter is verplicht voor de handeling \"reply\".",
+ "flow-error-invalid-replyto": "De parameter \"replyTo\" is ongeldig. Het opgegeven bericht kon niet worden gevonden.",
+ "flow-error-delete-failure": "Het verwijderen van dit item is mislukt.",
+ "flow-error-hide-failure": "Het verbergen van dit item is mislukt.",
+ "flow-error-missing-postId": "Er is geen parameter \"postId\" opgegeven. Deze parameter is verplicht bij het wijzigingen van een bericht.",
+ "flow-error-invalid-postId": "De parameter \"postId\" is ongeldig. Het opgegeven bericht ($1) kan niet worden gevonden.",
+ "flow-error-restore-failure": "Het terugplaatsen van dit item is mislukt.",
+ "flow-error-invalid-moderation-state": "Er is een ongeldige waarde opgegeven voor \"moderationState\".",
+ "flow-error-invalid-moderation-reason": "Geef een reden op voor de moderatie.",
+ "flow-error-not-allowed": "Onvoldoende rechten voor het uitvoeren van deze handeling.",
+ "flow-error-title-too-long": "Onderwerpen kunnen niet langer zijn dan $1 {{PLURAL:$1|teken|tekens}}.",
+ "flow-error-no-existing-workflow": "Deze workflow bestaat nog niet.",
+ "flow-error-not-a-post": "Dit onderwerp kan niet als bericht worden opgeslagen.",
+ "flow-error-missing-header-content": "De koptekst heeft geen inhoud. Zonder inhoud voor de koptekst, kunt u niet opslaan.",
+ "flow-error-missing-prev-revision-identifier": "Het ID van de vorige versie ontbreekt.",
+ "flow-error-prev-revision-mismatch": "Een andere gebruiker bewerkte deze bijdrage al een paar seconden geleden. Weet u zeker dat u deze recente verandering wilt overschrijven?{{GENDER:$3|}}",
+ "flow-error-prev-revision-does-not-exist": "De vorige versie kon niet gevonden worden.",
+ "flow-error-default": "Er is een fout opgetreden.",
+ "flow-error-invalid-input": "Er is een ongeldige waarde opgegeven voor het laden van inhoud van Flow.",
+ "flow-error-invalid-title": "Er is een ongeldige paginanaam opgegeven.",
+ "flow-error-fail-load-history": "Het laden de geschiedenis is mislukt.",
+ "flow-error-missing-revision": "Er is geen versie gevonden om inhoud van Flow van te downloaden.",
+ "flow-error-fail-commit": "Het opslaan van de inhoud van Flow is mislukt.",
+ "flow-error-insufficient-permission": "Onvoldoende rechten om de inhoud te kunnen bekijken.",
+ "flow-error-revision-comparison": "Verschillen tussen twee versies kunnen alleen weergegeven worden als het om hetzelfde bericht gaat.",
+ "flow-error-missing-topic-title": "Kan de onderwerptitel voor de huidige werkstroom niet vinden.",
+ "flow-error-fail-load-data": "Fout bij het laden van de gevraagde gegevens.",
+ "flow-error-invalid-workflow": "Kan de gevraagde werkstroom niet vinden.",
+ "flow-error-process-data": "Er is een fout opgetreden tijdens het verwerken van de gegevens in uw aanvraag.",
+ "flow-error-process-wikitext": "Er is een fout opgetreden tijdens het verwerken van HTML/wikitext conversie.",
+ "flow-error-no-index": "Geen index voor het uitvoeren van een zoekopdracht.",
+ "flow-error-no-render": "De opgegeven handeling wordt niet herkend.",
+ "flow-error-no-commit": "De opgegeven handeling kon niet worden uitgevoerd.",
+ "flow-error-fetch-after-lock": "Er is een fout opgetreden tijdens het opvragen van nieuwe gegevens. De handeling om te sluiten of te openen is wel geslaagd. De foutmelding is: $1",
+ "flow-error-content-too-long": "De inhoud is te lang. Na uitbreiding van sjablonen mag de grootte maximaal $1 {{PLURAL:$1|byte|bytes}} zijn.",
+ "flow-error-move": "Een prikbord hernoemen is op dit moment niet mogelijk.",
+ "flow-error-invalid-topic-uuid-title": "Ongeldige paginanaam",
+ "flow-error-unknown-workflow-id-title": "Onbekend onderwerp",
+ "flow-edit-header-placeholder": "Beschrijf dit overlegbord",
+ "flow-edit-header-submit": "Koptekst opslaan",
+ "flow-edit-header-submit-overwrite": "Koptekst overschrijven",
+ "flow-summarize-topic-submit": "Samenvatten",
+ "flow-summarize-topic-submit-overwrite": "Samenvatting overschrijven",
+ "flow-lock-topic-submit": "Onderwerp vergrendelen",
+ "flow-lock-topic-submit-overwrite": "Samenvatting voor sluiten onderwerp overschrijven",
+ "flow-unlock-topic-submit": "Onderwerp ontgrendelen",
+ "flow-unlock-topic-submit-overwrite": "Samenvatting voor openen onderwerp overschrijven",
+ "flow-edit-title-submit": "Onderwerp wijzigen",
+ "flow-edit-title-submit-overwrite": "Onderwerp overschrijven",
+ "flow-edit-post-submit": "Wijzigingen opslaan",
+ "flow-edit-post-submit-overwrite": "Wijzigingen overschrijven",
+ "flow-rev-message-edit-post": "[$1|$2] {{GENDER:$2|heeft}} een [$3 reactie] bewerkt op \"$4\"",
+ "flow-rev-message-reply": "$1 {{GENDER:$2|heeft}} een [$3 reactie] toegevoegd op \"$4\" (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "Er {{PLURAL:$1|is|zijn}} <strong>$1 {{PLURAL:$1|reactie|reacties}}</strong> toegevoegd.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|heeft}} het onderwerp \"[$3 $4]\" aangemaakt",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|heeft}} het onderwerp gewijzigd van \"$5\" naar \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|heeft}} de kop aangemaakt",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|heeft}} de kop bewerkt",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|heeft}} een onderwerpsamenvatting gemaakt voor $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|heeft}} de onderwerpsamenvatting bewerkt op $3",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|heeft}} een [$4 reactie] verborgen op \"$6\" (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|heeft}} een [$4 reactie] verwijderd op \"$6\" (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|heeft}} een [$4 reactie] onderdrukt op \"$6\" (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|heeft}} een [$4 reactie] teruggeplaatst op \"$6\" (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|heeft}} het [$4 onderwerp] \"$6\" verborgen (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|heeft}} het [$4 onderwerp] \"$6\" verwijderd (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|heeft}} het [$4 onderwerp] \"$6\" onderdrukt (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|heeft}} het [$4 onderwerp] $6 gesloten (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|heeft}} het [$4 onderwerp] \"$6\" teruggeplaatst (<em>$5</em>)",
+ "flow-rc-topic-of-board": "$1 op $2",
+ "flow-board-history": "Geschiedenis van \"$1\"",
+ "flow-board-history-empty": "Dit bord heeft nog geen geschiedenis.",
+ "flow-topic-history": "Onderwerpgeschiedenis van \"$1\"",
+ "flow-post-history": "Berichtgeschiedenis van \"Reactie van {{GENDER:$2|$2}}\"",
+ "flow-history-last4": "Laatste 4 uur",
+ "flow-history-day": "Vandaag",
+ "flow-history-week": "Afgelopen week",
+ "flow-history-pages-topic": "Komt voor op het [$1 prikbord \"$2\"]",
+ "flow-history-pages-post": "Komt voor op [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|0={{GENDER:$2|Reageer}} als eerste!|Eén reactie|$1 reacties}}",
+ "flow-comment-restored": "Teruggeplaatste reactie",
+ "flow-comment-deleted": "Verwijderde reactie",
+ "flow-comment-hidden": "Verborgen reactie",
+ "flow-comment-moderated": "Gemodereerde reactie",
+ "flow-last-modified": "Ongeveer $1 voor het laatst bewerkt",
+ "flow-workflow": "workflow",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 heeft {{GENDER:$1|gereageerd}} op '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 en $5 {{PLURAL:$6|iemand anders|anderen}} hebben {{GENDER:$1|gereageerd}} op '''$3'''.",
+ "flow-notification-edit": "$1 {{GENDER:$1|heeft}} een <span class=\"plainlinks\">[$5 bericht]</span> geplaatst in $2 op [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 en $5 {{PLURAL:$6|andere gebruiker|anderen}} {{GENDER:$1|hebben}} een <span class=\"plainlinks\">[$4 bericht]</span> geplaatst in \"$2\" op \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|heeft}} een nieuw onderwerp aangemaakt op '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|nieuw onderwerp|nieuwe onderwerpen}} op '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|heeft}} het onderwerp <span class=\"plainlinks\">[$2 $3]</span> hernoemd naar \"$4\" op [[$5|$6]].",
+ "flow-notification-mention": "$1 heeft {{GENDER:$5|u}} genoemd in {{GENDER:$1|zijn|haar|zijn/haar}} <span class=\"plainlinks\">[$2 bericht]</span> in \"$3\" op \"$4\".",
+ "flow-notification-link-text-view-post": "Bericht bekijken",
+ "flow-notification-link-text-view-topic": "Onderwerp bekijken",
+ "flow-notification-reply-email-subject": "$2 op $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|heeft}} gereageerd op \"$2\" op \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 en {{PLURAL:$5|iemand anders|$4 anderen}} {{GENDER:$1|hebben}} gereageerd op \"$2\" op \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|heeft}} {{GENDER:$3|u}} genoemd op \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 heeft {{GENDER:$4|u}} genoemd in {{GENDER:$1|zijn|haar|zijn/haar}} bericht in \"$2\" op \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|heeft}} een bericht bewerkt",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|heeft}} een bericht bewerkt in \"$2\" op \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 en $4 {{PLURAL:$5|andere gebruiker|anderen}} {{GENDER:$1|hebben}} een bericht bewerkt in \"$2\" op \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|heeft}} uw onderwerp een andere naam gegeven",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|heeft}} uw onderwerp \"$2\" hernoemd naar \"$3\" op \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|heeft}} een nieuw onderwerp aangemaakt op \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|heeft}} op $3 een nieuw onderwerp aangemaakt met de naam \"$2\"",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Informeer mij wanneer er aan mij gerelateerde handelingen in Flow plaatsvinden.",
+ "flow-link-post": "bericht",
+ "flow-link-topic": "onderwerp",
+ "flow-link-history": "geschiedenis",
+ "flow-link-post-revision": "berichtversie",
+ "flow-link-topic-revision": "onderwerpversie",
+ "flow-link-header-revision": "koptekstversie",
+ "flow-moderation-title-suppress-post": "Bericht onderdrukken?",
+ "flow-moderation-title-delete-post": "Bericht verwijderen?",
+ "flow-moderation-title-hide-post": "Bericht verbergen?",
+ "flow-moderation-title-unsuppress-post": "Bericht niet langer onderdrukken?",
+ "flow-moderation-title-undelete-post": "Bericht terugplaatsen?",
+ "flow-moderation-title-unhide-post": "Bericht zichtbaar maken?",
+ "flow-moderation-placeholder-suppress-post": "{{GENDER:$3|Geef}} een reden op waarom u dit bericht onderdrukt.",
+ "flow-moderation-placeholder-delete-post": "{{GENDER:$3|Geef}} een reden op waarom u dit bericht verwijdert.",
+ "flow-moderation-placeholder-hide-post": "{{GENDER:$3|Geef}} een reden op waarom u dit bericht verbergt.",
+ "flow-moderation-placeholder-unsuppress-post": "{{GENDER:$3|Leg}} uit waarom u dit bericht niet langer onderdrukt.",
+ "flow-moderation-placeholder-undelete-post": "{{GENDER:$3|Leg}} uit waarom u dit bericht terugplaatst.",
+ "flow-moderation-placeholder-unhide-post": "{{GENDER:$3|Leg}} uit waarom u dit bericht zichtbaar maakt.",
+ "flow-moderation-confirm-suppress-post": "Onderdrukken",
+ "flow-moderation-confirm-delete-post": "Verwijderen",
+ "flow-moderation-confirm-hide-post": "Verbergen",
+ "flow-moderation-confirm-unsuppress-post": "Niet langer onderdrukken",
+ "flow-moderation-confirm-undelete-post": "Terugplaatsen",
+ "flow-moderation-confirm-unhide-post": "Zichtbaar maken",
+ "flow-moderation-confirm-suppress-topic": "Onderdrukken",
+ "flow-moderation-confirm-delete-topic": "Verwijderen",
+ "flow-moderation-confirm-hide-topic": "Verbergen",
+ "flow-moderation-confirm-unsuppress-topic": "Niet langer onderdrukken",
+ "flow-moderation-confirm-undelete-topic": "Terugplaatsen",
+ "flow-moderation-confirm-unhide-topic": "Zichtbaar maken",
+ "flow-moderation-confirmation-suppress-post": "Het bericht is onderdrukt.\n{{GENDER:$2|Overweeg}} $1 terugkoppeling te geven over dit bericht.",
+ "flow-moderation-confirmation-delete-post": "Het bericht is verwijderd.\n{{GENDER:$2|Overweeg}} {{GENDER:$1|$1}} terugkoppeling te geven over dit bericht.",
+ "flow-moderation-confirmation-hide-post": "Het bericht is verborgen.\n{{GENDER:$2|Overweeg}} {{GENDER:$1|$1}} terugkoppeling te geven over dit bericht.",
+ "flow-moderation-confirmation-unsuppress-post": "Het bovenstaande bericht wordt niet langer onderdrukt.",
+ "flow-moderation-confirmation-undelete-post": "Het bovenstaande bericht is teruggeplaatst.",
+ "flow-moderation-confirmation-unhide-post": "Het bovenstaande bericht is weer zichtbaar.",
+ "flow-moderation-confirmation-suppress-topic": "Dit onderwerp is onderdrukt.",
+ "flow-moderation-confirmation-delete-topic": "Dit onderwerp is verwijderd.",
+ "flow-moderation-confirmation-hide-topic": "Dit onderwerp is verborgen.",
+ "flow-moderation-confirmation-unsuppress-topic": "Dit onderwerp wordt niet langer onderdrukt.",
+ "flow-moderation-confirmation-undelete-topic": "Dit onderwerp is teruggeplaatst.",
+ "flow-moderation-confirmation-unhide-topic": "Dit onderwerp is weer zichtbaar.",
+ "flow-moderation-title-suppress-topic": "Onderwerp onderdrukken?",
+ "flow-moderation-title-delete-topic": "Onderwerp verwijderen?",
+ "flow-moderation-title-hide-topic": "Onderwerp verbergen?",
+ "flow-moderation-title-unsuppress-topic": "Onderwerp niet langer onderdrukken?",
+ "flow-moderation-title-undelete-topic": "Onderwerp terugplaatsen?",
+ "flow-moderation-title-unhide-topic": "Onderwerp zichtbaar maken?",
+ "flow-moderation-placeholder-suppress-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp onderdrukt.",
+ "flow-moderation-placeholder-delete-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp verwijdert.",
+ "flow-moderation-placeholder-hide-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp verbergt.",
+ "flow-moderation-placeholder-unsuppress-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp niet langer onderdrukt.",
+ "flow-moderation-placeholder-undelete-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp terugplaatst.",
+ "flow-moderation-placeholder-unhide-topic": "{{GENDER:$3|Leg}} uit waarom u dit onderwerp zichtbaar maakt.",
+ "flow-topic-permalink-warning": "Dit onderwerp is gestart op [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Dit onderwerp is gestart op het [$2 prikbord van {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Dit is een permanente koppeling naar een enkele versie van dit bericht.\nDeze versie is van $1.\nU kunt de [$5 verschillen ten opzichte van de vorige versie] bekijken, of andere versies bekijken op de [$4 geschiedenispagina van het bericht].",
+ "flow-revision-permalink-warning-post-first": "Dit is een permanente koppeling naar de eerste versie van dit bericht.\nU kunt nieuwere versies bekijken op de [$4 geschiedenispagina van dit bericht].",
+ "flow-revision-permalink-warning-postsummary": "Dit is een permanente koppeling naar een enkele versie van de samenvatting van dit bericht.\nDeze versie is van $1.\nU kunt de [$5 verschillen ten opzichte van de vorige versie] bekijken, of andere versies bekijken op de [$4 geschiedenispagina van het bericht].",
+ "flow-revision-permalink-warning-postsummary-first": "Dit is een permanente koppeling naar de eerste versie van de samenvatting van dit bericht.\nU kunt nieuwere versies bekijken op de [$4 geschiedenispagina van dit bericht].",
+ "flow-revision-permalink-warning-header": "Dit is een permanente koppeling naar een enkele versie van de koptekst. Deze versie is van $1. U kunt de [$3 verschillen ten opzichte van de vorige versie] bekijken, of andere versies bekijken op de [$2 geschiedenispagina van het board].",
+ "flow-revision-permalink-warning-header-first": "Dit is een permanente koppeling naar de eerste versie van deze kop.\nU kunt nieuwere versies bekijken op de [$2 geschiedenispagina van dit board].",
+ "flow-compare-revisions-revision-header": "Version van $1 door {{GENDER:$2|$2}}",
+ "flow-compare-revisions-header-post": "Op deze pagina worden de verschillen tussen twee versies weergegeven van een bericht van {{GENDER:$3|$3}} in het onderwerp \"[$5 $2]\" op [$4 $1].\nU kunt de andere versie van dit bericht bekijken op de [$6 geschiedenispagina].",
+ "flow-compare-revisions-header-postsummary": "Op deze pagina worden de verschillen tussen twee versies weergegeven van de samenvatting van een bericht in het onderwerp \"[$4 $2]\" op [$3 $1].\nU kunt de andere versie van dit bericht bekijken op de [$5 geschiedenispagina].",
+ "flow-compare-revisions-header-header": "Op deze pagina worden de {{GENDER:$2|verschillen}} tussen twee versies weergegeven van de kop op [$3 $1].\nU kunt de andere versie van de kop bekijken op de [$4 geschiedenispagina].",
+ "right-flow-hide": "Onderwerpen en berichten verbergen",
+ "right-flow-lock": "Flowonderwerpen vergrendelen",
+ "right-flow-delete": "Onderwerpen en berichten verwijderen",
+ "right-flow-edit-post": "Berichten van andere gebruikers bewerken",
+ "right-flow-suppress": "Versies van Flow onderdrukken",
+ "flow-terms-of-use-new-topic": "Door op \"{{int:flow-newtopic-save}}\" te klikken gaat u akkoord met de gebruiksvoorwaarden van deze wiki.",
+ "flow-terms-of-use-reply": "Door te klikken op \"{{int:flow-reply-submit}}\", gaat u akkoord met de gebruiksvoorwaarden van deze wiki.",
+ "flow-terms-of-use-edit": "Door deze wijzigingen op te slaan, gaat u akkoord met de gebruiksvoorwaarden van deze wiki.",
+ "flow-anon-warning": "U bent niet aangemeld. Om te naamsvermelding te krijgen met uw naam in plaats van uw IP-adres, kunt u [$1 aanmelden] of [$2 een gebruiker aanmaken].",
+ "flow-cancel-warning": "U hebt tekst ingevoerd in dit formulier. Weet u zeker dat u deze wilt verwijderen?",
+ "flow-topic-first-heading": "Onderwerp op $1",
+ "flow-topic-html-title": "$1 op $2",
+ "flow-topic-count": "Onderwerpen ($1)",
+ "flow-load-more": "Meer laden",
+ "flow-no-more-fwd": "Er zijn geen oudere onderwerpen",
+ "flow-add-topic": "Onderwerp toevoegen",
+ "flow-newest-topics": "Nieuwste onderwerpen",
+ "flow-recent-topics": "Recent actieve onderwerpen",
+ "flow-sorting-tooltip-newest": "{{GENDER:|U}} leest op het moment de nieuwste onderwerpen eerst. Klik voor meer sorteeropties.",
+ "flow-toggle-small-topics": "Kleine onderwerpen weergeven",
+ "flow-toggle-topics": "Alleen onderwerpen weergeven",
+ "flow-toggle-topics-posts": "Onderwerpen en berichten weergeven",
+ "flow-terms-of-use-summarize": "Door te klikken op \"{{int:flow-summarize-topic-submit}}\" gaat u akkoord met de gebruiksvoorwaarden van deze website.",
+ "flow-terms-of-use-lock-topic": "Door te klikken op \"{{int:flow-lock-topic-submit}}\" gaat u akkoord met de gebruiksvoorwaarden van deze website.",
+ "flow-terms-of-use-unlock-topic": "Door te klikken op \"{{int:flow-unlock-topic-submit}}\" gaat u akkoord met de gebruiksvoorwaarden van deze website.",
+ "flow-whatlinkshere-post": "van een [$1 bericht]",
+ "flow-whatlinkshere-header": "van de [$1 koptekst]",
+ "flow": "Flow",
+ "flow-special-desc": "Deze speciale pagina verwijst door naar een workflow van Flow of een bericht van Flow op basis van een UUID.",
+ "flow-special-type": "Type",
+ "flow-special-type-post": "Bericht",
+ "flow-special-type-workflow": "Workflow",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Er was geen inhoud die overeenkomt met het type en het UUID.",
+ "flow-spam-confirmedit-form": "Bevestig alstublieft dat u een mens bent door onderstaande captcha op te lossen: $1",
+ "flow-preview-warning": "U bekijkt een voorvertoning. Sla het formulier op om het plaatsen te voltooien of klik op \"{{int:flow-preview-return-edit-post}}\" om door te gaan met schrijven.",
+ "flow-preview-return-edit-post": "Doorgaan met bewerken",
+ "flow-anonymous": "Anoniem",
+ "flow-embedding-unsupported": "Overleg kan nog niet ingebed worden.",
+ "mw-ui-unsubmitted-confirm": "U hebt nog niet opgeslagen wijzigingen op deze pagina. Weet u zeker dat u door weg te gaan van deze pagina uw wijzigingen wilt verliezen?",
+ "flow-edited": "Bewerkt",
+ "flow-edited-by": "Bewerkt door $1",
+ "flow-undo-latest-revision": "Huidige versie",
+ "flow-undo-your-text": "Uw tekst",
+ "flow-undo-edit-header": "Het bewerken van de header",
+ "flow-undo-edit-topic-summary": "Bewerken van de samenvatting van het onderwerp",
+ "flow-undo-edit-post": "Het bewerken van een bericht"
+}
diff --git a/Flow/i18n/oc.json b/Flow/i18n/oc.json
new file mode 100644
index 00000000..59ac611b
--- /dev/null
+++ b/Flow/i18n/oc.json
@@ -0,0 +1,122 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cedric31"
+ ]
+ },
+ "flow-desc": "Sistèma de gestion del flux de trabalh",
+ "flow-talk-taken-over": "Aquesta pagina de discussion es estada remplaçada per un [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal ''Flow board''].",
+ "log-name-flow": "Jornal de flux d’activitat",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|a suprimit}} una [$4 publicacion] sus « [[$3|$5]] » sus [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|a restablit}} una [$4 nòta] sus \"[[$3|$5]]\" lo [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|a suprimit}} una [$4 publicacion] sus « [[$3|$5]] » sus [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|a suprimit}} una [$4 publicacion] sus « [[$3|$5]] » sus [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|a suprimit}} lo subjècte « [[$3|$5]] » sus [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|a restablit}} lo subjècte « [[$3|$5]] » sus [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|a suprimit}} un [$4 subjècte] sus [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|a suprimit}} un [$4 subjècte] sus [[$3]]",
+ "flow-user-moderated": "Utilizaire moderat",
+ "flow-edit-header-link": "Modificar l’entèsta",
+ "flow-post-moderated-toggle-hide-show": "Afichar lo comentari {{GENDER:$1|amagat}} per $2",
+ "flow-post-moderated-toggle-delete-show": "Afichar lo comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-post-moderated-toggle-suppress-show": "Afichar lo comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-post-moderated-toggle-hide-hide": "Amagar lo comentari {{GENDER:$1|amagat}} per $2",
+ "flow-post-moderated-toggle-delete-hide": "Amagar lo comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-post-moderated-toggle-suppress-hide": "Amagar lo comentari {{GENDER:$1|suprimit}} per $2",
+ "flow-hide-post-content": "Aqueste comentari es estat {{GENDER:$1|amagat}} per $1",
+ "flow-hide-title-content": "Lo subjècte es estat {{GENDER:$1|amagat}} per $1",
+ "flow-hide-header-content": "{{GENDER:$1|Amagat}} per $2",
+ "flow-delete-post-content": "Aqueste comentari es estat {{GENDER:$1|suprimit}} per $1 ([$2 istoric])",
+ "flow-delete-title-content": "Lo subjècte es estat {{GENDER:$1|suprimit}} per $1",
+ "flow-delete-header-content": "{{GENDER:$1|Suprimit}} per $2",
+ "flow-suppress-post-content": "Aqueste comentari es estat {{GENDER:$1|suprimit}} per $1",
+ "flow-suppress-title-content": "Lo subjècte es estat {{GENDER:$1|suprimit}} per $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimit}} per $2",
+ "flow-suppress-usertext": "<em>Nom d’utilizaire suprimit</em>",
+ "flow-post-actions": "Accions",
+ "flow-topic-actions": "Accions",
+ "flow-cancel": "Anullar",
+ "flow-preview": "Previsualizar",
+ "flow-show-change": "Veire los cambiaments",
+ "flow-last-modified-by": "{{GENDER:$1|Modificat}} en darrièr per $1",
+ "flow-newtopic-title-placeholder": "Subjècte novèl",
+ "flow-newtopic-content-placeholder": "Mandar un messatge novèl sus « $1 »",
+ "flow-newtopic-header": "Apondre un subjècte novèl",
+ "flow-newtopic-save": "Apondre un subjècte",
+ "flow-newtopic-start-placeholder": "Començar un subjècte novèl",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentar}} « $2 »",
+ "flow-reply-submit": "{{GENDER:$1|Respondre}}",
+ "flow-reply-link": "{{GENDER:$1|Respondre}}",
+ "flow-thank-link": "{{GENDER:$1|Mercejar}}",
+ "flow-post-edited": "Nòta {{GENDER:$1|modificada}} per $1 $2",
+ "flow-post-action-view": "Ligam permanent",
+ "flow-post-action-post-history": "Istoric",
+ "flow-post-action-suppress-post": "Suprimir",
+ "flow-post-action-delete-post": "Suprimir",
+ "flow-post-action-hide-post": "Amagar",
+ "flow-post-action-edit-post": "Modificar",
+ "flow-post-action-restore-post": "Restablir",
+ "flow-topic-action-view": "Ligam permanent",
+ "flow-topic-action-watchlist": "Lista de seguiment",
+ "flow-topic-action-edit-title": "Modificar lo títol",
+ "flow-topic-action-history": "Istoric",
+ "flow-topic-action-hide-topic": "Amagar lo subjècte",
+ "flow-topic-action-delete-topic": "Suprimir lo subjècte",
+ "flow-topic-action-suppress-topic": "Suprimir lo subjècte",
+ "flow-topic-action-restore-topic": "Restablir lo subjècte",
+ "flow-error-http": "Una error s'es producha en comunicant amb lo servidor.",
+ "flow-error-other": "Una error imprevista s'es producha.",
+ "flow-error-external": "Una error s'es producha.<br />Lo messatge d'error recebut èra :$1",
+ "flow-error-edit-restricted": "Sètz pas autorizat(ada) a modificar aquesta nòta",
+ "flow-error-external-multi": "D'errors se son produchas.<br /> $1",
+ "flow-error-missing-content": "Lo messatge a pas cap de contengut. Un contengut es obligatòri per enregistrar un messatge.",
+ "flow-error-missing-summary": "Lo resumit a pas cap de contengut. Un contengut es obligatòri per enregistrar un resumit.",
+ "flow-error-missing-title": "Lo subjècte a pas cap de títol. Un títol es obligatòri per enregistrar un subjècte.",
+ "flow-error-parsoid-failure": "Impossible d'analisar lo contengut a causa d'una pana de Parsoid.",
+ "flow-error-missing-replyto": "Cap de paramètre « replyTo » es pas estat provesit. Aqueste paramètre es requesit per l'accion « respondre ».",
+ "flow-error-invalid-replyto": "Lo paramètre « replyTo » èra pas valid. Lo messatge especificat es pas estat trobat.",
+ "flow-error-delete-failure": "Fracàs de la supression d'aquesta entrada.",
+ "flow-error-hide-failure": "L'amagatge d'aqueste element a fracassat.",
+ "flow-error-missing-postId": "Cap de paramètre « postId » es pas estat provesit. Aqueste paramètre es requesit per manipular un messatge.",
+ "flow-error-invalid-postId": "Lo paramètre « postId » èra pas valid. Lo messatge especificat ($1) es pas estat trobat.",
+ "flow-error-restore-failure": "Fracàs del restabliment d'aquesta entrada.",
+ "flow-edit-title-submit": "Cambiar lo títol",
+ "flow-edit-post-submit": "Sometre las modificacions",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|a modificat}} un [$3 comentari] sus « $4 ».",
+ "flow-rev-message-reply": "$1 {{GENDER:$2|a apondut}} un [$3 comentari] sus « $4 » (<em>$5</em>).",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|a creat}} lo subjècte « [$3 $4] ».",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|a creat}} un resumit de subjècte sus $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|a creat}} lo resumit de subjècte sus $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|a amagat}} un [$4 comentari] sus « $6 » (<em>$5</em>)..",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|a suprimit}} un [$4 comentari] sus « $6 » (<em>$5</em>)..",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|a amagat}} lo [$4 subjècte] « $6 » (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|a suprimit}} lo [$4 subjècte] « $6 » (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|a suprimit}} lo [$4 subjècte] « $6 » (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|a restablit}} lo [$4 subjècte] « $6 » (<em>$5</em>).",
+ "flow-board-history": "Istoric de « $1 »",
+ "flow-board-history-empty": "Actualament, aqueste conselh a pas cap d'istoric.",
+ "flow-topic-history": "Istoric del subjècte « $1 »",
+ "flow-comment-restored": "Comentari restablit",
+ "flow-comment-deleted": "Comentari suprimit",
+ "flow-comment-hidden": "Comentari amagat",
+ "flow-last-modified": "Darrièr cambiament $1",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|a respondut}} sus '''$4'''.",
+ "flow-notification-edit": "$1 {{GENDER:$1|a modificat}} una <span class=\"plainlinks\">[$5 nòta]</span> sus « $2 » en [[$3|$4]].",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|a creat}} una \n discussion novèla sus '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|a modificat}} lo títol de <span class=\"plainlinks\">[$2 $3]</span> en « $4 » sus [[$5|$6]].",
+ "echo-category-title-flow-discussion": "Flux",
+ "flow-link-post": "nòta",
+ "flow-link-topic": "subjècte",
+ "flow-link-history": "istoric",
+ "flow-link-post-revision": "version de la nòta",
+ "flow-link-topic-revision": "version del subjècte",
+ "flow-link-header-revision": "version de l’entèsta",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Suprimir",
+ "flow-moderation-confirm-hide-post": "Amagar",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Suprimir",
+ "flow-moderation-confirm-hide-topic": "Amagar",
+ "flow-previous-diff": "← Cambiament precedent",
+ "flow-next-diff": "Cambiament seguent →"
+}
diff --git a/Flow/i18n/om.json b/Flow/i18n/om.json
new file mode 100644
index 00000000..9e72788b
--- /dev/null
+++ b/Flow/i18n/om.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Tumsaa"
+ ]
+ },
+ "flow-topic-action-resummarize-topic": "Cuunfaa mataduree gulaali",
+ "flow-error-not-allowed-suppress": "Matadureen kun haqameera.",
+ "flow-previous-diff": "← Gulaala duraanii",
+ "flow-next-diff": "Gulaala haaraa →"
+}
diff --git a/Flow/i18n/pa.json b/Flow/i18n/pa.json
new file mode 100644
index 00000000..63cc211b
--- /dev/null
+++ b/Flow/i18n/pa.json
@@ -0,0 +1,37 @@
+{
+ "@metadata": {
+ "authors": [
+ "Babanwalia",
+ "Satdeep gill"
+ ]
+ },
+ "enableflow": "ਫਲੋ ਲਾਗੂ ਕਰੋ",
+ "flow-topic-moderated-reason-prefix": "ਕਾਰਨ:",
+ "flow-newtopic-save": "ਵਿਸ਼ਾ ਜੋੜੋ",
+ "flow-newtopic-start-placeholder": "ਨਵਾਂ ਵਿਸ਼ਾ ਸ਼ੁਰੂ ਕਰੋ",
+ "flow-newtopic-first-heading": "$1 ਉੱਤੇ ਨਵਾਂ ਵਿਸ਼ਾ ਸ਼ੁਰੂ ਕਰੋ",
+ "flow-summarize-topic-placeholder": "ਇਸ ਚਰਚਾ ਦਾ ਸਾਰ ਕੱਢੋ",
+ "flow-history-action-delete-post": "ਮਿਟਾਓ",
+ "flow-history-action-hide-post": "ਓਹਲੇ",
+ "flow-history-action-restore-post": "ਮੁੜ-ਸਟੋਰ",
+ "flow-post-action-edit-post": "ਸੋਧੋ",
+ "flow-topic-action-history": "ਅਤੀਤ",
+ "flow-topic-action-summarize-topic": "ਸਾਰ ਕੱਢੋ",
+ "flow-topic-action-undo-moderation": "ਅਣਕੀਤਾ ਕਰੋ",
+ "flow-summarize-topic-submit": "ਸਾਰ ਕੱਢੋ",
+ "flow-notification-reply-email-subject": "$3 'ਤੇ $2",
+ "flow-moderation-confirmation-suppress-topic": "ਇਹ ਵਿਸ਼ਾ ਦਬਾ ਦਿੱਤਾ ਗਿਆ ਹੈ।",
+ "flow-moderation-confirmation-delete-topic": "ਇਹ ਵਿਸ਼ਾ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ।",
+ "flow-moderation-confirmation-hide-topic": "ਇਹ ਵਿਸ਼ਾ ਲੁਕਾ ਦਿੱਤਾ ਗਿਆ ਹੈ।",
+ "flow-topic-permalink-warning": "ਇਹ ਵਿਸ਼ਾ [$2 $1] ਨੂੰ ਸ਼ੁਰੂ ਕੀਤਾ ਗਿਆ ਸੀ",
+ "flow-topic-first-heading": "$1 ਉੱਤੇ ਵਿਸ਼ਾ",
+ "flow-topic-html-title": "$2 'ਤੇ $1",
+ "flow-topic-count": "ਵਿਸ਼ਾ ($1)",
+ "flow-newest-topics": "ਸਭ ਤੋਂ ਨਵੇਂ ਵਿਸ਼ੇ",
+ "flow": "ਫਲੋ",
+ "flow-special-type": "ਕਿਸਮ",
+ "flow-undo-your-text": "ਤੁਹਾਡੀ ਲਿਖਤ",
+ "group-flow-bot": "ਫਲੋ ਬੌਟਸ",
+ "group-flow-bot-member": "ਫਲੋ ਬੌਟ",
+ "grouppage-flow-bot": "ਯੋਜਨਾ:ਫਲੋ ਬੌਟਸ"
+}
diff --git a/Flow/i18n/pam.json b/Flow/i18n/pam.json
new file mode 100644
index 00000000..41c2d45b
--- /dev/null
+++ b/Flow/i18n/pam.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "Leeheonjin"
+ ]
+ },
+ "flow-error-not-allowed-suppress": "Mebura ne ing paksang ini.",
+ "flow-error-not-allowed-suppress-extract": "Mebura ne ing paksang ini. Ati yu king lalam ing deletion log para king reperensya.",
+ "apihelp-flow+new-topic-param-topic": "Ing tekstu para king bayung bansag ning paksa.",
+ "flow-previous-diff": "Mas minunang edit",
+ "flow-next-diff": "Mas bayung pamanalili (edit) →"
+}
diff --git a/Flow/i18n/pl.json b/Flow/i18n/pl.json
new file mode 100644
index 00000000..520a5942
--- /dev/null
+++ b/Flow/i18n/pl.json
@@ -0,0 +1,139 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chrumps",
+ "Jacenty359",
+ "Pio387",
+ "Rzuwig",
+ "Vuh",
+ "Woytecr",
+ "Alan ffm",
+ "Wiochman",
+ "Ty221",
+ "WTM",
+ "Tar Lócesilion",
+ "Darellur",
+ "Py64",
+ "VerMa"
+ ]
+ },
+ "flow-edit-header-link": "Edytuj nagłówek",
+ "flow-topic-moderated-reason-prefix": "Powód:",
+ "flow-cancel": "Anuluj",
+ "flow-preview": "Podgląd",
+ "flow-show-change": "Pokaż zmiany",
+ "flow-stub-post-content": "''Ze względu na błąd techniczny, ten post nie mógł zostać przywrócony.''",
+ "flow-newtopic-title-placeholder": "Nowy wątek",
+ "flow-newtopic-content-placeholder": "Wyślij nową wiadomość do „$1”",
+ "flow-newtopic-header": "Dodaj nowy wątek",
+ "flow-newtopic-save": "Dodaj wątek",
+ "flow-newtopic-start-placeholder": "Rozpocznij nowy wątek",
+ "flow-newtopic-first-heading": "Rozpocznij nowy wątek w $1",
+ "flow-reply-topic-title-placeholder": "Odpowiedź na „$1”",
+ "flow-reply-submit": "{{GENDER:$1|Odpowiedz}}",
+ "flow-reply-link": "{{GENDER:$1|Odpowiedz}}",
+ "flow-thank-link": "{{GENDER:$1|Podziękuj}}",
+ "flow-history-action-delete-post": "usuń",
+ "flow-history-action-hide-post": "ukryj",
+ "flow-history-action-undelete-post": "odtwórz",
+ "flow-history-action-restore-post": "przywróć",
+ "flow-post-action-view": "Bezpośredni link",
+ "flow-post-action-post-history": "Historia",
+ "flow-post-action-delete-post": "Usuń",
+ "flow-post-action-hide-post": "Ukryj",
+ "flow-post-action-edit-post": "Edytuj",
+ "flow-post-action-edit-post-submit": "Zapisz zmiany",
+ "flow-post-action-undelete-post": "Odtwórz",
+ "flow-post-action-unhide-post": "Nie ukrywaj",
+ "flow-post-action-restore-post": "Odtwórz",
+ "flow-post-action-undo-moderation": "Cofnij",
+ "flow-topic-action-view": "Bezpośredni link",
+ "flow-topic-action-watchlist": "Obserwowane",
+ "flow-topic-action-edit-title": "Zmień tytuł",
+ "flow-topic-action-history": "Historia",
+ "flow-topic-action-hide-topic": "Ukryj wątek",
+ "flow-topic-action-delete-topic": "Usuń wątek",
+ "flow-topic-action-summarize-topic": "Podsumuj",
+ "flow-topic-action-resummarize-topic": "Opis zmian",
+ "flow-topic-action-undo-moderation": "Cofnij",
+ "flow-topic-notification-subscribe-title": "Ten wątek został dodany do {{GENDER:$1|twojej}} listy obserwowanych.",
+ "flow-topic-notification-subscribe-description": "Będziesz {{GENDER:$1|otrzymywał|otrzymywała}} powiadomienia, kiedy inni odpowiedzą w tym wątku.",
+ "flow-error-prev-revision-mismatch": "Ktoś inny kilka sekund temu edytował ten wpis. Na pewno {{GENDER:$3|chcesz}} nadpisać jego zmiany?",
+ "flow-error-default": "Wystąpił błąd.",
+ "flow-error-content-too-long": "Treść jest zbyt duża. Po rozbudowie treść jest ograniczona do $1 {{PLURAL:$1|bajta|bajtów}}.",
+ "flow-error-invalid-topic-uuid-title": "Niepoprawny tytuł",
+ "flow-edit-header-submit": "Zapisz nagłówek",
+ "flow-edit-header-submit-overwrite": "Nadpisz nagłówek",
+ "flow-summarize-topic-submit": "Podsumuj",
+ "flow-edit-title-submit": "Zmień tytuł",
+ "flow-edit-title-submit-overwrite": "Nadpisz tytuł",
+ "flow-edit-post-submit": "Zapisz zmiany",
+ "flow-edit-post-submit-overwrite": "Nadpisz zmiany",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|zmienił|zmieniła}} [$3 komentarz] w temacie „$4”",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|skomentował|skomentowała}}] „$4” (<em>$5</em>)",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|założył|założyła}} wątek „[$3 $4]”",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|utworzył|utworzyła}} nagłówek",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|zmienił|zmieniła}} nagłówek",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|ukrył|ukryła}} [$4 komentarz] w temacie „$6” (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|usunął|usunęła}} [$4 komentarz] w temacie „$6” (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|ukrył|ukryła}} [$4 wątek] „$6” (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|usunął|usunęła}} [$4 wątek] „$6” (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|przywrócił|przywróciła}} [$4 wątek] „$6” (<em>$5</em>)",
+ "flow-history-last4": "Ostatnie 4 godziny",
+ "flow-history-day": "Dzisiaj",
+ "flow-history-week": "Ostatni tydzień",
+ "flow-topic-comments": "{{PLURAL:$1|$1 komentarz|$1 komentarze|$1 komentarzy|0=Skomentuj jako{{GENDER:$2|pierwszy|pierwsza}}!}}",
+ "flow-comment-deleted": "Komentarz usunięty",
+ "flow-comment-hidden": "Ukryty komentarz",
+ "flow-comment-moderated": "Komentarz moderowany",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|utworzył|utworzyła}} nowy wątek na stronie '''$3'''.",
+ "flow-notification-mention": "$1 {{GENDER:$1|wspomniał|wspomniała}} o {{GENDER:$5|Tobie}} w {{GENDER:$1|swoim}} <span class=\"plainlinks\">[$2 wpisie]</span> w temacie „$3” na stronie „$4”.",
+ "flow-notification-reply-email-subject": "$2 w $3",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|wspomniał|wspomniała}} o {{GENDER:$3|Tobie}} w „$2”",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|wspomniał|wspomniała}} o {{GENDER:$4|Tobie}} w {{GENDER:$1|swoim}} wpisie w temacie „$2” na stronie „$3”",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|zmienił|zmieniła}} nazwę twojego wąteku",
+ "flow-link-post": "wpis",
+ "flow-link-topic": "wątek",
+ "flow-link-history": "historia",
+ "flow-moderation-confirm-delete-post": "Usuń",
+ "flow-moderation-confirm-hide-post": "Ukryj",
+ "flow-moderation-confirm-undelete-post": "Odtwórz",
+ "flow-moderation-confirm-unhide-post": "Nie ukrywaj",
+ "flow-moderation-confirm-delete-topic": "Usuń",
+ "flow-moderation-confirm-hide-topic": "Ukryj",
+ "flow-moderation-confirm-undelete-topic": "Odtwórz",
+ "flow-moderation-confirm-unhide-topic": "Nie ukrywaj",
+ "flow-moderation-confirmation-delete-topic": "Ten wątek został usunięty.",
+ "flow-moderation-confirmation-hide-topic": "Ten wątek został ukryty.",
+ "flow-revision-permalink-warning-header": "To jest link do pojedynczej wersji nagłówka.\nTa wersja jest z $1. Możesz zobaczyć [$3 różnice od poprzedniej wersji] lub zobaczyć inne wersje na [$2 stronie historii].",
+ "flow-revision-permalink-warning-header-first": "To jest link do pierwszej wersji nagłówka.\nMożesz zobaczyć późniejsze wersje na [$2 stronie historii].",
+ "flow-compare-revisions-revision-header": "Wersja autorstwa {{GENDER:$2|$2}} • $1",
+ "flow-compare-revisions-header-header": "Ta strona pokazuje {{GENDER:$2|zmiany}} pomiędzy dwiema wersjami nagłówka w [$3 $1]. Możesz również zobaczyć inne wersje na [$4 stronie historii].",
+ "flow-terms-of-use-new-topic": "Klikając na \"{{int:flow-newtopic-save}}\", zgadzasz się na zasady użytkowania tej wiki.",
+ "flow-terms-of-use-reply": "Klikając na \"{{int:flow-reply-submit}}\", zgadzasz się na warunki użytkowania tej wiki.",
+ "flow-terms-of-use-edit": "Zapisując zmiany, zgadzasz się na warunki użytkowania tej wiki.",
+ "flow-topic-first-heading": "Wątek na $1",
+ "flow-topic-html-title": "$1 na $2",
+ "flow-add-topic": "Dodaj wątek",
+ "flow-newest-topics": "Najnowsze wątki",
+ "flow-recent-topics": "Wątki ostatnio aktywne",
+ "flow-sorting-tooltip-newest": "Teraz {{GENDER:|czytasz}} wątki w kolejności od najnowszego. Kliknij, aby aby wybrać inną kolejność sortowania.",
+ "flow-toggle-small-topics": "Przełącz się na widok małych nagłówków",
+ "flow-toggle-topics": "Przełącz się na widok samych nagłówków",
+ "flow-toggle-topics-posts": "Przełącz się na widok nagłówków i wpisów",
+ "flow-terms-of-use-summarize": "Klikając na \"{{int:flow-summarize-topic-submit}}\", zgadzasz się na zasady użytkowania tej wiki.",
+ "flow-preview-warning": "Widzisz teraz podgląd. Wyślij formularz, aby opublikować swój wpis albo kliknij \"{{int:flow-preview-return-edit-post}}\", aby kontynuować pisanie.",
+ "flow-preview-return-edit-post": "Wróć do edytowania",
+ "apihelp-flow+moderate-post-param-reason": "Powód moderacji.",
+ "apihelp-flow+moderate-topic-param-reason": "Powód moderacji.",
+ "flow-previous-diff": "← Poprzednia edycja",
+ "flow-next-diff": "Następna edycja →",
+ "flow-undo": "cofnij",
+ "flow-undo-latest-revision": "Aktualna wersja",
+ "flow-undo-your-text": "Twój tekst",
+ "flow-ve-mention-inspector-title": "Oznacz",
+ "flow-ve-mention-inspector-remove-label": "Usuń",
+ "flow-ve-mention-tool-title": "Oznacz użytkownika",
+ "flow-ve-mention-inspector-invalid-user": "Nazwa użytkownika '$1' nie jest zarejestrowana.",
+ "flow-wikitext-editor-help": "Wikitekst $1."
+}
diff --git a/Flow/i18n/ps.json b/Flow/i18n/ps.json
new file mode 100644
index 00000000..c449ed53
--- /dev/null
+++ b/Flow/i18n/ps.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ahmed-Najib-Biabani-Ibrahimkhel"
+ ]
+ },
+ "flow-hide-title-content": "{{GENDER:$1|پټ شو}} د $1 لخوا",
+ "flow-delete-post-content": "دا تبصره د $1 لخوا {{GENDER:$1|ړنگه شوې}} ([$2 پېښليک])",
+ "flow-delete-title-content": "{{GENDER:$1|ړنگ شو}} د $1 لخوا",
+ "flow-delete-header-content": "{{GENDER:$1|ړنگ شو}} د $2 لخوا",
+ "flow-post-edited": "ليکنه د $1 لخوا په $2 {{GENDER:$1|سمه شوه}}",
+ "flow-notification-edit-email-subject": "$1 ستاسې ليکنه {{GENDER:$1|سمه کړه}}",
+ "flow-notification-rename-email-subject": "$1 ستاسې سرليک {{GENDER:$1|نوم بدل کړ}}",
+ "apihelp-flow+new-topic-param-topic": "د نوې موضوع د سرليک متن.",
+ "flow-previous-diff": "→ زوړ سمون",
+ "flow-next-diff": "نوی سمون ←",
+ "flow-undo": "ناکړل",
+ "flow-undo-your-text": "ستاسې متن",
+ "flow-ve-mention-inspector-remove-label": "غورځول"
+}
diff --git a/Flow/i18n/pt-br.json b/Flow/i18n/pt-br.json
new file mode 100644
index 00000000..5a8a4af4
--- /dev/null
+++ b/Flow/i18n/pt-br.json
@@ -0,0 +1,188 @@
+{
+ "@metadata": {
+ "authors": [
+ "Helder.wiki",
+ "Tuliouel",
+ "Rodrigo codignoli",
+ "!Silent",
+ "Dianakc",
+ "OTAVIO1981",
+ "Teles",
+ "Jefersonmoraes",
+ "Ptrke",
+ "Diego Queiroz"
+ ]
+ },
+ "flow-desc": "Sistema de gerenciamento do fluxo de trabalho",
+ "flow-talk-taken-over": "Esta página de discussão usa o [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestor de páginas de discussão do Flow",
+ "log-name-flow": "Registro de atividade do Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|eliminou}} uma [$4 mensagem] em [[$3]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restaurou}} uma [$4 mensagem] em [[$3]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|suprimiu}} uma [$4 mensagem] em [[$3]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|eliminou}} uma [$4 mensagem] em [[$3]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|eliminou}} um [$4 tópico] em [[$3]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|restaurou}} um [$4 tópico] em [[$3]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|suprimiu}} um [$4 tópico] em [[$3]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|eliminou}} um [$4 tópico] em [[$3]]",
+ "flow-user-moderated": "Usuário moderado",
+ "flow-edit-header-link": "Editar cabeçalho",
+ "flow-post-moderated-toggle-hide-show": "Mostrar comentário {{GENDER:$1|ocultado}} por $2",
+ "flow-post-moderated-toggle-delete-show": "Mostrar comentário {{GENDER:$1|excluído}} por $2",
+ "flow-post-moderated-toggle-suppress-show": "Mostrar comentário {{GENDER:$1|suprimido}} por $2",
+ "flow-post-moderated-toggle-hide-hide": "Ocultar comentário {{GENDER:$1|ocultado}} por $2",
+ "flow-post-moderated-toggle-delete-hide": "Ocultar comentário {{GENDER:$1|excluído}} por $2",
+ "flow-post-moderated-toggle-suppress-hide": "Ocultar comentário {{GENDER:$1|suprimido}} por $2",
+ "flow-topic-moderated-reason-prefix": "Razão:",
+ "flow-hide-post-content": "Este comentário foi {{GENDER:$1|ocultado}} por $1",
+ "flow-hide-title-content": "Este tópico foi {{GENDER:$1|ocultado}} por $1",
+ "flow-lock-title-content": "Este tópico foi {{GENDER:$1|trancado}} por $1",
+ "flow-hide-header-content": "{{GENDER:$1|Ocultado}} por $2",
+ "flow-delete-post-content": "Este comentário foi {{GENDER:$1|excluído}} por $1",
+ "flow-delete-title-content": "Este tópico foi {{GENDER:$1|excluído}} por $1",
+ "flow-delete-header-content": "{{GENDER:$1|Excluído}} por $2",
+ "flow-suppress-post-content": "Este comentário foi {{GENDER:$1|suprimido}} por $1",
+ "flow-suppress-title-content": "Este comentário foi {{GENDER:$1|suprimido}} por $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimido}} por $2",
+ "flow-suppress-usertext": "<em>Nome de usuário suprimido</em>",
+ "flow-post-actions": "Ações",
+ "flow-topic-actions": "Ações",
+ "flow-cancel": "Cancelar",
+ "flow-preview": "Pré-visualizar",
+ "flow-show-change": "Mostrar alterações",
+ "flow-last-modified-by": "{{GENDER:$1|Modificado}} pela última vez por $1",
+ "flow-stub-post-content": "\"Devido a um erro técnico, esta mensagem não pode ser recuperada.\"",
+ "flow-newtopic-title-placeholder": "Novo tópico",
+ "flow-newtopic-content-placeholder": "Publicar nova mensagem para \"$1\"",
+ "flow-newtopic-header": "Adicionar um novo tópico",
+ "flow-newtopic-save": "Adicionar tópico",
+ "flow-newtopic-start-placeholder": "Comece um novo tópico",
+ "flow-newtopic-first-heading": "Iniciar um novo tópico em $1",
+ "flow-summarize-topic-placeholder": "Por favor, resuma esta discussão",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentou}} em \"$2\"",
+ "flow-reply-topic-title-placeholder": "Responder a \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Agradecer}}",
+ "flow-lock-link": "{{GENDER:$1|Trancar}}",
+ "flow-history-action-suppress-post": "suprimir",
+ "flow-history-action-delete-post": "apagar",
+ "flow-history-action-hide-post": "ocultar",
+ "flow-history-action-unsuppress-post": "Cancelar supressão",
+ "flow-history-action-undelete-post": "Cancelar eliminação",
+ "flow-history-action-unhide-post": "mostrar",
+ "flow-history-action-restore-post": "restaurar",
+ "flow-history-action-lock-topic": "trancar",
+ "flow-history-action-unlock-topic": "destrancar",
+ "flow-post-edited": "Publicação {{GENDER:$1|editada}} por $1 $2",
+ "flow-post-action-view": "Link permanente",
+ "flow-post-action-post-history": "Histórico",
+ "flow-post-action-suppress-post": "Suprimir",
+ "flow-post-action-delete-post": "Excluir",
+ "flow-post-action-hide-post": "Ocultar",
+ "flow-post-action-edit-post": "Editar",
+ "flow-post-action-edit-post-submit": "Salvar alterações",
+ "flow-post-action-unsuppress-post": "Cancelar supressão",
+ "flow-post-action-undelete-post": "Restaurar",
+ "flow-post-action-unhide-post": "Mostrar",
+ "flow-post-action-restore-post": "Restaurar",
+ "flow-post-action-undo-moderation": "Desfazer",
+ "flow-topic-action-view": "Link permanente",
+ "flow-topic-action-watchlist": "Páginas vigiadas",
+ "flow-topic-action-edit-title": "Editar título",
+ "flow-topic-action-history": "Histórico",
+ "flow-topic-action-hide-topic": "Ocultar tópico",
+ "flow-topic-action-delete-topic": "Excluir tópico",
+ "flow-topic-action-lock-topic": "Trancar tópico",
+ "flow-topic-action-unlock-topic": "Destrancar tópico",
+ "flow-topic-action-summarize-topic": "Resumir",
+ "flow-topic-action-resummarize-topic": "Editar resumo",
+ "flow-topic-action-suppress-topic": "Suprimir tópico",
+ "flow-topic-action-unhide-topic": "Mostrar tópico",
+ "flow-topic-action-undelete-topic": "Restaurar tópico",
+ "flow-topic-action-unsuppress-topic": "Cancelar supressão do tópico",
+ "flow-topic-action-restore-topic": "Restaurar tópico",
+ "flow-topic-action-undo-moderation": "Desfazer",
+ "flow-topic-notification-subscribe-title": "Este tópico foi adicionado à {{GENDER:$1|sua}} lista de vigiados.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Você}} receberá notificações sobre todas as atividades neste tópico.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Você}} está inscrito neste quadro de discussão!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Você}} receberá uma notificação quando um novo tópico for criado nessa discussão.",
+ "flow-error-http": "Ocorreu um erro ao contactar o servidor.",
+ "flow-error-other": "Ocorreu um erro inesperado.",
+ "flow-error-external": "Ocorreu um erro.<br />A mensagem de erro recebida foi: $1",
+ "flow-error-edit-restricted": "Você não tem permissão para editar esta publicação.",
+ "flow-error-topic-is-locked": "Este tópico está bloqueado para quaisquer outras atividades.",
+ "flow-error-lock-moderated-post": "Você não pode trancar uma publicação moderada.",
+ "flow-error-external-multi": "Foram encontrados erros.<br /> $1",
+ "flow-error-missing-content": "A publicação não tem conteúdo. É necessário haver conteúdo para salvar uma publicação.",
+ "flow-error-missing-summary": "O resumo não tem conteúdo. É necessário haver conteúdo para salvar um resumo.",
+ "flow-error-missing-title": "O tópico não tem título. É necessário definir um título para salvar um tópico.",
+ "flow-error-parsoid-failure": "Não foi possível analisar o conteúdo devido a uma falha do Parsoid.",
+ "flow-error-missing-replyto": "Não foi fornecido nenhum parâmetro \"responderA\". Este parâmetro é necessário para a ação de \"resposta\".",
+ "flow-error-invalid-replyto": "Parâmetro \"responderA\" inválido. A mensagem especificada não foi encontrada.",
+ "flow-error-delete-failure": "A exclusão deste item falhou.",
+ "flow-error-hide-failure": "Falha ao ocultar este item.",
+ "flow-error-missing-postId": "Não foi fornecido nenhum parâmetro \"postId\". Este parâmetro é necessário para manipular uma mensagem.",
+ "flow-error-restore-failure": "A restauração deste item falhou.",
+ "flow-error-invalid-moderation-state": "Foi fornecido um valor inválido para moderationState.",
+ "flow-error-invalid-moderation-reason": "Por favor, informe uma razão para a moderação.",
+ "flow-error-not-allowed": "Permissões insuficientes para executar esta ação.",
+ "flow-error-not-allowed-hide": "Este tópico foi oculto.",
+ "flow-error-not-allowed-delete": "Este tópico foi eliminado.",
+ "flow-error-not-allowed-suppress": "Este tópico foi suprimido.",
+ "flow-error-not-a-post": "O título do tópico não pode ser salvo como uma publicação.",
+ "flow-error-missing-header-content": "O cabeçalho não tem conteúdo. É necessário haver conteúdo para salvar um cabeçalho.",
+ "flow-error-missing-prev-revision-identifier": "O identificador da revisão anterior está em falta.",
+ "flow-error-prev-revision-mismatch": "Outro usuário editou esta mensagem há poucos segundos. {{GENDER:$3|Você}} tem certeza de que pretende substituir esta alteração recente?",
+ "flow-error-prev-revision-does-not-exist": "Não foi possível encontrar a revisão anterior.",
+ "flow-error-default": "Ocorreu um erro.",
+ "flow-error-fail-load-data": "Falha ao carregar os dados solicitados.",
+ "flow-error-no-index": "Falha ao localizar um índice para executar a busca de dados.",
+ "flow-error-no-render": "A ação especificada não foi reconhecida.",
+ "flow-error-no-commit": "A ação especificada não pode ser salva.",
+ "flow-edit-header-submit": "Salvar cabeçalho",
+ "flow-summarize-topic-submit": "Resumir",
+ "flow-summarize-topic-submit-overwrite": "Sobrescrever resumo",
+ "flow-lock-topic-submit": "Trancar tópico",
+ "flow-unlock-topic-submit": "Destrancar tópico",
+ "flow-edit-title-submit": "Mudar título",
+ "flow-edit-title-submit-overwrite": "Sobrescrever título",
+ "flow-edit-post-submit": "Enviar alterações",
+ "flow-edit-post-submit-overwrite": "Sobrescrever alterações",
+ "flow-rc-topic-of-board": "$1 em $2",
+ "flow-board-history": "Histórico de \"$1\"",
+ "flow-history-last4": "Últimas 4 horas",
+ "flow-history-day": "Hoje",
+ "flow-history-week": "Semana passada",
+ "flow-comment-restored": "Comentário recuperado",
+ "flow-comment-deleted": "Comentário excluído",
+ "flow-comment-hidden": "Comentário oculto",
+ "flow-comment-moderated": "Comentário moderado",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 \"$2\"]</span><br />$1 {{GENDER:$1|respondeu}} em \"$4\".",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 \"$2\"]</span><br />$1 e $5 {{PLURAL:$6|outro|outros}} {{GENDER:$1|responderam}} em \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|criou}} um novo tópico em '''$3'''.",
+ "flow-notification-link-text-view-post": "Ver publicação",
+ "flow-notification-link-text-view-topic": "Ver tópico",
+ "flow-notification-reply-email-subject": "$2 em $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Notifique-me quando ações relacionadas a mim ocorrer no Flow.",
+ "flow-link-post": "publicar",
+ "flow-link-topic": "tópico",
+ "flow-link-history": "histórico",
+ "flow-moderation-title-delete-post": "Excluir publicação?",
+ "flow-moderation-title-hide-post": "Esconder mensagem?",
+ "flow-moderation-title-unsuppress-post": "Retirar supressão da mensagem?",
+ "flow-moderation-title-undelete-post": "Restaurar mensagem?",
+ "flow-moderation-title-unhide-post": "Mostrar publicação?",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Eliminar",
+ "flow-moderation-confirm-hide-post": "Ocultar",
+ "flow-moderation-confirm-delete-topic": "Excluir",
+ "flow-moderation-confirm-hide-topic": "Ocultar",
+ "flow-topic-html-title": "$1 em $2",
+ "flow-add-topic": "Adicionar tópico",
+ "flow-special-type": "Tipo",
+ "flow-special-type-post": "Publicação",
+ "flow-special-type-workflow": "Fluxo de Trabalho",
+ "flow-preview-warning": "Você está vendo uma pré-visualização. Submeta o formulário para concluir seu envio, ou clique \"{{int:flow-preview-return-edit-post}} para continuar escrevendo."
+}
diff --git a/Flow/i18n/pt.json b/Flow/i18n/pt.json
new file mode 100644
index 00000000..17ac4374
--- /dev/null
+++ b/Flow/i18n/pt.json
@@ -0,0 +1,484 @@
+{
+ "@metadata": {
+ "authors": [
+ "Helder.wiki",
+ "Imperadeiro98",
+ "SandroHc",
+ "Vitorvicentevalente",
+ "Waldir",
+ "Fúlvio",
+ "Ti4goc",
+ "Diego Queiroz",
+ "Lijealso",
+ "He7d3r",
+ "Imperadeiro90",
+ "Opraco"
+ ]
+ },
+ "enableflow": "Ativar Flow",
+ "flow-desc": "Sistema de gestão do fluxo de trabalho",
+ "flow-talk-taken-over": "Esta página de discussão usa o [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Gestor de páginas de discussão do Flow",
+ "log-name-flow": "Registo de actividade do Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|eliminou}} uma [$4 mensagem] em \"[[$3|$5]]\" no [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restaurou}} uma [$4 mensagem] em \"[[$3|$5]]\" no [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|ocultou}} uma [$4 mensagem] em \"[[$3|$5]]\" no [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|eliminou}} uma [$4 mensagem] de \"[[$3|$5]]\" em [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|eliminou}} o tópico \"[[$3|$5]]\" em [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|restaurou}} o tópico \"[[$3|$5]]\" em [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|ocultou}} o tópico \"[[$3|$5]]\" em [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|eliminou}} o tópico \"[[$3|$5]]\" em [[$6]]",
+ "flow-user-moderated": "Utilizador em moderação",
+ "flow-board-header-browse-topics-link": "Pesquisar tópicos",
+ "flow-edit-header-link": "Editar cabeçalho",
+ "flow-post-moderated-toggle-hide-show": "Mostrar comentário {{GENDER:$1|ocultado}} por $2",
+ "flow-post-moderated-toggle-delete-show": "Mostrar comentário {{GENDER:$1|eliminado}} por $2",
+ "flow-post-moderated-toggle-suppress-show": "Mostrar comentário {{GENDER:$1|suprimido}} por $2",
+ "flow-post-moderated-toggle-hide-hide": "Ocultar comentário {{GENDER:$1|ocultado}} por $2",
+ "flow-post-moderated-toggle-delete-hide": "Ocultar comentário {{GENDER:$1|eliminado}} por $2",
+ "flow-post-moderated-toggle-suppress-hide": "Ocultar comentário {{GENDER:$1|suprimido}} por $2",
+ "flow-topic-moderated-reason-prefix": "Motivo:",
+ "flow-hide-post-content": "Este comentário foi {{GENDER:$1|ocultado}} por $1 ([$2 histórico])",
+ "flow-hide-title-content": "Este tópico foi {{GENDER:$1|ocultado}} por $1",
+ "flow-lock-title-content": "Este tópico foi {{GENDER:$1|bloqueado}} por $1",
+ "flow-hide-header-content": "{{GENDER:$1|Ocultado}} por $2",
+ "flow-delete-post-content": "Este comentário foi {{GENDER:$1|eliminado}} por $1 ([$2 histórico])",
+ "flow-delete-title-content": "Este tópico foi {{GENDER:$1|eliminado}} por $1",
+ "flow-delete-header-content": "{{GENDER:$1|Eliminado}} por $2",
+ "flow-suppress-post-content": "Este comentário foi {{GENDER:$1|suprimido}} por $1 ([$2 histórico])",
+ "flow-suppress-title-content": "Este tópico foi {{GENDER:$1|suprimido}} por $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Suprimido}} por $2",
+ "flow-suppress-usertext": "<em>Utilizador suprimido</em>",
+ "flow-post-actions": "Acções",
+ "flow-topic-actions": "Acções",
+ "flow-cancel": "Cancelar",
+ "flow-preview": "Antevisão",
+ "flow-show-change": "Mostrar alterações",
+ "flow-last-modified-by": "Última {{GENDER:$1|modificação}} por $1",
+ "flow-stub-post-content": "\"Devido a um erro técnico, esta mensagem não pode ser recuperada.\"",
+ "flow-newtopic-title-placeholder": "Novo tópico",
+ "flow-newtopic-content-placeholder": "Publicar nova mensagem em \"$1\"",
+ "flow-newtopic-header": "Adicionar um novo tópico",
+ "flow-newtopic-save": "Adicionar tópico",
+ "flow-newtopic-start-placeholder": "Começar um novo tópico",
+ "flow-newtopic-first-heading": "Iniciar um novo tópico em $1",
+ "flow-summarize-topic-placeholder": "Por favor, resuma esta discussão",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Comentário}} em \"$2\"",
+ "flow-reply-topic-title-placeholder": "Resposta a \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Responder}}",
+ "flow-reply-link": "{{GENDER:$1|Responder}}",
+ "flow-thank-link": "{{GENDER:$1|Agradecer}}",
+ "flow-lock-link": "{{GENDER:$1|Bloquear}}",
+ "flow-thank-link-title": "Agradecer publicamente a mensagem",
+ "flow-history-action-suppress-post": "suprimir",
+ "flow-history-action-delete-post": "eliminar",
+ "flow-history-action-hide-post": "ocultar",
+ "flow-history-action-unsuppress-post": "retirar supressão",
+ "flow-history-action-undelete-post": "restaurar",
+ "flow-history-action-unhide-post": "mostrar",
+ "flow-history-action-restore-post": "restaurar",
+ "flow-history-action-lock-topic": "bloquear",
+ "flow-history-action-unlock-topic": "desbloquear",
+ "flow-post-edited": "Mensagem {{GENDER:$1|editada}} por $1, $2",
+ "flow-post-action-view": "Ligação permanente",
+ "flow-post-action-post-history": "Histórico",
+ "flow-post-action-suppress-post": "Suprimir",
+ "flow-post-action-delete-post": "Eliminar",
+ "flow-post-action-hide-post": "Ocultar",
+ "flow-post-action-edit-post": "Editar",
+ "flow-post-action-edit-post-submit": "Gravar alterações",
+ "flow-post-action-unsuppress-post": "Retirar supressão",
+ "flow-post-action-undelete-post": "Restaurar",
+ "flow-post-action-unhide-post": "Mostrar",
+ "flow-post-action-restore-post": "Restaurar",
+ "flow-post-action-undo-moderation": "Desfazer",
+ "flow-topic-action-view": "Ligação permanente",
+ "flow-topic-action-watchlist": "Páginas vigiadas",
+ "flow-topic-action-edit-title": "Editar título",
+ "flow-topic-action-history": "Histórico",
+ "flow-topic-action-hide-topic": "Ocultar tópico",
+ "flow-topic-action-delete-topic": "Eliminar tópico",
+ "flow-topic-action-lock-topic": "Bloquear tópico",
+ "flow-topic-action-unlock-topic": "Desbloquear tópico",
+ "flow-topic-action-summarize-topic": "Resumir",
+ "flow-topic-action-resummarize-topic": "Editar resumo do tópico",
+ "flow-topic-action-suppress-topic": "Suprimir tópico",
+ "flow-topic-action-unhide-topic": "Exibir tópico",
+ "flow-topic-action-undelete-topic": "Restaurar tópico",
+ "flow-topic-action-unsuppress-topic": "Tópico não suprimido",
+ "flow-topic-action-restore-topic": "Restaurar tópico",
+ "flow-topic-action-undo-moderation": "Desfazer",
+ "flow-topic-notification-subscribe-title": "Este tópico foi adicionado à {{GENDER:$1|sua}} lista de vigiados.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Irá}} receber notificações sobre todas as atividades neste tópico.",
+ "flow-board-notification-subscribe-title": "Está {{GENDER:$1|inscrito|inscrita|inscrito(a)}} neste espaço de discussão!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Receberá}} uma notificação quando um novo tópico for adicionado.",
+ "flow-error-http": "Ocorreu um erro ao contactar o servidor.",
+ "flow-error-other": "Ocorreu um erro inesperado.",
+ "flow-error-external": "Ocorreu um erro.<br />A mensagem de erro recebida é a seguinte: $1",
+ "flow-error-edit-restricted": "Não tem permissão para editar esta mensagem.",
+ "flow-error-topic-is-locked": "Este tópico está bloqueado para quaisquer outras actividades.",
+ "flow-error-lock-moderated-post": "Não pode bloquear uma mensagem em moderação.",
+ "flow-error-external-multi": "Foram encontrados erros.<br /> $1",
+ "flow-error-missing-content": "A mensagem não tem conteúdo. É necessário haver conteúdo para gravar uma mensagem.",
+ "flow-error-missing-summary": "O resumo não tem conteúdo. É necessário haver conteúdo para gravar um resumo.",
+ "flow-error-missing-title": "O tópico não tem título. É necessário definir um título para gravar um tópico.",
+ "flow-error-parsoid-failure": "Não foi possível analisar o conteúdo devido a uma falha do Parsoid.",
+ "flow-error-missing-replyto": "Não foi fornecido nenhum parâmetro \"responderA\". Este parâmetro é necessário para a acção de \"resposta\".",
+ "flow-error-invalid-replyto": "Parâmetro \"responderA\" inválido. A mensagem especificada não pôde ser encontrada.",
+ "flow-error-delete-failure": "A eliminação deste item falhou.",
+ "flow-error-hide-failure": "Falha ao ocultar este item.",
+ "flow-error-missing-postId": "Não foi fornecido nenhum parâmetro \"IDpost\". Este parâmetro é necessário para a manutenção da mensagem.",
+ "flow-error-invalid-postId": "Parâmetro \"postID\" inválido. A mensagem especificada ($1) não pôde ser encontrada.",
+ "flow-error-restore-failure": "A restauração deste item falhou.",
+ "flow-error-invalid-moderation-state": "Um valor inválido para o parâmetro ('moderationState') foi fornecido ao API do Flow.",
+ "flow-error-invalid-moderation-reason": "Por favor, indique um motivo para a moderação.",
+ "flow-error-not-allowed": "Permissões insuficientes para executar esta acção.",
+ "flow-error-not-allowed-hide": "Este tópico foi ocultado.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Não pode responder porque o tópico foi ocultado.",
+ "flow-error-not-allowed-delete": "Este tópico foi eliminado.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Não pode responder porque o tópico foi eliminado.",
+ "flow-error-not-allowed-suppress": "Este tópico foi eliminado.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Não pode responder porque o tópico foi eliminado.",
+ "flow-error-not-allowed-hide-extract": "Este tópico foi ocultado. O registo para o tópico é fornecido abaixo para referência.",
+ "flow-error-not-allowed-delete-extract": "Este tópico foi eliminado. O registo de eliminação para o tópico é fornecido abaixo para referência.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Não pode responder porque este tópico foi eliminado. O registo de eliminação para o tópico é fornecido abaixo para referência.",
+ "flow-error-not-allowed-suppress-extract": "Este tópico foi eliminado. O registo de eliminação para o tópico é fornecido abaixo para referência.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Não pode responder porque este tópico foi suprimido. O registo de supressão para o tópico é fornecido abaixo para referência.",
+ "flow-error-title-too-long": "Os títulos dos tópicos estão restritos a {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-no-existing-workflow": "Este fluxo de trabalho não existe ainda.",
+ "flow-error-not-a-post": "O título do tópico não pode ser gravado como uma mensagem.",
+ "flow-error-missing-header-content": "O cabeçalho não tem conteúdo. É necessário haver conteúdo para gravar um cabeçalho.",
+ "flow-error-missing-prev-revision-identifier": "O identificador de revisão anterior está em falta.",
+ "flow-error-prev-revision-mismatch": "Outro utilizador editou esta mensagem há alguns segundos. {{GENDER:$3|Tem}} a certeza que pretende substituir esta alteração recente?",
+ "flow-error-prev-revision-does-not-exist": "Não foi possível encontrar a revisão anterior.",
+ "flow-error-default": "Ocorreu um erro.",
+ "flow-error-invalid-input": "Foi fornecido um valor inválido para carregar o conteúdo do fluxo.",
+ "flow-error-invalid-title": "Foi inserido um título de página inválido.",
+ "flow-error-fail-load-history": "Falha ao carregar o conteúdo do histórico.",
+ "flow-error-missing-revision": "Não foi possível encontrar uma revisão para carregar o conteúdo do fluxo.",
+ "flow-error-fail-commit": "Falha ao gravar o conteúdo do fluxo.",
+ "flow-error-insufficient-permission": "Permissões insuficientes para aceder ao conteúdo.",
+ "flow-error-revision-comparison": "A operação só pode ser efectuada com duas revisões pertencentes à mesma mensagem.",
+ "flow-error-missing-topic-title": "Não foi possível encontrar o título do tópico no fluxo de trabalho actual.",
+ "flow-error-missing-metadata": "Não foi possível encontrar os metadados necessários para esta edição.",
+ "flow-error-fail-load-data": "Falha ao carregar os dados solicitados.",
+ "flow-error-invalid-workflow": "Não foi possível encontrar o fluxo de trabalho solicitado.",
+ "flow-error-process-data": "Ocorreu um erro ao processar os dados que foram pedidos por si.",
+ "flow-error-process-wikitext": "Ocorreu um erro durante o processamento de conversão HTML/wikitexto.",
+ "flow-error-no-index": "Falha ao localizar um índice para executar a pesquisa de dados.",
+ "flow-error-no-render": "A ação especificada não foi reconhecida.",
+ "flow-error-no-commit": "Não foi possível gravar a ação especificada.",
+ "flow-error-fetch-after-lock": "Foi encontrado um erro ao solicitar novos dados. Ainda assim, a operação de bloquear/desbloquear foi bem sucedida. A mensagem de erro foi: $1",
+ "flow-error-content-too-long": "O conteúdo é muito grande. Após a expansão, o conteúdo é limitado a $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-move": "Mover um espaço de discussão não é permitido atualmente.",
+ "flow-error-invalid-topic-uuid-title": "Título inválido",
+ "flow-error-invalid-topic-uuid": "O título da página fornecida é inválido. Páginas no domínio Tópico são criadas automaticamente pelo Flow.",
+ "flow-error-unknown-workflow-id-title": "Tópico desconhecido",
+ "flow-error-unknown-workflow-id": "O tópico solicitado não existe.",
+ "flow-edit-header-placeholder": "Descreva este espaço de discussão",
+ "flow-edit-header-submit": "Gravar cabeçalho",
+ "flow-edit-header-submit-overwrite": "Reescrever cabeçalho",
+ "flow-summarize-topic-submit": "Resumir",
+ "flow-summarize-topic-submit-overwrite": "Reescrever resumo",
+ "flow-lock-topic-submit": "Bloquear tópico",
+ "flow-lock-topic-submit-overwrite": "Reescrever sumário de bloqueio do tópico",
+ "flow-unlock-topic-submit": "Desbloquear tópico",
+ "flow-unlock-topic-submit-overwrite": "Reescrever sumário de desbloqueio do tópico",
+ "flow-edit-title-submit": "Alterar título",
+ "flow-edit-title-submit-overwrite": "Reescrever título",
+ "flow-edit-post-submit": "Enviar alterações",
+ "flow-edit-post-submit-overwrite": "Reescrever alterações",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|editou}} um [$3 comentário] em \"$4\".",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Editou}} uma mensagem",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|comentou}}] em \"$4\" (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|comentário|comentários}}</strong> {{PLURAL:$1|foi adicionado|foram adicionados}}",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|criou}} o tópico \"[$3 $4]\"",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Criou}} um novo tópico",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|alterou}} o título do tópico de \"$5\" para \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|criou}} o cabeçalho.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|editou}} o cabeçalho.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|criou}} um resumo de tópico em $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|editou}} o resumo do tópico em $3",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|ocultou}} um [$4 comentário] em \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|eliminou}} um [$4 comentário] em \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suprimiu}} um [$4 comentário] em \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restaurou}} um [$4 comentário] em \"$6\" (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|ocultou}} o [$4 tópico] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|eliminou}} o [$4 tópico] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|suprimiu}} o [$4 tópico] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|bloqueou}} o [$4 tópico] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|restaurou}} o [$4 tópico] \"$6\" (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 em $2",
+ "flow-board-history": "Histórico de \"$1\"",
+ "flow-board-history-empty": "Atualmente, este espaço não tem histórico.",
+ "flow-topic-history": "Histórico do tópico \"$1\"",
+ "flow-post-history": "Histórico de \"Comentário por {{GENDER:$2|$2}}\"",
+ "flow-history-last4": "Últimas 4 horas",
+ "flow-history-day": "Hoje",
+ "flow-history-week": "Última semana",
+ "flow-history-pages-topic": "Aparece no [$1 espaço \"$2\"]",
+ "flow-history-pages-post": "Aparece em [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 comentário|$1 comentários|0=Seja {{GENDER:$2|o primeiro|a primeira}} a comentar!}}",
+ "flow-comment-restored": "Comentário restaurado",
+ "flow-comment-deleted": "Comentário eliminado",
+ "flow-comment-hidden": "Comentário ocultado",
+ "flow-comment-moderated": "Comentário moderado",
+ "flow-last-modified": "Modificado pela última vez em $1",
+ "flow-workflow": "fluxo de trabalho",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 \"$2\"]</span><br />$1 {{GENDER:$1|respondeu}} em \"$4\".",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 \"$2\"]</span><br />$1 e $5 {{PLURAL:$6|outro|outros}} {{GENDER:$1|responderam}} em \"$3\".",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|editou}} a sua <span class=\"plainlinks\">[$5 mensagem]</span> em [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 e $5 {{PLURAL:$6|outro|outros}} {{GENDER:$1|editaram}} uma <span class=\"plainlinks\">[$4 mensagem]</span> em \"$2\", em \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|criou}} um novo tópico em '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=+250}} {{PLURAL:$1|novo tópico|novos tópicos}} em '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|modificou}} o título de <span class=\"plainlinks\">[$2 $3]</span> para \"$4\" em [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$5|mencionou-o|mencionou-a}} {{GENDER:$1|na sua}} <span class=\"plainlinks\">[$2 mensagem]</span> em \"$3\" de \"$4\".",
+ "flow-notification-link-text-view-post": "Ver mensagem",
+ "flow-notification-link-text-view-topic": "Ver tópico",
+ "flow-notification-reply-email-subject": "$2 em $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondeu}} a \"$2\" em \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 e $4 {{PLURAL:$5|outro|outros}} {{GENDER:$1|responderam}} à sua mensagem a \"$2\" em \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|mencionou-{{GENDER:$3|o|a|o(a)}}}} em \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$4|mencionou-o|mencionou-a}} {{GENDER:$1|na sua}} mensagem em \"$2\" de \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|editou}} uma mensagem",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|editou}} uma mensagem em \"$2\" de \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 e $4 {{PLURAL:$5|outro|outros}} {{GENDER:$1|editaram}} uma mensagem em \"$2\" de \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|renomeou}} o seu tópico",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|renomeou}} o seu tópico \"$2\" para \"$3\" em \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|criuo}} um novo tópico em \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|criou}} um novo tópico com o título \"$2\" em $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Notificar-me quando ocorrerem acções relacionadas com o Flow.",
+ "flow-link-post": "mensagem",
+ "flow-link-topic": "tópico",
+ "flow-link-history": "histórico",
+ "flow-link-post-revision": "revisão de mensagem",
+ "flow-link-topic-revision": "revisão de tópico",
+ "flow-link-header-revision": "revisão de cabeçalho",
+ "flow-link-summary-revision": "resumo da revisão",
+ "flow-moderation-title-suppress-post": "Suprimir mensagem?",
+ "flow-moderation-title-delete-post": "Eliminar mensagem?",
+ "flow-moderation-title-hide-post": "Ocultar mensagem?",
+ "flow-moderation-title-unsuppress-post": "Retirar supressão da mensagem?",
+ "flow-moderation-title-undelete-post": "Restaurar mensagem?",
+ "flow-moderation-title-unhide-post": "Voltar a exibir mensagem?",
+ "flow-moderation-placeholder-suppress-post": "Por favor, {{GENDER:$3|indique}} a razão para a supressão desta mensagem.",
+ "flow-moderation-placeholder-delete-post": "Por favor, {{GENDER:$3|indique}} a razão para a eliminação desta mensagem.",
+ "flow-moderation-placeholder-hide-post": "Por favor, {{GENDER:$3|indique}} a razão para ocultar esta mensagem.",
+ "flow-moderation-placeholder-unsuppress-post": "Por favor, {{GENDER:$3|indique}} a razão para voltar a exibir esta mensagem.",
+ "flow-moderation-placeholder-undelete-post": "Por favor, {{GENDER:$3|indique}} a razão a restauração deste tópico.",
+ "flow-moderation-placeholder-unhide-post": "Por favor, {{GENDER:$3|indique}} a razão para voltar a exibir esta mensagem.",
+ "flow-moderation-confirm-suppress-post": "Suprimir",
+ "flow-moderation-confirm-delete-post": "Eliminar",
+ "flow-moderation-confirm-hide-post": "Ocultar",
+ "flow-moderation-confirm-unsuppress-post": "Retirar supressão",
+ "flow-moderation-confirm-undelete-post": "Restaurar",
+ "flow-moderation-confirm-unhide-post": "Mostrar",
+ "flow-moderation-confirm-suppress-topic": "Suprimir",
+ "flow-moderation-confirm-delete-topic": "Eliminar",
+ "flow-moderation-confirm-hide-topic": "Ocultar",
+ "flow-moderation-confirm-lock-topic": "Bloquear",
+ "flow-moderation-confirm-unsuppress-topic": "Retirar supressão",
+ "flow-moderation-confirm-undelete-topic": "Restaurar",
+ "flow-moderation-confirm-unhide-topic": "Mostrar",
+ "flow-moderation-confirm-unlock-topic": "Desbloquear",
+ "flow-moderation-confirmation-suppress-post": "A mensagem foi suprimida com sucesso. {{GENDER:$2|Considere}} comentar esta acção com $1.",
+ "flow-moderation-confirmation-delete-post": "A mensagem foi eliminada com sucesso. {{GENDER:$2|Considere}} comentar esta acção com $1.",
+ "flow-moderation-confirmation-hide-post": "A mensagem foi oculta com sucesso. {{GENDER:$2|Considere}} comentar esta acção com $1.",
+ "flow-moderation-confirmation-unsuppress-post": "Retirou a supressão da mensagem acima com sucesso.",
+ "flow-moderation-confirmation-undelete-post": "A mensagem acima foi restaurada com sucesso.",
+ "flow-moderation-confirmation-unhide-post": "A mensagem acima está a ser exibida novamente.",
+ "flow-moderation-confirmation-suppress-topic": "Este tópico foi suprimido.",
+ "flow-moderation-confirmation-delete-topic": "Este tópico foi eliminado.",
+ "flow-moderation-confirmation-hide-topic": "Este tópico foi ocultado.",
+ "flow-moderation-confirmation-unsuppress-topic": "Retirou a supressão deste tópico com sucesso.",
+ "flow-moderation-confirmation-undelete-topic": "O tópico foi restaurado com sucesso.",
+ "flow-moderation-confirmation-unhide-topic": "O tópico acima está a ser exibido novamente.",
+ "flow-moderation-title-suppress-topic": "Suprimir tópico?",
+ "flow-moderation-title-delete-topic": "Eliminar tópico?",
+ "flow-moderation-title-hide-topic": "Ocultar tópico?",
+ "flow-moderation-title-unsuppress-topic": "Retirar supressão do tópico?",
+ "flow-moderation-title-undelete-topic": "Restaurar tópico?",
+ "flow-moderation-title-unhide-topic": "Exibir novamente o tópico?",
+ "flow-moderation-placeholder-suppress-topic": "Por favor, {{GENDER:$3|indique}} o motivo para a supressão deste tópico.",
+ "flow-moderation-placeholder-delete-topic": "Por favor, {{GENDER:$3|indique}} o motivo para a eliminação deste tópico.",
+ "flow-moderation-placeholder-hide-topic": "Por favor, {{GENDER:$3|indique}} o motivo para ocultar este tópico.",
+ "flow-moderation-placeholder-lock-topic": "Por favor, {{GENDER:$3|explique}} o motivo para o bloqueio deste tópico.",
+ "flow-moderation-placeholder-unsuppress-topic": "Por favor, {{GENDER:$3|indique}} o motivo para retirar a supressão deste tópico.",
+ "flow-moderation-placeholder-undelete-topic": "Por favor, {{GENDER:$3|indique}} o motivo para restaurar este tópico.",
+ "flow-moderation-placeholder-unhide-topic": "Por favor, {{GENDER:$3|indique}} o motivo para exibir de novo este tópico.",
+ "flow-moderation-placeholder-unlock-topic": "Por favor, {{GENDER:$3|explique}} o motivo para o desbloqueio deste tópico.",
+ "flow-topic-permalink-warning": "Este tópico foi iniciado em [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Este tópico foi iniciado no [$2 espaço de {{GENDER:$1|$1}}]",
+ "flow-revision-permalink-warning-post": "Esta é uma ligação permanente para a versão simples desta mensagem. \nEsta versão é de $1. \nPode ver as [$5 diferenças das outras versões], ou ver outras versões na [$4 página do histórico da mensagem].",
+ "flow-revision-permalink-warning-post-first": "Esta é uma ligação permanente para a primeira versão desta mensagem. \nPode ver outras versões na [$4 página de histórico da mensagem].",
+ "flow-revision-permalink-warning-postsummary": "Esta é uma ligação permanente para a versão simples do resumo desta mensagem. Esta versão é de $1. \nPode ver as [$5 diferenças das outras versões], ou ver outras versões na [$4 página do histórico da mensagem].",
+ "flow-revision-permalink-warning-postsummary-first": "Esta é uma ligação permanente para a primeira versão do resumo desta mensagem.\nPode ver outras versões na [$4 página de histórico da mensagem].",
+ "flow-revision-permalink-warning-header": "Esta é uma ligação permanente para a versão simples do cabeçalho. \nEsta versão é de $1. Pode ver as [$3 diferenças das outras versões], ou ver outras versões na [$2 página do histórico da mensagem].",
+ "flow-revision-permalink-warning-header-first": "Esta é uma ligação permanente para a primeira versão do cabeçalho.\nPode ver outras versões na [$2 página de histórico do espaço].",
+ "flow-compare-revisions-revision-header": "Versão por {{GENDER:$2|$2}}, a $1",
+ "flow-compare-revisions-header-post": "Esta página mostra as {{GENDER:$3|alterações}} entre duas versões de uma mensagem de $3, no tópico \"[$5 $2]\" em [$4 $1]. Pode ver outras versões desta mensagem no seu [$6 histórico].",
+ "flow-compare-revisions-header-postsummary": "Esta página mostra as alterações entre duas versões de resumos da mensagem \"[$4 $2]\" em [$3 $1]. \nPode ver outras versões desta mensagem no seu [$5 histórico].",
+ "flow-compare-revisions-header-header": "Esta página mostra as {{GENDER:$2|alterações}} entre duas versões do cabeçalho em [$3 $1].\nPode ver outras versões desta mensagem no seu [$4 histórico].",
+ "right-flow-hide": "Ocultar tópicos e mensagens do Flow",
+ "right-flow-lock": "Bloquear tópicos do Flow",
+ "right-flow-delete": "Eliminar tópicos e mensagens do Flow",
+ "right-flow-edit-post": "Editar mensagens do Flow de outros utilizadores",
+ "right-flow-suppress": "Suprimir revisões do Flow",
+ "flow-terms-of-use-new-topic": "Ao clicar em \"{{int:flow-newtopic-save}}\", concorda com os termos de uso desta wiki.",
+ "flow-terms-of-use-reply": "Ao clicar em \"{{int:flow-reply-submit}}\", concorda com os termos de uso desta wiki.",
+ "flow-terms-of-use-edit": "Ao gravar as suas alterações, concorda com os termos de uso desta wiki.",
+ "flow-anon-warning": "Não está autenticado(a). Para receber atribuição com o seu nome em vez do seu endereço IP, pode [$1 iniciar sessão] ou [$2 criar uma conta].",
+ "flow-cancel-warning": "Introduziu texto neste formulário. Tem a certeza de que pretende descartá-lo?",
+ "flow-topic-first-heading": "Tópico em $1",
+ "flow-topic-html-title": "$1 em $2",
+ "flow-topic-count": "Tópicos ($1)",
+ "flow-load-more": "Carregar mais",
+ "flow-no-more-fwd": "Não existem tópicos antigos",
+ "flow-add-topic": "Adicionar tópico",
+ "flow-newest-topics": "Tópicos mais recentes",
+ "flow-recent-topics": "Últimos tópicos ativos",
+ "flow-sorting-tooltip-newest": "Atualmente, {{GENDER:|está}} a ler os novos tópicos em primeiro lugar. Clique para mais opções de ordenação.",
+ "flow-sorting-tooltip-recent": "Atualmente, {{GENDER:|está}} a ler os tópicos ativos mais recentes em primeiro lugar. Clique para mais opções de ordenação.",
+ "flow-toggle-small-topics": "Alterar para modo de tópicos pequenos",
+ "flow-toggle-topics": "Alternar para modo apenas de tópicos",
+ "flow-toggle-topics-posts": "Alterar para modo de tópicos e mensagens",
+ "flow-terms-of-use-summarize": "Ao clicar em \"{{int:flow-summarize-topic-submit}}\", concorda com os termos de uso desta wiki.",
+ "flow-terms-of-use-lock-topic": "Ao clicar em \"{{int:flow-lock-topic-submit}}\", concorda com as condições de uso desta wiki.",
+ "flow-terms-of-use-unlock-topic": "Ao clicar em \"{{int:flow-unlock-topic-submit}}\", concorda com as condições de uso desta wiki.",
+ "flow-whatlinkshere-post": "de uma [$1 mensagem]",
+ "flow-whatlinkshere-header": "de um [$1 cabeçalho]",
+ "flow": "Flow",
+ "flow-special-desc": "Esta página especial redirecciona para um fluxo de trabalho do Flow ou para uma mensagem de Flow após dado um Identificador Global Único.",
+ "flow-special-type": "Tipo",
+ "flow-special-type-post": "Publicar",
+ "flow-special-type-workflow": "Fluxo de trabalho",
+ "flow-special-uuid": "Identificador Global Único",
+ "flow-special-invalid-uuid": "Não foi possível encontrar o conteúdo correspondente com o tipo e o UUID.",
+ "flow-special-enableflow-legend": "Ativar Flow numa página nova",
+ "flow-spam-confirmedit-form": "Por favor, confirme que é um ser humano ao resolver o código CAPTHA abaixo: $1",
+ "flow-preview-warning": "Esta é uma pré-visualização. Clique em \"{{int:flow-newtopic-save}}\" para publicar, ou em \"{{int:flow-preview-return-edit-post}}\" para continuar a escrever.",
+ "flow-preview-return-edit-post": "Continuar a editar",
+ "flow-anonymous": "Anónimo",
+ "flow-embedding-unsupported": "As discussões ainda não podem ser incorporadas.",
+ "mw-ui-unsubmitted-confirm": "Esta página possui alterações que ainda não foram gravadas. Tem a certeza que pretende continuar a navegar e perder todo o seu trabalho?",
+ "flow-post-undo-hide": "desfazer ocultar",
+ "flow-post-undo-delete": "desfazer eliminação",
+ "flow-post-undo-suppress": "desfazer supressão",
+ "flow-topic-undo-hide": "desfazer ocultar",
+ "flow-topic-undo-delete": "desfazer eliminação",
+ "flow-topic-undo-suppress": "desfazer supressão",
+ "flow-importer-lqt-converted-template": "Página LQT convertida para Flow",
+ "flow-importer-lqt-converted-archive-template": "Arquivo da página LQT convertida",
+ "flow-importer-wt-converted-template": "Página de discussão em wikitexto convertida para o Flow",
+ "flow-importer-wt-converted-archive-template": "Arquivo para a página de discussão em wikitexto convertida",
+ "apihelp-flow-description": "Permite que ações sejam realizadas nas páginas que utilizam o Flow",
+ "apihelp-flow-param-submodule": "Submódulo do Flow a ser invocado.",
+ "apihelp-flow-param-page": "A página sobre a qual será tomada uma ação",
+ "apihelp-flow-param-render": "Defina isto com um valor para incluir um uma renderização específica de bloco na saída.",
+ "apihelp-flow-example-1": "Editar o cabeçalho de \"[[Talk:Sandbox]]\".",
+ "apihelp-flow+close-open-topic-description": "Depreciado em favor de [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "Estado em que o tópico será colocado (bloqueado ou desbloqueado).",
+ "apihelp-flow+close-open-topic-param-reason": "Motivo para bloquear ou desbloquear o tópico.",
+ "apihelp-flow+edit-header-description": "Editar o cabeçalho de um espaço de discussão.",
+ "apihelp-flow+edit-header-param-prev_revision": "ID da revisão atual do cabeçalho, para conferir conflitos entre edições.",
+ "apihelp-flow+edit-header-param-content": "Conteúdo do cabeçalho.",
+ "apihelp-flow+edit-header-example-1": "Editar o cabeçalho de [[Talk:Sandbox]]",
+ "apihelp-flow+edit-header-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+edit-post-description": "Editar publicação",
+ "apihelp-flow+edit-post-param-postId": "ID da publicação.",
+ "apihelp-flow+edit-post-param-prev_revision": "ID da revisão atual da publicação, para conferir conflitos entre edições.",
+ "apihelp-flow+edit-post-param-content": "Conteúdo para a publicação.",
+ "apihelp-flow+edit-post-example-1": "Editar uma publicação em [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-post-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+edit-title-description": "Edita o título de um tópico.",
+ "apihelp-flow+edit-title-param-prev_revision": "ID da revisão atual do título, para conferir conflitos entre edições.",
+ "apihelp-flow+edit-title-param-content": "Conteúdo para o título.",
+ "apihelp-flow+edit-title-example-1": "Editar o título de [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+edit-topic-summary-description": "Editar o conteúdo do sumário de um tópico.",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "ID da revisão atual do sumário do tópico, se houver, para conferir conflitos entre edições.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Conteúdo para o sumário.",
+ "apihelp-flow+edit-topic-summary-example-1": "Editar o sumário de [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+lock-topic-description": "Bloquear ou desbloquear um tópico do Flow.",
+ "apihelp-flow+lock-topic-param-moderationState": "Estado em que o tópico será colocado (bloqueado ou desbloqueado).",
+ "apihelp-flow+lock-topic-param-reason": "Motivo para bloquear ou desbloquear o tópico.",
+ "apihelp-flow+lock-topic-example-1": "Bloquear [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+moderate-post-description": "Moderar uma publicação com Flow",
+ "apihelp-flow+moderate-post-param-moderationState": "Nível de moderação",
+ "apihelp-flow+moderate-post-param-reason": "Razão para moderação.",
+ "apihelp-flow+moderate-post-param-postId": "ID da publicação a moderar.",
+ "apihelp-flow+moderate-post-example-1": "Eliminar uma publicação no tópico [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+moderate-topic-description": "Moderar um tópico do Flow.",
+ "apihelp-flow+moderate-topic-param-moderationState": "Nível de moderação.",
+ "apihelp-flow+moderate-topic-param-reason": "Razão para moderação.",
+ "apihelp-flow+moderate-topic-example-1": "Eliminar o tópico [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+new-topic-description": "Criar um novo tópico do Flow no fluxo de trabalho dado.",
+ "apihelp-flow+new-topic-param-topic": "Texto para novo título de tópico.",
+ "apihelp-flow+new-topic-param-content": "Conteúdo para a resposta inicial do tópico.",
+ "apihelp-flow+new-topic-example-1": "Criar um novo tópico em [[Talk:Sandbox]]",
+ "apihelp-flow+new-topic-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+reply-description": "Respostas a uma publicação.",
+ "apihelp-flow+reply-param-replyTo": "ID da publicação a responder.",
+ "apihelp-flow+reply-param-content": "Conteúdo para novo tópico.",
+ "apihelp-flow+reply-example-1": "Resposta a uma publicação em [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+reply-param-metadataonly": "Se devem ser incluídos apenas metadados sobre o novo conteúdo, excluindo todo o resto",
+ "apihelp-flow+view-header-description": "Ver um cabeçalho de um espaço de discussão.",
+ "apihelp-flow+view-header-param-contentFormat": "Formato em que o conteúdo deverá ser retornado.",
+ "apihelp-flow+view-header-param-revId": "Carregar esta revisão, em vez da mais recente.",
+ "apihelp-flow+view-header-example-1": "Obter o cabeçalho de [[Talk:Sandbox]] como wikitexto",
+ "apihelp-flow+view-post-description": "Ver uma publicação.",
+ "apihelp-flow+view-post-param-postId": "ID da publicação a ser visualizada.",
+ "apihelp-flow+view-post-param-contentFormat": "Formato em que o conteúdo deverá ser retornado.",
+ "apihelp-flow+view-post-example-1": "Obter o conteúdo de uma publicação em [[Topic:S2tycnas4hcucw8w]] como wikitexto",
+ "apihelp-flow+view-topic-description": "Ver um tópico.",
+ "apihelp-flow+view-topic-example-1": "Ver [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "Ver o sumário de um tópico.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "Formato em que o conteúdo deverá ser retornado.",
+ "apihelp-flow+view-topic-summary-param-revId": "Carregar esta revisão, em vez da mais recente.",
+ "apihelp-flow+view-topic-summary-example-1": "Ver o sumário de [[Topic:S2tycnas4hcucw8w]] como wikitexto",
+ "apihelp-flow+view-topiclist-description": "Ver uma lista de tópicos.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Direção para ordenar os tópicos.",
+ "apihelp-flow+view-topiclist-param-sortby": "Opções de ordenação dos tópicos.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Salvar a opção de ordenação, se estiver definida.",
+ "apihelp-flow+view-topiclist-param-offset-id": "Valor inicial (no formato UUID) para a obtenção de tópicos.",
+ "apihelp-flow+view-topiclist-param-offset": "Valor inicial para a obtenção de tópicos.",
+ "apihelp-flow+view-topiclist-param-limit": "Número de tópicos a obter.",
+ "apihelp-flow+view-topiclist-param-render": "Renderizar os tópicos em HTML.",
+ "apihelp-flow+view-topiclist-example-1": "Listar os tópicos em [[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "Converter texto entre wikitexto e HTML.",
+ "apihelp-flow-parsoid-utils-param-from": "Formato a partir do qual o conteúdo deverá ser convertido.",
+ "apihelp-flow-parsoid-utils-param-to": "O formato para o qual o conteúdo deverá ser convertido.",
+ "apihelp-flow-parsoid-utils-param-content": "Conteúdo a ser convertido.",
+ "apihelp-flow-parsoid-utils-param-title": "Título da página. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "ID da página. Não pode ser utilizado em conjunto com $1title.",
+ "apihelp-flow-parsoid-utils-example-1": "Converter o wikitexto <nowiki>'''lorem''' ''blá''</nowiki> para HTML",
+ "apihelp-query+flowinfo-description": "Obter informações básicas do Flow sobre uma página.",
+ "apihelp-query+flowinfo-example-1": "Obter informações do Flow sobre [[Talk:Sandbox]], [[Main Page]], e [[Talk:Flow]]",
+ "flow-edited": "Editada",
+ "flow-edited-by": "Editada por $1",
+ "flow-lqt-redirect-reason": "Redirecionando uma publicação com LiquidThreads redirecionada para a publicação com Flow correspondente",
+ "flow-talk-conversion-move-reason": "Conversão de página de discussão em wikitexto para Flow a partir de $1",
+ "flow-talk-conversion-archive-edit-reason": "Conversão de página de discussão em wikitexto para Flow",
+ "flow-previous-diff": "← Edição anterior",
+ "flow-next-diff": "Edição posterior →",
+ "flow-undo": "desfazer",
+ "flow-undo-latest-revision": "Última revisão",
+ "flow-undo-your-text": "O seu texto",
+ "flow-undo-edit-header": "A editar cabeçalho",
+ "flow-undo-edit-topic-summary": "A editar o resumo do tópico",
+ "flow-undo-edit-post": "A editar mensagem",
+ "flow-undo-edit-content": "É possível desfazer a edição. Verifique a comparação abaixo, para se certificar que corresponde ao que pretende fazer. Depois grave as alterações, para finalizar e desfazer a edição.",
+ "flow-undo-edit-failure": "Não foi possível desfazer a edição por conflito com alterações intermédias.",
+ "group-flow-bot": "Flow bots",
+ "group-flow-bot-member": "Flow bot",
+ "grouppage-flow-bot": "Project:Flow bots",
+ "flow-ve-mention-inspector-invalid-user": "O nome de utilizador(a) '$1' não está registado.",
+ "flow-wikitext-editor-help-preview-the-result": "pré-visualizar o resultado",
+ "flow-wikitext-switch-editor-tooltip": "Mudar para o Editor Visual",
+ "flow-ve-switch-editor-tool-title": "Mudar para o editor de wikitexto"
+}
diff --git a/Flow/i18n/qqq.json b/Flow/i18n/qqq.json
new file mode 100644
index 00000000..65615cac
--- /dev/null
+++ b/Flow/i18n/qqq.json
@@ -0,0 +1,552 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Beta16",
+ "Lloffiwr",
+ "Lokal Profil",
+ "Raymond",
+ "Shirayuki",
+ "Siebrand",
+ "Withoutaname",
+ "Robby",
+ "Liuxinyu970226",
+ "Nemo bis",
+ "Umherirrender",
+ "Sunpriat"
+ ]
+ },
+ "enableflow": "{{doc-special|EnableFlow}}",
+ "flow-desc": "{{desc|name=Flow|url=https://www.mediawiki.org/wiki/Extension:Flow}}",
+ "flow-talk-taken-over": "Content to replace existing page content by for pages that are turned into Flow boards.",
+ "flow-talk-username": "Username used for the revision added when Flow takes over a talk page. Avoid changing this unnecessarily, as it will cause a new user to be used for future actions.",
+ "log-name-flow": "{{doc-logpage}}\nName of the Flow log filter on the [[Special:Log]] page.",
+ "logentry-delete-flow-delete-post": "Text for a deletion log entry when a post was deleted. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the post was moderated\n* $4 - permalink URL to the moderated post\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-delete-flow-restore-post": "Text for a deletion log entry when a deleted post was restored. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the post was moderated\n* $4 - permalink URL to the moderated post\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-suppress-flow-suppress-post": "Text for a deletion log entry when a post was suppressed. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the post was moderated\n* $4 - permalink URL to the moderated post\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-suppress-flow-restore-post": "Text for a deletion log entry when a suppressed post was restored. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the post was moderated\n* $4 - permalink URL to the moderated post\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-delete-flow-delete-topic": "Text for a deletion log entry when a topic was deleted. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the topic was moderated\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-delete-flow-restore-topic": "Text for a deletion log entry when a deleted topic was restored. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the topic was moderated\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-suppress-flow-suppress-topic": "Text for a deletion log entry when a topic was suppressed. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the topic was moderated\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-suppress-flow-restore-topic": "Text for a deletion log entry when a suppressed topic was restored. Parameters:\n* $1 - the user: link to the user page\n* $2 - the username. Can be used for GENDER.\n* $3 - the page where the topic was moderated\n* $4 - permalink URL to the moderated topic\n* $5 - The topic title text\n* $6 - The board page\n{{Related|Flow-logentry}}",
+ "logentry-import-lqt-to-flow-topic": "Text for an import log entry when a topic has been imported from LiquidThreads to Flow. Parameters:\n* $1 - The page within the topic namespace to which the topic was imported.\n* $2 - The title of the LiquidThreads thread being imported.\n* $3 - The board that was converted from LiquidThreads to Flow.",
+ "flow-user-moderated": "Name to display when the current user is not allowed to see the users name due to moderation",
+ "flow-board-header-browse-topics-link": "Text to show in the board header which links to the topics list.",
+ "flow-edit-header-link": "Used as text for the button that either allows editing the header in place or brings the user to a page for editing the header.",
+ "flow-post-moderated-toggle-hide-show": "Message to display instead of content when a hidden post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-post-moderated-toggle-delete-show": "Message to display instead of content when a deleted post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-post-moderated-toggle-suppress-show": "Message to display instead of content when a suppressed post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-post-moderated-toggle-hide-hide": "Message to display instead of content when a hidden post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-post-moderated-toggle-delete-hide": "Message to display instead of content when a deleted post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-post-moderated-toggle-suppress-hide": "Message to display instead of content when a suppressed post has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - user link and tool links for the user\n{{Related|Flow-post-moderated-toggle}}",
+ "flow-topic-moderated-reason-prefix": "Message to display in the topic title immediatly preceding the user supplied reason for moderating the topic.\n{{Identical|Reason}}",
+ "flow-hide-post-content": "Message to display instead of content when the post has been hidden.\n\nParameters:\n* $1 - username that hid the post, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-hide-title-content": "Message to display instead of content when the title has been hidden.\n\nParameters:\n* $1 - username that hid the title, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-lock-title-content": "Message to display instead of content when the title has been locked.\n\nParameters:\n* $1 - username that locked the title, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-hide-header-content": "Message to display instead of content when the header has been hidden.\n\nParameters:\n* $1 - username that hid the header, can be used for GENDER\n* $2 - user link and tool links for the user.\n{{Related|Flow-content}}",
+ "flow-hide-usertext": "Used as username if the post was hidden.\n\nParameters:\n* $1 - Username of the post creator. Can be used for GENDER",
+ "flow-delete-post-content": "Message to display instead of content when the post has been deleted.\n\nParameters:\n* $1 - username that deleted the post, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-delete-title-content": "Message to display instead of content when the title has been deleted.\n\nParameters:\n* $1 - username that deleted the title, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-delete-header-content": "Message to display instead of content when the header has been deleted.\n\nParameters:\n* $1 - username that deleted the header, can be used for GENDER\n* $2 - user link and tool links for the user.\n{{Related|Flow-content}}",
+ "flow-delete-usertext": "Used as username if the post was deleted.\n\nParameters:\n* $1 - Username of the post creator. Can be used for GENDER",
+ "flow-suppress-post-content": "Message to display instead of content when the post has been suppressed.\n\nParameters:\n* $1 - username that suppressed the post, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].",
+ "flow-suppress-title-content": "Message to display instead of content when the title has been suppressed.\n\nParameters:\n* $1 - username that suppressed the title, can be used for GENDER\n* $2 - link to topic history\n{{Related|Flow-content}}",
+ "flow-suppress-header-content": "Message to display instead of content when the header has been suppressed.\n\nParameters:\n* $1 - username that suppressed the header, can be used for GENDER\n* $2 - user link and tool links for the user.\n{{Related|Flow-content}}\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].",
+ "flow-suppress-usertext": "Used as username if the post was suppressed.\n\nParameters:\n* $1 - Username of the post creator. Can be used for GENDER",
+ "flow-post-actions": "Used as link text.\n{{Identical|Action}}",
+ "flow-topic-actions": "Used as link text.\n{{Identical|Action}}",
+ "flow-cancel": "Used as action link text.\n{{Identical|Cancel}}",
+ "flow-preview": "Used as action link text.\n{{Identical|Preview}}",
+ "flow-show-change": "Used as action link text.\n\nChanges refers to diff between revisions.\n{{Identical|Show change}}",
+ "flow-last-modified-by": "Used as text to show who made the last content modification. Parameters:\n* $1 - username of the user who last made the content modification, can be used for GENDER support",
+ "flow-system-usertext": "Stub username to be displayed when a post's information could not be loaded due to technical issues.",
+ "flow-stub-post-content": "Stub post content to be displayed when the real post could not be loaded due to technical issues.",
+ "flow-newtopic-title-placeholder": "Used as placeholder for the \"Subject/Title for topic\" textarea.\n{{Identical|New topic}}",
+ "flow-newtopic-content-placeholder": "Used as placeholder for the \"Content\" textarea.\nParameters:\n* $1 - The prefixed name of the current page.",
+ "flow-newtopic-header": "Unused at this time.",
+ "flow-newtopic-save": "Used as label for the Submit button.\n\nAlso used in:\n* {{msg-mw|Flow-terms-of-use-new-topic}}\n* {{msg-mw|Wikimedia-flow-terms-of-use-new-topic}}\n{{Identical|Add topic}}",
+ "flow-newtopic-start-placeholder": "Used as placeholder for the \"Topic\" textarea.",
+ "flow-newtopic-first-heading": "First heading on the page with the form to create a new topic. Parameters:\n* $1 - the title of the page where a new topic will be created on",
+ "flow-summarize-topic-placeholder": "Used as placeholder for summarizing topic textarea.",
+ "flow-reply-topic-placeholder": "Used as placeholder for the \"reply to this topic\" textarea. Parameters:\n* $1 - username of the logged in user, can be used for GENDER\n* $2 - topic title",
+ "flow-reply-topic-title-placeholder": "Used as placeholder for the content textarea when replying. Parameters:\n* $1 - When writing a top-level reply, the topic title. When replying to a comment, the first words of the comment to which the reply is being written.\n{{Identical|Reply to}}",
+ "flow-reply-submit": "Used as label for the Submit button. Parameters:\n* $1 - username, can be used for GENDER\nAlso used in:\n* {{msg-mw|Flow-terms-of-use-reply}}\n* {{msg-mw|Wikimedia-flow-terms-of-use-reply}}\n{{Identical|Reply}}",
+ "flow-reply-link": "Text for the link that appears near the post and offers the user to reply to it. Clicking the link will display the reply editor. Parameters:\n* $1 - username, can be used for GENDER\n{{Identical|Reply}}",
+ "flow-thank-link": "Link text of the button that will (when clicked) thank the editor of the comment Parameters:\n* $1 - username, can be used for GENDER\n{{Identical|Thank}}",
+ "flow-lock-link": "Text for the link for closing topic/post. Parameters:\n* $1 - username, can be used for GENDER\n{{Identical|Lock}}",
+ "flow-thank-link-title": "Tooltip text of the button that will (when clicked) thank the editor of the comment. (poster - one who posts a message, who has written a post)",
+ "flow-history-action-suppress-post": "Text link for suppressing a post or topic from the board history page.\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].\n\n{{Identical|Suppress}}",
+ "flow-history-action-delete-post": "Text link for deleting a post or topic from the board history page.\n{{Identical|Delete}}",
+ "flow-history-action-hide-post": "Text link for hiding a post or topic from the board history page.\n{{Identical|Hide}}",
+ "flow-history-action-unsuppress-post": "Text link for unsuppressing a post or topic from the board history page.\n{{Identical|Unsuppress}}",
+ "flow-history-action-undelete-post": "Text link for undeleting a post or topic from the board history page.\n{{Identical|Undelete}}",
+ "flow-history-action-unhide-post": "Text link for unhiding a post or topic from the board history page.\n{{Identical|Unhide}}",
+ "flow-history-action-restore-post": "Text link for restoring a post or topic from the board history page.\n{{Identical|Restore}}",
+ "flow-history-action-lock-topic": "Text link for locking a post or topic from the board history page.\n{{Identical|Lock}}",
+ "flow-history-action-unlock-topic": "Text link for unlocking a post or topic from the board history page.\n{{Identical|Unlock}}",
+ "flow-post-interaction-separator": "{{optional}}",
+ "flow-post-edited": "Text displayed to notify the user a post has been modified. Parameters:\n* $1 - username that created the most recent revision of the post\n* $2 - humanized timestamp, relative to now, of when the edit occurred; rendered by MWTimestamp::getHumanTimestamp",
+ "flow-post-action-view": "Used as text for the link which is used to view.\n{{Identical|Permalink}}",
+ "flow-post-action-post-history": "Used as text for the link which is used to view post-history of the topic.\n{{Identical|History}}",
+ "flow-post-action-suppress-post": "Used as a link in a dropdown menu to suppress a post.\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].\n{{Related|Flow-action}}\n{{Identical|Suppress}}",
+ "flow-post-action-delete-post": "Used as a link in a dropdown menu to delete a post.\n{{Related|Flow-action}}\n{{Identical|Delete}}",
+ "flow-post-action-hide-post": "Used as a link in a dropdown menu to hide a post.\n{{Related|Flow-action}}\n{{Identical|Hide}}",
+ "flow-post-action-edit-post": "Used as text for the link which is used to edit the post.\n{{Related|Flow-action}}\n{{Identical|Edit}}",
+ "flow-post-action-edit-post-submit": "Used as text for a button to submit an 'edit post' form.",
+ "flow-post-action-unsuppress-post": "Used as a link in a dropdown menu to unsuppress a post.\n{{Related|Flow-action}}\n{{Identical|Unsuppress}}",
+ "flow-post-action-undelete-post": "Used as a link in a dropdown menu to undelete a post.\n{{Related|Flow-action}}\n{{Identical|Undelete}}",
+ "flow-post-action-unhide-post": "Used as a link in a dropdown menu to unhide a post.\n{{Related|Flow-action}}\n{{Identical|Unhide}}",
+ "flow-post-action-restore-post": "Used as a link in a dropdown menu to clear the moderation state of a post.\n{{Related|Flow-action}}\n{{Identical|Restore}}",
+ "flow-post-action-undo-moderation": "Used as link text to undo a recently performed moderation action.\n{{Identical|Undo}}",
+ "flow-topic-action-view": "Title text for topic's permalink icon.\n{{Identical|Permalink}}",
+ "flow-topic-action-watchlist": "Title text for topic's watchlist icon.\n{{Identical|Watchlist}}",
+ "flow-topic-action-edit-title": "Used as title for the link which is used to edit the title.",
+ "flow-topic-action-history": "Used as text for the link which is used to view topic-history.\n{{Identical|History}}",
+ "flow-topic-action-hide-topic": "Used as a link in a dropdown menu to hide a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-delete-topic": "Used as a link in a dropdown menu to delete a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-lock-topic": "Used as a link in a dropdown menu to lock a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-unlock-topic": "Used as a link in a dropdown menu to unlock a topic.\n{{Related:Flow-action}}",
+ "flow-topic-action-summarize-topic": "Used as a link in a dropdown menu to summarize a topic.\n{{Related|Flow-action}}\n{{Identical|Summarize}}",
+ "flow-topic-action-resummarize-topic": "Appears as a link in the dropdown menu on a topic if a topic summary had already been written, and allows editing the summary.\n{{Related|Flow-action}}",
+ "flow-topic-action-suppress-topic": "Used as a link in a dropdown menu to suppress a topic.\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].\n{{Related|Flow-action}}",
+ "flow-topic-action-unhide-topic": "Used as a link in a dropdown menu to unhide a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-undelete-topic": "Used as a link in a dropdown menu to undelete a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-unsuppress-topic": "Used as a link in a dropdown menu to unsuppress a topic.\n{{Related|Flow-action}}",
+ "flow-topic-action-restore-topic": "Used as a link in a dropdown menu to clear the moderation state of a topic.\n{{Related|Flow-action}}\n{{Identical|Restore}}",
+ "flow-topic-action-undo-moderation": "Used as link text to undo a recently performed moderation action.\n{{Identical|Undo}}",
+ "flow-topic-notification-subscribe-title": "Title text for the overlay when a topic is added to watchlist.",
+ "flow-topic-notification-subscribe-description": "Description text for the overlay when a topic is added to watchlist.",
+ "flow-board-notification-subscribe-title": "Title text for the overlay when a board is added to watchlist.",
+ "flow-board-notification-subscribe-description": "Description text for the overlay when a board is added to watchlist.",
+ "flow-error-http": "Used as error message on HTTP error.",
+ "flow-error-other": "Used as generic error message.",
+ "flow-error-external": "Uses as error message. Parameters:\n* $1 - error message\nSee also:\n* {{msg-mw|Flow-error-external-multi}}",
+ "flow-error-edit-restricted": "Used as error message when a user attempts to edit a post they do not have the permissions for.",
+ "flow-error-topic-is-locked": "Used as error message when a user attempts to moderate/create/edit title/post when a topic is locked.",
+ "flow-error-lock-moderated-post": "Used as error message when user attempts to lock a moderated topic/post.",
+ "flow-error-external-multi": "Used as error message. Parameters:\n* $1 - list of error messages\nSee also:\n* {{msg-mw|Flow-error-external}}",
+ "flow-error-missing-content": "Used as error message.\n{{Related|Flow-error-missing}}",
+ "flow-error-missing-summary": "Used as error message.\n{{Related|Flow-error-missing}}",
+ "flow-error-missing-title": "Used as error message.\n{{Related|Flow-error-missing}}",
+ "flow-error-parsoid-failure": "Used as error message.\n\nParsoid is a bidirectional wikitext parser and runtime. Converts back and forth between wikitext and HTML/XML DOM with RDFa. See [[mw:Parsoid]].",
+ "flow-error-missing-replyto": "Used as error message.\n\nThe variable name \"replyTo\" is invisible to users, so \"replyTo\" can be translated.",
+ "flow-error-invalid-replyto": "Used as error message.\n\nThe variable name \"replyTo\" is invisible to users, so \"replyTo\" can be translated.",
+ "flow-error-delete-failure": "Used as error message.\n\n\"this item\" refers either \"this topic\" or \"this post\".",
+ "flow-error-hide-failure": "Used as error message.\n\n\"this item\" refers either \"this topic\" or \"this post\".",
+ "flow-error-missing-postId": "Used as error message when deleting/restoring a post.\n\n\"manipulate\" refers either \"delete\" or \"restore\".",
+ "flow-error-invalid-postId": "Used as error message when deleting/restoring a post.\n\nThe variable name \"postId\" is invisible to users, so \"postId\" can be translated.\n\nParameters:\n* $1 - contains the postId that was specified",
+ "flow-error-restore-failure": "Used as error message when restoring a post.\n\n\"this item\" seems to refer \"this post\".",
+ "flow-error-invalid-moderation-state": "Used as error message.\n\nUsually indicates a code bug (possibly in a third-party use of the Flow API), so technical terminology is okay.\n\nValid values for moderationState are: (none), hidden, deleted, suppressed. Do not translate the word 'moderationState'.",
+ "flow-error-invalid-moderation-reason": "Used as error message when no reason is given for the moderation of a post.",
+ "flow-error-not-allowed": "Error message when the user has insufficient permissions to execute this action",
+ "flow-error-not-allowed-hide": "Error message when the user has insufficient permissions to execute this action because the topic is hidden.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Error message shown when the user has insufficient permissions to create a new reply because the topic is hidden.",
+ "flow-error-not-allowed-delete": "Error message when the user has insufficient permissions to execute this action because the topic is deleted.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Error message shown when the user has insufficient permissions to create a new reply because the topic is deleted.",
+ "flow-error-not-allowed-suppress": "Error message when the user has insufficient permissions to execute this action because the topic is suppressed. Although the content is suppressed, we still call this 'deleted' for security reasons.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Error message shown when the user has insufficient permissions to create a new reply because the topic is suppressed. Although the content is suppressed, we still call this 'deleted' for security reasons.",
+ "flow-error-not-allowed-hide-extract": "Error message when the user has insufficient permissions to execute this action because the topic is hidden, with a Special:Log excerpt for this page.",
+ "flow-error-not-allowed-delete-extract": "Error message when the user has insufficient permissions to execute this action because the topic is deleted, with a Special:Log excerpt for this page.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Error message when the user has insufficient permissions to create a new reply because the topic is deleted, with a Special:Log excerpy for this page.",
+ "flow-error-not-allowed-suppress-extract": "Error message when the user has insufficient permissions to execute this action because the topic is suppressed, with a Special:Log excerpt for this page.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Error message when the user has insufficient permissions to create a new reply because the topic is suppressed, with a Special:Log excerpt for this page.",
+ "flow-error-title-too-long": "Used as error message when a user submits a topic title that is too long to save.\n\nParameters:\n* $1 - The number of bytes allowed",
+ "flow-error-no-existing-workflow": "Error message when an edit to a non-existing topic is performed.",
+ "flow-error-not-a-post": "Error message when a topic title is attempted to be saved as post (most likely a code issue - shouldn't happen).",
+ "flow-error-missing-header-content": "Error message when the header is submitted without content.\n{{Related|Flow-error-missing}}",
+ "flow-error-missing-prev-revision-identifier": "Error message when the identifier for the previous header revision is missing.",
+ "flow-error-prev-revision-mismatch": "Error message when the provided previous revision identifier does not match the last stored revision.",
+ "flow-error-prev-revision-does-not-exist": "Error message when the provided previous revision identifier could not be found.",
+ "flow-error-core-topic-deletion": "Error message when the user tries to use core's deletion mechanism for a Topic (this is not exposed in the UI, and should not be used currently). Parameters:\n* $1 - Full URL of topic page.",
+ "flow-error-default": "General error message for flow.",
+ "flow-error-invalid-input": "Error message when invalid input is provided.",
+ "flow-error-invalid-title": "Error message when invalid title is provided.",
+ "flow-error-invalid-action": "Error message when invalid action is provided.\n\nRefers to:\n* {{msg-mw|Nosuchactiontext}}.",
+ "flow-error-fail-load-history": "Error message when load history content is failed to load.",
+ "flow-error-missing-revision": "Error message when a revision is missing.",
+ "flow-error-fail-commit": "Error message when a commit action fails.",
+ "flow-error-insufficient-permission": "Error message when user does not have sufficient permission to perform an action.",
+ "flow-error-revision-comparison": "Error message when revision comparison fails.",
+ "flow-error-missing-topic-title": "Error message when a topic title is missing.",
+ "flow-error-missing-metadata": "Error message when a metadata is missing for a revision.",
+ "flow-error-fail-load-data": "General error message when failing to load data.",
+ "flow-error-invalid-workflow": "Error message when invalid workflow is provided.",
+ "flow-error-process-data": "General error message when failing to process data.",
+ "flow-error-process-wikitext": "Error message when failing to process html/wikitext conversion.",
+ "flow-error-no-index": "Error message when failing to find an index to perform data search.",
+ "flow-error-no-render": "Error message when nothing was able to render the request (data was requested but it could not be processed).",
+ "flow-error-no-commit": "Error message when nothing was able to commit the request (data was submitted but it could not be processed).",
+ "flow-error-fetch-after-lock": "Error message to be displayed when failing to request the new data after successfully performing lock/unlock topic. This is meant to indicate to the user that some error was encountered, but that the lock/unlock actually succeeded just fine - we just failed to get the new data to display the new status. Parameters:\n* $1 - The error message received.",
+ "flow-error-content-too-long": "Error message when the expanded(html) output of a post is too large.\n\nParameters:\n* $1 - post content lengh limit in byte, could be used for plural support.",
+ "flow-error-move": "Error message when attempting to move a flow board (which is not yet supported)",
+ "flow-error-invalid-topic-uuid-title": "Title displayed at top of page and in browser title bar when the user requests a page within the Topic namespace that is not a valid UUID",
+ "flow-error-invalid-topic-uuid": "Body of page displayed when the user requests a page within the Topic namespace that is not a valid UUID",
+ "flow-error-unknown-workflow-id-title": "Title displayed at top of page and in browser title bar when the user requests a page within the Topic namespace that is not a known topic",
+ "flow-error-unknown-workflow-id": "Body of page displayed when the user requests a page within the Topic namespace that is not a known topic",
+ "flow-edit-header-placeholder": "Used as placeholder when editing the header of a Flow board",
+ "flow-edit-header-submit": "Used as label for the Submit button.",
+ "flow-edit-header-submit-overwrite": "Used as label for the Submit button, when submitting will overwrite a more recent change.",
+ "flow-summarize-topic-submit": "Used as label for the Summarize button.\n\nAlso used in:\n* {{msg-mw|Flow-terms-of-use-summarize}}\n* {{msg-mw|Wikimedia-flow-terms-of-use-summarize}}\n{{Identical|Summarize}}",
+ "flow-summarize-topic-submit-overwrite": "Used as label for the Summarize button, when submitting will overwrite a more recent summary.",
+ "flow-lock-topic-submit": "Used as label for the Lock topic button.\n\nAlso used in:\n* {{msg-mw|Flow-terms-of-use-lock-topic}}\n* {{msg-mw|Flow-terms-of-use-lock-topic}}",
+ "flow-lock-topic-submit-overwrite": "Used as label for the Lock topic button, when submitting will overwrite a more recent summary.",
+ "flow-unlock-topic-submit": "Used as label for the Restore topic button.\n\nAlso used in:\n* {{msg-mw|Flow-terms-of-use-unlock-topic}}\n* {{msg-mw:Wikimedia-flow-terms-of-use-unlock-topic}}",
+ "flow-unlock-topic-submit-overwrite": "Used as label for the Restore topic button, when submitting will overwrite a more recent summary.",
+ "flow-edit-title-submit": "Used as label for the Submit button.",
+ "flow-edit-title-submit-overwrite": "Used as label for the Submit button, when submitting will overwrite a more recent change.",
+ "flow-edit-post-submit": "Used as label for the Submit button.",
+ "flow-edit-post-submit-overwrite": "Used as label for the Submit button, when submitting will overwrite a more recent change.",
+ "flow-rev-message-edit-post": "Used as a revision comment when a post has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the post. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-post-recentchanges": "Used as a revision comment (in RecentChanges) when a post has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the post. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-post-recentchanges-summary": "Used as edit summary (in RecentChanges) when a post has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the post. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-post-contributions": "Used as a revision comment (in Contributions) when a post has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the post. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-post-irc": "{{notranslate}}",
+ "flow-rev-message-reply": "Used as a revision comment when a new reply has been posted.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who replied. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that was commented on\n* $5 - truncated summary of the reply content\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-reply-recentchanges": "Used as a revision comment (in RecentChanges) when a new reply has been posted.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who replied. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that was commented on\n* $5 - truncated summary of the reply content\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-reply-contributions": "Used as a revision comment (in Contributions) when a new reply has been posted.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who replied. Can be used for GENDER\n* $3 - the URL of the post\n* $4 - the name of the topic that was commented on\n* $5 - truncated summary of the reply content\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-reply-irc": "{{notranslate}}",
+ "flow-rev-message-reply-bundle": "When multiple replies have been posted, they're bundled. This is the message to describe that multiple replies were posted.\n\nParameters:\n* $1 - the amount of replies posted\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-new-post": "Used as revision comment when the topic has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username. Can be used for GENDER\n* $3 - the URL of the topic\n* $4 - the topic title\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-new-post-recentchanges": "Used as revision comment (in RecentChanges) when the topic has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username. Can be used for GENDER\n* $3 - the URL of the topic\n* $4 - the topic title\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-new-post-recentchanges-summary": "Used as edit summary (in RecentChanges) when the topic has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username. Can be used for GENDER\n* $3 - the URL of the topic\n* $4 - the topic title\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-new-post-contributions": "Used as revision comment (in Contributions) when the topic has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username. Can be used for GENDER\n* $3 - the URL of the topic\n* $4 - the topic title\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-new-post-irc": "{{notranslate}}",
+ "flow-rev-message-edit-title": "Used as revision comment when a post has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the title. Can be used for GENDER\n* $3 - the URL of the topic\n* $4 - the topic title\n* $5 - the previous topic title\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-title-irc": "{{notranslate}}",
+ "flow-rev-message-create-header": "Used as revision comment when the header has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who created the header. Can be used for GENDER\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-create-header-irc": "{{notranslate}}",
+ "flow-rev-message-edit-header": "Used as revision comment when the header has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the header. Can be used for GENDER\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-header-irc": "{{notranslate}}",
+ "flow-rev-message-create-topic-summary": "Used as revision comment when a topic summary has been created.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who created the topic summary. Can be used for GENDER\n* $3 - the topic this summary belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-create-topic-summary-irc": "{{notranslate}}",
+ "flow-rev-message-edit-topic-summary": "Used as revision comment when a topic summary has been edited.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who edited the topic summary. Can be used for GENDER\n* $3 - the topic this summary belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-edit-topic-summary-irc": "{{notranslate}}",
+ "flow-rev-message-hid-post": "Used as revision comment when a post has been hidden.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the comment. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the comment. Can be used for GENDER\n* $4 - permalink to the comment\n* $5 - Reason, from the moderating user, for moderating this post\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-hid-post-irc": "{{notranslate}}",
+ "flow-rev-message-deleted-post": "Used as revision comment when a post has been deleted.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the comment. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the comment. Can be used for GENDER\n* $4 - permalink to the comment\n* $5 - Reason, from the moderating user, for moderating this post\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-deleted-post-irc": "{{notranslate}}",
+ "flow-rev-message-suppressed-post": "Used as revision comment when a post has been suppressed.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the comment. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the comment. Can be used for GENDER\n* $4 - permalink to the comment\n* $5 - Reason, from the moderating user, for moderating this post\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-suppressed-post-irc": "{{notranslate}}",
+ "flow-rev-message-restored-post": "Used as revision comment when a post has been restored (un-hidden).\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who restored the comment. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the comment. Can be used for GENDER\n* $4 - permalink to the comment\n* $5 - Reason, from the moderating user, for moderating this post\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-restored-post-irc": "{{notranslate}}",
+ "flow-rev-message-hid-topic": "Used as revision comment when a topic has been hidden.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the topic. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the topic. Can be used for GENDER\n* $4 - permalink to the topic\n* $5 - Reason, from the moderating user, for moderating this topic\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-hid-topic-irc": "{{notranslate}}",
+ "flow-rev-message-deleted-topic": "Used as revision comment when a topic has been deleted.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the topic. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the topic. Can be used for GENDER\n* $4 - permalink to the topic\n* $5 - Reason, from the moderating user, for moderating this topic\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-deleted-topic-irc": "{{notranslate}}",
+ "flow-rev-message-suppressed-topic": "Used as revision comment when a topic has been suppressed.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the topic. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the topic. Can be used for GENDER\n* $4 - permalink to the topic\n* $5 - Reason, from the moderating user, for moderating this topic\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-suppressed-topic-irc": "{{notranslate}}",
+ "flow-rev-message-locked-topic": "Used as revision comment when a topic has been locked.\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who moderated the topic. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the topic. Can be used for GENDER\n* $4 - permalink to the topic\n* $5 - Reason, from the moderating user, for moderating this topic\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-locked-topic-irc": "{{notranslate}}",
+ "flow-rev-message-restored-topic": "Used as revision comment when a topic has been restored (un-hidden).\n\nParameters:\n* $1 - user link and tool links for the user.\n* $2 - username of the user who restored the topic. Can be used for GENDER\n* $3 - (Optional) username of the user who had posted the topic. Can be used for GENDER\n* $4 - permalink to the topic\n* $5 - Reason, from the moderating user, for moderating this topic\n* $6 - Name of the topic the post belongs to\n{{Related|Flow-rev-message}}",
+ "flow-rev-message-restored-topic-irc": "{{notranslate}}",
+ "flow-rc-topic-of-board": "Part of an entry in RecentChanges, the text for the link to the topic.\n\nParameters:\n* $1 - topic title text.\n* $2 - board name text.\n----\nHere's an example of the RecentChanges line that was generated by me just adding a discussion topic:\n\n(diff | hist) . . Just a small test on Talk:Sandbox; 16:53 . . (+17)‎ . . Mmullie (WMF) (talk | contribs | block) (←Created new topic)\n\nFlow-rc-topic-of-board is for the \"Just a small test on Talk:Sandbox\" part, where \"Just a small test\" is the title of the discussion I just created, and \"Talk:Sandbox\" is the board I posted this in. \n\n{{Identical|On}}",
+ "flow-board-history": "Used as <code><nowiki><h1></nowiki></code> heading and HTML title in the \"Board history\" page.\n\nParameters:\n* $1 - the title to which the flow board belongs\n{{Identical|History}}",
+ "flow-board-history-empty": "Displayed when no board history is available.",
+ "flow-topic-history": "Used as <code><nowiki><h1></nowiki></code> heading and HTML title in the \"Topic history\" page.\n\nParameters:\n* $1 - the topic title",
+ "flow-post-history": "Used as <code><nowiki><h1></nowiki></code> heading and HTML title in the \"Post history\" page.\n\nParameters:\n* $1 - the topic title\n* $2 - the username of the creator of the post. Can be used for GENDER",
+ "flow-history-last4": "Used as <code><nowiki><h2></nowiki></code> heading in the \"Topic history\" page to display all history of the last 4 hours",
+ "flow-history-day": "Used as <code><nowiki><h2></nowiki></code> heading in the \"Topic history\" page to display all history of today.\n{{Identical|Today}}",
+ "flow-history-week": "Used as <code><nowiki><h2></nowiki></code> heading in the \"Topic history\" page to display all history of last week.\n\nThis \"Last week\" is equal to \"Last 7 days\".\n{{Identical|Last week}}",
+ "flow-history-pages-topic": "Used to describe what board the topic is added to. Parameters:\n* $1 - the link to the page\n* $2 - the page title",
+ "flow-history-pages-post": "Used to describe what topic the post is added to. Parameters:\n* $1 - the link to the topic\n* $2 - the topic title",
+ "flow-topic-comments": "Message to display the amount of comments in this topic. Shown as a link after the topic title and the line with the topic authors. Clicking the link lets the current user write a new comment.\n\nParameters:\n* $1 - the number of comments on this topic, can be used for PLURAL\n* $2 - the name of the current user, can be used for GENDER\nSee also:\n* {{msg-mw|Flow-topic-meta-minimal}}",
+ "flow-comment-restored": "Used as revision comment when the post has been restored.\n\nSee also:\n* {{msg-mw|Flow-comment-deleted}}",
+ "flow-comment-deleted": "Used as revision comment when the post has been deleted.\n\nSee also:\n* {{msg-mw|Flow-comment-restored}}",
+ "flow-comment-hidden": "Used as revision comment when the post has been hidden.",
+ "flow-comment-moderated": "Used as a revision comment when the post has been oversighted.",
+ "flow-last-modified": "Followed by the timestamp.\n\nParameters:\n* $1 - most significant unit of time since modification rendered by MWTimestamp::getHumanTimestamp",
+ "flow-workflow": "Anchor link text for linking to a workflow.\n{{Identical|Workflow}}",
+ "flow-notification-reply": "Notification text for when a user receives a reply. Parameters:\n* $1 - username of the person who replied\n* $2 - title of the topic\n* $3 - (Unused) title for the Flow board, this parameter is not used for the message at this moment\n* $4 - title for the page that the Flow board is attached to\n* $5 - permanent URL for the post\n{{Related|Flow-notification}}",
+ "flow-notification-reply-bundle": "Notification text for when a user receives replies from multiple users on the same topic.\n\nParameters:\n* $1 - username of the person who replied\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n* $4 - permantent URL for the post\n* $5 - the count of other action performers, could be number or {{msg-mw|Echo-notification-count}}. e.g. 7 others or 99+ others\n* $6 - a number used for plural support\nSee also:\n* {{msg-mw|Flow-notification-reply-email-batch-bundle-body}}\n{{Related|Flow-notification}}",
+ "flow-notification-edit": "Notification text for when a user's post is edited. Parameters:\n* $1 - username of the person who edited the post\n* $2 - title of the topic\n* $3 - title for the Flow board\n* $4 - title for the page that the Flow board is attached to\n* $5 - permanent URL for the post\n* $6 - permanent URL for the topic\n{{Related|Flow-notification}}",
+ "flow-notification-edit-bundle": "Notification text for when a user receives post edits from multiple users on the same topic.\n\nParameters:\n* $1 - username of the person who edited post\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n* $4 - permantent URL for the topic\n* $5 - the count of other action performers, could be number or {{msg-mw|Echo-notification-count}}. e.g. 7 others or 99+ others\n* $6 - a number used for plural support\nSee also:\n* {{msg-mw|Flow-notification-edit-email-batch-bundle-body}}\n{{Related|Flow-notification}}",
+ "flow-notification-newtopic": "Notification text for when a new topic is created. Parameters:\n* $1 - username of the person who created the topic\n* $2 - (Unused) title for the Flow board\n* $3 - title for the page that the Flow board is attached to\n* $4 - title of the topic\n* $5 - Fully qualified url to view the created topic.\n{{Related|Flow-notification}}",
+ "flow-notification-newtopic-bundle": "Notification text for when multiple new topics are created on the same page. Parameters:\n* $1 - The number of topics that were created. This value is capped to 250. When this value is 250 it means 250 or more topics have been created.\n* $2 - The title of the page the topics were created on\n* $3 - Fully qualified url to view the related board sorted by newest topics.\n{{Related|Flow-notification}}",
+ "flow-notification-rename": "Notification text for when the subject of a topic is changed. Parameters:\n* $1 - username of the person who edited the title, can be used for GENDER\n* $2 - permalink to the topic\n* $3 - old topic subject\n* $4 - new topic subject\n* $5 - title for the Flow board\n* $6 - title for the page that the Flow board is attached to\n{{Related|Flow-notification}}",
+ "flow-notification-mention": "{{doc-singularthey}}\nNotification text for when a user is mentioned in another conversation. Parameters:\n* $1 - username of the person who made the post, can be used for GENDER\n* $2 - permalink to the post\n* $3 - title of the topic\n* $4 - title for the page that the Flow board is attached to\n* $5 - username of the person who receives the notification, can be used for GENDER\n{{Related|Flow-notification}}",
+ "flow-notification-link-text-view-post": "Label for button that links to a flow post.",
+ "flow-notification-link-text-view-topic": "Link text in for the view topic button in a notification",
+ "flow-notification-reply-email-subject": "Email notification subject when a user receives a reply. Parameters:\n* $1 - username of the person who replied (Unused now)\n* $2 - the topic title being replied to\n* $3 - title of the page the topic belongs to\n{{Related|Flow-notification-email}}\n{{Identical|On}}",
+ "flow-notification-reply-email-batch-body": "Email notification body when a user receives a reply, this message is used in both single email and email digest.\n\nParameters:\n* $1 - username of the person who replied\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n{{Related|Flow-notification-email}}",
+ "flow-notification-reply-email-batch-bundle-body": "Email notification body when a user receives reply from multiple users, this message is used in both single email and email digest.\n\nParameters:\n* $1 - username of the person who replied\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n* $4 - the count of other action performers, could be number or {{msg-mw|Echo-notification-count}}. e.g. 7 others or 99+ others\n* $5 - a number used for plural support\n{{Related|Flow-notification-email}}",
+ "flow-notification-mention-email-subject": "Email notification subject when a user is mentioned in a post. Parameters:\n* $1 - username of the person who mentions other users, can be used for GENDER\n* $2 - flow title text\n* $3 - username of the person who receives the notification, can be used for GENDER\n{{Related|Flow-notification-email}}",
+ "flow-notification-mention-email-batch-body": "{{doc-singularthey}}\nEmail notification body when a user is mentioned in a post, this message is used in both single email and email digest.\n\nParameters:\n* $1 - username of the person who mentions other users, can be used for GENDER\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n* $4 - username of the person who receives the notification, can be used for GENDER\n{{Related|Flow-notification-email}}",
+ "flow-notification-edit-email-subject": "Subject line of notification email for post being edited. Parameters:\n* $1 - name of the user that edited the post\n{{Related|Flow-notification-email}}",
+ "flow-notification-edit-email-batch-body": "Email notification for post being edited. Parameters:\n* $1 - name of the user that edited the post\n* $2 - name of the topic the edited post belongs to\n* $3 - title of the page the topic belongs to\n{{Related|Flow-notification-email}}",
+ "flow-notification-edit-email-batch-bundle-body": "Email notification body when a user receives post edits from multiple users, this message is used in both single email and email digest.\n\nParameters:\n* $1 - username of the person who replied\n* $2 - title of the topic\n* $3 - title for the page that the Flow board is attached to\n* $4 - the count of other action performers, could be number or {{msg-mw|Echo-notification-count}}. e.g. 7 others or 99+ others\n* $5 - a number used for plural support\n{{Related|Flow-notification-email}}",
+ "flow-notification-rename-email-subject": "Subject line of notification email for topic being renamed. Parameters:\n* $1 - name of the user that renamed the topic\n{{Related|Flow-notification-email}}",
+ "flow-notification-rename-email-batch-body": "Email notification for topic being renamed. Parameters:\n* $1 - name of the user that renamed the topic\n* $2 - the original topic title\n* $3 - the new topic title\n* $4 - title of the page the topic belongs to\n{{Related|Flow-notification-email}}",
+ "flow-notification-newtopic-email-subject": "Subject line of notification email for new topic creation. Parameters:\n* $1 - name of the user that created a new topic\n* $2 - title\n{{Related|Flow-notification-email}}",
+ "flow-notification-newtopic-email-batch-body": "Email notification for new topic creation. Parameters:\n* $1 - name of the user that created a new topic\n* $2 - the title of the new topic\n* $3 - title of the page the topic belongs to\n{{Related|Flow-notification-email}}",
+ "echo-category-title-flow-discussion": "This is a short title for notification category. Parameters:\n* $1 - number of mentions, for PLURAL support\n{{Related|Echo-category-title}}\n{{Identical|Flow}}",
+ "echo-pref-tooltip-flow-discussion": "This is a short description of the flow-discussion notification category.\n{{Related|Echo-pref-tooltip}}",
+ "flow-link-post": "Tooltip text used when linking to a post from recentchanges.\n{{Identical|Post}}",
+ "flow-link-topic": "Tooltip text used when linking to a topic from recentchanges.\n{{Identical|Topic}}",
+ "flow-link-board": "Tooltip text used when linking to a board from recentchanges. Parameters:\n* $1 - Page name of the discussion board",
+ "flow-link-history": "Tooltip text used when linking to history of a post/topic from recentchanges.\n{{Identical|History}}",
+ "flow-link-post-revision": "Tooltip text used when linking to a specific revision of a post. This means \"revision of a post\".\n{{Related|Flow-link-revision}}",
+ "flow-link-topic-revision": "Tooltip text used when linking to a specific revision of a topic. This means \"revision of a topic\".\n{{Related|Flow-link-revision}}",
+ "flow-link-header-revision": "Tooltip text used when linking to a specific revision of a header. This means \"revision of a header\".\n{{Related|Flow-link-revision}}",
+ "flow-link-summary-revision": "Tooltip text used when linking to a specific revision of a topic summary. This means \"revision of a topic summary\".\n{{Related|Flow-link-revision}}",
+ "flow-moderation-title-suppress-post": "Title for the moderation confirmation dialog when a post is having its suppressed status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-delete-post": "Title for the moderation confirmation dialog when a post is being deleted.\n{{Related|Flow-moderation-title}}\n{{Identical|Delete post}}",
+ "flow-moderation-title-hide-post": "Title for the moderation confirmation dialog when a post is being hidden.\n{{Related|Flow-moderation-title}}\n{{Identical|Hide post}}",
+ "flow-moderation-title-unsuppress-post": "Title for the moderation confirmation dialog when a post is being unsuppressed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-undelete-post": "Title for the moderation confirmation dialog when a post is having its deleted status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-unhide-post": "Title for the moderation confirmation dialog when a post is having its hidden status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-placeholder-suppress-post": "Placeholder for the moderation confirmation dialog when a post is being suppressed. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-delete-post": "Placeholder for the moderation confirmation dialog when a post is being deleted. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-hide-post": "Placeholder for the moderation confirmation dialog when a post is being hidden. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-unsuppress-post": "Placeholder for the moderation confirmation dialog when a post is being unsuppressed. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-undelete-post": "Placeholder for the moderation confirmation dialog when a post is being undeleted. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-unhide-post": "Placeholder for the moderation confirmation dialog when a post is being unhidden. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-confirm-suppress-post": "Label for a button that will confirm suppression of a post.\n\nFor meaning of \"suppress\" see [[Thread:Support/About MediaWiki:Flow-post-action-suppress-post/qqq]] and [[Thread:Support/About MediaWiki:Flow-suppress-post-content/sv]].\n\n{{Related|Flow-moderation-confirm}}\n{{Identical|Suppress}}",
+ "flow-moderation-confirm-delete-post": "Label for a button that will confirm deletion of a post.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Delete}}",
+ "flow-moderation-confirm-hide-post": "Label for a button that will confirm hiding of a post.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Hide}}",
+ "flow-moderation-confirm-unsuppress-post": "Label for a button that will confirm unsuppressing of a post.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Unsuppress}}",
+ "flow-moderation-confirm-undelete-post": "Label for a button that will confirm undeleting of a post.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Undelete}}",
+ "flow-moderation-confirm-unhide-post": "Label for a button that will confirm unhiding of a post.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Unhide}}",
+ "flow-moderation-confirm-suppress-topic": "Label for a button that will confirm suppression of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Suppress}}",
+ "flow-moderation-confirm-delete-topic": "Label for a button that will confirm deletion of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Delete}}",
+ "flow-moderation-confirm-hide-topic": "Label for a button that will confirm hiding of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Hide}}",
+ "flow-moderation-confirm-lock-topic": "Label for a button that will confirm locking of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Lock}}",
+ "flow-moderation-confirm-unsuppress-topic": "Label for a button that will confirm unsuppressing of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Unsuppress}}",
+ "flow-moderation-confirm-undelete-topic": "Label for a button that will confirm undeleting of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Undelete}}",
+ "flow-moderation-confirm-unhide-topic": "Label for a button that will confirm unhiding of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Unhide}}",
+ "flow-moderation-confirm-unlock-topic": "Label for a button that will confirm unlocking of a topic.\n{{Related|Flow-moderation-confirm}}\n{{Identical|Unlock}}",
+ "flow-moderation-confirmation-suppress-post": "Message displayed after a successful suppression of a post. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, for GENDER support\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-delete-post": "Message displayed after a successful deletion of a post. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, for GENDER support\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-hide-post": "Message displayed after a successful hiding of a post. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, for GENDER support\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-unsuppress-post": "Message displayed after successfull removal of a posts suppressed status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-undelete-post": "Message displayed after a successfull removal of a posts deleted status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-unhide-post": "Message displayed after successfull removal of a posts hidden status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-suppress-topic": "Message displayed after a successful suppression of a topic. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, for GENDER support\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-delete-topic": "Message displayed after a successful deletion of a topic. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, for GENDER support\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-hide-topic": "Message displayed after a successful hiding of a topic. Parameters:\n* $1 - the user whose post is being moderated\n* $2 - the username, for GENDER support\n* $3 - (Unused) the current user, can be used for GENDER\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-unsuppress-topic": "Message displayed after successfully removing a topic's suppressed status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-undelete-topic": "Message displayed after successfully removing a topic's deleted status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-confirmation-unhide-topic": "Message displayed after successfully removing a topic's hidden status.\n{{Related|Flow-moderation-confirmation}}",
+ "flow-moderation-title-suppress-topic": "Title for the moderation confirmation dialog when a topic is being suppressed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-delete-topic": "Title for the moderation confirmation dialog when a topic is being deleted.\n{{Related|Flow-moderation-title}}\n{{Identical|Delete topic}}",
+ "flow-moderation-title-hide-topic": "Title for the moderation confirmation dialog when a topic is being hidden.\n{{Related|Flow-moderation-title}}\n{{Identical|Hide topic}}",
+ "flow-moderation-title-unsuppress-topic": "Title for the moderation confirmation dialog when a topic is having its suppressed status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-undelete-topic": "Title for the moderation confirmation dialog when a topic is having its deleted status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-title-unhide-topic": "Title for the moderation confirmation dialog when a topic is having its hidden status removed.\n{{Related|Flow-moderation-title}}",
+ "flow-moderation-placeholder-suppress-topic": "Placeholder for the moderation confirmation dialog when a topic is being suppressed. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-delete-topic": "Placeholder for the moderation confirmation dialog when a topic is being deleted. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-hide-topic": "Placeholder for the moderation confirmation dialog when a topic is being hidden. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-lock-topic": "Placeholder for the moderation confirmation dialog when a topic is being locked. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-unsuppress-topic": "Placeholder for the moderation confirmation dialog when a topic is being unsuppressed. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-undelete-topic": "Placeholder for the moderation confirmation dialog when a topic is being undeleted. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-unhide-topic": "Placeholder for the moderation confirmation dialog when a topic is being unhidden. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-moderation-placeholder-unlock-topic": "Placeholder for the moderation confirmation dialog when a topic is being unlocked. Parameters:\n* $1 - (Unused) The user whose post is being moderated.\n* $2 - (Unused) The subject.\n* $3 - the user who is moderating the post. GENDER supported.\n{{Related|Flow-moderation-intro}}",
+ "flow-topic-permalink-warning": "Displayed at the top of a page when a person has clicked on a permanent link to a topic.\n\nParameters:\n* $1 - display text for a link to the board that the topic comes from\n* $2 - URL for a link to the board that the topic comes from\nSee also:\n* {{msg-mw|Flow-topic-permalink-warning-user-board}}",
+ "flow-topic-permalink-warning-user-board": "Displayed at the top of a page when a person has clicked on a permanent link to a topic from a user's board.\n\nParameters:\n* $1 - the user's name. Supports GENDER.\n* $2 - URL for a link to the board that the topic comes from\nSee also:\n* {{msg-mw|Flow-topic-permalink-warning}}",
+ "flow-revision-permalink-warning-post": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of a post.\n\nThis message will not appear for the first revision, which has its own message ({{msg-mw|Flow-revision-permalink-warning-post-first}}).\n\nNote that the \"topic permalink warning\" (see {{msg-mw|Flow-topic-permalink-warning}}) will also be displayed.\n\nParameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - title of the Flow Board that the post appears on. Example: User talk:Andrew\n* $3 - title of the topic that this post appears in\n* $4 - URL to the history page\n* $5 - URL to the diff from the previous revision to this one\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-post-first}}\n* {{msg-mw|Flow-revision-permalink-warning-header}}",
+ "flow-revision-permalink-warning-post-first": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of a post.\n\nThis message will only be shown for the first revision.\n\nNote that the \"topic permalink warning\" (see {{msg-mw|Flow-topic-permalink-warning}}) will also be displayed.\n\nParameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - title of the Flow Board that the post appears on. Example: User talk:Andrew\n* $3 - title of the topic that this post appears in\n* $4 - URL to the history page\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-post}}\n* {{msg-mw|Flow-revision-permalink-warning-header-first}}",
+ "flow-revision-permalink-warning-postsummary": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of summary for a post.\n\nThis message will not appear for the first revision, which has its own message ({{msg-mw|Flow-revision-permalink-warning-postsummary-first}}).\n\nParameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - title of the Flow Board that the post appears on. Example: User talk:Andrew\n* $3 - title of the topic that this post appears in\n* $4 - URL to the history page\n* $5 - URL to the diff from the previous revision to this one\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-postsummary-first}}\n* {{msg-mw|Flow-revision-permalink-warning-header}}",
+ "flow-revision-permalink-warning-postsummary-first": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of a post summary.\n\nThis message will only be shown for the first revision.\n\nNote that the \"topic permalink warning\" (see {{msg-mw|Flow-topic-permalink-warning}}) will also be displayed.\n\nParameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - title of the Flow Board that the post appears on. Example: User talk:Andrew\n* $3 - title of the topic that this post appears in\n* $4 - URL to the history page\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-post}}\n* {{msg-mw|Flow-revision-permalink-warning-header-first}}",
+ "flow-revision-permalink-warning-header": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of board header.\n\nThis message will not appear for the first revision, which has its own message ({{msg-mw|Flow-revision-permalink-warning-header-first}}).\n\nParameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - URL to the history page\n* $3 - URL to the diff from the previous revision to this one\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-header-first}}\n* {{msg-mw|Flow-revision-permalink-warning-post}}",
+ "flow-revision-permalink-warning-header-first": "Header displayed at the top of a page when somebody is viewing a single-revision permalink of board header.\n\nThis message will only be shown for the first revision.\n\nParameters:\n* $1 - (Unused) date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC2822 timestamp when hovered over.\n* $2 - URL to the history page\nSee also:\n* {{msg-mw|Flow-revision-permalink-warning-header}}\n* {{msg-mw|Flow-revision-permalink-warning-post-first}}",
+ "flow-compare-revisions-revision-header": "Diff column header for a revision. Parameters:\n* $1 - date and timestamp, formatted as most are in Flow. That is, a human-readable timestamp that changes into an RFC-2822 timestamp when hovered over.\n* $2 - user who made this revision",
+ "flow-compare-revisions-header-post": "Header for a page showing a \"diff\" between two revisions of a Flow post. Parameters:\n* $1 - the title of the Board on which this post sits. Example: User talk:Andrew\n* $2 - the subject of the Topic in which this post sits\n* $3 - the username of the author of the post\n* $4 - URL to the Board, with the fragment set to the post in question\n* $5 - URL to the Topic, with the fragment set to the post in question\n* $6 - URL to the history page for this post\n{{Related|Flow-compare-revisions-header}}",
+ "flow-compare-revisions-header-postsummary": "Header for a page showing a \"diff\" between two revisions of a Flow post summary. Parameters:\n* $1 - the title of the Board on which this post sits. Example: User talk:Andrew\n* $2 - the subject of the Topic in which this post sits\n* $3 - URL to the Board, with the fragment set to the post in question\n* $4 - URL to the Topic, with the fragment set to the post in question\n* $5 - URL to the history page for this post\n{{Related|Flow-compare-revisions-header}}",
+ "flow-compare-revisions-header-header": "Header for a page showing a \"diff\" between two revisions of a Flow board header. Parameters:\n* $1 - the title of the Board on which this header sits. Example: User talk:Andrew\n* $2 - the username of the author of the header\n* $3 - URL to the Board, with the fragment set to the post in question\n* $4 - URL to the history page for this post\n{{Related|Flow-compare-revisions-header}}",
+ "action-flow-create-board": "{{doc-action|flow-create-board}}",
+ "right-flow-create-board": "{{doc-right|flow-create-board}}",
+ "right-flow-hide": "{{doc-right|flow-hide}}",
+ "right-flow-lock": "{{doc-right|flow-lock}}",
+ "right-flow-delete": "{{doc-right|flow-delete}}",
+ "right-flow-edit-post": "{{doc-right|flow-edit-post}}",
+ "right-flow-suppress": "{{doc-right|flow-suppress}}",
+ "flow-terms-of-use-new-topic": "Terms of use for adding a new topic.\n\nThis should be consistent with {{msg-mw|Flow-newtopic-save}}.\n{{Related|Flow-terms-of-use}}",
+ "flow-terms-of-use-reply": "Terms of use for posting a reply.\n\nRefers to {{msg-mw|Flow-reply-submit}}.\n{{Related|Flow-terms-of-use}}",
+ "flow-terms-of-use-edit": "Terms of use for editing a header/topic/post.\n{{Related|Flow-terms-of-use}}",
+ "flow-anon-warning": "Warning message to be displayed when anonymous user starts writing a new topic or reply.\n* $1 is a URL to log in.\n* $2 is a URL to register an account.",
+ "flow-cancel-warning": "Warning message to be displayed when user tries to discard the text they have entered in a form field",
+ "flow-topic-first-heading": "First heading on any page in the topic namespace. Parameters:\n* $1 - the title of the page that is being linked to",
+ "flow-topic-html-title": "Message displayed in the browser title bar when visiting a page in the Topic namespace. Parameters:\n* $1 - The title of the topic\n* $2 - The page the topic started on\n{{Identical|On}}",
+ "flow-topic-count": "Message displayed at the top of the sidebar showing the number of topics loaded on the page\n{{Identical|Topic}}",
+ "flow-load-more": "Message displayed inside a button that fetches more topics and appends them to the bottom of the page\n{{Identical|Load more}}",
+ "flow-no-more-fwd": "Displayed instead of 'flow-load-more' when there are no more topics to show in the forward direction",
+ "flow-add-topic": "Button text for submitting a new topic to the page\n{{Identical|Add topic}}",
+ "flow-newest-topics": "Filter label text for \"Newest topics\".\n\nSee also:\n* {{msg-mw|Flow-recent-topics}}",
+ "flow-recent-topics": "Filter label text for \"Recent active topics\".\n\nSee also:\n* {{msg-mw|Flow-newest-topics}}",
+ "flow-sorting-tooltip-newest": "Tooltip displayed when mouse hovering over the sorting drop down menu and '{{msg-mw|flow-newest-topics}}' is selected.",
+ "flow-sorting-tooltip-recent": "Tooltip displayed when mouse hovering over the sorting drop down menu and '{{msg-mw|flow-recent-topics}}' is selected.",
+ "flow-toggle-small-topics": "Tooltip displayed when mouse hovering over the full page topic collapser. When clicked only topic titles are displayed.\n\nSee also:\n* {{msg-mw|Flow-toggle-topics}}\n* {{msg-mw|Flow-toggle-topics-posts}}",
+ "flow-toggle-topics": "Tooltip displayed when mouse hovering over the full page topic collapser. When clicked posts are not displayed, only topic titles and metadata are visible.\n\nSee also:\n* {{msg-mw|Flow-toggle-small-topics}}\n* {{msg-mw|Flow-toggle-topics-posts}}",
+ "flow-toggle-topics-posts": "Tooltip displayed when mouse hovering over the full page topic collapser. When clicked posts will be displayed with topic titles.\n\nSee also:\n* {{msg-mw|Flow-toggle-small-topics}}\n* {{msg-mw|Flow-toggle-topics}}",
+ "flow-terms-of-use-summarize": "Terms of use for summarizing a header/topic/post.\n\nRefers to {{msg-mw|Flow-summarize-topic-submit}}.\n{{Related|Flow-terms-of-use}}",
+ "flow-terms-of-use-lock-topic": "Terms of use for locking a topic.\n\nRefers to {{msg-mw|Flow-lock-topic-submit}}.\n{{Related|Flow-terms-of-use}}",
+ "flow-terms-of-use-unlock-topic": "Terms of use for unlocking a topic.\n\nRefers to {{msg-mw|Flow-unlock-topic-submit}}.\n{{Related|Flow-terms-of-use}}",
+ "flow-whatlinkshere-post": "Displayed in parentheses on [[Special:WhatLinksHere]] for an entry that relates to a Flow post.\n\nParameters:\n* $1 - a URL to the post\nSee also:\n* {{msg-mw|Flow-whatlinkshere-header}}",
+ "flow-whatlinkshere-header": "Displayed in parentheses on [[Special:WhatLinksHere]] for an entry that relates to a Flow Board header.\n\nParameters:\n* $1 - a URL to the header\nSee also:\n* {{msg-mw|Flow-whatlinkshere-post}}",
+ "flow": "{{doc-special|Flow}}\n{{Identical|Flow}}",
+ "flow-special-desc": "Description at the top of the redirector special page",
+ "flow-special-type": "Label for the type (Workflow or PostRevision) dropdown on the redirector special page.\n{{Identical|Type}}",
+ "flow-special-type-post": "Label for PostRevision in the type dropdown.\n{{Identical|Post}}",
+ "flow-special-type-workflow": "Label for Workflow in the type dropdown.\n{{Identical|Workflow}}",
+ "flow-special-uuid": "Label for the UUID field on the redirector special page.\n\nUUID is unique identifier for the revisioned object containing the reference.",
+ "flow-special-invalid-uuid": "Error message shown on the redirector special page if the specified type / UUID combination is invalid",
+ "flow-special-enableflow-legend": "Legend for Special:EnableFlow form",
+ "flow-special-enableflow-page": "Label for the page field of Special:EnableFlow",
+ "flow-special-enableflow-header": "Label for the header field of Special:EnableFlow",
+ "flow-special-enableflow-board-already-exists": "Error given on Special:EnableFlow if board already exists at requested page name. Parameters:\n$1 - Page name where user requested to put Flow board",
+ "flow-special-enableflow-invalid-title": "Error given on Special:EnableFlow if the provided page is not a valid page name.",
+ "flow-special-enableflow-page-already-exists": "Error given on Special:EnableFlow if a non-Flow page already exists at requested page name. Parameters:\n$1 - Page name where user requested to put Flow board",
+ "flow-special-enableflow-confirmation": "Confirmation message on Special:EnableFlow saying that you have successfully created a board Parameters:\n$1 - Page name of new Flow board",
+ "flow-spam-confirmedit-form": "Error message when ConfirmEdit flagged the submitted content (because an anonymous user submitted external links, possibly spam). A captcha will be displayed after this error message. Parameters:\n* $1 - the HTML for the captcha form.",
+ "flow-preview-warning": "Refers to {{msg-mw|flow-newtopic-save}} (Add topic) and {{msg-mw|Flow-preview-return-edit-post}} (Keep editing).",
+ "flow-preview-return-edit-post": "Used as text for a button that hides previewed text and returns to the editing view",
+ "flow-anonymous": "{{Identical|Anonymous}}",
+ "flow-embedding-unsupported": "Error message displayed if a user tries to transclude a Flow page.",
+ "mw-ui-unsubmitted-confirm": "You have unsubmitted changes on this page. Are you sure you want to navigate away and lose your work?",
+ "flow-post-undo-hide": "Automatic moderation summary when undoing a post hide that was just performed.",
+ "flow-post-undo-delete": "Automatic moderation summary when undoing a post deletion that was just performed.",
+ "flow-post-undo-suppress": "Automatic moderation summary when undoing a post suppression that was just performed.",
+ "flow-topic-undo-hide": "Automatic moderation summary when undoing a topic hide that was just performed.",
+ "flow-topic-undo-delete": "Automatic moderation summary when undoing a topic deletion that was just performed.",
+ "flow-topic-undo-suppress": "Automatic moderation summary when undoing a topic suppression that was just performed.",
+ "flow-importer-lqt-moved-thread-template": "Name of a wikitext template that is used as the content of LQT moved thread stubs when they are imported to Flow.",
+ "flow-importer-lqt-converted-template": "Name of a wikitext template that is added to the header of Flow boards that were converted from LiquidThreads",
+ "flow-importer-lqt-converted-archive-template": "Name of a wikitext template that is added to the archived copy of a LiquidThreads page converted to Flow.",
+ "flow-importer-wt-converted-template": "Name of a wikitext template that is added to the header of a Flow boards that were converted from Wikitext",
+ "flow-importer-wt-converted-archive-template": "Name of a wikitext template that is added to the archived copy of a wikitext talk page converted to Flow.",
+ "flow-importer-lqt-suppressed-user-template": "Name of a wikitext template that is added to a revision imported from liquidthreads that is owned by a suppressed user.",
+ "apihelp-flow-description": "{{doc-apihelp-description|flow}}",
+ "apihelp-flow-param-submodule": "{{doc-apihelp-param|flow|submodule}}",
+ "apihelp-flow-param-page": "{{doc-apihelp-param|flow|page}}",
+ "apihelp-flow-param-render": "{{doc-apihelp-param|flow|render}}",
+ "apihelp-flow-example-1": "{{doc-apihelp-example|flow}} (don't translate the [[Talk:Sandbox]] part, that's the name of the page for the API example)",
+ "apihelp-flow+close-open-topic-description": "{{doc-apihelp-description|flow+close-open-topic}}",
+ "apihelp-flow+close-open-topic-param-moderationState": "{{doc-apihelp-param|flow+close-open-topic|moderationState}}",
+ "apihelp-flow+close-open-topic-param-reason": "{{doc-apihelp-param|flow+close-open-topic|reason}}",
+ "apihelp-flow+edit-header-description": "{{doc-apihelp-description|flow+edit-header}}",
+ "apihelp-flow+edit-header-param-prev_revision": "{{doc-apihelp-param|flow+edit-header|prev_revision}}",
+ "apihelp-flow+edit-header-param-content": "{{doc-apihelp-param|flow+edit-header|content}}",
+ "apihelp-flow+edit-header-param-format": "{{doc-apihelp-param|flow+edit-header|format}}",
+ "apihelp-flow+edit-header-example-1": "{{doc-apihelp-example|flow+edit-header}}",
+ "apihelp-flow+edit-header-param-metadataonly": "{{doc-apihelp-param|flow+edit-header|metadataonly}}",
+ "apihelp-flow+edit-post-description": "{{doc-apihelp-description|flow+edit-post}}",
+ "apihelp-flow+edit-post-param-postId": "{{doc-apihelp-param|flow+edit-post|postId}}",
+ "apihelp-flow+edit-post-param-prev_revision": "{{doc-apihelp-param|flow+edit-post|prev_revision}}",
+ "apihelp-flow+edit-post-param-content": "{{doc-apihelp-param|flow+edit-post|content}}",
+ "apihelp-flow+edit-post-param-format": "{{doc-apihelp-param|flow+edit-post|format}}",
+ "apihelp-flow+edit-post-example-1": "{{doc-apihelp-example|flow+edit-post}}",
+ "apihelp-flow+edit-post-param-metadataonly": "{{doc-apihelp-param|flow+edit-post|metadataonly}}",
+ "apihelp-flow+edit-title-description": "{{doc-apihelp-description|flow+edit-title}}",
+ "apihelp-flow+edit-title-param-prev_revision": "{{doc-apihelp-param|flow+edit-title|prev_revision}}",
+ "apihelp-flow+edit-title-param-content": "{{doc-apihelp-param|flow+edit-title|content}}",
+ "apihelp-flow+edit-title-example-1": "{{doc-apihelp-example|flow+edit-title}}",
+ "apihelp-flow+edit-title-param-metadataonly": "{{doc-apihelp-param|flow+edit-title|metadataonly}}",
+ "apihelp-flow+edit-topic-summary-description": "{{doc-apihelp-description|flow+edit-topic-summary}}",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "{{doc-apihelp-param|flow+edit-topic-summary|prev_revision}}",
+ "apihelp-flow+edit-topic-summary-param-summary": "{{doc-apihelp-param|flow+edit-topic-summary|summary}}",
+ "apihelp-flow+edit-topic-summary-param-format": "{{doc-apihelp-param|flow+edit-topic-summary|format}}",
+ "apihelp-flow+edit-topic-summary-example-1": "{{doc-apihelp-example|flow+edit-topic-summary}}",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "{{doc-apihelp-param|flow+edit-topic-summary|metadataonly}}",
+ "apihelp-flow+lock-topic-description": "{{doc-apihelp-description|flow+lock-topic}}",
+ "apihelp-flow+lock-topic-param-moderationState": "{{doc-apihelp-param|flow+lock-topic|moderationState}}",
+ "apihelp-flow+lock-topic-param-reason": "{{doc-apihelp-param|flow+lock-topic|reason}}",
+ "apihelp-flow+lock-topic-example-1": "{{doc-apihelp-example|flow+lock-topic}}",
+ "apihelp-flow+lock-topic-param-metadataonly": "{{doc-apihelp-param|flow+lock-topic|metadataonly}}",
+ "apihelp-flow+moderate-post-description": "{{doc-apihelp-description|flow+moderate-post}}",
+ "apihelp-flow+moderate-post-param-moderationState": "{{doc-apihelp-param|flow+moderate-post|moderationState}}",
+ "apihelp-flow+moderate-post-param-reason": "{{doc-apihelp-param|flow+moderate-post|reason}}",
+ "apihelp-flow+moderate-post-param-postId": "{{doc-apihelp-param|flow+moderate-post|postId}}",
+ "apihelp-flow+moderate-post-example-1": "{{doc-apihelp-example|flow+moderate-post}}",
+ "apihelp-flow+moderate-post-param-metadataonly": "{{doc-apihelp-param|flow+moderate-post|metadataonly}}",
+ "apihelp-flow+moderate-topic-description": "{{doc-apihelp-description|flow+moderate-topic}}",
+ "apihelp-flow+moderate-topic-param-moderationState": "{{doc-apihelp-param|flow+moderate-topic|moderationState}}",
+ "apihelp-flow+moderate-topic-param-reason": "{{doc-apihelp-param|flow+moderate-topic|reason}}",
+ "apihelp-flow+moderate-topic-example-1": "{{doc-apihelp-example|flow+moderate-topic}}",
+ "apihelp-flow+moderate-topic-param-metadataonly": "{{doc-apihelp-param|flow+moderate-topic|metadataonly}}",
+ "apihelp-flow+new-topic-description": "{{doc-apihelp-description|flow+new-topic}}",
+ "apihelp-flow+new-topic-param-topic": "{{doc-apihelp-param|flow+new-topic|topic}}",
+ "apihelp-flow+new-topic-param-content": "{{doc-apihelp-param|flow+new-topic|content}}",
+ "apihelp-flow+new-topic-param-format": "{{doc-apihelp-param|flow+new-topic|format}}",
+ "apihelp-flow+new-topic-example-1": "{{doc-apihelp-example|flow+new-topic}}",
+ "apihelp-flow+new-topic-param-metadataonly": "{{doc-apihelp-param|flow+new-topic|metadataonly}}",
+ "apihelp-flow+reply-description": "{{doc-apihelp-description|flow+reply}}",
+ "apihelp-flow+reply-param-replyTo": "{{doc-apihelp-param|flow+reply|replyTo}}",
+ "apihelp-flow+reply-param-content": "{{doc-apihelp-param|flow+reply|content}}",
+ "apihelp-flow+reply-param-format": "{{doc-apihelp-param|flow+reply|format}}",
+ "apihelp-flow+reply-example-1": "{{doc-apihelp-example|flow+reply}}",
+ "apihelp-flow+reply-param-metadataonly": "{{doc-apihelp-param|flow+reply|metadataonly}}",
+ "apihelp-flow+view-header-description": "{{doc-apihelp-description|flow+view-header}}",
+ "apihelp-flow+view-header-param-contentFormat": "{{doc-apihelp-param|flow+view-header|contentFormat}}",
+ "apihelp-flow+view-header-param-revId": "{{doc-apihelp-param|flow+view-header|revId}}",
+ "apihelp-flow+view-header-example-1": "{{doc-apihelp-example|flow+view-header}}",
+ "apihelp-flow+view-post-description": "{{doc-apihelp-description|flow+view-post}}",
+ "apihelp-flow+view-post-param-postId": "{{doc-apihelp-param|flow+view-post|postId}}",
+ "apihelp-flow+view-post-param-contentFormat": "{{doc-apihelp-param|flow+view-post|contentFormat}}",
+ "apihelp-flow+view-post-example-1": "{{doc-apihelp-example|flow+view-post}}",
+ "apihelp-flow+view-topic-description": "{{doc-apihelp-description|flow+view-topic}}",
+ "apihelp-flow+view-topic-example-1": "{{doc-apihelp-example|flow+view-topic}}",
+ "apihelp-flow+view-topic-summary-description": "{{doc-apihelp-description|flow+view-topic-summary}}",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "{{doc-apihelp-param|flow+view-topic-summary|contentFormat}}",
+ "apihelp-flow+view-topic-summary-param-revId": "{{doc-apihelp-param|flow+view-topic-summary|revId}}",
+ "apihelp-flow+view-topic-summary-example-1": "{{doc-apihelp-example|flow+view-topic-summary}}",
+ "apihelp-flow+view-topiclist-description": "{{doc-apihelp-description|flow+view-topiclist}}",
+ "apihelp-flow+view-topiclist-param-offset-dir": "{{doc-apihelp-param|flow+view-topiclist|offset-dir}}",
+ "apihelp-flow+view-topiclist-param-sortby": "{{doc-apihelp-param|flow+view-topiclist|sortby}}",
+ "apihelp-flow+view-topiclist-param-savesortby": "{{doc-apihelp-param|flow+view-topiclist|savesortby}}",
+ "apihelp-flow+view-topiclist-param-offset-id": "{{doc-apihelp-param|flow+view-topiclist|offset-id}}",
+ "apihelp-flow+view-topiclist-param-offset": "{{doc-apihelp-param|flow+view-topiclist|offset}}",
+ "apihelp-flow+view-topiclist-param-limit": "{{doc-apihelp-param|flow+view-topiclist|limit}}",
+ "apihelp-flow+view-topiclist-param-render": "{{doc-apihelp-param|flow+view-topiclist|render}}",
+ "apihelp-flow+view-topiclist-example-1": "{{doc-apihelp-example|flow+view-topiclist}}",
+ "apihelp-flow-parsoid-utils-description": "{{doc-apihelp-description|flow-parsoid-utils}}",
+ "apihelp-flow-parsoid-utils-param-from": "{{doc-apihelp-param|flow-parsoid-utils|from}}",
+ "apihelp-flow-parsoid-utils-param-to": "{{doc-apihelp-param|flow-parsoid-utils|to}}",
+ "apihelp-flow-parsoid-utils-param-content": "{{doc-apihelp-param|flow-parsoid-utils|content}}",
+ "apihelp-flow-parsoid-utils-param-title": "{{doc-apihelp-param|flow-parsoid-utils|title}}",
+ "apihelp-flow-parsoid-utils-param-pageid": "{{doc-apihelp-param|flow-parsoid-utils|pageid}}",
+ "apihelp-flow-parsoid-utils-example-1": "{{doc-apihelp-example|flow-parsoid-utils}}",
+ "apihelp-query+flowinfo-description": "{{doc-apihelp-description|query+flowinfo}}",
+ "apihelp-query+flowinfo-example-1": "{{doc-apihelp-example|query+flowinfo}}",
+ "apihelp-flow+undo-edit-header-description": "{{doc-apihelp-description|flow+undo-edit-header}}",
+ "apihelp-flow+undo-edit-header-param-startId": "{{doc-apihelp-param|flow+undo-edit-header|startId}}",
+ "apihelp-flow+undo-edit-header-param-endId": "{{doc-apihelp-param|flow+undo-edit-header|endId}}",
+ "apihelp-flow+undo-edit-header-example-1": "{{doc-apihelp-example|flow+undo-edit-header}}",
+ "apihelp-flow+undo-edit-post-description": "{{doc-apihelp-description|flow+undo-edit-post}}",
+ "apihelp-flow+undo-edit-post-param-postId": "{{doc-apihelp-param|flow+undo-edit-post|postId}}",
+ "apihelp-flow+undo-edit-post-param-startId": "{{doc-apihelp-param|flow+undo-edit-post|startId}}",
+ "apihelp-flow+undo-edit-post-param-endId": "{{doc-apihelp-param|flow+undo-edit-post|endId}}",
+ "apihelp-flow+undo-edit-post-example-1": "{{doc-apihelp-example|flow+undo-edit-post}}",
+ "apihelp-flow+undo-edit-topic-summary-description": "{{doc-apihelp-description|flow+undo-edit-topic-summary}}",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "{{doc-apihelp-param|flow+undo-edit-topic-summary|startId}}",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "{{doc-apihelp-param|flow+undo-edit-topic-summary|endId}}",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "{{doc-apihelp-example|flow+undo-edit-topic-summary}}",
+ "flow-edited": "Message displayed below a post to indicate it has last been edited by the original author\n{{Identical|Edited}}",
+ "flow-edited-by": "Message displayed below a post to indicate it has last been edited by a user other than the original author",
+ "flow-lqt-redirect-reason": "Edit summary used to redirect old LQT thread pages to Flow topics",
+ "flow-talk-conversion-move-reason": "Message used as an edit summary when moving an existing talk page to an archive location in preparation for enabling flow on that page.\nParameters:\n* $1 - Title the page was moved from",
+ "flow-talk-conversion-archive-edit-reason": "Message used as an edit summary when appending a template to a wikitext talk page after archiving it in preparation for conversion to Flow.",
+ "flow-previous-diff": "Text used on diff pages to link to the previous diff. For right-to-left languages use →.",
+ "flow-next-diff": "Text used on diff pages to link to the next diff. For right-to-left languages use ←.",
+ "flow-undo": "Used as link text to go to the page to undo a revision\n{{Identical|Undo}}",
+ "flow-undo-latest-revision": "Text shown above an undo diff for the left hand side.",
+ "flow-undo-your-text": "Text shown above an undo diff for the right hand side.",
+ "flow-undo-edit-header": "Page title when undoing a header edit.",
+ "flow-undo-edit-topic-summary": "Page title when undoing a topic summary edit.",
+ "flow-undo-edit-post": "Page title when undoing a post edit.",
+ "flow-undo-edit-content": "Text shown on undo pages informing the user that the edit can be undone cleanly.",
+ "flow-undo-edit-failure": "Error message shown on undo pages when the revision cannot be directly undone.",
+ "group-flow-bot": "{{doc-group|flow-bot}}",
+ "group-flow-bot-member": "{{doc-group|flow-bot|member}}",
+ "grouppage-flow-bot": "{{doc-group|flow-bot|page}}",
+ "flow-ve-mention-context-item-label": "Label of a button for mentioning users in Flow's VisualEditor.",
+ "flow-ve-mention-inspector-title": "Title for the user mention panel (inspector) in Flow's VisualEditor.",
+ "flow-ve-mention-inspector-remove-label": "Text of remove button on Flow's user mention panel (inspector) in the VisualEditor.",
+ "flow-ve-mention-tool-title": "Title text for the user mention tool on Flow's VisualEditor toolbar.",
+ "flow-ve-mention-template": "Name of on-wiki template used for user mentions. The template should accept a call in the form <nowiki>{{templatename|Username}}</nowiki>, to mention Username. It will use content language.",
+ "flow-ve-mention-inspector-invalid-user": "Error shown when the poster attempts to mention a user that does not exist. Parameters:\n$1: Username. The username is not registered; thus, gender is unknown.",
+ "flow-wikitext-editor-help": "Text shown at the bottom of a wikitext editing box when visualeditor is not available to switch to.\n\n$1 is {{msg-mw|Flow-wikitext-editor-help-uses-wikitext}}.",
+ "flow-wikitext-editor-help-and-preview": "Text shown at the bottom of a wikitext editing box when visualeditor is available to switch to.\n* $1 is {{msg-mw|flow-wikitext-editor-help-uses-wikitext}}\n* $2 is {{msg-mw|flow-wikitext-editor-help-preview-the-result}}",
+ "flow-wikitext-editor-help-uses-wikitext": "Link to wikitext help. Used in the following messages:\n* {{msg-mw|flow-wikitext-editor-help}}\n* {{msg-mw|flow-wikitext-editor-help-and-preview}}",
+ "flow-wikitext-editor-help-preview-the-result": "Text of link that will switch from wikitext editor to an html preview. See also flow-wikitext-editor-help",
+ "flow-wikitext-switch-editor-tooltip": "Tooltip for button shown below a wikitext editor to switch to VisualEditor.",
+ "flow-ve-switch-editor-tool-title": "Tooltip for button in visualeditor toolbar to switch to the wikitext editor."
+}
diff --git a/Flow/i18n/qu.json b/Flow/i18n/qu.json
new file mode 100644
index 00000000..b9d9cb9d
--- /dev/null
+++ b/Flow/i18n/qu.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "AlimanRuna"
+ ]
+ },
+ "flow-thank-link": "{{GENDER:$1|Añaychay}}",
+ "flow-last-modified": "Qhipaq hukchasqa $1 ñaqha"
+}
diff --git a/Flow/i18n/ro.json b/Flow/i18n/ro.json
new file mode 100644
index 00000000..6cc80554
--- /dev/null
+++ b/Flow/i18n/ro.json
@@ -0,0 +1,41 @@
+{
+ "@metadata": {
+ "authors": [
+ "Minisarm",
+ "Tuxilina"
+ ]
+ },
+ "flow-user-moderated": "Utilizator moderat",
+ "flow-topic-moderated-reason-prefix": "Motiv:",
+ "flow-post-actions": "Acțiuni",
+ "flow-topic-actions": "Acțiuni",
+ "flow-cancel": "Revocare",
+ "flow-preview": "Previzualizare",
+ "flow-show-change": "Arată modificările",
+ "flow-stub-post-content": "''Din cauza unei erori de natură tehnică, acest mesaj nu a putut fi obținut.''",
+ "flow-newtopic-title-placeholder": "Subiect nou",
+ "flow-newtopic-header": "Adaugă un nou subiect",
+ "flow-newtopic-start-placeholder": "Începeți un nou subiect",
+ "flow-summarize-topic-placeholder": "Vă rugăm să faceți un rezumat al acestei discuții",
+ "flow-history-action-delete-post": "șterge",
+ "flow-history-action-hide-post": "ascunde",
+ "flow-history-action-lock-topic": "blochează",
+ "flow-history-action-unlock-topic": "deblochează",
+ "flow-post-action-view": "Legătură permanentă",
+ "flow-post-action-post-history": "Istoric",
+ "flow-post-action-delete-post": "Șterge",
+ "flow-post-action-hide-post": "Ascunde",
+ "flow-post-action-edit-post": "Modifică",
+ "flow-post-action-edit-post-submit": "Salvează modificările",
+ "flow-topic-action-view": "Legătură permanentă",
+ "flow-topic-action-history": "Istoric",
+ "flow-topic-action-hide-topic": "Ascunde subiectul",
+ "flow-topic-action-summarize-topic": "Rezumare",
+ "flow-topic-action-resummarize-topic": "Modifică rezumatul",
+ "flow-error-http": "A apărut o eroare în momentul accesării serverului.",
+ "flow-error-other": "A intervenit o eroare neașteptată.",
+ "flow-error-external": "A intervenit o eroare.<br />Mesajul de eroare primit a fost: $1.",
+ "flow-error-edit-restricted": "Nu aveți permisiunea de a modifica acest mesaj.",
+ "flow-error-topic-is-locked": "Acest subiect este blocat pentru orice acțiuni suplimentare.",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|a redenumit}} subiectul dv."
+}
diff --git a/Flow/i18n/roa-tara.json b/Flow/i18n/roa-tara.json
new file mode 100644
index 00000000..e402c12c
--- /dev/null
+++ b/Flow/i18n/roa-tara.json
@@ -0,0 +1,88 @@
+{
+ "@metadata": {
+ "authors": [
+ "Joetaras"
+ ]
+ },
+ "enableflow": "Abbilite Flow",
+ "flow-desc": "Sisteme de Gestione de le Flusse de fatìe",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|scangellate}} 'nu [$4 messàgge] 'u \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|repristinate}} 'nu [$4 messàgge] 'u \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|luate}} 'nu [$4 messàgge] 'u \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|scangellate}} 'nu [$4 messàgge] 'u \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|scangellate}} l' argomende \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|repristinate}} l'argomende \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|luate}} l'argomende \"[[$3|$5]]\" sus a [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|scangellate}} l'argomende \"[[$3|$5]]\" sus a [[$6]]",
+ "flow-user-moderated": "Utende moderate",
+ "flow-edit-header-link": "Cange 'a testate",
+ "flow-post-moderated-toggle-delete-show": "Fà 'ndrucà 'u commende {{GENDER:$1|scangellate}} da $2",
+ "flow-post-moderated-toggle-hide-hide": "Scunne 'u commende {{GENDER:$1|scunnute}} da $2",
+ "flow-post-moderated-toggle-suppress-hide": "Scunne 'u commende {{GENDER:$1|scangellate}} da $2",
+ "flow-topic-moderated-reason-prefix": "Mutive:",
+ "flow-hide-header-content": "{{GENDER:$1|Scunnute}} da $2",
+ "flow-post-actions": "Aziune",
+ "flow-topic-actions": "Aziune",
+ "flow-cancel": "Annulle",
+ "flow-preview": "Andeprime",
+ "flow-show-change": "Fa vedè le cangiaminde",
+ "flow-last-modified-by": "Urteme {{GENDER:$1|cangiate}} da $1",
+ "flow-newtopic-title-placeholder": "Argomende nuève",
+ "flow-newtopic-content-placeholder": "Manne 'nu messàgge nuève a \"$1\"",
+ "flow-newtopic-header": "Aggiunge 'n'argomende nuève",
+ "flow-newtopic-save": "Aggiunge 'n'argomende",
+ "flow-newtopic-start-placeholder": "Accuminze 'nu 'ngazzamende nuève.",
+ "flow-newtopic-first-heading": "Accuminze 'n'argomende nuève sus a $1",
+ "flow-summarize-topic-placeholder": "Pe piacere riepiloghe stu 'ngazzamende",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Commende}} 'u \"$2\"",
+ "flow-reply-topic-title-placeholder": "Respunne a \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Respunne}}",
+ "flow-reply-link": "{{GENDER:$1|Respunne}}",
+ "flow-thank-link": "{{GENDER:$1|Ringrazie}}",
+ "flow-lock-link": "{{GENDER:$1|Achiude}}",
+ "flow-history-action-suppress-post": "sopprime",
+ "flow-history-action-delete-post": "scangille",
+ "flow-history-action-hide-post": "scunne",
+ "flow-history-action-unsuppress-post": "abbevisce",
+ "flow-history-action-undelete-post": "repristine",
+ "flow-history-action-unhide-post": "fà vedè",
+ "flow-history-action-restore-post": "repristine",
+ "flow-history-action-lock-topic": "blocche",
+ "flow-history-action-unlock-topic": "sblocche",
+ "flow-post-edited": "Messàgge {{GENDER:$1|cangiate}} da $1 $2",
+ "flow-post-action-view": "Collegamende permanende",
+ "flow-post-action-post-history": "Cunde",
+ "flow-post-action-suppress-post": "Sopprime",
+ "flow-post-action-delete-post": "Scangìlle",
+ "flow-post-action-hide-post": "Scunne",
+ "flow-post-action-edit-post": "Cange",
+ "flow-post-action-edit-post-submit": "Reggistre le cangiaminde",
+ "flow-post-action-unsuppress-post": "Abbevisce",
+ "flow-post-action-undelete-post": "Repristine",
+ "flow-post-action-unhide-post": "Fà vedè",
+ "flow-post-action-restore-post": "Repristine",
+ "flow-post-action-undo-moderation": "Annulle",
+ "flow-topic-action-view": "Collegamende permanende",
+ "flow-topic-action-watchlist": "Pàggene condrollate",
+ "flow-topic-action-edit-title": "Cange 'u titole",
+ "flow-topic-action-history": "Cunde",
+ "flow-topic-action-hide-topic": "Scunne l'argomende",
+ "flow-topic-action-delete-topic": "Scangille l'argomende",
+ "flow-topic-action-lock-topic": "Blocche l'argomende",
+ "flow-topic-action-unlock-topic": "Sblocche l'argomende",
+ "flow-topic-action-summarize-topic": "Riepiloghe",
+ "flow-topic-action-resummarize-topic": "Cange 'u riepiloghe",
+ "flow-topic-action-suppress-topic": "Sopprime l'argomende",
+ "flow-topic-action-unhide-topic": "Fà vedè l'argomende",
+ "flow-topic-action-undelete-topic": "Repristine l'argomende",
+ "flow-topic-action-unsuppress-topic": "Abbevisce l'argomende",
+ "flow-topic-action-restore-topic": "Repristine l'argomende",
+ "flow-topic-action-undo-moderation": "Annulle",
+ "flow-error-http": "Ha assute 'n'errore condattanne 'u server.",
+ "flow-error-other": "Ha assute 'n'errore inaspettate.",
+ "flow-edit-title-submit": "Cange 'u titole",
+ "flow-moderation-confirmation-suppress-topic": "St'argomende ha state luate.",
+ "flow-moderation-confirmation-delete-topic": "St'argomende ha state scangellate.",
+ "flow-moderation-confirmation-hide-topic": "St'argomende ha state scunnute.",
+ "flow-topic-html-title": "$1 sus a $2"
+}
diff --git a/Flow/i18n/ru.json b/Flow/i18n/ru.json
new file mode 100644
index 00000000..9d79a52e
--- /dev/null
+++ b/Flow/i18n/ru.json
@@ -0,0 +1,526 @@
+{
+ "@metadata": {
+ "authors": [
+ "Alexandr Efremov",
+ "Ignatus",
+ "Kaganer",
+ "Midnight Gambler",
+ "Okras",
+ "Tucvbif",
+ "Meshkov.a",
+ "Agilight",
+ "HarpyWar",
+ "Nirovulf",
+ "Sunpriat",
+ "Striking Blue",
+ "NBS",
+ "Максим777",
+ "Batumski brodyaga",
+ "Dicto23456"
+ ]
+ },
+ "enableflow": "Включить Flow",
+ "flow-desc": "Система управления потоками работ",
+ "flow-talk-taken-over": "Это обсуждение использует [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Управляющий Flow-обсуждением",
+ "log-name-flow": "Журнал активности Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|удалил|удалила}} [$4 сообщение] в «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|восстановил|восстановила}} [$4 сообщение] в «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|подавил|подавила}} [$4 сообщение] в «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|удалил|удалила}} [$4 сообщение] в «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 удалил{{GENDER:$2||а}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 восстановил{{GENDER:$2||а}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|подавил|подавила}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 удалил{{GENDER:$2||а}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] в [[$3]] была импортирована из LiquidThreads в Flow",
+ "flow-user-moderated": "Участник промодерирован",
+ "flow-board-header-browse-topics-link": "Обзор тем",
+ "flow-edit-header-link": "Править описание",
+ "flow-post-moderated-toggle-hide-show": "Показать комментарий, скрытый {{GENDER:$1|участником|участницей}} $2",
+ "flow-post-moderated-toggle-delete-show": "Показать комментарий, {{GENDER:$1|удалённый}} $2",
+ "flow-post-moderated-toggle-suppress-show": "Показать комментарий, подавленный {{GENDER:$1|участником|участницей}} $2",
+ "flow-post-moderated-toggle-hide-hide": "Скрыть комментарий, {{GENDER:$1|скрытый}} $2",
+ "flow-post-moderated-toggle-delete-hide": "Скрыть комментарий, удалённый {{GENDER:$1|участником|участницей}} $2",
+ "flow-post-moderated-toggle-suppress-hide": "Скрыть комментарий, подавленный {{GENDER:$1|участником|участницей}} $2",
+ "flow-topic-moderated-reason-prefix": "Причина:",
+ "flow-hide-post-content": "Этот комментарий был скрыт {{GENDER:$1|участником|участницей}} $1 ([$2 история])",
+ "flow-hide-title-content": "Эта тема была скрыта {{GENDER:$1|участником|участницей}} $1",
+ "flow-lock-title-content": "Эта тема была закрыта {{GENDER:$1|участником|участницей}} $1",
+ "flow-hide-header-content": "Скрыто {{GENDER:$1|участником|участницей}} $2",
+ "flow-delete-post-content": "Этот комментарий был удалён {{GENDER:$1|участником|участницей}} $1 ([$2 история])",
+ "flow-delete-title-content": "Эта тема была удалена {{GENDER:$1|участником|участницей}} $1",
+ "flow-delete-header-content": "Удалено {{GENDER:$1|участником|участницей}} $2",
+ "flow-suppress-post-content": "Этот комментарий был подавлен {{GENDER:$1|участником|участницей}} $1 ([$2 история])",
+ "flow-suppress-title-content": "Эта тема была подавлена {{GENDER:$1|участником|участницей}} $1",
+ "flow-suppress-header-content": "Подавлено {{GENDER:$1|участником|участницей}} $2",
+ "flow-suppress-usertext": "<em>Имя участника подавлено</em>",
+ "flow-post-actions": "Действия",
+ "flow-topic-actions": "Действия",
+ "flow-cancel": "Отменить",
+ "flow-preview": "Предпросмотр",
+ "flow-show-change": "Показать изменения",
+ "flow-last-modified-by": "Последний раз изменено {{GENDER:$1|участником|участницей}} $1",
+ "flow-stub-post-content": "«Из-за технической ошибки это сообщение не удалось получить.»",
+ "flow-newtopic-title-placeholder": "Новая тема",
+ "flow-newtopic-content-placeholder": "Разместите новое сообщение на «$1»",
+ "flow-newtopic-header": "Добавить новую тему",
+ "flow-newtopic-save": "Добавить тему",
+ "flow-newtopic-start-placeholder": "Начать новую тему",
+ "flow-newtopic-first-heading": "Начать новую тему на странице $1",
+ "flow-summarize-topic-placeholder": "Пожалуйста, кратко опишите содержание этого обсуждения",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Комментарий}} в «$2»",
+ "flow-reply-topic-title-placeholder": "Ответ на «$1»",
+ "flow-reply-submit": "{{GENDER:$1|Ответить}}",
+ "flow-reply-link": "{{GENDER:$1|Ответить}}",
+ "flow-thank-link": "{{GENDER:$1|Поблагодарить}}",
+ "flow-lock-link": "{{GENDER:$1Закрыть}}",
+ "flow-thank-link-title": "Публично поблагодарить автора сообщения",
+ "flow-history-action-suppress-post": "подавить",
+ "flow-history-action-delete-post": "удалить",
+ "flow-history-action-hide-post": "скрыть",
+ "flow-history-action-unsuppress-post": "отменить подавление",
+ "flow-history-action-undelete-post": "отменить удаление",
+ "flow-history-action-unhide-post": "отменить скрытие",
+ "flow-history-action-restore-post": "восстановить",
+ "flow-history-action-lock-topic": "закрыть",
+ "flow-history-action-unlock-topic": "отменить закрытие",
+ "flow-post-edited": "Сообщение отредактировано {{GENDER:$1|участником|участницей}} $1 $2",
+ "flow-post-action-view": "Постоянная ссылка",
+ "flow-post-action-post-history": "История",
+ "flow-post-action-suppress-post": "Подавить",
+ "flow-post-action-delete-post": "Удалить",
+ "flow-post-action-hide-post": "Скрыть",
+ "flow-post-action-edit-post": "Править",
+ "flow-post-action-edit-post-submit": "Сохранить изменения",
+ "flow-post-action-unsuppress-post": "Отменить подавление",
+ "flow-post-action-undelete-post": "Отменить удаление",
+ "flow-post-action-unhide-post": "Отменить скрытие",
+ "flow-post-action-restore-post": "Восстановить",
+ "flow-post-action-undo-moderation": "Отменить",
+ "flow-topic-action-view": "Постоянная ссылка",
+ "flow-topic-action-watchlist": "Список наблюдения",
+ "flow-topic-action-edit-title": "Править заголовок",
+ "flow-topic-action-history": "История",
+ "flow-topic-action-hide-topic": "Скрыть тему",
+ "flow-topic-action-delete-topic": "Удалить тему",
+ "flow-topic-action-lock-topic": "Закрыть тему",
+ "flow-topic-action-unlock-topic": "Отменить закрытие темы",
+ "flow-topic-action-summarize-topic": "Кратко описать содержание",
+ "flow-topic-action-resummarize-topic": "Править краткое содержание",
+ "flow-topic-action-suppress-topic": "Подавить тему",
+ "flow-topic-action-unhide-topic": "Отменить скрытие темы",
+ "flow-topic-action-undelete-topic": "Отменить удаление темы",
+ "flow-topic-action-unsuppress-topic": "Отменить подавление темы",
+ "flow-topic-action-restore-topic": "Восстановить тему",
+ "flow-topic-action-undo-moderation": "Отменить",
+ "flow-topic-notification-subscribe-title": "Эта тема добавлена в {{GENDER:$1|ваш}} список наблюдения.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Вы}} будете получать уведомления обо всех действиях в этой теме.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Вы}} подписались на эту доску обсуждений!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Вы}} получите уведомление, когда на этой доске появится новая тема.",
+ "flow-error-http": "Произошла ошибка при обращении к серверу.",
+ "flow-error-other": "Произошла непредвиденная ошибка.",
+ "flow-error-external": "Произошла ошибка.<br />Было получено следующее сообщение об ошибке: $1",
+ "flow-error-edit-restricted": "Вам не разрешено редактировать это сообщение.",
+ "flow-error-topic-is-locked": "Эта тема закрыта для любой деятельности.",
+ "flow-error-lock-moderated-post": "Вы не можете закрыть промодерированное сообщение.",
+ "flow-error-external-multi": "Были обнаружены ошибки.<br />$1",
+ "flow-error-missing-content": "Сообщение не имеет содержимого. Для сохранения сообщения требуется содержимое.",
+ "flow-error-missing-summary": "В кратком содержании нет содержимого. Для того, чтобы сохранить краткое содержание, требуется содержимое.",
+ "flow-error-missing-title": "Тема не имеет заголовка. Заголовок необходим для сохранения темы.",
+ "flow-error-parsoid-failure": "Не удаётся выполнить разбор содержимого из-за сбоя Parsoid.",
+ "flow-error-missing-replyto": "Параметр «ответить на» не был предоставлен. Этот параметр является обязательным для действия «ответить».",
+ "flow-error-invalid-replyto": "Недопустимый параметр «ответить на». Не удалось найти указанное сообщение.",
+ "flow-error-delete-failure": "Не удалось удалить этот элемент.",
+ "flow-error-hide-failure": "Не удалось скрыть этот элемент.",
+ "flow-error-missing-postId": "Параметр «идентификатор сообщения» не был предоставлен. Этот параметр является обязательным для управления сообщением.",
+ "flow-error-invalid-postId": "Недопустимый параметр «идентификатор сообщения». Указанное сообщение ($1) не удается найти.",
+ "flow-error-restore-failure": "Не удалось восстановить этот элемент.",
+ "flow-error-invalid-moderation-state": "Для Flow API было представлено недопустимое значение параметра ('moderationState').",
+ "flow-error-invalid-moderation-reason": "Пожалуйста, введите причину модерации.",
+ "flow-error-not-allowed": "Недостаточно прав для выполнения этого действия.",
+ "flow-error-not-allowed-hide": "Эта тема была скрыта.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Вы не можете ответить, потому что эта тема была скрыта.",
+ "flow-error-not-allowed-delete": "Эта тема была удалена.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Вы не можете ответить, потому что эта тема была удалена.",
+ "flow-error-not-allowed-suppress": "Эта тема была удалена.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Вы не можете ответить, потому что эта тема была удалена.",
+ "flow-error-not-allowed-hide-extract": "Эта тема была скрыта. Журнал скрытий для этой темы приводится ниже для справки.",
+ "flow-error-not-allowed-delete-extract": "Эта тема была удалена. Журнал удалений для этой темы приводится ниже для справки.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Вы не можете ответить, потому что эта тема была удалена. Журнал удалений для этой темы приводится ниже для справки.",
+ "flow-error-not-allowed-suppress-extract": "Эта тема была удалена. Журнала удалений для этой темы приводится ниже для справки.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Вы не можете ответить, потому что эта тема была подавлена. Журнал подавлений для этой темы приводится ниже для справки.",
+ "flow-error-title-too-long": "Заголовки тем ограничены $1 {{PLURAL:$1|байтом|байтами}}.",
+ "flow-error-no-existing-workflow": "Этот поток работ ещё не существует.",
+ "flow-error-not-a-post": "Название темы не может быть сохранено как сообщение.",
+ "flow-error-missing-header-content": "Описание не имеет содержимого. Для того, чтобы сохранить описание, требуется содержимое.",
+ "flow-error-missing-prev-revision-identifier": "Отсутствует идентификатор предыдущей версии.",
+ "flow-error-prev-revision-mismatch": "Другой участник отредактировал это сообщение несколько секунд назад. {{GENDER:$3|Вы}} уверены, что хотите перезаписать недавние изменения?",
+ "flow-error-prev-revision-does-not-exist": "Не удалось найти предыдущую версию.",
+ "flow-error-core-topic-deletion": "Чтобы удалить тему, используйте ... меню на доске Flow или [$1 на странице темы]. Не посещайте действие=удалить для темы напрямую.",
+ "flow-error-default": "Произошла ошибка.",
+ "flow-error-invalid-input": "Было предоставлено недопустимое значение для загрузки содержимого Flow.",
+ "flow-error-invalid-title": "Было предоставлено недопустимое название страницы.",
+ "flow-error-fail-load-history": "Не удалось загрузить содержимое истории.",
+ "flow-error-missing-revision": "Не удалось найти версию для загрузки содержимого Flow.",
+ "flow-error-fail-commit": "Не удалось сохранить содержимое Flow.",
+ "flow-error-insufficient-permission": "Недостаточно прав для доступа к содержимому.",
+ "flow-error-revision-comparison": "Операция сравнения может быть сделана только для двух версий, принадлежащих одному и тому же сообщению.",
+ "flow-error-missing-topic-title": "Не удалось найти название темы для текущего потока работ.",
+ "flow-error-missing-metadata": "Не удалось найти необходимые метаданные для этой версии.",
+ "flow-error-fail-load-data": "Не удалось загрузить запрошенные данные.",
+ "flow-error-invalid-workflow": "Не удалось найти запрошенный поток работ.",
+ "flow-error-process-data": "Произошла ошибка при обработке данных в вашем запросе.",
+ "flow-error-process-wikitext": "Произошла ошибка при обработке преобразования HTML/викитекста.",
+ "flow-error-no-index": "Не удалось найти индекс для выполнения поиска данных.",
+ "flow-error-no-render": "Указанное действие не было распознано.",
+ "flow-error-no-commit": "Указанное действие не может быть сохранено.",
+ "flow-error-fetch-after-lock": "Произошла ошибка при запросе новых данных. Но операция закрытия/отмены закрытия прошла успешно. Сообщение об ошибке: $1",
+ "flow-error-content-too-long": "Содержимое является слишком большим. После расширения, содержимое ограничено {{PLURAL:$1|1=байтом|$1 байтами}}.",
+ "flow-error-move": "Перемещение доски обсуждений в настоящее время не поддерживается.",
+ "flow-error-invalid-topic-uuid-title": "Недопустимое название",
+ "flow-error-invalid-topic-uuid": "Запрашиваемое название страницы является недопустимым. Страницы в пространстве имен Topic создаются Flow автоматически.",
+ "flow-error-unknown-workflow-id-title": "Неизвестная тема",
+ "flow-error-unknown-workflow-id": "Запрошенная тема не существует.",
+ "flow-edit-header-placeholder": "Опишите эту доску обсуждений",
+ "flow-edit-header-submit": "Сохранить описание",
+ "flow-edit-header-submit-overwrite": "Перезаписать описание",
+ "flow-summarize-topic-submit": "Записать краткое содержание",
+ "flow-summarize-topic-submit-overwrite": "Перезаписать краткое содержание",
+ "flow-lock-topic-submit": "Закрыть тему",
+ "flow-lock-topic-submit-overwrite": "Перезаписать краткое содержание закрытой темы",
+ "flow-unlock-topic-submit": "Отменить закрытие темы",
+ "flow-unlock-topic-submit-overwrite": "Перезаписать краткое содержание открытой темы",
+ "flow-edit-title-submit": "Изменить заголовок",
+ "flow-edit-title-submit-overwrite": "Перезаписать заголовок",
+ "flow-edit-post-submit": "Подтвердить изменения",
+ "flow-edit-post-submit-overwrite": "Перезаписать изменения",
+ "flow-rev-message-edit-post": "$1 отредактировал{{GENDER:$2||а}} [$3 комментарий] в «$4»",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Отредактировал|Отредактировала}} сообщение",
+ "flow-rev-message-reply": "$1 [$3 прокомментировал{{GENDER:$2||а}}] «$4» (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|комментарий|комментария|комментариев}}</strong> {{PLURAL:$1|был|было|были}} {{PLURAL:$1|добавлен|добавлено|добавлены}}",
+ "flow-rev-message-new-post": "$1 создал{{GENDER:$2||а}} тему «[$3 $4]»",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Создал|Создала}} новую тему",
+ "flow-rev-message-edit-title": "$1 изменил{{GENDER:$2||а}} название темы с «$5» на «[$3 $4]»",
+ "flow-rev-message-create-header": "$1 создал{{GENDER:$2||а}} описание",
+ "flow-rev-message-edit-header": "$1 отредактировал{{GENDER:$2||а}} описание",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|создал|создала}} краткое описание темы «$3»",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|отредактировал|отредактировала}} краткое содержание темы «$3»",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|скрыл|скрыла}} [$4 комментарий] в «$6» (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 удалил{{GENDER:$2||а}} [$4 комментарий] в «$6» (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|подавил|подавила}} [$4 комментарий] в «$6» (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|восстановил|восстановила}} [$4 комментарий] в «$6» (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|скрыл|скрыла}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|удалил|удалила}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|подавил|подавила}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 закрыл{{GENDER:$2||а}} [$4 тему] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|восстановил|восстановила}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rc-topic-of-board": "$1 на $2",
+ "flow-board-history": "История «$1»",
+ "flow-board-history-empty": "У этой доски в настоящее время нет истории.",
+ "flow-topic-history": "История темы «$1»",
+ "flow-post-history": "«Комментарий {{GENDER:$2|$2}}» история сообщения",
+ "flow-history-last4": "За последние 4 часа",
+ "flow-history-day": "Сегодня",
+ "flow-history-week": "На прошлой неделе",
+ "flow-history-pages-topic": "Показывается на [$1 доске «$2»]",
+ "flow-history-pages-post": "Показывается в [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 комментарий|$1 комментария|$1 комментариев|0={{GENDER:$2|Оставь первым|Оставь первой}} комментарий!}}",
+ "flow-comment-restored": "Восстановленный комментарий",
+ "flow-comment-deleted": "Удалённый комментарий",
+ "flow-comment-hidden": "Скрытый комментарий",
+ "flow-comment-moderated": "Промодерированный комментарий",
+ "flow-last-modified": "Последнее изменение около $1",
+ "flow-workflow": "поток работ",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1| ответил|ответила}} на '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 и $5 {{PLURAL:$6|другой|других}} {{GENDER:$1|ответил|ответила}} на странице '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 отредактировал{{GENDER:$1||а}} ваше <span class=\"plainlinks\">[$5 сообщение]</span> на [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 и $5 {{PLURAL:$6|другой|других}} {{GENDER:$1|отредактировал|отредактировала}} <span class=\"plainlinks\">[$4 сообщение]</span> в «$2» на «$3».",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|создал|создала}} новую тему на '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} нов{{PLURAL:$1|ая тема|ых темы|ых тем}} в '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|изменил|изменила}} заголовок темы <span class=\"plainlinks\">[$2 $3]</span> на «$4» на странице [[$5|$6]].",
+ "flow-notification-mention": "$1 упомянул{{GENDER:$1||а}} {{GENDER:$5|вас}} в {{GENDER:$1|своём}} <span class=\"plainlinks\">[$2 сообщении]</span> в «$3» на «$4».",
+ "flow-notification-link-text-view-post": "Посмотреть сообщение",
+ "flow-notification-link-text-view-topic": "Посмотреть тему",
+ "flow-notification-reply-email-subject": "$2 на $3",
+ "flow-notification-reply-email-batch-body": "$1 ответил{{GENDER:$1||а}} в «$2» на «$3»",
+ "flow-notification-reply-email-batch-bundle-body": "$1 и $4 {{PLURAL:$5|другой|других}} {{GENDER:$1|ответил|ответила}} в «$2» на «$3»",
+ "flow-notification-mention-email-subject": "$1 упомянул{{GENDER:$1||а}} {{GENDER:$3|вас}} в «$2»",
+ "flow-notification-mention-email-batch-body": "$1 упомянул{{GENDER:$1||а}} {{GENDER:$4|вас}} в {{GENDER:$1|своём}} сообщении в «$2» на «$3»",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|отредактировал|отредактировала}} сообщение",
+ "flow-notification-edit-email-batch-body": "$1 отредактировал{{GENDER:$2||а}} сообщение в «$2» на «$3»",
+ "flow-notification-edit-email-batch-bundle-body": "$1 и $4 {{PLURAL:$5|другой|других}} {{GENDER:$1|отредактировал|отредактировала}} сообщение в «$2» на «$3»",
+ "flow-notification-rename-email-subject": "$1 переименовал{{GENDER:$1||а}} вашу тему",
+ "flow-notification-rename-email-batch-body": "$1 переименовал{{GENDER:$1||а}} вашу тему с «$2» на «$3» на странице «$4»",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|создал|создала}} новую тему на странице «$2»",
+ "flow-notification-newtopic-email-batch-body": "$1 создал{{GENDER:$1||а}} новую тему с названием «$2» на странице $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Уведомить меня, когда связанные со мной действия происходят в Flow.",
+ "flow-link-post": "сообщение",
+ "flow-link-topic": "тема",
+ "flow-link-history": "история",
+ "flow-link-post-revision": "версия сообщения",
+ "flow-link-topic-revision": "версия темы",
+ "flow-link-header-revision": "версия описания",
+ "flow-link-summary-revision": "версия краткого содержания",
+ "flow-moderation-title-suppress-post": "Подавить сообщение?",
+ "flow-moderation-title-delete-post": "Удалить сообщение?",
+ "flow-moderation-title-hide-post": "Скрыть сообщение?",
+ "flow-moderation-title-unsuppress-post": "Отменить подавление сообщения?",
+ "flow-moderation-title-undelete-post": "Отменить удаление сообщения?",
+ "flow-moderation-title-unhide-post": "Отменить скрытие сообщения?",
+ "flow-moderation-placeholder-suppress-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы подавляете это сообщение.",
+ "flow-moderation-placeholder-delete-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы удаляете это сообщение.",
+ "flow-moderation-placeholder-hide-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы скрываете это сообщение.",
+ "flow-moderation-placeholder-unsuppress-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете подавление этого сообщения.",
+ "flow-moderation-placeholder-undelete-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете удаление этого сообщения.",
+ "flow-moderation-placeholder-unhide-post": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете скрытие этого сообщения.",
+ "flow-moderation-confirm-suppress-post": "Подавить",
+ "flow-moderation-confirm-delete-post": "Удалить",
+ "flow-moderation-confirm-hide-post": "Скрыть",
+ "flow-moderation-confirm-unsuppress-post": "Отменить подавление",
+ "flow-moderation-confirm-undelete-post": "Отменить удаление",
+ "flow-moderation-confirm-unhide-post": "Отменить скрытие",
+ "flow-moderation-confirm-suppress-topic": "Подавить",
+ "flow-moderation-confirm-delete-topic": "Удалить",
+ "flow-moderation-confirm-hide-topic": "Скрыть",
+ "flow-moderation-confirm-lock-topic": "Закрыть",
+ "flow-moderation-confirm-unsuppress-topic": "Отменить подавление",
+ "flow-moderation-confirm-undelete-topic": "Отменить удаление",
+ "flow-moderation-confirm-unhide-topic": "Отменить скрытие",
+ "flow-moderation-confirm-unlock-topic": "Отменить закрытие",
+ "flow-moderation-confirmation-suppress-post": "Сообщение было успешно подавлено.\n{{GENDER:$2|Рассмотрите}} возможность предоставления для $1 отзыва на это сообщение.",
+ "flow-moderation-confirmation-delete-post": "Сообщение было успешно удалено.\n{{GENDER:$2|Рассмотрите}} возможность предоставления для $1 отзыва на это сообщение.",
+ "flow-moderation-confirmation-hide-post": "Сообщение было успешно скрыто.\n{{GENDER:$2|Рассмотрите}} возможность предоставления для $1 отзыва на это сообщение.",
+ "flow-moderation-confirmation-unsuppress-post": "Вы успешно отменили подавление вышеуказанного сообщения.",
+ "flow-moderation-confirmation-undelete-post": "Вы успешно отменили удаление вышеуказанного сообщения.",
+ "flow-moderation-confirmation-unhide-post": "Вы успешно отменили скрытие вышеуказанного сообщения.",
+ "flow-moderation-confirmation-suppress-topic": "Эта тема была подавлена.",
+ "flow-moderation-confirmation-delete-topic": "Эта тема была удалена.",
+ "flow-moderation-confirmation-hide-topic": "Эта тема была скрыта.",
+ "flow-moderation-confirmation-unsuppress-topic": "Вы успешно отменили подавление этой темы.",
+ "flow-moderation-confirmation-undelete-topic": "Вы успешно отменили удаление этой темы.",
+ "flow-moderation-confirmation-unhide-topic": "Вы успешно отменили скрытие этой темы.",
+ "flow-moderation-title-suppress-topic": "Подавить тему?",
+ "flow-moderation-title-delete-topic": "Удалить тему?",
+ "flow-moderation-title-hide-topic": "Скрыть тему?",
+ "flow-moderation-title-unsuppress-topic": "Отменить подавление темы?",
+ "flow-moderation-title-undelete-topic": "Отменить удаление темы?",
+ "flow-moderation-title-unhide-topic": "Отменить скрытие темы?",
+ "flow-moderation-placeholder-suppress-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы подавляете эту тему.",
+ "flow-moderation-placeholder-delete-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы удаляете эту тему.",
+ "flow-moderation-placeholder-hide-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы хотите скрыть эту тему.",
+ "flow-moderation-placeholder-lock-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы закрываете эту тему.",
+ "flow-moderation-placeholder-unsuppress-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете подавление этой темы.",
+ "flow-moderation-placeholder-undelete-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете удаление этой темы.",
+ "flow-moderation-placeholder-unhide-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете скрытие этой темы.",
+ "flow-moderation-placeholder-unlock-topic": "Пожалуйста, {{GENDER:$3|поясните}} почему вы отменяете закрытие этой темы.",
+ "flow-topic-permalink-warning": "Эта тема была начата на [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Эта тема была начата на [$2 доске {{GENDER:$1|участника|участницы}} $1]",
+ "flow-revision-permalink-warning-post": "Это постоянная ссылка на одну версию этого сообщения.\nЭта версия от $1.\nВы можете увидеть [$5 отличия от предыдущей версии], или просмотреть другие версии на [$4 на странице истории этого сообщения].",
+ "flow-revision-permalink-warning-post-first": "Это постоянная ссылка на первую версию этого сообщения.\nВы можете посмотреть более поздние версии на [$4 странице истории этого сообщения].",
+ "flow-revision-permalink-warning-postsummary": "Это постоянная ссылка на одну версию краткого содержания этого сообщения. Эта версия от $1.\nВы можете увидеть [$5 отличия от предыдущей версии], или просмотреть другие версии на [$4 странице истории этого сообщения].",
+ "flow-revision-permalink-warning-postsummary-first": "Это постоянная ссылка на первую версию краткого содержания этого сообщения.\nВы можете посмотреть более поздние версии на [$4 странице истории этого сообщения].",
+ "flow-revision-permalink-warning-header": "Это постоянная ссылка на одну версию описания.\nЭта версия от $1. Вы можете увидеть [$3 отличия от предыдущей версии], или просмотреть другие версии на [$2 странице истории этой доски].",
+ "flow-revision-permalink-warning-header-first": "Это постоянная ссылка на первую версию этого описания.\nВы можете посмотреть более поздние версии на [$2 странице истории этой доски].",
+ "flow-compare-revisions-revision-header": "Версия {{GENDER:$2|участника|участницы}} $2 от $1",
+ "flow-compare-revisions-header-post": "На этой странице показаны {{GENDER:$3|изменения}} между двумя версиями сообщения от участника $3 в теме «[$5 $2]» на [$4 $1].\nВы можете посмотреть другие версии этого сообщения на его [$6 странице истории].",
+ "flow-compare-revisions-header-postsummary": "Эта страница показывает изменения между двумя версиями краткого содержания в сообщении «[$4 $2]» на [$3 $1].\nВы можете увидеть другие версии этого сообщения на его [$5 странице истории].",
+ "flow-compare-revisions-header-header": "Эта страница показывает {{GENDER:$2|изменения}} между двумя версиями описания на [$3 $1].\nВы можете увидеть другие версии описания на её [$4 странице истории].",
+ "action-flow-create-board": "создание досок Flow в любом месте",
+ "right-flow-create-board": "Создавать доски Flow в любом месте",
+ "right-flow-hide": "Скрывать темы и сообщения Flow",
+ "right-flow-lock": "Закрывать Flow-темы",
+ "right-flow-delete": "Удалять темы и сообщения Flow",
+ "right-flow-edit-post": "Править Flow-сообщения других участников",
+ "right-flow-suppress": "Подавлять версии Flow",
+ "flow-terms-of-use-new-topic": "Нажимая «{{int:flow-newtopic-save}}», вы соглашаетесь с условиями использования этой вики.",
+ "flow-terms-of-use-reply": "Нажимая «{{int:flow-reply-submit}}», вы соглашаетесь с условиями использования этой вики.",
+ "flow-terms-of-use-edit": "Сохраняя изменения, вы соглашаетесь с условиями использования этой вики.",
+ "flow-anon-warning": "Вы не вошли в систему. Чтобы получить соотнесение с вашим именем вместо вашего IP-адреса, вы можете [$1 войти] или [$2 создать аккаунт].",
+ "flow-cancel-warning": "У вас есть введённый текст в этой форме. Вы уверены, что вы хотите удалить его?",
+ "flow-topic-first-heading": "Тема на странице $1",
+ "flow-topic-html-title": "$1 на $2",
+ "flow-topic-count": "Темы ($1)",
+ "flow-load-more": "Загрузить ещё",
+ "flow-no-more-fwd": "Нет старых тем",
+ "flow-add-topic": "Добавить тему",
+ "flow-newest-topics": "Новейшие темы",
+ "flow-recent-topics": "Недавно активные темы",
+ "flow-sorting-tooltip-newest": "Сейчас {{GENDER:|вы}} читаете сначала новейшие темы. Нажмите для выбора параметров сортировки.",
+ "flow-sorting-tooltip-recent": "Сейчас {{GENDER:|вы}} читаете сначала самые активные темы. Нажмите для выбора параметров сортировки.",
+ "flow-toggle-small-topics": "Переключиться в режим малых тем",
+ "flow-toggle-topics": "Переключиться в режим только тем",
+ "flow-toggle-topics-posts": "Переключитесь в режим тем и сообщений",
+ "flow-terms-of-use-summarize": "Нажимая «{{int:flow-summarize-topic-submit}}», вы соглашаетесь с условиями использования этой вики.",
+ "flow-terms-of-use-lock-topic": "Нажимая \"{{int:flow-lock-topic-submit}}\", вы соглашаетесь с условиями использования этой вики.",
+ "flow-terms-of-use-unlock-topic": "Нажимая \"{{int:flow-unlock-topic-submit}}\", вы соглашаетесь с условиями использования этой вики.",
+ "flow-whatlinkshere-post": "из [$1 сообщения]",
+ "flow-whatlinkshere-header": "из [$1 описания]",
+ "flow": "Flow",
+ "flow-special-desc": "Эта специальная страница перенаправляет на Flow-поток работ или на Flow-сообщение по UUID.",
+ "flow-special-type": "Тип",
+ "flow-special-type-post": "Сообщение",
+ "flow-special-type-workflow": "Поток работ",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Не удалось найти содержимое, соответствующее типу и UUID.",
+ "flow-special-enableflow-legend": "Включить Flow на новой странице",
+ "flow-special-enableflow-page": "Страница для включения Flow на ней",
+ "flow-special-enableflow-header": "Начальное описание доски Flow (викитекст)",
+ "flow-special-enableflow-board-already-exists": "Там уже доска Flow на [[$1]].",
+ "flow-special-enableflow-invalid-title": "Недопустимое название у предоставленной страницы",
+ "flow-special-enableflow-page-already-exists": "Там уже есть не Flow страница на [[$1]]. Если вы все ещё хотите разместить там доску Flow, пожалуйста переместите существующую страницу в архив, удалите перенаправление, затем используйте Служебная:EnableFlow снова. Включите имя архива в описание.",
+ "flow-special-enableflow-confirmation": "Вы успешно создали доску Flow на [[$1]].",
+ "flow-spam-confirmedit-form": "Пожалуйста, введите текст с картинки ниже, если вы не робот: $1",
+ "flow-preview-warning": "Вы видите предварительный просмотр. Нажмите «{{int:flow-newtopic-save}}» для публикации или «{{int:flow-preview-return-edit-post}}», чтобы продолжить писать.",
+ "flow-preview-return-edit-post": "Продолжить редактирование",
+ "flow-anonymous": "Аноним",
+ "flow-embedding-unsupported": "Обсуждения пока не могут быть вставленным куда-либо ещё.",
+ "mw-ui-unsubmitted-confirm": "У вас есть неприменённые изменения на этой странице. Вы уверены, что хотите уйти и потерять изменения?",
+ "flow-post-undo-hide": "отмена скрытия",
+ "flow-post-undo-delete": "отмена удаления",
+ "flow-post-undo-suppress": "отмена подавления",
+ "flow-topic-undo-hide": "отмена скрытия",
+ "flow-topic-undo-delete": "отменить удаление",
+ "flow-topic-undo-suppress": "отмена подавления",
+ "flow-importer-lqt-moved-thread-template": "LQT Moved thread stub преобразован во Flow",
+ "flow-importer-lqt-converted-template": "LQT-страница преобразована во Flow",
+ "flow-importer-lqt-converted-archive-template": "Архив преобразованной LQT-страницы",
+ "flow-importer-wt-converted-template": "Вики-текст страницы обсуждения преобразован во Flow",
+ "flow-importer-wt-converted-archive-template": "Архив преобразованного вики-текста страницы обсуждения",
+ "flow-importer-lqt-suppressed-user-template": "Эта версия была импортирована из LiquidThreads с подавленным пользователем. Она была переназначена на текущего пользователя.",
+ "apihelp-flow-description": "Позволяет применять действия к Flow-страницам.",
+ "apihelp-flow-param-submodule": "Вызываемый подмодуль Flow",
+ "apihelp-flow-param-page": "Страница, к которой применяются действия.",
+ "apihelp-flow-param-render": "Задайте в это что-либо, чтобы включить основанный на блоках рендеринг на выходе.",
+ "apihelp-flow-example-1": "Править описание у «[[Talk:Sandbox]]»",
+ "apihelp-flow+close-open-topic-description": "Устарело в пользу [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "Состояние, задаваемое теме, - закрыта или открыта",
+ "apihelp-flow+close-open-topic-param-reason": "Основание для закрытия или отмены закрытия темы.",
+ "apihelp-flow+edit-header-description": "Правки описания доски.",
+ "apihelp-flow+edit-header-param-prev_revision": "Revision ID текущей версии описания, для проверки на конфликты редактирования.",
+ "apihelp-flow+edit-header-param-content": "Содержимое для описания.",
+ "apihelp-flow+edit-header-param-format": "Формат описания (wikitext|html)",
+ "apihelp-flow+edit-header-example-1": "Править описание у [[Talk:Sandbox]]",
+ "apihelp-flow+edit-header-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+edit-post-description": "Правки содержимого сообщения.",
+ "apihelp-flow+edit-post-param-postId": "ID сообщения.",
+ "apihelp-flow+edit-post-param-prev_revision": "Revision ID текущей версии сообщения, для проверки на конфликты редактирования.",
+ "apihelp-flow+edit-post-param-content": "Содержимое для сообщения.",
+ "apihelp-flow+edit-post-param-format": "Формат содержимого сообщения (wikitext|html)",
+ "apihelp-flow+edit-post-example-1": "Править сообщение в [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-post-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+edit-title-description": "Правки заголовка темы.",
+ "apihelp-flow+edit-title-param-prev_revision": "Revision ID текущей версии заголовка, для проверки на конфликты редактирования.",
+ "apihelp-flow+edit-title-param-content": "Содержимое для заголовка.",
+ "apihelp-flow+edit-title-example-1": "Править заголовок у [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+edit-topic-summary-description": "Правки содержимого краткого содержания темы.",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "Revision ID текущей версии краткого содержания темы, для проверки на конфликты редактирования.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Содержимое для краткого содержания.",
+ "apihelp-flow+edit-topic-summary-param-format": "Формат краткого содержания (wikitext|html)",
+ "apihelp-flow+edit-topic-summary-example-1": "Править краткое содержание в [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+lock-topic-description": "Закрыть или отменить закрытие Flow-темы.",
+ "apihelp-flow+lock-topic-param-moderationState": "Состояние, задаваемое теме, - закрыта или открыта",
+ "apihelp-flow+lock-topic-param-reason": "Причина закрытия или отмены закрытия темы.",
+ "apihelp-flow+lock-topic-example-1": "Закрыть [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+moderate-post-description": "Модерирования Flow-сообщения.",
+ "apihelp-flow+moderate-post-param-moderationState": "Какой уровень модерации.",
+ "apihelp-flow+moderate-post-param-reason": "Причина модерации.",
+ "apihelp-flow+moderate-post-param-postId": "ID сообщения для модерации.",
+ "apihelp-flow+moderate-post-example-1": "Удалить сообщение в теме [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+moderate-topic-description": "Модерирования Flow-темы.",
+ "apihelp-flow+moderate-topic-param-moderationState": "Какой уровень модерации.",
+ "apihelp-flow+moderate-topic-param-reason": "Причина модерации.",
+ "apihelp-flow+moderate-topic-example-1": "Удалить тему [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+new-topic-description": "Создает новую Flow-тему в данном потоке работ.",
+ "apihelp-flow+new-topic-param-topic": "Текст для нового заголовка темы.",
+ "apihelp-flow+new-topic-param-content": "Содержимое для начального ответа в теме.",
+ "apihelp-flow+new-topic-param-format": "Формат начального ответа в новой теме (wikitext|html)",
+ "apihelp-flow+new-topic-example-1": "Создать новую тему в [[Talk:Sandbox]]",
+ "apihelp-flow+new-topic-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+reply-description": "Ответы на сообшение.",
+ "apihelp-flow+reply-param-replyTo": "ID сообщения для ответа.",
+ "apihelp-flow+reply-param-content": "Содержимое для нового сообщения.",
+ "apihelp-flow+reply-param-format": "Формат нового сообщения (wikitext|html)",
+ "apihelp-flow+reply-example-1": "Ответить на сообщение в [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+reply-param-metadataonly": "Следует ли включить только метаданные о новом содержимом, исключая всё остальное",
+ "apihelp-flow+view-header-description": "Посмотреть описание доски.",
+ "apihelp-flow+view-header-param-contentFormat": "Формат, в котором вернуть содержимое",
+ "apihelp-flow+view-header-param-revId": "Загрузить эту версию, вместо текущей.",
+ "apihelp-flow+view-header-example-1": "Извлечь описание у [[Talk:Sandbox]] как вики-текст",
+ "apihelp-flow+view-post-description": "Посмотреть сообщение.",
+ "apihelp-flow+view-post-param-postId": "ID сообщения для просмотра.",
+ "apihelp-flow+view-post-param-contentFormat": "Формат, в котором вернуть содержимое",
+ "apihelp-flow+view-post-example-1": "Извлечь содержимое сообщения у [[Topic:S2tycnas4hcucw8w]] как вики-текст",
+ "apihelp-flow+view-topic-description": "Посмотреть тему.",
+ "apihelp-flow+view-topic-example-1": "Просмотреть [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "Посмотреть краткое содержание темы.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "Формат, в котором вернуть содержимое",
+ "apihelp-flow+view-topic-summary-param-revId": "Загрузить эту версию, вместо текущей.",
+ "apihelp-flow+view-topic-summary-example-1": "Посмотреть краткое содержание у [[Topic:S2tycnas4hcucw8w]] как вики-текст",
+ "apihelp-flow+view-topiclist-description": "Посмотреть список тем.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Направление порядка тем.",
+ "apihelp-flow+view-topiclist-param-sortby": "Вариант сортировки тем.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Сохранить параметр sortby, если установлен.",
+ "apihelp-flow+view-topiclist-param-offset-id": "Значение смещения (в формате UUID), чтобы начать выборку тем.",
+ "apihelp-flow+view-topiclist-param-offset": "Значение смещения, чтобы начать выборку тем.",
+ "apihelp-flow+view-topiclist-param-limit": "Количество тем для извлечения.",
+ "apihelp-flow+view-topiclist-param-render": "Отобразить темы в HTML.",
+ "apihelp-flow+view-topiclist-example-1": "Список тем в [[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "Преобразовать текст между вики-текстом и HTML.",
+ "apihelp-flow-parsoid-utils-param-from": "Формат из которого преобразуется содержимое.",
+ "apihelp-flow-parsoid-utils-param-to": "Формат в который преобразуется содержимое.",
+ "apihelp-flow-parsoid-utils-param-content": "Содержимое которое нежно преобразовать",
+ "apihelp-flow-parsoid-utils-param-title": "Заголовок страницы. Не может использоваться вместе с $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "ID страницы. Не может использоваться вместе с $1title.",
+ "apihelp-flow-parsoid-utils-example-1": "Преобразование вики-текста <nowiki>'''lorem''' ''blah''</nowiki> в HTML",
+ "apihelp-query+flowinfo-description": "Получить основную Flow-информацию о странице.",
+ "apihelp-query+flowinfo-example-1": "Извлечь Flow-информацию о [[Talk:Sandbox]], [[Main Page]] и [[Talk:Flow]]",
+ "apihelp-flow+undo-edit-header-description": "Получить информацию, необходимую для отмены правок описания.",
+ "apihelp-flow+undo-edit-header-param-startId": "Id версии начала отмены.",
+ "apihelp-flow+undo-edit-header-param-endId": "Id версии конца отмены.",
+ "apihelp-flow+undo-edit-header-example-1": "Извлечь информацию об отмене правки описания у [[Talk:Sandbox]]",
+ "apihelp-flow+undo-edit-post-description": "Получить информацию, необходимую для отмены правки сообщения.",
+ "apihelp-flow+undo-edit-post-param-postId": "ID сообщения, которое будет отменено.",
+ "apihelp-flow+undo-edit-post-param-startId": "Id версии начала отмены.",
+ "apihelp-flow+undo-edit-post-param-endId": "Id версии конца отмены.",
+ "apihelp-flow+undo-edit-post-example-1": "Извлечь информацию об отмене правок сообщения в отдельной теме.",
+ "apihelp-flow+undo-edit-topic-summary-description": "Получать информацию, необходимую для отмены правок краткого содердания темы.",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "Id версии начала отмены.",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "Id версии конца отмены.",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "Извлечь сведения об отмене правок краткого содержания темы в отдельной теме",
+ "flow-edited": "Отредактировано",
+ "flow-edited-by": "Отредактировано $1",
+ "flow-lqt-redirect-reason": "Перенаправление устаревшего сообщения LiquidThreads на преобразованное во Flow сообщение",
+ "flow-talk-conversion-move-reason": "Преобразование обсуждения из вики-текста во Flow со страницы $1",
+ "flow-talk-conversion-archive-edit-reason": "Преобразование обсуждения из вики-текста во Flow",
+ "flow-previous-diff": "← Предыдущая правка",
+ "flow-next-diff": "Следующая правка →",
+ "flow-undo": "отменить",
+ "flow-undo-latest-revision": "Текущая версия",
+ "flow-undo-your-text": "Ваш текст",
+ "flow-undo-edit-header": "Редактирование описания",
+ "flow-undo-edit-topic-summary": "Редактирование краткого содержания темы",
+ "flow-undo-edit-post": "Редактировании сообщения",
+ "flow-undo-edit-content": "Правка может быть отменена. Пожалуйста, проверьте сравнение версий, чтобы убедиться, что это именно то, что вы хотели, и сохраните изменения чтобы закончить отмену правки.",
+ "flow-undo-edit-failure": "Правка не может быть отменена из-за несовместимости промежуточных изменений.",
+ "group-flow-bot": "Flow-боты",
+ "group-flow-bot-member": "Flow-бот",
+ "grouppage-flow-bot": "Project:Flow bots",
+ "flow-ve-mention-context-item-label": "Упоминание",
+ "flow-ve-mention-inspector-title": "Упоминание",
+ "flow-ve-mention-inspector-remove-label": "Убрать",
+ "flow-ve-mention-tool-title": "Упомянуть участника",
+ "flow-ve-mention-template": "пинг",
+ "flow-ve-mention-inspector-invalid-user": "Участник '$1' не зарегистрирован.",
+ "flow-wikitext-editor-help": "Вики-текст $1.",
+ "flow-wikitext-editor-help-and-preview": "Вики-текст $1 и вы можете $2 в любое время.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|использует разметку]]",
+ "flow-wikitext-editor-help-preview-the-result": "предварительно просмотреть результат",
+ "flow-wikitext-switch-editor-tooltip": "Переключить на Визуальный редактор",
+ "flow-ve-switch-editor-tool-title": "Переключить на редактор вики-текста"
+}
diff --git a/Flow/i18n/sc.json b/Flow/i18n/sc.json
new file mode 100644
index 00000000..5c719851
--- /dev/null
+++ b/Flow/i18n/sc.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Taxandru"
+ ]
+ },
+ "flow-edited-ago-minute": "Cambiadu $1 {{PLURAL:$1|minutu|minutos}} a como",
+ "flow-time-ago-minute": "$1 {{PLURAL:$1|minutu|minutos}} a como"
+}
diff --git a/Flow/i18n/scn.json b/Flow/i18n/scn.json
new file mode 100644
index 00000000..3bc16097
--- /dev/null
+++ b/Flow/i18n/scn.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gmelfi"
+ ]
+ },
+ "flow-thank-link": "{{GENDER:$1|Arringràzzia}}"
+}
diff --git a/Flow/i18n/sco.json b/Flow/i18n/sco.json
new file mode 100644
index 00000000..df2de273
--- /dev/null
+++ b/Flow/i18n/sco.json
@@ -0,0 +1,84 @@
+{
+ "@metadata": {
+ "authors": [
+ "John Reid"
+ ]
+ },
+ "flow-post-moderated-toggle-hide-show": "Shaw comment {{GENDER:$1|skauk't}} bi $2",
+ "flow-post-moderated-toggle-delete-show": "Shaw comment {{GENDER:$1|delytit}} bi $2",
+ "flow-post-moderated-toggle-suppress-show": "Shaw comment {{GENDER:$1|suppressed}} bi $2",
+ "flow-post-moderated-toggle-hide-hide": "Skauk comment {{GENDER:$1|skauk't}} bi $2",
+ "flow-post-moderated-toggle-delete-hide": "Skauk comment {{GENDER:$1|delytit}} bi $2",
+ "flow-post-moderated-toggle-suppress-hide": "Skauk comment {{GENDER:$1|suppressed}} bi $2",
+ "flow-post-action-post-history": "Histerie",
+ "flow-post-action-edit-post": "Eidit",
+ "flow-post-action-unsuppress-post": "Onsuppress",
+ "flow-post-action-undelete-post": "Ondelyte",
+ "flow-post-action-unhide-post": "Onskauk",
+ "flow-topic-action-history": "Histerie",
+ "flow-topic-action-unhide-topic": "Onskauk topíc",
+ "flow-topic-action-undelete-topic": "Ondelyte topíc",
+ "flow-topic-action-unsuppress-topic": "Onsuppress topíc",
+ "flow-error-prev-revision-mismatch": "Anither uiser jyst eidited this post ae few seiconts back. Ar ye sair ye wan tae owerwrite the recent chynge?",
+ "flow-edit-header-submit-overwrite": "Owerwrite heider",
+ "flow-edit-title-submit-overwrite": "Owerwrite title",
+ "flow-edit-post-submit-overwrite": "Owerwrite chynges",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|eeditit}} ae [$3 comment] oan \"$4\".",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|commentit}}] oan \"$4\" (<em>$5</em>).",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|cræftit}} the topeec \"[$3 $4]\".",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|chynged}} the topeec title fae \"$5\" til \"[$3 $4]\".",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|makit}} the heider.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|eidited}} the heider.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|skaukt}} ae [$4 comment] oan \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|delytit}} ae [$4 comment] oan \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suppresst}} ae [$4 comment] oan \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|restored}} ae [$4 comment] oan \"$6\" (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|skaukt}} the [$4 topeec] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|delytit}} the [$4 topeec] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|suppresst}} the [$4 topeec] \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|restored}} the [$4 topeec] \"$6\" (<em>$5</em>).",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|respondit}} oan '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 n $5 {{PLURAL:$6|ither|ithers}} {{GENDER:$1|respondit}} oan '''$3'''.",
+ "flow-notification-edit": "$1 {{GENDER:$1|edited}} ae <span class=\"plainlinks\">[$5 post]</span> in \"$2\" on [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 n $5 {{PLURAL:$6|ither|ithers}} {{GENDER:$1|eidited}} ae <span class=\"plainlinks\">[$4 post]</span> in \"$2\" oan \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|creautit}} ae new topic oan '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|chynged}} the title o <span class=\"plainlinks\">[$2 $3]</span> til \"$4\" oan [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|mentioned}} {{GENDER:$5|ye}} in {{GENDER:$1|his|her|thair}} <span class=\"plainlinks\">[$2 post]</span> in \"$3\" oan \"$4\".",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|respondit}} ti yer post in \"$2\" oan \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 n $4 {{PLURAL:$5|ither|ithers}} {{GENDER:$1|respondit}} ti yer post in \"$2\" oan \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|mentioned}} {{GENDER:$3|ye}} oan \"$2\"",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|edited}} ae post in \"$2\" on \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 n $4 {{PLURAL:$5|ither|ithers}} {{GENDER:$1|eeditit}} ae post in \"$2\" oan \"$3\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|created}} ae new topic on \"$2\"",
+ "flow-moderation-title-unsuppress-post": "Onsuppress post?",
+ "flow-moderation-title-undelete-post": "Ondelyte post?",
+ "flow-moderation-title-unhide-post": "Onskauk post?",
+ "flow-moderation-placeholder-unsuppress-post": "Please {{GENDER:$3|explain}} why ye'r onsuppressin this post.",
+ "flow-moderation-placeholder-undelete-post": "Please {{GENDER:$3|explain}} why ye'r ondelytin this post.",
+ "flow-moderation-placeholder-unhide-post": "Please {{GENDER:$3|explain}} why ye'r onskaukin this post.",
+ "flow-moderation-confirm-unsuppress-post": "Onsuppress",
+ "flow-moderation-confirm-undelete-post": "Ondelyte",
+ "flow-moderation-confirm-unhide-post": "Onskauk",
+ "flow-moderation-confirm-unsuppress-topic": "Onsuppress",
+ "flow-moderation-confirm-undelete-topic": "Ondelyte",
+ "flow-moderation-confirm-unhide-topic": "Onskauk",
+ "flow-moderation-confirmation-unsuppress-post": "Ye'v successfulie onsuppressed the abuin post.",
+ "flow-moderation-confirmation-undelete-post": "Ye'v successfulie ondelytit the abuin post.",
+ "flow-moderation-confirmation-unhide-post": "Ye'v successfulie onskaukt the abuin post.",
+ "flow-moderation-confirmation-unsuppress-topic": "Ye'v successfulie onsuppressed this topíc.",
+ "flow-moderation-confirmation-undelete-topic": "Ye'v successfulie ondelytit this topíc.",
+ "flow-moderation-confirmation-unhide-topic": "Ye'v successfulie onskaukt this topíc.",
+ "flow-moderation-title-unsuppress-topic": "Onsuppress topíc?",
+ "flow-moderation-title-undelete-topic": "Ondelyte topíc?",
+ "flow-moderation-title-unhide-topic": "Onskauk topíc?",
+ "flow-moderation-placeholder-unsuppress-topic": "Please {{GENDER:$3|explain}} why ye'r onsuppressin this topíc.",
+ "flow-moderation-placeholder-undelete-topic": "Please {{GENDER:$3|explain}} why ye'r ondelytin this topíc.",
+ "flow-moderation-placeholder-unhide-topic": "Please {{GENDER:$3|explain}} why ye'r onskaukin this topíc.",
+ "flow-revision-permalink-warning-header": "This is ae permenant airtin til ae single version o the heider.\nThis version is fae $1. Ye can see the [$3 differances fae the afore-gaun version], or see ither versions oan the [$2 buird histerie page].",
+ "flow-revision-permalink-warning-header-first": "This is ae permanant airtin til the firstwhile version o the heider.\nYe can see later versions oan the [$2 buird histerie page].",
+ "flow-compare-revisions-header-header": "This page shaws the {{GENDER:$2|chynges}} atween twa versions o the heider oan [$3 $1].\nYe can see ither versions o the heider at its [$4 histerie page].",
+ "flow-terms-of-use-new-topic": "Bi clapin on \"{{int:flow-newtopic-save}}\", ye'r agreein til the terms o uiss fer this wiki.",
+ "flow-terms-of-use-reply": "Bi clapin oan \"{{int:flow-reply-submit}}\", ye'r agreein til the terms o uiss fer this wiki.",
+ "flow-terms-of-use-edit": "Bi savin yer chynges, ye'r agreein til the terms o uiss fer this wiki.",
+ "flow-anon-warning": "Ye'r na loggi in. Tae receeve attreebution wi yer name in steid o yer IP address, ye can [$1 log in] or [$2 cræft aen accoont]."
+}
diff --git a/Flow/i18n/si.json b/Flow/i18n/si.json
new file mode 100644
index 00000000..d107a772
--- /dev/null
+++ b/Flow/i18n/si.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sahan.ssw",
+ "Thirsty",
+ "Sandaru"
+ ]
+ },
+ "flow-board-header-browse-topics-link": "ගවේෂණ මාතෘකා",
+ "flow-error-not-allowed-suppress": "මෙම මාතෘකාව මකා දමා ඇත.",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|අදහස් දැක්වුවා}}] $4 පිළිබඳව.",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|ඉවත් කර ඇත.}} [$4 මාතෘකාව] $6 (<em>$5</em>).",
+ "flow-terms-of-use-new-topic": "\"{{int:flow-newtopic-save}}\"ක්ලික් කිරීමෙන්, මෙම විකිය භාවිතාකිරීමට ඇති නීතිවලට ඔබ එකඟ වේ.",
+ "flow-terms-of-use-reply": "\"{{int:flow-reply-submit}}\"click කිරීමෙන්, මෙම විකිය භාවිතාකිරීමට ඇති නීතිවලට ඔබ එකඟ වේ.",
+ "flow-terms-of-use-edit": "ඔබගේ වෙනස්කම් සුරැකීමෙන්,ඔබ විකිය භාවිතා කිරීමට ඇති කොන්දේසි වලට එකඟ වේ."
+}
diff --git a/Flow/i18n/sl.json b/Flow/i18n/sl.json
new file mode 100644
index 00000000..be574bc5
--- /dev/null
+++ b/Flow/i18n/sl.json
@@ -0,0 +1,29 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dbc334",
+ "Eleassar"
+ ]
+ },
+ "flow-error-missing-replyto": "Podan ni bil noben parameter »odgovori na«. Ta parameter je za dejanje »odgovorite« obvezen.",
+ "flow-error-invalid-replyto": "Parameter »odgovori« je bil neveljaven. Navedene objave ni bilo mogoče najti.",
+ "flow-error-missing-postId": "Podan ni bil noben parameter »postId«. Ta parameter je za upravljanje z objavo obvezen.",
+ "flow-error-invalid-postId": "Parameter »postId« ni veljaven. Navedene objave ($1) ni bilo mogoče najti.",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 se je {{GENDER:$1|odzval|odzvala|odzval(-a)}} na <span class=\"plainlinks\">[$5 $2]</span> na strani '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 in $5 {{PLURAL:$6|drug|druga|drugi|drugih}} so se odzvali na <span class=\"plainlinks\">[$4 $2]</span> na strani '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 je {{GENDER:$1|urejal|urejala}} vašo <span class=\"plainlinks\">[$5 objavo]</span> v razdelku »$2« na [[$3|$4]].",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|je ustvaril|je ustvarila}} novo temo na '''$3'''.",
+ "flow-notification-rename": "$1 {{GENDER:$1|je spremenil|je spremenila}} naslov <span class=\"plainlinks\">[$2 $3]</span> v »$4« na [[$5|$6]].",
+ "flow-notification-link-text-view-post": "Ogled objave",
+ "flow-notification-reply-email-subject": "$2 na $3",
+ "flow-notification-reply-email-batch-body": "$1 se je {{GENDER:$1|odzval|odzvala|odzval(-a)}} na »$2« na strani »$3«",
+ "flow-notification-reply-email-batch-bundle-body": "$1 in $4 {{PLURAL:$5|drugi|druga|drugi|drugih}} {{PLURAL:$5|sta se {{GENDER:$1|odzvala}}|so se odzvali}} na »$2« na strani »$3«",
+ "echo-category-title-flow-discussion": "Tok",
+ "echo-pref-tooltip-flow-discussion": "Obvesti me, ko se v Toku pojavijo dejanja v zvezi z mano.",
+ "flow-link-post": "objava",
+ "flow-link-topic": "tema",
+ "flow-link-history": "zgodovina",
+ "flow-moderation-title-suppress-post": "Cenzoriraj objavo",
+ "flow-moderation-title-delete-post": "Izbriši objavo",
+ "flow-moderation-title-hide-post": "Skrij objavo"
+}
diff --git a/Flow/i18n/sq.json b/Flow/i18n/sq.json
new file mode 100644
index 00000000..bd611f48
--- /dev/null
+++ b/Flow/i18n/sq.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gertakapllani"
+ ]
+ },
+ "flow-error-no-commit": "veprimi specifik nuk mund te ruhet",
+ "flow-spam-confirmedit-form": "Ju lutem konfirmoni se ju jeni njeri duke zgjidhur në captcha më poshtë: <span class=\"notranslate\" translate=\"asnjë\">$1</span>"
+}
diff --git a/Flow/i18n/sr-ec.json b/Flow/i18n/sr-ec.json
new file mode 100644
index 00000000..93935d9c
--- /dev/null
+++ b/Flow/i18n/sr-ec.json
@@ -0,0 +1,34 @@
+{
+ "@metadata": {
+ "authors": [
+ "Milicevic01",
+ "Rancher"
+ ]
+ },
+ "flow-edit-header-link": "Уреди заглавље",
+ "flow-hide-post-content": "Овај коментар је {{GENDER:$1|сакрио|сакрила}} $1",
+ "flow-hide-title-content": "Ову тему је {{GENDER:$1|сакрио|сакрила}} $1",
+ "flow-delete-post-content": "Овај коментар је {{GENDER:$1|обрисао|обрисала}} $1",
+ "flow-post-actions": "Радње",
+ "flow-topic-actions": "Радње",
+ "flow-cancel": "Откажи",
+ "flow-preview": "Претпреглед",
+ "flow-last-modified-by": "Последњу измену је {{GENDER:$1|начинио|начинила}} $1",
+ "flow-newtopic-title-placeholder": "Нова тема",
+ "flow-newtopic-save": "Додај тему",
+ "flow-post-action-post-history": "Историја",
+ "flow-post-action-delete-post": "Обриши",
+ "flow-post-action-hide-post": "Сакриј",
+ "flow-post-action-edit-post": "Уреди",
+ "flow-post-action-edit-post-submit": "Сачувај измене",
+ "flow-topic-action-edit-title": "Уреди наслов",
+ "flow-topic-action-history": "Историја",
+ "flow-error-no-commit": "Наведену радњу није могуће сачувати.",
+ "flow-edit-post-submit": "Сачувај",
+ "flow-rc-topic-of-board": "$1 на $2",
+ "flow-history-day": "Данас",
+ "flow-link-topic": "тема",
+ "flow-terms-of-use-new-topic": "Кликом на „{{int:flow-newtopic-save}}“, прихватате услове коришћења на овом викију.",
+ "flow-terms-of-use-reply": "Кликом на „{{int:flow-reply-submit}}“, прихватате услове коришћења на овом викију.",
+ "flow-terms-of-use-edit": "Чувањем измена, прихватате услове коришћења на овом викију."
+}
diff --git a/Flow/i18n/sr-el.json b/Flow/i18n/sr-el.json
new file mode 100644
index 00000000..259c2aba
--- /dev/null
+++ b/Flow/i18n/sr-el.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Milicevic01"
+ ]
+ },
+ "flow-hide-post-content": "Ovaj komentar je {{GENDER:$1|sakrio|sakrila}} $1",
+ "flow-hide-title-content": "Ovu temu je {{GENDER:$1|sakrio|sakrila}} $1",
+ "flow-delete-post-content": "Ovaj komentar je {{GENDER:$1|obrisao|obrisala}} $1",
+ "flow-preview": "Pretpregled",
+ "flow-post-action-edit-post-submit": "Sačuvaj izmene",
+ "flow-error-no-commit": "Navedenu radnju nije moguće sačuvati.",
+ "flow-rc-topic-of-board": "$1 na $2"
+}
diff --git a/Flow/i18n/sv.json b/Flow/i18n/sv.json
new file mode 100644
index 00000000..e4a4597a
--- /dev/null
+++ b/Flow/i18n/sv.json
@@ -0,0 +1,469 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ainali",
+ "Jopparn",
+ "Lokal Profil",
+ "Tobulos1",
+ "WikiPhoenix",
+ "Platinawolf",
+ "Bittin",
+ "Albinomamba",
+ "Stens51",
+ "Jenniesarina"
+ ]
+ },
+ "enableflow": "Aktivera Flow",
+ "flow-desc": "Arbetsflödeshanteringssystem",
+ "flow-talk-taken-over": "Denna diskussionssida använder [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Flow-diskussionssideshanterare",
+ "log-name-flow": "Aktivitetslogg för Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|raderade}} ett [$4 inlägg] i \"[[$3|$5]]\" på [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|återställde}} ett [$4 inlägg] i \"[[$3|$5]]\" på [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|censurerade}} ett [$4 inlägg] i \"[[$3|$5]]\" på [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|raderade}} ett [$4 inlägg] i \"[[$3|$5]]\" på [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|raderade}} ämne \"[[$3|$5]]\" på [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|återställde}} ämne \"[[$3|$5]]\" på [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|censurerade}} ämne \"[[$3|$5]]\" på [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|raderade}} ämne \"[[$3|$5]]\" på [[$6]]",
+ "flow-user-moderated": "Modererad användare",
+ "flow-edit-header-link": "Redigera sidhuvud",
+ "flow-post-moderated-toggle-hide-show": "Visa kommentar {{GENDER:$1|dold}} av $2",
+ "flow-post-moderated-toggle-delete-show": "Visa kommentar {{GENDER:$1|raderad}} av $2",
+ "flow-post-moderated-toggle-suppress-show": "Visa kommentar {{GENDER:$1|censurerad}} av $2",
+ "flow-post-moderated-toggle-hide-hide": "Dölj kommentar {{GENDER:$1|dold}} av $2",
+ "flow-post-moderated-toggle-delete-hide": "Dölj kommentar {{GENDER:$1|raderad}} av $2",
+ "flow-post-moderated-toggle-suppress-hide": "Dölj kommentar {{GENDER:$1|censurerad}} av $2",
+ "flow-topic-moderated-reason-prefix": "Anledning:",
+ "flow-hide-post-content": "Denna kommentar {{GENDER:$1|doldes}} av $1 ([$2 historik])",
+ "flow-hide-title-content": "Detta ämne {{GENDER:$1|doldes}} av $1",
+ "flow-lock-title-content": "Detta ämne {{GENDER:$1|låstes}} av $1",
+ "flow-hide-header-content": "{{GENDER:$1|Dold}} av $2",
+ "flow-delete-post-content": "Denna kommentar {{GENDER:$1|raderades}} av $1 ([$2 historik])",
+ "flow-delete-title-content": "Detta ämne {{GENDER:$1|raderades}} av $1",
+ "flow-delete-header-content": "{{GENDER:$1|Raderad}} av $2",
+ "flow-suppress-post-content": "Denna kommentar {{GENDER:$1|censurerades}} av $1 ([$2 historik])",
+ "flow-suppress-title-content": "Detta ämne {{GENDER:$1|censurerades}} av $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Censurerades}} av $2",
+ "flow-suppress-usertext": "<em>Användarnamn censurerat</em>",
+ "flow-post-actions": "Åtgärder",
+ "flow-topic-actions": "Åtgärder",
+ "flow-cancel": "Avbryt",
+ "flow-preview": "Förhandsgranska",
+ "flow-show-change": "Visa ändringar",
+ "flow-last-modified-by": "Senast {{GENDER:$1|ändrad}} av $1",
+ "flow-stub-post-content": "\"På grund av ett tekniskt fel, kunde detta inlägg inte hämtas.\"",
+ "flow-newtopic-title-placeholder": "Nytt ämne",
+ "flow-newtopic-content-placeholder": "Skicka ett nytt meddelande till \"$1\"",
+ "flow-newtopic-header": "Lägg till ett nytt ämne",
+ "flow-newtopic-save": "Lägg till ämne",
+ "flow-newtopic-start-placeholder": "Starta ett nytt ämne",
+ "flow-newtopic-first-heading": "Starta ett nytt ämne på $1",
+ "flow-summarize-topic-placeholder": "Vänligen sammanfatta denna diskussion",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Kommentera}} på \"$2\"",
+ "flow-reply-topic-title-placeholder": "Svar till \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|Svara}}",
+ "flow-reply-link": "{{GENDER:$1|Svara}}",
+ "flow-thank-link": "{{GENDER:$1|Tacka}}",
+ "flow-lock-link": "{{GENDER:$1|Lås}}",
+ "flow-history-action-suppress-post": "censurera",
+ "flow-history-action-delete-post": "radera",
+ "flow-history-action-hide-post": "dölj",
+ "flow-history-action-unsuppress-post": "avcensurera",
+ "flow-history-action-undelete-post": "återställ",
+ "flow-history-action-unhide-post": "sluta dölja",
+ "flow-history-action-restore-post": "återställ",
+ "flow-history-action-lock-topic": "lås",
+ "flow-history-action-unlock-topic": "lås upp",
+ "flow-post-edited": "Inlägg {{GENDER:$1|redigerat}} av $1 $2",
+ "flow-post-action-view": "Permanent länk",
+ "flow-post-action-post-history": "Historik",
+ "flow-post-action-suppress-post": "Censurera",
+ "flow-post-action-delete-post": "Radera",
+ "flow-post-action-hide-post": "Dölj",
+ "flow-post-action-edit-post": "Redigera",
+ "flow-post-action-edit-post-submit": "Spara ändringar",
+ "flow-post-action-unsuppress-post": "Avcensurera",
+ "flow-post-action-undelete-post": "Återställ",
+ "flow-post-action-unhide-post": "Sluta dölja",
+ "flow-post-action-restore-post": "Återställ",
+ "flow-post-action-undo-moderation": "Ångra",
+ "flow-topic-action-view": "Permanent länk",
+ "flow-topic-action-watchlist": "Bevakningslista",
+ "flow-topic-action-edit-title": "Redigera rubrik",
+ "flow-topic-action-history": "Historik",
+ "flow-topic-action-hide-topic": "Dölj ämne",
+ "flow-topic-action-delete-topic": "Radera ämne",
+ "flow-topic-action-lock-topic": "Lås ämnet",
+ "flow-topic-action-unlock-topic": "Låsa upp ämnet",
+ "flow-topic-action-summarize-topic": "Sammanfatta",
+ "flow-topic-action-resummarize-topic": "Redigera ämnets sammanfattning",
+ "flow-topic-action-suppress-topic": "Censurera ämne",
+ "flow-topic-action-unhide-topic": "Sluta dölja ämne",
+ "flow-topic-action-undelete-topic": "Återställ ämne",
+ "flow-topic-action-unsuppress-topic": "Avcensurera ämne",
+ "flow-topic-action-restore-topic": "Återställ ämne",
+ "flow-topic-action-undo-moderation": "Ångra",
+ "flow-topic-notification-subscribe-title": "Detta ämne har lagts till i {{GENDER:$1|din}} bevakningslista.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Du}} kommer få meddelande om alla aktiviteter på detta ämne.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Du}} prenumererar på detta diskussionsforum!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Du}} kommer att få ett meddelande när ett nytt ämne skapas på detta forum.",
+ "flow-error-http": "Ett fel uppstod när servern kontaktades.",
+ "flow-error-other": "Ett oväntat fel uppstod.",
+ "flow-error-external": "Ett fel uppstod.<br />Felmeddelandet var: $1",
+ "flow-error-edit-restricted": "Du har inte behörighet att redigera detta inlägg.",
+ "flow-error-topic-is-locked": "Detta ämne är låst för ytterligare verksamhet.",
+ "flow-error-lock-moderated-post": "Du kan inte låsa ett modererat inlägg.",
+ "flow-error-external-multi": "Fel uppstod.<br />$1",
+ "flow-error-missing-content": "Inlägget har inget innehåll. Innehåll krävs för att spara ett inlägg.",
+ "flow-error-missing-summary": "Sammanfattningen har inget innehåll. Innehåll krävs för att spara en sammanfattning.",
+ "flow-error-missing-title": "Ämnet har ingen rubrik. En rubrik krävs för att spara ett ämne.",
+ "flow-error-parsoid-failure": "Det gick inte att parsa innehållet på grund av ett Parsoid-fel.",
+ "flow-error-missing-replyto": "Ingen \"replyTo\"-parameter tillhandahölls. Denna parameter krävs för åtgärden \"svara\".",
+ "flow-error-invalid-replyto": "\"replyTo\"-parametern var ogiltig. Det angivna inlägget kunde inte hittas.",
+ "flow-error-delete-failure": "Radering av detta objekt misslyckades.",
+ "flow-error-hide-failure": "Döljandet av detta objekt misslyckades.",
+ "flow-error-missing-postId": "Ingen \"postId\"-parameter tillhandahölls. Denna parameter krävs för att påverka ett inlägg.",
+ "flow-error-invalid-postId": "Parametern \"postId\" var ogiltig. Det angivna inlägget ($1) kunde inte hittas.",
+ "flow-error-restore-failure": "Det gick inte att återställa objektet.",
+ "flow-error-invalid-moderation-state": "Ett ogiltigt värde för parametern ('moderationState') lämnades till Flow API:et.",
+ "flow-error-invalid-moderation-reason": "Vänligen ange en orsak för moderationen",
+ "flow-error-not-allowed": "Otillräcklig behörighet att utföra denna åtgärd",
+ "flow-error-not-allowed-hide": "Detta ämne har dolts.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Du kan inte svara eftersom detta ämne har dolts.",
+ "flow-error-not-allowed-delete": "Detta ämne har raderats.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Du kan inte svara eftersom detta ämne har raderats.",
+ "flow-error-not-allowed-suppress": "Detta ämne har raderats.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Du kan inte svara eftersom detta ämne har raderats.",
+ "flow-error-not-allowed-hide-extract": "Detta ämne har dolts. Döljningsloggen för detta ämne visas nedan för information.",
+ "flow-error-not-allowed-delete-extract": "Detta ämne har raderats. Raderingsloggen för detta ämne visas nedan för information.",
+ "flow-error-not-allowed-suppress-extract": "Detta ämne har raderats. Raderingsloggen för detta ämne visas nedan för information.",
+ "flow-error-title-too-long": "Ämnesrubriker är begränsade till $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-no-existing-workflow": "Detta arbetsflöde finns ännu inte.",
+ "flow-error-not-a-post": "En ämnesrubrik kan inte sparas som ett inlägg.",
+ "flow-error-missing-header-content": "Sidhuvudet har inget innehåll. Innehåll krävs för att spara ett sidhuvud.",
+ "flow-error-missing-prev-revision-identifier": "Identifieraren för den tidigare versionen saknas.",
+ "flow-error-prev-revision-mismatch": "En annan användare redigerade just inlägget för några sekunder sedan. Är {{GENDER:$3|du}} säker på att du vill skriva över de senaste ändringarna?",
+ "flow-error-prev-revision-does-not-exist": "Kunde inte hitta den tidigare versionen.",
+ "flow-error-default": "Ett fel har uppstått.",
+ "flow-error-invalid-input": "Ett ogiltigt värde angavs för att läsa in Flow-innehåll.",
+ "flow-error-invalid-title": "Ogiltig sidrubrik angavs.",
+ "flow-error-fail-load-history": "Innehållet i historiken kunde inte läsas in.",
+ "flow-error-missing-revision": "Det gick inte att hitta en version för att ladda Flow-innehåll.",
+ "flow-error-fail-commit": "Flow-innehållet kunde inte sparas.",
+ "flow-error-insufficient-permission": "Otillräcklig behörighet för att komma åt innehållet.",
+ "flow-error-revision-comparison": "Diff-funktionen kan endast användas för två revideringar som hör till samma post.",
+ "flow-error-missing-topic-title": "Kunde inte hitta ämnesrubriken för det aktuella arbetsflödet.",
+ "flow-error-missing-metadata": "Kunde inte hitta den metadata som krävs för den här versionen.",
+ "flow-error-fail-load-data": "Det gick inte att läsa in de begärda uppgifterna.",
+ "flow-error-invalid-workflow": "Kunde inte hitta det önskade arbetsflödet.",
+ "flow-error-process-data": "Ett fel uppstod under bearbetning av uppgifterna i din begäran.",
+ "flow-error-process-wikitext": "Ett fel uppstod under bearbetning av HTML/wikitext konvertering.",
+ "flow-error-no-index": "Det gick inte att hitta ett index för att utföra datasökning.",
+ "flow-error-no-render": "Den angivna åtgärden kunde kändes inte igen.",
+ "flow-error-no-commit": "Den angivna åtgärden kunde inte sparas.",
+ "flow-error-fetch-after-lock": "Ett fel uppstod när den nya data begärdes. Lås/lås upp-åtgärden genomfördes dock utan problem. Felmeddelandet var: $1",
+ "flow-error-content-too-long": "Innehållet är för stort. Innehåll, efter expansion, är begränsad till $1 {{PLURAL:$1|byte|bytes}}.",
+ "flow-error-move": "Flytta av ett diskussionsforum stöds för närvarande inte.",
+ "flow-error-invalid-topic-uuid-title": "Ogiltig titel",
+ "flow-error-invalid-topic-uuid": "Den begärda sidatiteln var ogiltig. Sidor i ämnesnamnrymden skapas automatiskt av Flow.",
+ "flow-error-unknown-workflow-id-title": "Okänt ämne",
+ "flow-error-unknown-workflow-id": "Det begärda ämnet finns inte.",
+ "flow-edit-header-placeholder": "Beskriva detta diskussionsforum",
+ "flow-edit-header-submit": "Spara sidhuvud",
+ "flow-edit-header-submit-overwrite": "Skriv över sidhuvudet",
+ "flow-summarize-topic-submit": "Sammanfatta",
+ "flow-summarize-topic-submit-overwrite": "Skriva över sammanfattningen",
+ "flow-lock-topic-submit": "Lås ämnet",
+ "flow-lock-topic-submit-overwrite": "Skriva över lås ämnet-sammanfattningen",
+ "flow-unlock-topic-submit": "Låsa upp ämnet",
+ "flow-unlock-topic-submit-overwrite": "Skriva över lås upp ämnet-sammanfattningen",
+ "flow-edit-title-submit": "Ändra rubrik",
+ "flow-edit-title-submit-overwrite": "Skriva över rubriken",
+ "flow-edit-post-submit": "Skicka ändringar",
+ "flow-edit-post-submit-overwrite": "Skriver över ändringar",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|redigerade}} en [$3 kommentar] på \"$4\".",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Redigerade}} ett inlägg",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|kommenterade}}] på \"$4\" (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>{{PLURAL:$1|En kommentar|$1 kommentarer}}</strong> har {{PLURAL:$1|lagts till}}.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|skapade}} ämnet \"[$3 $4]\".",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Skapade}} ett nytt ämne",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|ändrade}} ämnesrubriken till \"[$3 $4]\" från \"$5\".",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|skapade}} sidhuvudet.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|redigerade}} sidhuvudet.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|skapade}} ämnessammanfattning på $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|redigerade}} ämnessammanfattning på $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|dolde}} en [$4 kommentar] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|raderade}} en [$4 kommentar] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|censurerade}} en [$4 kommentar] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|återställde}} en [$4 kommentar] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|dolde}} [$4 ämnet] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|tog bort}} [$4 ämnet] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|censurerade}} [$4 ämnet] på \"$6\" (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|låste}} [$4 ämnet] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|återställde}} [$4 ämnet] på \"$6\" (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 på $2",
+ "flow-board-history": "\"$1\" historik",
+ "flow-board-history-empty": "Detta forum har för närvarande ingen historik.",
+ "flow-topic-history": "Ämneshistorik för \"$1\"",
+ "flow-post-history": "\"Kommenterad av {{GENDER:$2|$2}}\" inläggshistorik",
+ "flow-history-last4": "Senaste 4 timmarna",
+ "flow-history-day": "I dag",
+ "flow-history-week": "Senaste veckan",
+ "flow-history-pages-topic": "Visas på [$1 \"$2\" forum]",
+ "flow-history-pages-post": "Visas på [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 kommentar|$1 kommentarer|0=Bli den {{GENDER:$2|förste|första}} att kommentera!}}",
+ "flow-comment-restored": "Återställd kommentar",
+ "flow-comment-deleted": "Raderad kommentar",
+ "flow-comment-hidden": "Dold kommentar",
+ "flow-comment-moderated": "Modererad kommentar",
+ "flow-last-modified": "Senast ändrad $1",
+ "flow-workflow": "arbetsflöde",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|svarade}} på '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 och $5 {{PLURAL:$6|annan|andra}} {{GENDER:$1|svarade}} på '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 har {{GENDER:$1|redigerat}} ditt <span class=\"plainlinks\">[$5 inlägg]</span> på [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 och $5 {{PLURAL:$6|annan|andra}} {{GENDER:$1|redigerade}} ett <span class=\"plainlinks\">[$4 inlägg]</span> i \"$2\" på \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|skapade}} ett nytt ämne på '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|nytt ämne|nya ämnen}} på '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|ändrade}} rubriken för <span class=\"plainlinks\">[$2 $3]</span> till \"$4\" på [[$5|$6]].",
+ "flow-notification-mention": "$1 {{GENDER:$1|nämnde}} {{GENDER:$5|dig}} i {{GENDER:$1|hans|hennes|sitt}} <span class=\"plainlinks\">[$2 inlägg]</span> i \"$3\" på \"$4\".",
+ "flow-notification-link-text-view-post": "Visa inlägg",
+ "flow-notification-link-text-view-topic": "Visa ämne",
+ "flow-notification-reply-email-subject": "$2 på $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|svarade}} på \"$2\" på \"$3\"",
+ "flow-notification-reply-email-batch-bundle-body": "$1 och $4 {{PLURAL:$5|annan|andra}} {{GENDER:$1|svarade}} på \"$2\" på \"$3\"",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|omnämnde}} {{GENDER:$3|dig}} på \"$2\"",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|omnämnde}} {{GENDER:$4|dig}} i {{GENDER:$1|hans|hennes|sitt}} inlägg i \"$2\" på \"$3\"",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|redigerade}} ett inlägg",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|redigerade}} ett inlägg i \"$2\" på \"$3\"",
+ "flow-notification-edit-email-batch-bundle-body": "$1 och $4 {{PLURAL:$5|annan|andra}} {{GENDER:$1|redigerade}} ett inlägg i \"$2\" på \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|byt namn på}} ditt ämne",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|byt namn på}} ditt ämne \"$2\" till \"$3\" på \"$4\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|skapade}} ett nytt ämne på \"$2\"",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|skapade}} ett ny ämne med rubriken \"$2\" på $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Meddela mig när åtgärder som rör mig förekommer i Flow.",
+ "flow-link-post": "inlägg",
+ "flow-link-topic": "ämne",
+ "flow-link-history": "historik",
+ "flow-link-post-revision": "version för inlägget",
+ "flow-link-topic-revision": "version för ämnet",
+ "flow-link-header-revision": "version för sidhuvudet",
+ "flow-moderation-title-suppress-post": "Censurera inlägget?",
+ "flow-moderation-title-delete-post": "Radera inlägget?",
+ "flow-moderation-title-hide-post": "Dölj inlägget?",
+ "flow-moderation-title-unsuppress-post": "Avcensurera inlägget?",
+ "flow-moderation-title-undelete-post": "Återställ inlägget?",
+ "flow-moderation-title-unhide-post": "Sluta dölja inlägget?",
+ "flow-moderation-placeholder-suppress-post": "Var god {{GENDER:$3|förklara}} varför du censurerar detta inlägg.",
+ "flow-moderation-placeholder-delete-post": "Var god {{GENDER:$3|förklara}} varför du raderar detta inlägg.",
+ "flow-moderation-placeholder-hide-post": "Var god {{GENDER:$3|förklara}} varför du döljer detta inlägg.",
+ "flow-moderation-placeholder-unsuppress-post": "Var god {{GENDER:$3|förklara}} varför du avcensurerar detta inlägg.",
+ "flow-moderation-placeholder-undelete-post": "Var god {{GENDER:$3|förklara}} varför du återställer detta inlägg.",
+ "flow-moderation-placeholder-unhide-post": "Var god {{GENDER:$3|förklara}} varför du slutar dölja detta inlägg.",
+ "flow-moderation-confirm-suppress-post": "Censurera",
+ "flow-moderation-confirm-delete-post": "Radera",
+ "flow-moderation-confirm-hide-post": "Dölj",
+ "flow-moderation-confirm-unsuppress-post": "Avcensurera",
+ "flow-moderation-confirm-undelete-post": "Återställ",
+ "flow-moderation-confirm-unhide-post": "Sluta dölja",
+ "flow-moderation-confirm-suppress-topic": "Censurera",
+ "flow-moderation-confirm-delete-topic": "Radera",
+ "flow-moderation-confirm-hide-topic": "Dölj",
+ "flow-moderation-confirm-lock-topic": "Lås",
+ "flow-moderation-confirm-unsuppress-topic": "Avcensurera",
+ "flow-moderation-confirm-undelete-topic": "Återställ",
+ "flow-moderation-confirm-unhide-topic": "Sluta dölja",
+ "flow-moderation-confirm-unlock-topic": "Lås upp",
+ "flow-moderation-confirmation-suppress-post": "Inlägget censurerades framgångsrikt.\n{{GENDER:$2|Överväg}} att ge feedback åt $1 gällande detta inlägg.",
+ "flow-moderation-confirmation-delete-post": "Inlägget raderades framgångsrikt.\n{{GENDER:$2|Överväg}} att ge feedback åt $1 gällande detta inlägg.",
+ "flow-moderation-confirmation-hide-post": "Inlägget doldes framgångsrikt.\n{{GENDER:$2|Överväg}} att ge feedback åt $1 gällande detta inlägg.",
+ "flow-moderation-confirmation-unsuppress-post": "Du har framgångsrikt avcensurerat ovanstående inlägg.",
+ "flow-moderation-confirmation-undelete-post": "Du har framgångsrikt återställt ovanstående inlägg.",
+ "flow-moderation-confirmation-unhide-post": "Du har framgångsrikt slutat dölja ovanstående inlägg.",
+ "flow-moderation-confirmation-suppress-topic": "Detta ämne har censurerats.",
+ "flow-moderation-confirmation-delete-topic": "Detta ämne har raderats.",
+ "flow-moderation-confirmation-hide-topic": "Detta ämne har dolts.",
+ "flow-moderation-confirmation-unsuppress-topic": "Du har framgångsrikt avcensurerat detta ämne.",
+ "flow-moderation-confirmation-undelete-topic": "Du har framgångsrikt återställt detta ämne.",
+ "flow-moderation-confirmation-unhide-topic": "Du har framgångsrikt slutat dölja detta ämne.",
+ "flow-moderation-title-suppress-topic": "Censurera ämnet?",
+ "flow-moderation-title-delete-topic": "Radera ämnet?",
+ "flow-moderation-title-hide-topic": "Dölja ämnet?",
+ "flow-moderation-title-unsuppress-topic": "Avcensurera ämnet?",
+ "flow-moderation-title-undelete-topic": "Återställ ämnet?",
+ "flow-moderation-title-unhide-topic": "Sluta dölja ämnet?",
+ "flow-moderation-placeholder-suppress-topic": "Var god {{GENDER:$3|förklara}} varför du censurerar detta ämne.",
+ "flow-moderation-placeholder-delete-topic": "Var god {{GENDER:$3|förklara}} varför du raderar detta ämne.",
+ "flow-moderation-placeholder-hide-topic": "Var god {{GENDER:$3|förklara}} varför du döljer detta ämne.",
+ "flow-moderation-placeholder-lock-topic": "Var god {{GENDER:$3|förklara}} varför du låser detta ämne.",
+ "flow-moderation-placeholder-unsuppress-topic": "Var god {{GENDER:$3|förklara}} varför du avcensurerar detta ämne.",
+ "flow-moderation-placeholder-undelete-topic": "Var god {{GENDER:$3|förklara}} varför du återställer detta ämne.",
+ "flow-moderation-placeholder-unhide-topic": "Var god {{GENDER:$3|förklara}} varför du slutar dölja detta ämne.",
+ "flow-moderation-placeholder-unlock-topic": "Var god {{GENDER:$3|förklara}} varför du låser upp detta ämne.",
+ "flow-topic-permalink-warning": "Detta ämne påbörjades den [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Detta ämne startades på [$2 {{GENDER:$1|$1}}s forum]",
+ "flow-revision-permalink-warning-post": "Detta är en permanent länk till en enskild version av det här inlägget.\nDenna version är från $1.\nDu kan se [$5 skillnader från föregående version], eller visa andra versioner på [$4 inläggets historiksida].",
+ "flow-revision-permalink-warning-post-first": "Detta är en permanent länk till den första versionen av detta inlägg.\nDu kan se senare versioner på [$4 inläggets historiksida].",
+ "flow-revision-permalink-warning-postsummary": "Detta är en permanent länk till en enskild version av sammanfattningen av det här inlägget. Denna version är från $1.\nDu kan se [$5 skillnader från föregående version], eller visa andra versioner på [$4 inläggets historiksida].",
+ "flow-revision-permalink-warning-postsummary-first": "Detta är en permanent länk till den första versionen av denna inläggssammanfattning.\nDu kan visa senare versioner på [$4 inläggets historiksida].",
+ "flow-revision-permalink-warning-header": "Detta är en permanent länk till en enskild version av sidhuvudet.\nDenna version är från $1. Du kan se [$3 skillnaderna mot tidigare versioner] eller se andra versioner på [$2 forumets historiksida].",
+ "flow-revision-permalink-warning-header-first": "Detta är en permanent länk till den första versionen av sidhuvudet.\nDu kan se senare versioner på [$2 forumets historiksida].",
+ "flow-compare-revisions-revision-header": "Version av {{GENDER:$2|$2}} från $1",
+ "flow-compare-revisions-header-post": "Denna sida visar {{GENDER:$3|förändringar}} mellan två versioner av ett inlägg av $3 i ämnet \"[$5 $2]\" på [$4 $1].\nDu kan se andra versioner av detta inlägg genom dess [$6 historiksida].",
+ "flow-compare-revisions-header-postsummary": "Denna sida visar förändringarna mellan två versioner av en inläggssammanfattning i inlägget \"[$4 $2]\" på [$3 $1].\nDu kan se andra versioner av detta inlägg genom dess [$5 historiksida].",
+ "flow-compare-revisions-header-header": "Denna sida visar {{GENDER:$2|förändringarna}} mellan två sidor av sidhuvudet på [$3 $1].\nDu kan se andra versioner av sidhuvudet på dess [$4 historiksida].",
+ "right-flow-hide": "Dölj ämnen och inlägg i Flow",
+ "right-flow-lock": "Lås ämnen i Flow",
+ "right-flow-delete": "Radera ämnen och inlägg i Flow",
+ "right-flow-edit-post": "Redigera andras inlägg i Flow",
+ "right-flow-suppress": "Censurera versioner i Flow",
+ "flow-terms-of-use-new-topic": "Genom att klicka på \"{{int:flow-newtopic-save}}\" godkänner du användarvillkoren för denna wiki.",
+ "flow-terms-of-use-reply": "Genom att klicka på \"{{int:flow-reply-submit}}\" godkänner du användarvillkoren för denna wiki.",
+ "flow-terms-of-use-edit": "Genom att spara dina ändringar godkänner du användarvillkoren för denna wiki.",
+ "flow-anon-warning": "Du är inte inloggad. För att få erkännande med ditt namn istället för din IP-adress kan du [$1 logga in] eller [$2 skapa ett konto].",
+ "flow-cancel-warning": "Du har skrivit in text i denna formulär. Är du säker på att du vill överge den?",
+ "flow-topic-first-heading": "Ämne på $1",
+ "flow-topic-html-title": "$1 på $2",
+ "flow-topic-count": "Ämnen ($1)",
+ "flow-load-more": "Läs in fler",
+ "flow-no-more-fwd": "Det finns inga äldre ämnen",
+ "flow-add-topic": "Lägg till ämne",
+ "flow-newest-topics": "Nyaste ämnen",
+ "flow-recent-topics": "Senaste aktiva ämnen",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Du}} läser just nu det nyaste ämnet först. Klicka för fler sorteringsalternativ.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Du}} läser för tillfället de allra senaste aktiva ämnena först. Klicka för fler sorteringsalternativ.",
+ "flow-toggle-small-topics": "Byt till liten ämnesvy",
+ "flow-toggle-topics": "Byt till enbart ämnesvy",
+ "flow-toggle-topics-posts": "Byt till ämnes- och inläggsvy",
+ "flow-terms-of-use-summarize": "Genom att klicka på \"{{int:flow-summarize-topic-submit}}\" godkänner du användarvillkoren för denna wiki.",
+ "flow-terms-of-use-lock-topic": "Genom att klicka på \"{{int:flow-lock-topic-submit}}\" godkänner du användarvillkoren för denna wiki.",
+ "flow-terms-of-use-unlock-topic": "Genom att klicka på \"{{int:flow-unlock-topic-submit}}\" godkänner du användarvillkoren för denna wiki.",
+ "flow-whatlinkshere-post": "från ett [$1 inlägg]",
+ "flow-whatlinkshere-header": "från [$1 sidhuvudet]",
+ "flow": "Flow",
+ "flow-special-desc": "Denna specialsida omdirigerar till ett Flow-arbetsflöde eller ett Flow-inlägg för ett angivet UUID.",
+ "flow-special-type": "Typ",
+ "flow-special-type-post": "Inlägg",
+ "flow-special-type-workflow": "Arbetsflöde",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Kunde inte hitta innehåll som matchar typen och UUID:et.",
+ "flow-spam-confirmedit-form": "Vänligen bekräfta att du är en människa genom att lösa nedanstående captcha: $1",
+ "flow-preview-warning": "Du ser en förhandsvisning. Klicka \"{{int:flow-newtopic-save}}\" för att publicera detta, eller klicka på \"{{int:flow-preview-return-edit-post}}\" för att fortsätta skriva.",
+ "flow-preview-return-edit-post": "Fortsätt redigera",
+ "flow-anonymous": "Anonym",
+ "flow-embedding-unsupported": "Diskussioner kan ännu inte bäddas in.",
+ "mw-ui-unsubmitted-confirm": "Du har osparade ändringar på denna sida. Är du säker du vill lämna sidan och förlora ändringarna?",
+ "flow-post-undo-hide": "ångra dölj",
+ "flow-post-undo-delete": "ångra radera",
+ "flow-post-undo-suppress": "ångra censurering",
+ "flow-topic-undo-hide": "ångra dölj",
+ "flow-topic-undo-delete": "ångra radera",
+ "flow-importer-lqt-converted-template": "LQT-sida konverterad till Flow",
+ "flow-importer-lqt-converted-archive-template": "Arkiv för konverterad LQT-sida",
+ "flow-importer-wt-converted-template": "Wikitextdiskussionssida konverterad till Flow",
+ "flow-importer-wt-converted-archive-template": "Arkiv för konverterade wikitextdiskussionssidor",
+ "apihelp-flow-description": "GÖr det möjligt att utföra åtgärder på Flow-sidor.",
+ "apihelp-flow-param-submodule": "Den Flow-undermodul som ska anropas.",
+ "apihelp-flow-param-page": "Sidan som åtgärden ska utföras på.",
+ "apihelp-flow-example-1": "Redigera sidhuvudet för \"[[Talk:Sandbox]]\"",
+ "apihelp-flow+close-open-topic-description": "Utfasad i förmån för [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
+ "apihelp-flow+close-open-topic-param-moderationState": "Tillstånd att försätta ämnet i, antingen låst eller olåst.",
+ "apihelp-flow+close-open-topic-param-reason": "Skäl för att låsa eller låsa upp ämnet.",
+ "apihelp-flow+edit-header-description": "Redigera ett ämnes sidhuvud.",
+ "apihelp-flow+edit-header-param-prev_revision": "Versions-ID för den aktuella sidhuvudversionen, för att hantera redigeringskonflikter.",
+ "apihelp-flow+edit-header-param-content": "Innehåll för sidhuvud.",
+ "apihelp-flow+edit-header-example-1": "Redigera sidhuvudet på [[Talk:Sandbox]]",
+ "apihelp-flow+edit-post-description": "Redigerar ett inläggs innehåll.",
+ "apihelp-flow+edit-post-param-postId": "Inläggs-ID.",
+ "apihelp-flow+edit-post-param-prev_revision": "Versions-ID för den aktuella inläggsversionen, för att hantera redigeringskonflikter.",
+ "apihelp-flow+edit-post-param-content": "Innehåll för inlägg.",
+ "apihelp-flow+edit-post-example-1": "Redigera ett inlägg i [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-title-description": "Redigeringar en ämnesrubrik.",
+ "apihelp-flow+edit-title-param-prev_revision": "Versions-ID för den aktuella titelversionen, för att hantera redigeringskonflikter.",
+ "apihelp-flow+edit-title-param-content": "Innehåll för rubrik.",
+ "apihelp-flow+edit-title-example-1": "Redigera rubriken på [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+edit-topic-summary-description": "Redigera innehållet för en ämnessammanfattning.",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "Versions-ID för den aktuella ämnessammanfattningsversionen, för att hantera redigeringskonflikter.",
+ "apihelp-flow+edit-topic-summary-param-summary": "Innehåll för sammanfattningen.",
+ "apihelp-flow+edit-topic-summary-example-1": "Redigera sammanfattningen av [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-description": "Lås eller lås upp ett Flow-ämne.",
+ "apihelp-flow+lock-topic-param-moderationState": "Tillstånd att försätta ämnet i, antingen låst eller olåst.",
+ "apihelp-flow+lock-topic-param-reason": "Skäl för att låsa eller låsa upp ämnet.",
+ "apihelp-flow+lock-topic-example-1": "Lås [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-post-description": "Modererar ett Flow-inlägg.",
+ "apihelp-flow+moderate-post-param-moderationState": "Vilken nivå att moderera på.",
+ "apihelp-flow+moderate-post-param-reason": "Skäl för moderering.",
+ "apihelp-flow+moderate-post-param-postId": "ID för inlägget som ska modereras.",
+ "apihelp-flow+moderate-post-example-1": "Radera ett inlägg i ämne [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-description": "Moderera ett Flow-ämne.",
+ "apihelp-flow+moderate-topic-param-moderationState": "Vilken nivå att moderera på.",
+ "apihelp-flow+moderate-topic-param-reason": "Skäl för moderering.",
+ "apihelp-flow+moderate-topic-example-1": "Radera ämnet [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+new-topic-description": "Skapa ett nytt Flow-ämne på det angivna arbetsflödet.",
+ "apihelp-flow+new-topic-param-topic": "Text för ny ämnesrubrik.",
+ "apihelp-flow+new-topic-param-content": "Innehåll för nytt ämne.",
+ "apihelp-flow+new-topic-example-1": "Skapa ett nytt ämne på [[Talk:Sandbox]]",
+ "apihelp-flow+reply-description": "Svar på ett inlägg.",
+ "apihelp-flow+reply-param-replyTo": "Inläggs-ID att svara på.",
+ "apihelp-flow+reply-param-content": "Innehåll för nytt inlägg.",
+ "apihelp-flow+reply-example-1": "Svara på ett inlägg på [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-header-description": "Visa ett forums sidhuvud.",
+ "apihelp-flow+view-header-param-contentFormat": "Format att returnera innehållet i.",
+ "apihelp-flow+view-header-param-revId": "Ladda denna version, istället för den senaste.",
+ "apihelp-flow+view-header-example-1": "Hämta sidhuvudet för [[Talk:Sandbox]] som wikitext",
+ "apihelp-flow+view-post-description": "Visa ett inlägg",
+ "apihelp-flow+view-post-param-postId": "ID för inlägget som ska visas.",
+ "apihelp-flow+view-post-param-contentFormat": "Format att returnera innehållet i.",
+ "apihelp-flow+view-post-example-1": "Hämta innehållet i ett inlägg på [[Topic:S2tycnas4hcucw8w]] som wikitext",
+ "apihelp-flow+view-topic-description": "Visa ett ämne.",
+ "apihelp-flow+view-topic-example-1": "Visa [[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "Visa en ämnessammanfattning.",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "Format att returnera innehållet i.",
+ "apihelp-flow+view-topic-summary-param-revId": "Ladda denna version, istället för den senaste.",
+ "apihelp-flow+view-topic-summary-example-1": "Visa sammanfattningen för [[Topic:S2tycnas4hcucw8w]] som wikitext",
+ "apihelp-flow+view-topiclist-description": "Visa en lista av ämnen.",
+ "apihelp-flow+view-topiclist-param-offset-dir": "Riktning för sortering av ämnen.",
+ "apihelp-flow+view-topiclist-param-sortby": "Sorteringsalternativ för ämnena.",
+ "apihelp-flow+view-topiclist-param-savesortby": "Spara sorteringsalternativ, om angivet.",
+ "apihelp-flow+view-topiclist-param-offset-id": "Förskjutningsvärde (i UUID-format) från vilket ämnen ska börja hämtas.",
+ "apihelp-flow+view-topiclist-param-offset": "Förskjutningsvärde från vilket ämnen ska börja hämtas.",
+ "apihelp-flow+view-topiclist-param-limit": "Antal ämnen att hämta.",
+ "apihelp-flow+view-topiclist-param-render": "Rendera ämnena som HTML.",
+ "apihelp-flow+view-topiclist-example-1": "Lista ämnen på [[Talk:Sandbox]]",
+ "apihelp-flow-parsoid-utils-description": "Konvertera text mellan wikitext och HTML.",
+ "apihelp-flow-parsoid-utils-param-from": "Format att konvertera innehållet från.",
+ "apihelp-flow-parsoid-utils-param-to": "Format att konvertera innehållet till.",
+ "apihelp-flow-parsoid-utils-param-content": "Innehåll att konvertera.",
+ "apihelp-flow-parsoid-utils-param-title": "Titel för sidan. Kan inte användas tillsammans med $1pageid.",
+ "apihelp-flow-parsoid-utils-param-pageid": "ID för sidan. Kan inte användas tillsammans med $1title.",
+ "apihelp-flow-parsoid-utils-example-1": "Konvertera wikitext <nowiki>'''lorem''' ''blah''</nowiki> till HTML",
+ "apihelp-query+flowinfo-description": "Hämta grundläggande Flow-information för en sida.",
+ "apihelp-query+flowinfo-example-1": "Hämta Flow-information om [[Talk:Sandbox]], [[Main Page]], och [[Talk:Flow]]",
+ "flow-edited": "Redigerad",
+ "flow-edited-by": "Redigerad av $1",
+ "flow-lqt-redirect-reason": "Omdirigerar utfasad LiquidThreads-inlägg till dess konverterade Flow-inlägg",
+ "flow-talk-conversion-move-reason": "Konvertering av wikitext till Flow från $1",
+ "flow-talk-conversion-archive-edit-reason": "Konvertering av diskussion i wikitext till Flow",
+ "flow-previous-diff": "← Äldre redigering",
+ "flow-next-diff": "Nyare redigering →",
+ "flow-undo": "ångra",
+ "flow-undo-latest-revision": "Nuvarande version",
+ "flow-undo-your-text": "Din text",
+ "flow-undo-edit-header": "Redigera sidhuvud",
+ "flow-undo-edit-topic-summary": "Redigerar ämnets sammanfattning",
+ "flow-undo-edit-post": "Redigerar ett inlägg",
+ "group-flow-bot": "Flow-bot",
+ "group-flow-bot-member": "Flow-Bot",
+ "grouppage-flow-bot": "Project:Flow-bot",
+ "flow-ve-mention-context-item-label": "Nämn",
+ "flow-ve-mention-inspector-title": "Nämn",
+ "flow-ve-mention-inspector-remove-label": "Ta bort",
+ "flow-ve-mention-tool-title": "Nämn en användare",
+ "flow-ve-mention-inspector-invalid-user": "Användarnamnet '$1' är inte registerat.",
+ "flow-wikitext-editor-help": "Wikitext $1.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|använder wikitext]]",
+ "flow-wikitext-editor-help-preview-the-result": "förhandsgranska resultatet",
+ "flow-wikitext-switch-editor-tooltip": "Byt till VisualEditor",
+ "flow-ve-switch-editor-tool-title": "Byt till wikitext-redigeraren"
+}
diff --git a/Flow/i18n/ta.json b/Flow/i18n/ta.json
new file mode 100644
index 00000000..ce59bf34
--- /dev/null
+++ b/Flow/i18n/ta.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "ElangoRamanujam"
+ ]
+ },
+ "flow-history-action-delete-post": "நீக்கவும்",
+ "flow-history-action-hide-post": "மறைக்க",
+ "flow-lock-topic-submit": "தலைப்பைப் பூட்டுக",
+ "flow-unlock-topic-submit": "தலைப்பைத் திறக்கவும்",
+ "flow-topic-html-title": "$2ல் $1",
+ "flow-edited": "தொகுக்கப்பட்டது",
+ "flow-edited-by": "$1ஆல் தொகுக்கப்பட்டது"
+}
diff --git a/Flow/i18n/te.json b/Flow/i18n/te.json
new file mode 100644
index 00000000..a14a5ef9
--- /dev/null
+++ b/Flow/i18n/te.json
@@ -0,0 +1,84 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chaduvari",
+ "Ravichandra",
+ "రహ్మానుద్దీన్",
+ "వైజాసత్య"
+ ]
+ },
+ "flow-desc": "కార్యధార నిర్వహణా వ్యవస్థ",
+ "flow-talk-taken-over": "ఈ పుట [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow] ను వాడుతోంది.",
+ "flow-talk-username": "ఫ్లో చర్చ పుట నిర్వాహకి",
+ "log-name-flow": "ఫ్లో చర్య లాగ్",
+ "logentry-delete-flow-delete-post": "$1 [[$6]] లో [[$3|$5]] లోని [$4 టపాను] {{GENDER:$2|తొలగించారు}}",
+ "flow-board-header-browse-topics-link": "అంశాలను చూడండి",
+ "flow-edit-header-link": "శీర్షికను సవరించు",
+ "flow-topic-moderated-reason-prefix": "కారణం:",
+ "flow-post-actions": "చర్యలు",
+ "flow-topic-actions": "చర్యలు",
+ "flow-cancel": "రద్దుచేయి",
+ "flow-preview": "మునుజూపు",
+ "flow-show-change": "తేడాలను చూపించు",
+ "flow-newtopic-title-placeholder": "కొత్త అంశం",
+ "flow-newtopic-header": "కొత్త అంశాన్ని చేర్చు",
+ "flow-newtopic-save": "అంశాన్ని చేర్చు",
+ "flow-newtopic-start-placeholder": "కొత్త అంశాన్ని మొదలుపెట్టు",
+ "flow-newtopic-first-heading": "$1 విషయమై కొత్త అంశాన్ని మొదలుపెట్టు",
+ "flow-summarize-topic-placeholder": "దయచేసి ఈ చర్చకు సారాంశమివ్వండి",
+ "flow-reply-topic-title-placeholder": "\"$1\" కు జవాబివ్వు",
+ "flow-history-action-suppress-post": "కుదించు",
+ "flow-history-action-delete-post": "తొలగించు",
+ "flow-history-action-hide-post": "దాచు",
+ "flow-history-action-unsuppress-post": "కుదించినదాన్ని తిరిగి పూర్వస్థితికి చేర్చు",
+ "flow-history-action-undelete-post": "తొలగింపును రద్దుచెయ్యి",
+ "flow-history-action-unhide-post": "చూపు",
+ "flow-history-action-restore-post": "పునస్థాపించు",
+ "flow-history-action-lock-topic": "సంరక్షించు",
+ "flow-history-action-unlock-topic": "సంరక్షణను తీసివేయి",
+ "flow-post-action-view": "శాశ్వతలింకు",
+ "flow-post-action-post-history": "చరిత్ర",
+ "flow-post-action-suppress-post": "కుదించు",
+ "flow-post-action-delete-post": "తొలగించు",
+ "flow-post-action-hide-post": "దాచు",
+ "flow-post-action-edit-post": "సవరించు",
+ "flow-post-action-edit-post-submit": "మార్పులను భద్రపరచు",
+ "flow-post-action-unsuppress-post": "కుదించినదాన్ని తిరిగి పూర్వస్థితికి చేర్చు",
+ "flow-post-action-undelete-post": "తొలగింపును రద్దుచెయ్యి",
+ "flow-post-action-unhide-post": "చూపు",
+ "flow-post-action-restore-post": "పునఃస్థాపించు",
+ "flow-post-action-undo-moderation": "రద్దుచెయ్యి",
+ "flow-topic-action-view": "స్థిరలంకె",
+ "flow-topic-action-watchlist": "వీక్షణ జాబితా",
+ "flow-topic-action-edit-title": "శీర్షికను సవరించు",
+ "flow-topic-action-history": "చరిత్ర",
+ "flow-topic-action-resummarize-topic": "దిద్దుబాటు సారాంశం",
+ "flow-topic-action-undo-moderation": "రద్దుచెయ్యి",
+ "flow-error-not-allowed-suppress": "ఈ చర్చాహారాన్ని తొలగించాం.",
+ "flow-edit-header-submit-overwrite": "శీర్షాన్ని తిరగరాయి",
+ "flow-edit-title-submit-overwrite": "పేరును తిరగరాయి",
+ "flow-edit-post-submit-overwrite": "మార్పులను తిరగరాయి",
+ "flow-history-last4": "గత 4 గంటల్లో",
+ "flow-history-day": "నేడు",
+ "flow-history-week": "క్రిందటి వారం",
+ "echo-category-title-flow-discussion": "ఫ్లో",
+ "echo-pref-tooltip-flow-discussion": "ఫ్లోలో నాకు సంబంధించిన విషయాలేమైనా ఉంటే నాకు కబురుపెట్టు",
+ "flow-link-post": "పంపించు",
+ "flow-link-topic": "విషయం",
+ "flow-link-history": "చరిత్ర",
+ "flow-moderation-confirm-hide-post": "దాచు",
+ "flow-moderation-confirm-unsuppress-post": "కుదించినదాన్ని తిరిగి పూర్వస్థితికి చేర్చు",
+ "flow-moderation-confirm-undelete-post": "తొలగింపును రద్దుచెయ్యి",
+ "flow-moderation-confirm-unhide-post": "చూపు",
+ "flow-moderation-confirm-suppress-topic": "కుదించు",
+ "flow-moderation-confirm-delete-topic": "తొలగించు",
+ "flow-moderation-confirm-hide-topic": "దాచు",
+ "flow-moderation-confirm-lock-topic": "సంరక్షించు",
+ "flow-moderation-confirm-unsuppress-topic": "కుదించినదాన్ని తిరిగి పూర్వస్థితికి చేర్చు",
+ "flow-moderation-confirm-undelete-topic": "తొలగింపును రద్దుచెయ్యి",
+ "flow-moderation-confirm-unhide-topic": "చూపు",
+ "flow-moderation-confirm-unlock-topic": "సంరక్షణను తీసివేయి",
+ "flow-terms-of-use-new-topic": "\"{{int:flow-newtopic-save}}\" నొక్కడం ద్వారా, మీరు ఈ వికీ నియమాలను అంగీకరిస్తున్నారు.",
+ "flow-terms-of-use-reply": "\"{{int:flow-reply-submit}}\" నొక్కడం ద్వారా, మీరు ఈ వికీ విధివిధానాలను అంగీకరిస్తున్నట్లు.",
+ "flow-terms-of-use-edit": "మీ మార్పులను భద్రపరచడం ద్వారా, మీరు ఈ వికీ విధివిధానాలను అంగీకరిస్తున్నట్లు."
+}
diff --git a/Flow/i18n/tl.json b/Flow/i18n/tl.json
new file mode 100644
index 00000000..58220a31
--- /dev/null
+++ b/Flow/i18n/tl.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jewel457",
+ "Leeheonjin"
+ ]
+ },
+ "flow-post-moderated-toggle-hide-show": "Ipakita ang pagpuna.",
+ "flow-post-moderated-toggle-delete-show": "Ipakita ang mungkahi",
+ "flow-post-moderated-toggle-suppress-show": "Ipakita ang mga puna.",
+ "flow-post-moderated-toggle-delete-hide": "Itago ang pagpuna.",
+ "flow-post-moderated-toggle-suppress-hide": "Itago ang mungkahi",
+ "flow-post-action-post-history": "Kasaysayan",
+ "flow-post-action-edit-post": "Baguhin",
+ "flow-previous-diff": "← Mas lumang pagbabago",
+ "flow-next-diff": "Mas bagong pagbabago →"
+}
diff --git a/Flow/i18n/tr.json b/Flow/i18n/tr.json
new file mode 100644
index 00000000..8ae9c8a0
--- /dev/null
+++ b/Flow/i18n/tr.json
@@ -0,0 +1,30 @@
+{
+ "@metadata": {
+ "authors": [
+ "Rapsar",
+ "Rhinestorm",
+ "Sayginer",
+ "Violetanka"
+ ]
+ },
+ "flow-topic-moderated-reason-prefix": "Sebep:",
+ "flow-lock-link": "{{GENDER:$1|Kilitle}}",
+ "flow-topic-action-lock-topic": "Konuyu kilitle",
+ "flow-topic-action-unlock-topic": "Konunun kilidini aç",
+ "flow-topic-action-undo-moderation": "Geri alın",
+ "flow-board-notification-subscribe-description": "Bu tahtada yeni bir konu oluşturulduğunda bildirim alacaksınız.",
+ "flow-error-lock-moderated-post": "Denetlenen bir yazıyı kilitleyemezsiniz.",
+ "flow-error-content-too-long": "İçerik çok büyük. Genişlemeden sonraki içerik $1 {{PLURAL:$1|byte|byte}}'la sınırlanmıştır.",
+ "flow-lock-topic-submit": "Konuyu kilitle",
+ "flow-unlock-topic-submit": "Konunun kilidini aç",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 '''$3'başlığında yeni bir konu'{{GENDER:$1|oluşturdu}}'.",
+ "flow-notification-mention": "$1, \"$4\" sayfasındaki \"$3\" başlığındaki [$2 değişikliğinde] sizden {{GENDER:$1|bahsetti}}",
+ "flow-notification-mention-email-subject": "$1, $2 sayfasında sizden {{GENDER:$1|bahsetti}}",
+ "flow-notification-mention-email-batch-body": "$1, \"$3\" sayfasındaki \"$2\" başlığında sizden {{GENDER:$1|bahsetti}}",
+ "flow-link-history": "geçmiş",
+ "flow-moderation-confirmation-suppress-topic": "Bu konu askıya alınmıştır.",
+ "flow-moderation-confirmation-delete-topic": "Bu konu silindi.",
+ "flow-moderation-confirmation-hide-topic": "Bu konu gizlenmiştir.",
+ "flow-anon-warning": "Oturum açmadınız.",
+ "flow-topic-html-title": "$1 sayfa $2"
+}
diff --git a/Flow/i18n/tyv.json b/Flow/i18n/tyv.json
new file mode 100644
index 00000000..0ae4afac
--- /dev/null
+++ b/Flow/i18n/tyv.json
@@ -0,0 +1,7 @@
+{
+ "@metadata": {
+ "authors": [
+ "Agilight"
+ ]
+ }
+}
diff --git a/Flow/i18n/ug-arab.json b/Flow/i18n/ug-arab.json
new file mode 100644
index 00000000..e9b80d0b
--- /dev/null
+++ b/Flow/i18n/ug-arab.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Tel'et"
+ ]
+ },
+ "flow-post-action-delete-post": "ئۆچۈر",
+ "flow-post-action-hide-post": "يوشۇر",
+ "flow-moderation-title-delete-post": "بۇ ئۇچۇرنى ئۆچۈرەمسىز؟",
+ "flow-moderation-confirm-delete-post": "ئۆچۈر"
+}
diff --git a/Flow/i18n/uk.json b/Flow/i18n/uk.json
new file mode 100644
index 00000000..a5c2c744
--- /dev/null
+++ b/Flow/i18n/uk.json
@@ -0,0 +1,383 @@
+{
+ "@metadata": {
+ "authors": [
+ "Andriykopanytsia",
+ "Ата",
+ "Максим Підліснюк",
+ "Base",
+ "Mykola Swarnyk",
+ "Olion",
+ "Капитан Джон Шепард",
+ "Piramidion"
+ ]
+ },
+ "enableflow": "Увімкнути Flow",
+ "flow-desc": "Система управління робочими процесами",
+ "flow-talk-taken-over": "Ця сторінка обговорення використовує [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Менеджер сторінки обговорення Flow",
+ "log-name-flow": "Журнал активності потоку",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|вилучив|вилучила}} [$4 допис] у «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|відновив|відновила}} [$4 допис] у «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|прибрав|прибрала}} [$4 допис] у «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|вилучив|вилучила}} [$4 допис] у «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|вилучив|вилучила}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|відновив|відновила}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|прибрав|прибрала}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|вилучив|вилучила}} тему «[[$3|$5]]» на [[$6]]",
+ "logentry-import-lqt-to-flow-topic": "[[$1|$2]] на [[$3]] було імпортовано з LiquidThreads у Flow",
+ "flow-user-moderated": "Обмежений користувач",
+ "flow-board-header-browse-topics-link": "Огляд тем",
+ "flow-edit-header-link": "Редагувати заголовок",
+ "flow-post-moderated-toggle-hide-show": "Показати коментар, прихований {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-post-moderated-toggle-delete-show": "Показати коментар, вилучений {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-post-moderated-toggle-suppress-show": "Показати коментар, прибраний {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-post-moderated-toggle-hide-hide": "Приховати коментар, прихований {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-post-moderated-toggle-delete-hide": "Приховати коментар, вилучений {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-post-moderated-toggle-suppress-hide": "Приховати коментар, прибраний {{GENDER:$1|користувачем|користувачкою}} $2",
+ "flow-topic-moderated-reason-prefix": "Причина:",
+ "flow-hide-post-content": "Цей коментар був прихований {{GENDER:$1|користувачем|користувачкою}} $1 ([$2 історія])",
+ "flow-hide-title-content": "Ця тема була прихована {{GENDER:$1|користувачем|користувачкою}} $1",
+ "flow-lock-title-content": "Ця тема була закрита {{GENDER:$1|користувачем|користувачкою}} $1",
+ "flow-hide-header-content": "{{GENDER:$1|Приховано}} $2",
+ "flow-delete-post-content": "Цей коментар був вилучений {{GENDER:$1|користувачем|користувачкою}} $1 ([$2 история])",
+ "flow-delete-title-content": "$1 {{GENDER:$1|вилучив|вилучила}} цю тему",
+ "flow-delete-header-content": "{{GENDER:$1|Вилучено}} $2",
+ "flow-suppress-post-content": "Цей коментар був прибраний {{GENDER:$1|користувачем|користувачкою}} $1 ([$2 історія])",
+ "flow-suppress-title-content": "Ця тема була прибрана {{GENDER:$1|користувачем|користувачкою}} $1",
+ "flow-suppress-header-content": "{{GENDER:$1|Прибрано}} $2",
+ "flow-suppress-usertext": "<em>Ім'я користувача приховано</em>",
+ "flow-post-actions": "Дії",
+ "flow-topic-actions": "Дії",
+ "flow-cancel": "Скасувати",
+ "flow-preview": "Попередній перегляд",
+ "flow-show-change": "Показати зміни",
+ "flow-last-modified-by": "Востаннє {{GENDER:$1|змінив|змінила}} $1",
+ "flow-stub-post-content": "''Через технічну помилку цей допис не міг бути отриманим.''",
+ "flow-newtopic-title-placeholder": "Нова тема",
+ "flow-newtopic-content-placeholder": "Розмістити нове повідомлення на «$1»",
+ "flow-newtopic-header": "Додати нову тему",
+ "flow-newtopic-save": "Додати тему",
+ "flow-newtopic-start-placeholder": "Почати нову тему",
+ "flow-newtopic-first-heading": "Почати нову тему на $1",
+ "flow-summarize-topic-placeholder": "Будь ласка, підсумуйте це обговорення",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|Коментувати}} на \"$2\"",
+ "flow-reply-topic-title-placeholder": "Відповісти на «$1»",
+ "flow-reply-submit": "{{GENDER:$1|Відповісти}}",
+ "flow-reply-link": "{{GENDER:$1|Відповісти}}",
+ "flow-thank-link": "{{GENDER:$1|Подякувати}}",
+ "flow-lock-link": "{{GENDER:$1|Закрити}}",
+ "flow-thank-link-title": "Публічно подякувати авторові допису",
+ "flow-history-action-suppress-post": "прибрати",
+ "flow-history-action-delete-post": "вилучити",
+ "flow-history-action-hide-post": "приховати",
+ "flow-history-action-unsuppress-post": "скасувати прибирання",
+ "flow-history-action-undelete-post": "скасувати вилучення",
+ "flow-history-action-unhide-post": "скасувати приховування",
+ "flow-history-action-restore-post": "відновити",
+ "flow-history-action-lock-topic": "закрити",
+ "flow-history-action-unlock-topic": "скасувати закриття",
+ "flow-post-edited": "Допис {{GENDER:$1|відредагував|відредагувала}} $1 $2",
+ "flow-post-action-view": "Постійне посилання",
+ "flow-post-action-post-history": "Історія",
+ "flow-post-action-suppress-post": "Прибрати",
+ "flow-post-action-delete-post": "Видалити",
+ "flow-post-action-hide-post": "Приховати",
+ "flow-post-action-edit-post": "Редагувати",
+ "flow-post-action-edit-post-submit": "Зберегти зміни",
+ "flow-post-action-unsuppress-post": "Висвітити",
+ "flow-post-action-undelete-post": "Відновити",
+ "flow-post-action-unhide-post": "Відобразити",
+ "flow-post-action-restore-post": "Відновити",
+ "flow-post-action-undo-moderation": "Скасувати",
+ "flow-topic-action-view": "Постійне посилання",
+ "flow-topic-action-watchlist": "Список спостереження",
+ "flow-topic-action-edit-title": "Змінити заголовок",
+ "flow-topic-action-history": "Історія",
+ "flow-topic-action-hide-topic": "Приховати тему",
+ "flow-topic-action-delete-topic": "Видалити тему",
+ "flow-topic-action-lock-topic": "Закрити тему",
+ "flow-topic-action-unlock-topic": "Відкрити тему",
+ "flow-topic-action-summarize-topic": "Підсумувати",
+ "flow-topic-action-resummarize-topic": "Редагувати підсумок теми",
+ "flow-topic-action-suppress-topic": "Прибрати тему",
+ "flow-topic-action-unhide-topic": "Відобразити тему",
+ "flow-topic-action-undelete-topic": "Відновити тему",
+ "flow-topic-action-unsuppress-topic": "Висвітити тему",
+ "flow-topic-action-restore-topic": "Відновити тему",
+ "flow-topic-action-undo-moderation": "Повернути",
+ "flow-topic-notification-subscribe-title": "Ця тема була додана у {{GENDER:$1|Ваш}} список спостереження.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1|Ви}} будете отримувати сповіщення про будь-яку активність у цій темі.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|Ви}} підписалися на цю дошку обговорень!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1|Ви}} отримаєте сповіщення при створенні нової теми на цій дошці.",
+ "flow-error-http": "Сталася помилка при зверненні до сервера.",
+ "flow-error-other": "Трапилася неочікувана помилка.",
+ "flow-error-external": "Сталася помилка.<br />Отримане повідомлення було:$1",
+ "flow-error-edit-restricted": "Вам не дозволено редагувати цей допис.",
+ "flow-error-topic-is-locked": "Ця тема закрита для будь-яких подальших дій.",
+ "flow-error-lock-moderated-post": "Ви не можете закрити модерований допис.",
+ "flow-error-external-multi": "Виявлені помилки.<br /> $1",
+ "flow-error-missing-content": "Публікація не має ніякого вмісту. Необхідний вміст, щоб зберегти публікацію.",
+ "flow-error-missing-summary": "Підсумок не має вмісту. Щоб зберегти підсумок, у ньому має бути вміст.",
+ "flow-error-missing-title": "Тема не має назви. Потрібна назва, щоб зберегти тему.",
+ "flow-error-parsoid-failure": "Не вдалося проаналізувати вміст через помилку Parsoid.",
+ "flow-error-missing-replyto": "Параметр „reply-to“ не був наданий. Цей параметр є обов'язковим для дії \"відповідь\".",
+ "flow-error-invalid-replyto": "Параметр „replyTo“ неприпустимий. Не вдалося знайти вказану публікацію.",
+ "flow-error-delete-failure": "Не вдалося видалити цей елемент.",
+ "flow-error-hide-failure": "Приховання цього елементу не вдалося.",
+ "flow-error-missing-postId": "Параметр „postId“ не був наданий. Цей параметр вимагає, щоб маніпулювати публікацією.",
+ "flow-error-invalid-postId": "Параметр „postId“ неприпустимий. Не вдалося знайти вказану публікацію ($1).",
+ "flow-error-restore-failure": "Не вдалося виконати відновлення цього елемента.",
+ "flow-error-invalid-moderation-state": "Для Flow API було подано неприпустиме значення параметра ('moderationState').",
+ "flow-error-invalid-moderation-reason": "Будь ласка, вкажіть причину для модерації",
+ "flow-error-not-allowed": "Недостатні дозволи для виконання цієї дії",
+ "flow-error-not-allowed-hide": "Цю тему приховано.",
+ "flow-error-not-allowed-reply-to-hide-topic": "Ви не можете відповісти, оскільки ця тема була прихована.",
+ "flow-error-not-allowed-delete": "Цю тему вилучено.",
+ "flow-error-not-allowed-reply-to-delete-topic": "Ви не можете відповісти, оскільки ця тема була вилучена.",
+ "flow-error-not-allowed-suppress": "Цю тему вилучено.",
+ "flow-error-not-allowed-reply-to-suppress-topic": "Ви не можете відповісти, оскільки ця тема була вилучена.",
+ "flow-error-not-allowed-hide-extract": "Ця тема була вилучена. Журнал приховувань для цієї теми наведено нижче для довідки.",
+ "flow-error-not-allowed-delete-extract": "Ця тема була вилучена. Журнал вилучень для цієї теми наведено нижче для довідки.",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "Ви не можете відповісти, оскільки ця тема була вилучена. Журнал вилучень для цієї теми наведено нижче для довідки.",
+ "flow-error-not-allowed-suppress-extract": "Ця тема була вилучена. Журнал вилучень для цієї теми наведено нижче для довідки.",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "Ви не можете відповісти, оскільки ця тема була прибрана. Журнал прибирань для цієї теми наведено нижче для довідки.",
+ "flow-error-title-too-long": "Назви тем обмежені $1 {{PLURAL:$1|1=байтом|байтами}}.",
+ "flow-error-no-existing-workflow": "Цей робочий процес ще не існує.",
+ "flow-error-not-a-post": "Назву теми не можна зберегти як допис.",
+ "flow-error-missing-header-content": "Заголовок не має ніякого вмісту. Необхідний вміст, щоб зберегти заголовок.",
+ "flow-error-missing-prev-revision-identifier": "Ідентифікатор попередньої ревізії відсутній.",
+ "flow-error-prev-revision-mismatch": "Цей допис саме кілька секунд тому був відредагований іншим користувачем. {{GENDER:$3|Ви}} справді бажаєте перезаписати останні зміни?",
+ "flow-error-prev-revision-does-not-exist": "Не вдалося знайти попередню ревізію.",
+ "flow-error-core-topic-deletion": "Аби вилучити тему, використовуйте меню ... на дошці Flow або на [$1 сторінці теми]. Не переходьте безпосередньо на action=delete для цієї теми.",
+ "flow-error-default": "Сталася помилка.",
+ "flow-error-invalid-input": "Неприпустиме значення було надано для завантаження потоку даних.",
+ "flow-error-invalid-title": "Надана невірна сторінка заголовку.",
+ "flow-error-fail-load-history": "Не вдалося завантажити зміст історії.",
+ "flow-error-missing-revision": "Не вдалося знайти редакцію для завантаження вмісту потоку.",
+ "flow-error-fail-commit": "Не вдалося зберегти вміст потоку.",
+ "flow-error-insufficient-permission": "Недостатньо прав для доступу до вмісту.",
+ "flow-error-revision-comparison": "Операції порівняння може бути зроблена лише для двох ревізій, що належать й того ж допису.",
+ "flow-error-missing-topic-title": "Не вдалося знайти назву теми для поточного робочого циклу.",
+ "flow-error-missing-metadata": "Не вдалось знайти необхідні метадані для цієї версії.",
+ "flow-error-fail-load-data": "Не вдалося завантажити запитані дані.",
+ "flow-error-invalid-workflow": "Не вдалося знайти запитаний робочий процес.",
+ "flow-error-process-data": "Сталася помилка під час обробки даних у вашому запиті.",
+ "flow-error-process-wikitext": "Сталася помилка при обробці HTML/wiki перетворення.",
+ "flow-error-no-index": "Не вдалося знайти індекс, щоб виконати пошук у базі даних.",
+ "flow-error-no-render": "Зазначену дію не розпізнано.",
+ "flow-error-no-commit": "Зазначену дію не вдалося зберегти.",
+ "flow-error-fetch-after-lock": "Сталася помилка при отриманні нових даних. Однак дія відкриття/закриття вдалася добре. Повідомлення про помилку: $1",
+ "flow-error-content-too-long": "Вміст завеликий. Довжина після маркування обмежена $1 {{PLURAL:$1|байтом|байтами}}.",
+ "flow-error-move": "Переміщення дошки обговорень в даний час не підтримується.",
+ "flow-error-invalid-topic-uuid-title": "Неприпустима назва",
+ "flow-error-invalid-topic-uuid": "Запитувана назва сторінки є неприпустимою. Сторінки в просторі назв ''Topic'' створюються автоматично через Flow.",
+ "flow-error-unknown-workflow-id-title": "Невідома тема",
+ "flow-error-unknown-workflow-id": "Запитувана тема не існує.",
+ "flow-edit-header-placeholder": "Опишіть цю дошку обговорень",
+ "flow-edit-header-submit": "Зберегти заголовок",
+ "flow-edit-header-submit-overwrite": "Переписати заголовок",
+ "flow-summarize-topic-submit": "Підсумувати",
+ "flow-summarize-topic-submit-overwrite": "Переписати підсумок",
+ "flow-lock-topic-submit": "Заблокувати тему",
+ "flow-lock-topic-submit-overwrite": "Переписати підсумок закриття теми",
+ "flow-unlock-topic-submit": "Розблокувати тему",
+ "flow-unlock-topic-submit-overwrite": "Переписати підсумок відкриття теми",
+ "flow-edit-title-submit": "Змінити заголовок",
+ "flow-edit-title-submit-overwrite": "Переписати назву",
+ "flow-edit-post-submit": "Подати зміни",
+ "flow-edit-post-submit-overwrite": "Переписати зміни",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2|відредагував|відредагувала}} [$3 коментар] у темі «$4»",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Відредагував|Відредагувала}} допис",
+ "flow-rev-message-reply": "$1 [$3 прокоментува{{GENDER:$2|в|ла}}] тему «$4» (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 {{PLURAL:$1|коментар|коментарі|коментарів}} </strong> {{PLURAL:$1|1=був доданий|були додані}}.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|створив|створила}} тему «[$3 $4]»",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Створив|Створила}} нову тему",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|змінив|змінила}} назву теми з «$5» на «[$3 $4]»",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|створив|створила}} заголовок",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|змінив|змінила}} заголовок",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|створив|створила}} підсумок теми $3",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|відредагував|відредагувала}} підсумок теми $3",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|приховав|приховала}} [$4 коментар] у темі «$6» (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|видалив|видалила}} [$4 коментар] у темі «$6» (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|подавив|подавила}} [$4 коментар] у темі «$6» (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|відновив|відновила}} [$4 коментар] у темі «$6» (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|приховав|приховала}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|вилучив|вилучила}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|прибрав|прибрала}} [$4 тему] «$6» (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|закрив|закрила}} [$4 тему] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|відновив|відновила}} [$4 тему] «$6»(<em>$5</em>)",
+ "flow-rc-topic-of-board": "$1 на $2",
+ "flow-board-history": "Історія \"$1\"",
+ "flow-board-history-empty": "Ця дошка в даний час не має історії.",
+ "flow-topic-history": "Історія теми \"$1\"",
+ "flow-post-history": "Історія дописів «Коментар {{GENDER:$2|$2}}»",
+ "flow-history-last4": "Останні 4 години",
+ "flow-history-day": "Сьогодні",
+ "flow-history-week": "Останній тиждень",
+ "flow-history-pages-topic": "З'являється на [стіні $1 \"$2\"]",
+ "flow-history-pages-post": "З'являється на [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 коментар|$1 коментарі|$1 коментарів|0={{GENDER:$2|Залиште перший коментар!}}}}",
+ "flow-comment-restored": "Відновлений коментар",
+ "flow-comment-deleted": "Видалений коментар",
+ "flow-comment-hidden": "Прихований коментар",
+ "flow-comment-moderated": "Модерований коментар",
+ "flow-last-modified": "Остання зміна про $1",
+ "flow-workflow": "робочий процес",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1|відповів|відповіла}} на '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 та $5 {{PLURAL:$6|інший|інші|інших}} {{GENDER:$1|відповіли}} на '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|відредагував|відредагувала}} Ваш <span class=\"plainlinks\">[$5 допис]</span> на [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 та $5 {{PLURAL:$6|інший|інші|інших}} {{GENDER:$1|відредагував|відредагувала}} <span class=\"plainlinks\">[$4 допис]</span> у $2 на \"$3\".",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|створив|створила}} нову тему на '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} {{PLURAL:$1|нова тема|нові теми|нових тем}} на '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 {{GENDER:$1|змінив|змінила}} назву <span class=\"plainlinks\">[$2 $3]</span> на \"$4\" у [[$5|$6]]",
+ "flow-notification-mention": "$1 {{GENDER:$1|згадав|згадала}} {{GENDER:$5|вас}} у {{GENDER:$1|своєму}} <span class=\"plainlinks\">[$2 дописі]</span> у \"$3\" на \"$4\".",
+ "flow-notification-link-text-view-post": "Переглянути допис",
+ "flow-notification-link-text-view-topic": "Перегляд теми",
+ "flow-notification-reply-email-subject": "$2 на $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|відповів|відповіла}} у «$2» на «$3».",
+ "flow-notification-reply-email-batch-bundle-body": "$1 та $4 {{PLURAL:$5|інший|інші|інших}} {{GENDER:$1|відповіли}} у «$2» на «$3»",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|згадав|згадала}} {{GENDER:$3|Вас}} на «$2»",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|згадав|згадала}} {{GENDER:$4|Вас}} у {{GENDER:$1|своєму}} дописі у «$2» на «$3»",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|відредагував|відредагувала}} допис",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|відредагував|відредагувала}} допис у \"$2\" на „$3“",
+ "flow-notification-edit-email-batch-bundle-body": "$1 та $4 {{PLURAL:$5|інший|інші|інших}} {{GENDER:$1|відредагував|відредагувала}} допис у \"$2\" на \"$3\"",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|перейменував|перейменувала}} Вашу тему",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|перейменував|перейменувала}} Вашу тему з «$2» на «$3» у «$4»",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|створив|створила}} нову тему на «$2»",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|створив|створила}} нову тему під назвою «$2» на $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Повідомляти, коли у Flow відбуваються дії, пов'язані зі мною.",
+ "flow-link-post": "допис",
+ "flow-link-topic": "тема",
+ "flow-link-history": "історія",
+ "flow-link-post-revision": "версія допису",
+ "flow-link-topic-revision": "версія теми",
+ "flow-link-header-revision": "версія шапки",
+ "flow-link-summary-revision": "версія підсумку теми",
+ "flow-moderation-title-suppress-post": "Прибрати допис?",
+ "flow-moderation-title-delete-post": "Видалити допис?",
+ "flow-moderation-title-hide-post": "Приховати допис?",
+ "flow-moderation-title-unsuppress-post": "Висвітити допис?",
+ "flow-moderation-title-undelete-post": "Відновити допис?",
+ "flow-moderation-title-unhide-post": "Відобразити допис?",
+ "flow-moderation-placeholder-suppress-post": "Будь ласка, {{GENDER:$3|поясніть}}, чому ви прибрали цей допис.",
+ "flow-moderation-placeholder-delete-post": "Будь ласка, {{GENDER:$3|поясніть,}} чому ви хочете видалити цей допис.",
+ "flow-moderation-placeholder-hide-post": "Будь ласка, {{GENDER:$3|поясніть,}} чому ви приховуєте цей допис.",
+ "flow-moderation-placeholder-unsuppress-post": "Будь ласка, {{GENDER:$3|поясніть}}, чому ви не прибрали цей допис.",
+ "flow-moderation-placeholder-undelete-post": "Будь ласка, {{GENDER:$3|поясніть,}} чому ви не хочете видалити цей допис.",
+ "flow-moderation-placeholder-unhide-post": "Будь ласка, {{GENDER:$3|поясніть,}} чому ви не приховуєте цей допис.",
+ "flow-moderation-confirm-suppress-post": "Прибрати",
+ "flow-moderation-confirm-delete-post": "Видалити",
+ "flow-moderation-confirm-hide-post": "Приховати",
+ "flow-moderation-confirm-unsuppress-post": "Висвітити",
+ "flow-moderation-confirm-undelete-post": "Відновити",
+ "flow-moderation-confirm-unhide-post": "Відобразити",
+ "flow-moderation-confirm-suppress-topic": "Прибрати",
+ "flow-moderation-confirm-delete-topic": "Видалити",
+ "flow-moderation-confirm-hide-topic": "Приховати",
+ "flow-moderation-confirm-lock-topic": "Закрити",
+ "flow-moderation-confirm-unsuppress-topic": "Висвітити",
+ "flow-moderation-confirm-undelete-topic": "Відновити",
+ "flow-moderation-confirm-unhide-topic": "Відобразити",
+ "flow-moderation-confirm-unlock-topic": "Скасувати закриття",
+ "flow-moderation-confirmation-suppress-post": "Допис успішно усунено.\nРозгляньте відгук {{GENDER:$2|наданий}} $1 на цей допис.",
+ "flow-moderation-confirmation-delete-post": "Цей допис успішно вилучено.\nРозгляньте відгук, {{GENDER:$2|наданий}} $1, на цей допис.",
+ "flow-moderation-confirmation-hide-post": "Цей допис успішно приховано.\nРозгляньте відгук, {{GENDER:$2|наданий}} $1, на цей допис.",
+ "flow-moderation-confirmation-unsuppress-post": "Ви успішно відновили допис вище.",
+ "flow-moderation-confirmation-undelete-post": "Ви успішно відновили публікацію вище.",
+ "flow-moderation-confirmation-unhide-post": "Ви успішно показали публікацію вище.",
+ "flow-moderation-confirmation-suppress-topic": "Ця тема успішно усунена.",
+ "flow-moderation-confirmation-delete-topic": "Тему успішно вилучено.",
+ "flow-moderation-confirmation-hide-topic": "Тему успішно приховано.",
+ "flow-moderation-confirmation-unsuppress-topic": "Ви успішно відновили цю тему.",
+ "flow-moderation-confirmation-undelete-topic": "Ви успішно відновили цю тему.",
+ "flow-moderation-confirmation-unhide-topic": "Ви успішно показали цю тему.",
+ "flow-moderation-title-suppress-topic": "Прибрати тему?",
+ "flow-moderation-title-delete-topic": "Видалити тему?",
+ "flow-moderation-title-hide-topic": "Приховати тему?",
+ "flow-moderation-title-unsuppress-topic": "Висвітити тему?",
+ "flow-moderation-title-undelete-topic": "Відновити тему?",
+ "flow-moderation-title-unhide-topic": "Приховати тему?",
+ "flow-moderation-placeholder-suppress-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви прибрали цю тему.",
+ "flow-moderation-placeholder-delete-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви вилучаєте цю тему.",
+ "flow-moderation-placeholder-hide-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви приховуєте цю тему.",
+ "flow-moderation-placeholder-lock-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви закриваєте цю тему.",
+ "flow-moderation-placeholder-unsuppress-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви скасовуєте прибирання цієї теми.",
+ "flow-moderation-placeholder-undelete-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви скасовуєте вилучення цієї теми.",
+ "flow-moderation-placeholder-unhide-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви скасовуєте приховування цієї теми.",
+ "flow-moderation-placeholder-unlock-topic": "Будь ласка, {{GENDER:$3|поясніть}}, чому Ви скасовуєте закриття цієї теми.",
+ "flow-topic-permalink-warning": "Ця тема розпочата [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Ця тема розпочата на [$2 дошці {{GENDER:$1|користувача|користувачки}} $1]",
+ "flow-revision-permalink-warning-post": "Це постійне посилання на окрему версію цього допису.\nЦе версія за $1.\nВи можете подивитися [відмінності від попередньої версії $5] або переглянути інші версії на [сторінці історії допису $4].",
+ "flow-revision-permalink-warning-post-first": "Це постійне посилання на першу версію цього допису.\nВи можете переглядати пізніші версії на [сторінці історії допису $4].",
+ "flow-revision-permalink-warning-postsummary": "Це постійне посилання на окрему версію підсумку цього допису. Це версія за $1.\nВи можете подивитися [$5 відмінності від попередньої версії] або переглянути інші версії на [$4 сторінці історії допису].",
+ "flow-revision-permalink-warning-postsummary-first": "Це постійне посилання на першу версію підсумку цього допису.\nВи можете переглянути пізніші версії на [$4 сторінці історії допису].",
+ "flow-revision-permalink-warning-header": "Це постійне посилання на окрему версію заголовку.\nДана версія за $1.\nВи можете подивитися [$3 відмінності від попередньої версії] або переглянути інші версії на [$2 сторінці стіни історії].",
+ "flow-revision-permalink-warning-header-first": "Це постійне посилання на першу версію цього заголовку.\nВи можете переглядати пізніші версії на [$2 сторінці історії стіни].",
+ "flow-compare-revisions-revision-header": "Версія від {{GENDER:$2|$2}} за $1",
+ "flow-compare-revisions-header-post": "Ця сторінка відображає зміни між двома версіями допису від $3 у розділі \"[$5 $2]\" на [$4 $1].\nВи можете побачити інші версії цього допису на його [сторінці історії $6].",
+ "flow-compare-revisions-header-postsummary": "Ця сторінка відображає зміни між двома версіями підсумку допису у дописі «[$4 $2]» на [$3 $1].\nВи можете побачити інші версії цього допису на його [$5 сторінці історії].",
+ "flow-compare-revisions-header-header": "На цій сторінці відображаються {{GENDER:$2|зміни}} між двома версіями заголовку на [$3 $1].\nВи можете побачити інші версії заголовку на його [$4 сторінці історії].",
+ "action-flow-create-board": "створення дощок Flow у будь-якому місці",
+ "right-flow-create-board": "Створення дощок Flow у будь-якому місці",
+ "right-flow-hide": "Приховування тем і дописів Flow",
+ "right-flow-lock": "Закриття тем Flow",
+ "right-flow-delete": "Вилучення тем і дописів Flow",
+ "right-flow-edit-post": "Редагування дописів інших користувачів у Flow",
+ "right-flow-suppress": "Приховування версій Flow",
+ "flow-terms-of-use-new-topic": "Натискаючи кнопку «{{int:flow-newtopic-save}}», Ви погоджуєтеся з умовами використання цієї вікі.",
+ "flow-terms-of-use-reply": "Натискаючи кнопку «{{int:flow-reply-submit}}», Ви погоджуєтеся з умовами використання цієї вікі.",
+ "flow-terms-of-use-edit": "Зберігаючи зміни, ви погоджуєтесь з умовами використання для цього вікі.",
+ "flow-anon-warning": "Ви не ввійшли в систему. Для роботи під вашим власним іменем замість IP-адреси, ви можете [$1 увійти] або [$2 створити обліковий запис].",
+ "flow-cancel-warning": "Ви ввели текст у цю форму. Ви впевнені, що хочете відкинути його?",
+ "flow-topic-first-heading": "Тема на $1",
+ "flow-topic-html-title": "$1 на $2",
+ "flow-topic-count": "Теми ($1)",
+ "flow-load-more": "Завантажити більше",
+ "flow-no-more-fwd": "Немає старіших тем",
+ "flow-add-topic": "Додати тему",
+ "flow-newest-topics": "Найновіші теми",
+ "flow-recent-topics": "Останні активні теми",
+ "flow-sorting-tooltip-newest": "{{GENDER:|Ви}} зараз читає новіші теми спочатку. Натисніть кнопку, щоб вибрати інший порядок сортування.",
+ "flow-sorting-tooltip-recent": "{{GENDER:|Ви}} зараз переглядаєте спершу найактивніші теми. Клацніть, аби вибрати параметри сортування.",
+ "flow-toggle-small-topics": "Перемкнути на зменшений вигляд тем",
+ "flow-toggle-topics": "Перемкнути на вигляд заголовків",
+ "flow-toggle-topics-posts": "Перемкнути на відображення тем і дописів",
+ "flow-terms-of-use-summarize": "Натиснувши кнопку «{{int:flow-summarize-topic-submit}}», ви погоджуєтеся з умовами використання цієї вікі.",
+ "flow-terms-of-use-lock-topic": "Натиснувши кнопку \"{{int:flow-lock-topic-submit}}\", ви погоджуєтеся з умовами використання цієї вікі.",
+ "flow-terms-of-use-unlock-topic": "Натискаючи кнопку «{{int:flow-unlock-topic-submit}}», Ви погоджуєтеся з умовами використання цієї вікі.",
+ "flow-whatlinkshere-post": "із [$1 допису]",
+ "flow-whatlinkshere-header": "із [$1 шапки]",
+ "flow": "Flow",
+ "flow-special-desc": "Ця спеціальна сторінка перенаправляє на робочий процес Flow або допис Flow, згідно з UUID.",
+ "flow-special-type": "Тип",
+ "flow-special-type-post": "Допис",
+ "flow-special-type-workflow": "Робочий процес",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Не вдалося знайти вміст, що відповідає типу та UUID.",
+ "flow-special-enableflow-legend": "Увімкнути Flow на новій сторінці",
+ "flow-special-enableflow-page": "Сторінка, на якій має бути увімкнений Flow",
+ "flow-spam-confirmedit-form": "Будь ласка, підтвердіть, що Ви людина, ввівши каптчу нижче: $1",
+ "flow-preview-warning": "Ви бачите попередній перегляд. Клацніть «{{int:flow-newtopic-save}}», аби зберегти допис, або «{{int:flow-preview-return-edit-post}}», щоб продовжити писати.",
+ "flow-preview-return-edit-post": "Продовжити редагування",
+ "flow-anonymous": "Анонім",
+ "flow-embedding-unsupported": "Обговорення іще не можна вставити.",
+ "mw-ui-unsubmitted-confirm": "У вас є невідправлені зміни на цій сторінці. Ви впевнені, що хочете піти і втратити роботу?",
+ "apihelp-flow+edit-header-description": "Редагування преамбули дошки.",
+ "flow-edited": "Відредаговано",
+ "flow-edited-by": "Відредаговано $1",
+ "flow-undo": "скасувати",
+ "flow-undo-latest-revision": "Поточна версія",
+ "flow-undo-your-text": "Ваш текст",
+ "flow-ve-mention-inspector-title": "Згадка",
+ "flow-ve-mention-inspector-remove-label": "Прибрати",
+ "flow-ve-mention-tool-title": "Згадати учасника",
+ "flow-ve-mention-template": "пінг",
+ "flow-ve-mention-inspector-invalid-user": "Учасник '$1' не зареєстрований.",
+ "flow-wikitext-editor-help": "Вікірозмітка $1.",
+ "flow-wikitext-editor-help-and-preview": "Цей редактор $1 і Ви можете $2 в будь-який момент.",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|використовує вікірозмітку]]",
+ "flow-wikitext-editor-help-preview-the-result": "попередньо переглянути результат",
+ "flow-wikitext-switch-editor-tooltip": "Перемкнути на Візуальний редактор",
+ "flow-ve-switch-editor-tool-title": "Перемкнути на редактор вікірозмітки"
+}
diff --git a/Flow/i18n/uz.json b/Flow/i18n/uz.json
new file mode 100644
index 00000000..f9a403df
--- /dev/null
+++ b/Flow/i18n/uz.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sociologist"
+ ]
+ },
+ "flow-show-change": "Oʻzgarishlarni koʻrsat"
+}
diff --git a/Flow/i18n/vi.json b/Flow/i18n/vi.json
new file mode 100644
index 00000000..4062010c
--- /dev/null
+++ b/Flow/i18n/vi.json
@@ -0,0 +1,370 @@
+{
+ "@metadata": {
+ "authors": [
+ "Baonguyen21022003",
+ "Minh Nguyen",
+ "Withoutaname",
+ "Trần Nguyễn Minh Huy",
+ "Max20091",
+ "Dinhxuanduyet"
+ ]
+ },
+ "flow-desc": "Hệ thống quản lý luồng làm việc",
+ "flow-talk-taken-over": "Trang thảo luận này đang sử dụng [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
+ "flow-talk-username": "Trình quản lý trang thảo luận Flow",
+ "log-name-flow": "Nhật trình hoạt động Flow",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2}}đã xóa một [$4 bài đăng] về “[[$3|$5]]” tại [[$6]]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2}}đã phục hồi một [$4 bài đăng] về “[[$3|$5]]” tại [[$6]]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2}}đã đàn áp một [$4 chủ đề] về “[[$3|$5]]” tại [[$6]]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2}}đã xóa một [$4 bài đăng] về “[[$3|$5]]” tại [[$6]]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2}}đã xóa chủ đề “[[$3|$5]]” tại [[$6]]",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2}}đã phục hồi chủ đề “[[$3|$5]]” tại [[$6]]",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2}}đã đàn áp chủ đề “[[$3|$5]]” tại [[$6]]",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2}}đã xóa chủ đề “[[$3|$5]]” tại [[$6]]",
+ "flow-user-moderated": "Người dùng bị kiểm duyệt",
+ "flow-edit-header-link": "Sửa đầu đề",
+ "flow-post-moderated-toggle-hide-show": "Hiển thị bình luận đã bị $2 {{GENDER:$1}}ẩn",
+ "flow-post-moderated-toggle-delete-show": "Hiển thị bình luận đã bị $2 {{GENDER:$1}}xóa",
+ "flow-post-moderated-toggle-suppress-show": "Hiển thị bình luận đã bị $2 {{GENDER:$1}}đàn áp",
+ "flow-post-moderated-toggle-hide-hide": "Ẩn bình luận đã bị $2 {{GENDER:$1}}ẩn",
+ "flow-post-moderated-toggle-delete-hide": "Ẩn bình luận đã bị $2 {{GENDER:$1}}xóa",
+ "flow-post-moderated-toggle-suppress-hide": "Ẩn bình luận đã bị $2 {{GENDER:$1}}đàn áp",
+ "flow-topic-moderated-reason-prefix": "Lý do:",
+ "flow-hide-post-content": "Bình luận này đã bị {{GENDER:$1}}ẩn bởi $1 ([$2 lịch sử])",
+ "flow-hide-title-content": "Chủ đề này đã bị {{GENDER:$1}}ẩn bởi $1",
+ "flow-lock-title-content": "Chủ đề này đã bị {{GENDER:$1}}khóa bởi $1",
+ "flow-hide-header-content": "{{GENDER:$1}}Ẩn bởi $2",
+ "flow-delete-post-content": "Bình luận này đã bị {{GENDER:$1}}xóa bởi $1 ([$2 lịch sử])",
+ "flow-delete-title-content": "Chủ đề này đã bị {{GENDER:$1}}xóa bởi $1",
+ "flow-delete-header-content": "{{GENDER:$1}}Xóa bởi $2",
+ "flow-suppress-post-content": "Bình luận này đã bị {{GENDER:$1}}đàn áp bởi $1 ([$2 lịch sử])",
+ "flow-suppress-title-content": "Chủ đề này đã bị {{GENDER:$1}}đàn áp bởi $1",
+ "flow-suppress-header-content": "{{GENDER:$1}}Đàn áp bởi $2",
+ "flow-suppress-usertext": "<em>Tên người dùng bị đàn áp</em>",
+ "flow-post-actions": "Tác vụ",
+ "flow-topic-actions": "Tác vụ",
+ "flow-cancel": "Hủy bỏ",
+ "flow-preview": "Xem trước",
+ "flow-show-change": "Xem thay đổi",
+ "flow-last-modified-by": "Sửa đổi lần cuối cùng bởi $1",
+ "flow-stub-post-content": "''Không thể lấy bài đăng này do một lỗi kỹ thuật.''",
+ "flow-newtopic-title-placeholder": "Chủ đề mới",
+ "flow-newtopic-content-placeholder": "Nhắn tin mới tại “$1”",
+ "flow-newtopic-header": "Thêm chủ đề mới",
+ "flow-newtopic-save": "Thêm chủ đề",
+ "flow-newtopic-start-placeholder": "Bắt đầu một chủ đề mới",
+ "flow-newtopic-first-heading": "Bắt đầu một chủ đề mới trên $1",
+ "flow-summarize-topic-placeholder": "Xin hãy tóm lược cuộc thảo luận này",
+ "flow-reply-topic-placeholder": "{{GENDER:$1}}Bình luận về “$2”",
+ "flow-reply-topic-title-placeholder": "Trả lời “$1”",
+ "flow-reply-submit": "{{GENDER:$1}}Trả lời",
+ "flow-reply-link": "{{GENDER:$1}}Trả lời",
+ "flow-thank-link": "{{GENDER:$1}}Cảm ơn",
+ "flow-lock-link": "{{GENDER:$1}}Khóa",
+ "flow-history-action-suppress-post": "đàn áp",
+ "flow-history-action-delete-post": "xóa",
+ "flow-history-action-hide-post": "ẩn",
+ "flow-history-action-unsuppress-post": "ngừng đàn áp",
+ "flow-history-action-undelete-post": "phục hồi",
+ "flow-history-action-unhide-post": "bỏ ẩn",
+ "flow-history-action-restore-post": "phục hồi",
+ "flow-history-action-lock-topic": "khóa",
+ "flow-history-action-unlock-topic": "mở khóa",
+ "flow-post-edited": "Bài đăng được sửa đổi bởi $1 $2",
+ "flow-post-action-view": "Liên kết thường trực",
+ "flow-post-action-post-history": "Lịch sử",
+ "flow-post-action-suppress-post": "Đàn áp",
+ "flow-post-action-delete-post": "Xóa",
+ "flow-post-action-hide-post": "Ẩn",
+ "flow-post-action-edit-post": "Sửa đổi",
+ "flow-post-action-edit-post-submit": "Lưu các thay đổi",
+ "flow-post-action-unsuppress-post": "Ngừng đàn áp",
+ "flow-post-action-undelete-post": "Phục hồi",
+ "flow-post-action-unhide-post": "Ngừng ẩn",
+ "flow-post-action-restore-post": "Phục hồi",
+ "flow-post-action-undo-moderation": "Lùi lại",
+ "flow-topic-action-view": "Liên kết thường trực",
+ "flow-topic-action-watchlist": "Danh sách theo dõi",
+ "flow-topic-action-edit-title": "Sửa tiêu đề",
+ "flow-topic-action-history": "Lịch sử",
+ "flow-topic-action-hide-topic": "Ẩn chủ đề",
+ "flow-topic-action-delete-topic": "Xóa chủ đề",
+ "flow-topic-action-lock-topic": "Khóa chủ đề",
+ "flow-topic-action-unlock-topic": "Mở khóa chủ đề",
+ "flow-topic-action-summarize-topic": "Tóm lược",
+ "flow-topic-action-resummarize-topic": "Tóm lược sửa đổi",
+ "flow-topic-action-suppress-topic": "Đàn áp chủ đề",
+ "flow-topic-action-unhide-topic": "Ngừng ẩn chủ đề",
+ "flow-topic-action-undelete-topic": "Phục hồi chủ đề",
+ "flow-topic-action-unsuppress-topic": "Ngừng đàn áp chủ đề",
+ "flow-topic-action-restore-topic": "Phục hồi chủ đề",
+ "flow-topic-action-undo-moderation": "Lùi lại",
+ "flow-topic-notification-subscribe-title": "Chủ đề này đã được thêm vào danh sách theo dõi của {{GENDER:$1}}bạn.",
+ "flow-topic-notification-subscribe-description": "{{GENDER:$1}}Bạn sẽ nhận thông báo về tất cả hoạt động vào chủ đề này.",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1}}Bạn hiện theo dõi bảng tin này!",
+ "flow-board-notification-subscribe-description": "{{GENDER:$1}}Bạn sẽ được thông báo khi nào một chủ đề mới được tạo ra trên bảng tin này.",
+ "flow-error-http": "Đã xuất hiện lỗi khi liên lạc với máy chủ.",
+ "flow-error-other": "Đã xuất hiện lỗi bất ngờ.",
+ "flow-error-external": "Đã xuất hiện lỗi.<br />Lỗi nhận được là: $1",
+ "flow-error-edit-restricted": "Bạn không có quyền sửa đổi bài đăng này.",
+ "flow-error-topic-is-locked": "Chủ đề này đã bị khóa và không cho phép tác động tiếp.",
+ "flow-error-lock-moderated-post": "Bạn không thể khóa một bài đăng được điều phối.",
+ "flow-error-external-multi": "Đã xuất hiện lỗi.<br />$1",
+ "flow-error-missing-content": "Bài đăng không có nội dung. Bài đăng phải có nội dung để lưu.",
+ "flow-error-missing-summary": "Tóm lược không có nội dung. Tóm lược phải có nội dung để lưu.",
+ "flow-error-missing-title": "Chủ đề không có tiêu đề. Chủ đề phải có tiêu đề để lưu.",
+ "flow-error-parsoid-failure": "Không thể phân tích nội dung vì Parsoid bị thất bại.",
+ "flow-error-missing-replyto": "Tham số “replyTo” không được cung cấp. Tham số này cần để thực hiện tác vụ “trả lời”.",
+ "flow-error-invalid-replyto": "Tham số “replyTo” có giá trị không hợp lệ. Không tìm thấy bài đăng.",
+ "flow-error-delete-failure": "Thất bại khi xóa mục này.",
+ "flow-error-hide-failure": "Thất bại khi ẩn mục này.",
+ "flow-error-missing-postId": "Tham số “postId” không được cung cấp. Tham số này cần để xóa hoặc phục hồi bài đăng.",
+ "flow-error-invalid-postId": "Tham số “postId” có giá trị không hợp lệ. Không tìm thấy bài đăng được chỉ định ($1).",
+ "flow-error-restore-failure": "Thất bại khi phục hồi mục này.",
+ "flow-error-invalid-moderation-state": "Một giá trị không hợp lệ cho một tham số (“moderationState”) đã được gửi vào API Flow",
+ "flow-error-invalid-moderation-reason": "Xin vui lòng cung cấp một lý do kiểm duyệt",
+ "flow-error-not-allowed": "Không có đủ quyền để thực hiện tác vụ này",
+ "flow-error-not-allowed-hide": "Chủ đề này đã được ẩn.",
+ "flow-error-not-allowed-delete": "Chủ đề này đã bị xóa.",
+ "flow-error-not-allowed-suppress": "Chủ đề này đã bị xóa.",
+ "flow-error-not-allowed-hide-extract": "Chủ đề này đã được ẩn. Mục nhật trình ẩn của chủ đề được đưa ra dưới đây để tiện theo dõi.",
+ "flow-error-not-allowed-delete-extract": "Chủ đề này đã bị xóa. Mục nhật trình xóa của chủ đề được đưa ra dưới đây để tiện theo dõi.",
+ "flow-error-not-allowed-suppress-extract": "Chủ đề này đã bị xóa. Mục nhật trình xóa của chủ đề được đưa ra dưới đây để tiện theo dõi.",
+ "flow-error-title-too-long": "Tên chủ đề không được dài hơn $1 byte.",
+ "flow-error-no-existing-workflow": "Luồng làm việc này chưa tồn tại.",
+ "flow-error-not-a-post": "Không thể lưu tên chủ đề thành nội dung của bài đăng.",
+ "flow-error-missing-header-content": "Đầu đề không có nội dung. Đầu đề phải có nội dung để lưu.",
+ "flow-error-missing-prev-revision-identifier": "Thiếu định danh phiên bản trước.",
+ "flow-error-prev-revision-mismatch": "Một người dùng khác vừa sửa đổi bài đăng này cách đây vài giây. {{GENDER:$3}}Bạn có chắc chắn muốn ghi đè thay đổi đó?",
+ "flow-error-prev-revision-does-not-exist": "Không tìm thấy phiên bản trước.",
+ "flow-error-default": "Đã xuất hiện lỗi.",
+ "flow-error-invalid-input": "Đã cung cấp một giá trị không hợp lệ khi tải nội dung luồng.",
+ "flow-error-invalid-title": "Đã cung cấp tên trang không hợp lệ.",
+ "flow-error-fail-load-history": "Thất bại khi tải nội dung lịch sử.",
+ "flow-error-missing-revision": "Không tìm thấy phiên bản để tải nội dung luồng.",
+ "flow-error-fail-commit": "Thất bại khi lưu nội dung luồng.",
+ "flow-error-insufficient-permission": "Không đủ quyền để truy cập vào nội dung.",
+ "flow-error-revision-comparison": "Chỉ có thể so sánh hai phiên bản của cùng bài đăng.",
+ "flow-error-missing-topic-title": "Không tìm thấy tên chủ đề cho luồng làm việc hiện tại.",
+ "flow-error-missing-metadata": "Không tìm thấy siêu dữ liệu cần thiết cho thay đổi này.",
+ "flow-error-fail-load-data": "Thất bại khi tải dữ liệu được yêu cầu.",
+ "flow-error-invalid-workflow": "Không tìm thấy luồng làm việc.",
+ "flow-error-process-data": "Đã xuất hiện lỗi khi xử lý dữ liệu trong yêu cầu của bạn.",
+ "flow-error-process-wikitext": "Đã xuất hiện lỗi khi xử lý chuyển đổi HTML/mã wiki.",
+ "flow-error-no-index": "Không tìm thấy chỉ mục để tìm kiếm dữ liệu.",
+ "flow-error-no-render": "Không hiểu tác vụ được định rõ.",
+ "flow-error-no-commit": "Không thể lưu tác vụ được chỉ định.",
+ "flow-error-fetch-after-lock": "Đã xuất hiện lỗi khi yêu cầu dữ liệu mới. Tuy nhiên, chủ đề đã được khóa/mở khóa thành công. Thông báo lỗi: $1",
+ "flow-error-content-too-long": "Nội dung quá lớn. Nội dung không được quá $1 byte sau khi được bung.",
+ "flow-error-move": "Hiện không hỗ trợ việc di chuyển một bảng tin.",
+ "flow-error-invalid-topic-uuid-title": "Tựa trang sai",
+ "flow-edit-header-placeholder": "Miêu tả bảng tin này",
+ "flow-edit-header-submit": "Lưu đầu đề",
+ "flow-edit-header-submit-overwrite": "Ghi đè đầu đề",
+ "flow-summarize-topic-submit": "Tóm lược",
+ "flow-summarize-topic-submit-overwrite": "Ghi đè lên tóm lược mới hơn",
+ "flow-lock-topic-submit": "Khóa chủ đề",
+ "flow-lock-topic-submit-overwrite": "Ghi đè lời tóm lược khóa chủ đề",
+ "flow-unlock-topic-submit": "Mở khóa chủ đề",
+ "flow-unlock-topic-submit-overwrite": "Ghi đè lời tóm lược mở khóa chủ đề",
+ "flow-edit-title-submit": "Thay đổi tiêu đề",
+ "flow-edit-title-submit-overwrite": "Ghi đè tiêu đề",
+ "flow-edit-post-submit": "Gửi thay đổi",
+ "flow-edit-post-submit-overwrite": "Ghi đè thay đổi",
+ "flow-rev-message-edit-post": "$1 {{GENDER:$2}}đã sửa đổi một [$3 bình luận] về “$4”.",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2}}Đã sửa đổi bài đăng",
+ "flow-rev-message-reply": "$1 {{GENDER:$2}}đã [$3 bình luận] về “$4” (<em>$5</em>).",
+ "flow-rev-message-reply-bundle": "<strong>$1 bình luận</strong> được thêm vào.",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2}}đã tạo chủ đề “[$3 $4]”.",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2}}Đã tạo chủ đề mới",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2}}đã đổi tiêu đề của chủ đề từ “$5” thành “[$3 $4]”.",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2}}đã tạo đầu đề.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2}}đã sửa đổi đầu đề.",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2}}đã tóm lược chủ đề $3.",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2}}đã sửa tóm lược chủ đề $3.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2}}đã ẩn một [$4 bình luận] về “$6” (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2}}đã xóa một [$4 bình luận] về “$6” (<em>$5</em>).",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2}}đã đàn áp một [$4 bình luận] về “$6” (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2}}đã phục hồi một [$4 bình luận] về “$6” (<em>$5</em>).",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2}}đã ẩn [$4 chủ đề] “$6” (<em>$5</em>).",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2}}đã xóa [$4 chủ đề] “$6” (<em>$5</em>).",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2}}đã đàn áp [$4 chủ đề] “$6” (<em>$5</em>).",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2}}đã khóa [$4 chủ đề] $6 (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2}}đã phục hồi [$4 chủ đề] “$6” (<em>$5</em>).",
+ "flow-rc-topic-of-board": "$1 tại $2",
+ "flow-board-history": "Lịch sử “$1”",
+ "flow-board-history-empty": "Bảng này hiện không có lịch sử.",
+ "flow-topic-history": "Lịch sử chủ đề “$1”",
+ "flow-post-history": "Lịch sử bài đăng “Bình luận của $2”",
+ "flow-history-last4": "4 giờ trước đây",
+ "flow-history-day": "Hôm nay",
+ "flow-history-week": "Tuần trước",
+ "flow-history-pages-topic": "Xuất hiện trên [$1 bảng tin nhắn “$2”]",
+ "flow-history-pages-post": "Xuất hiện trên [$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1 bình luận|0={{GENDER:$2}}Hãy là người đầu tiên bình luận!}}",
+ "flow-comment-restored": "Bình luận đã được phục hồi",
+ "flow-comment-deleted": "Bình luận đã bị xóa",
+ "flow-comment-hidden": "Bình luận đã bị ẩn",
+ "flow-comment-moderated": "Bài đăng kiểm duyệt",
+ "flow-last-modified": "Thay đổi lần cuối cùng vào khoảng $1",
+ "flow-workflow": "luồng làm việc",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 {{GENDER:$1}}đã trả lời tại '''$4'''.",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 và $5 {{PLURAL:$6}}người khác đã trả lời tại '''$3'''.",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 đã sửa đổi <span class=\"plainlinks\">[$5 bài đăng]</span> của bạn tại [[$3|$4]].",
+ "flow-notification-edit-bundle": "$1 và $5 {{PLURAL:$6}}người khác đã {{GENDER:$1}}sửa đổi một <span class=\"plainlinks\">[$4 bài đăng]</span> về “$2” tại “$3”.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 đã tạo ra chủ đề mới tại '''$3'''.",
+ "flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} chủ đề mới trên '''<span class=\"plainlinks\">[$3 $2]</span>'''",
+ "flow-notification-rename": "$1 đã thay đổi tiêu đề của <span class=\"plainlinks\">[$2 $3]</span> thành “$4” tại [[$5|$6]].",
+ "flow-notification-mention": "$1 đã nói đến {{GENDER:$5}}bạn trong <span class=\"plainlinks\">[$2 bài đăng]</span> của họ về “$3” tại “$4”.",
+ "flow-notification-link-text-view-post": "Xem bài đăng",
+ "flow-notification-link-text-view-topic": "Xem chủ đề",
+ "flow-notification-reply-email-subject": "$2 tại $3",
+ "flow-notification-reply-email-batch-body": "$1 đã trả lời “$2” tại “$3”",
+ "flow-notification-reply-email-batch-bundle-body": "$1 và $4 {{PLURAL:$5}}người khác đã trả lời “$2” tại “$3”",
+ "flow-notification-mention-email-subject": "$1 đã nói đến {{GENDER:$3}}bạn tại “$2”",
+ "flow-notification-mention-email-batch-body": "$1 đã nói đến {{GENDER:$4}}bạn trong bài đăng của họ về “$2” tại “$3”.",
+ "flow-notification-edit-email-subject": "$1 đã sửa đổi một bài đăng",
+ "flow-notification-edit-email-batch-body": "$1 đã sửa đổi một bài đăng về “$2” tại “$3”",
+ "flow-notification-edit-email-batch-bundle-body": "$1 và $4 {{PLURAL:$5}}người khác đã sửa đổi một bài đăng về “$2” tại “$3”",
+ "flow-notification-rename-email-subject": "$1 đã đổi tên chủ đề của bạn",
+ "flow-notification-rename-email-batch-body": "$1 đã đổi tên chủ đề của bạn từ “$2” thành “$3” tại “$4”",
+ "flow-notification-newtopic-email-subject": "$1 đã bắt đầu một chủ đề mới tại “$2”",
+ "flow-notification-newtopic-email-batch-body": "$1 đã bắt đầu một chủ đề mới với tiêu đề “$2” tại $3",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "Thông báo cho tôi khi các hành động có liên quan đến tôi xảy ra trên Flow.",
+ "flow-link-post": "bài đăng",
+ "flow-link-topic": "chủ đề",
+ "flow-link-history": "lịch sử",
+ "flow-link-post-revision": "phiên bản bài đăng",
+ "flow-link-topic-revision": "phiên bản chủ đề",
+ "flow-link-header-revision": "phiên bản đầu đề",
+ "flow-moderation-title-suppress-post": "Đàn áp bài đăng?",
+ "flow-moderation-title-delete-post": "Xóa bài đăng?",
+ "flow-moderation-title-hide-post": "Ẩn bài đăng?",
+ "flow-moderation-title-unsuppress-post": "Ngừng đàn áp bài đăng?",
+ "flow-moderation-title-undelete-post": "Phục hồi bài đăng?",
+ "flow-moderation-title-unhide-post": "Ngừng ẩn bài đăng?",
+ "flow-moderation-placeholder-suppress-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn đàn áp bài đăng này.",
+ "flow-moderation-placeholder-delete-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn xóa bài đăng này.",
+ "flow-moderation-placeholder-hide-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn ẩn bài đăng này.",
+ "flow-moderation-placeholder-unsuppress-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn ngừng đàn áp bài đăng này.",
+ "flow-moderation-placeholder-undelete-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn phục hồi bài đăng này.",
+ "flow-moderation-placeholder-unhide-post": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn ngừng ẩn bài đăng này.",
+ "flow-moderation-confirm-suppress-post": "Đàn áp",
+ "flow-moderation-confirm-delete-post": "Xóa",
+ "flow-moderation-confirm-hide-post": "Ẩn",
+ "flow-moderation-confirm-unsuppress-post": "Ngừng đàn áp",
+ "flow-moderation-confirm-undelete-post": "Phục hồi",
+ "flow-moderation-confirm-unhide-post": "Ngừng ẩn",
+ "flow-moderation-confirm-suppress-topic": "Đàn áp",
+ "flow-moderation-confirm-delete-topic": "Xóa",
+ "flow-moderation-confirm-hide-topic": "Ẩn",
+ "flow-moderation-confirm-lock-topic": "Khóa",
+ "flow-moderation-confirm-unsuppress-topic": "Ngừng đàn áp",
+ "flow-moderation-confirm-undelete-topic": "Phục hồi",
+ "flow-moderation-confirm-unhide-topic": "Ngừng ẩn",
+ "flow-moderation-confirm-unlock-topic": "Mở khóa",
+ "flow-moderation-confirmation-suppress-post": "Bài đăng đã được đàn áp thành công. Xin hãy {{GENDER:$2}}nghĩ đến việc gửi phản hồi cho $1 về bài đăng này.",
+ "flow-moderation-confirmation-delete-post": "Bài đăng đã được xóa thành công. Xin hãy {{GENDER:$2}}nghĩ đến việc gửi phản hồi cho $1 về bài đăng này.",
+ "flow-moderation-confirmation-hide-post": "Bài đăng đã được ẩn thành công. Xin hãy {{GENDER:$2}}nghĩ đến việc gửi phản hồi cho $1 về bài đăng này.",
+ "flow-moderation-confirmation-unsuppress-post": "Bạn đã ngừng đàn áp bài đăng ở trên thành công.",
+ "flow-moderation-confirmation-undelete-post": "Bạn đã phục hồi bài đăng ở trên thành công.",
+ "flow-moderation-confirmation-unhide-post": "Bạn đã ngừng ẩn bài đăng ở trên thành công.",
+ "flow-moderation-confirmation-suppress-topic": "Chủ đề này đã được đàn áp.",
+ "flow-moderation-confirmation-delete-topic": "Chủ đề này đã được xóa.",
+ "flow-moderation-confirmation-hide-topic": "Chủ đề này đã được ẩn.",
+ "flow-moderation-confirmation-unsuppress-topic": "Bạn đã ngừng đàn áp chủ đề này thành công.",
+ "flow-moderation-confirmation-undelete-topic": "Bạn đã phục hồi chủ đề này thành công.",
+ "flow-moderation-confirmation-unhide-topic": "Bạn đã ngừng ẩn chủ đề này thành công.",
+ "flow-moderation-title-suppress-topic": "Đàn áp chủ đề?",
+ "flow-moderation-title-delete-topic": "Xóa chủ đề?",
+ "flow-moderation-title-hide-topic": "Ẩn chủ đề?",
+ "flow-moderation-title-unsuppress-topic": "Ngừng đàn áp chủ đề?",
+ "flow-moderation-title-undelete-topic": "Phục hồi chủ đề?",
+ "flow-moderation-title-unhide-topic": "Ngừng ẩn chủ đề?",
+ "flow-moderation-placeholder-suppress-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn đàn áp chủ đề này.",
+ "flow-moderation-placeholder-delete-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn xóa chủ đề này.",
+ "flow-moderation-placeholder-hide-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn ẩn chủ đề này.",
+ "flow-moderation-placeholder-lock-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn khóa chủ đề này.",
+ "flow-moderation-placeholder-unsuppress-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn ngừng đàn áp chủ đề này.",
+ "flow-moderation-placeholder-undelete-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn phục hồi chủ đề này.",
+ "flow-moderation-placeholder-unhide-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn ngừng ẩn chủ đề này.",
+ "flow-moderation-placeholder-unlock-topic": "Xin vui lòng {{GENDER:$3}}giải thích tại sao bạn muốn mở khóa chủ đề này.",
+ "flow-topic-permalink-warning": "Chủ đề này được bắt đầu tại [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "Chủ đề này được bắt đầu tại [$2 bảng tin nhắn của $1]",
+ "flow-revision-permalink-warning-post": "Đây là liên kết thường trực đến một phiên bản riêng của bài đăng này.\nPhiên bản này được lưu vào $1.\nBạn có thể xem [$5 khác biệt với bản trước], hoặc xem các phiên bản khác tại [$4 trang lịch sử bài đăng].",
+ "flow-revision-permalink-warning-post-first": "Đây là liên kết thường trực đến phiên bản đầu tiên của bài đăng này.\nBạn có thể xem các phiên bản sau tại [$4 trang lịch sử bài đăng].",
+ "flow-revision-permalink-warning-postsummary": "Đây là liên kết thường trực đến một phiên bản riêng của tóm lược bài đăng này. Phiên bản này được lưu vào $1.\nBạn có thể xem [$5 khác biệt với bản trước], hoặc xem các phiên bản khác tại [$4 trang lịch sử bài đăng].",
+ "flow-revision-permalink-warning-postsummary-first": "Đây là liên kết thường trực đến phiên bản đầu tiên của tóm lược bài đăng này.\nBạn có thể xem các phiên bản sau tại [$4 trang lịch sử bài đăng].",
+ "flow-revision-permalink-warning-header": "Đây là liên kết thường trực đến một phiên bản riêng của đầu đề này.\nPhiên bản này được lưu vào $1. Bạn có thể xem [$3 khác biệt với bản trước], hoặc xem các phiên bản khác tại [$2 trang lịch sử bảng tin].",
+ "flow-revision-permalink-warning-header-first": "Đây là liên kết thường trực đến phiên bản đầu tiên của đầu đề này.\nBạn có thể xem các phiên bản sau tại [$2 trang lịch sử bảng tin].",
+ "flow-compare-revisions-revision-header": "Phiên bản của $2 vào $1",
+ "flow-compare-revisions-header-post": "Trang này có các khác biệt giữa hai phiên bản của một bài đăng của $3 trong chủ đề “[$5 $2]” tại [$4 $1].\nBạn có thể xem các phiên bản khác của bài đăng này tại [$6 trang lịch sử] của nó.",
+ "flow-compare-revisions-header-postsummary": "Trang này có các khác biệt giữa hai phiên bản của một tóm lược bài đăng “[$4 $2]” tại [$3 $1].\nBạn có thể xem các phiên bản khác của bài đăng này tại [$5 trang lịch sử] của nó.",
+ "flow-compare-revisions-header-header": "{{GENDER:$2}}Trang này có các khác biệt giữa hai phiên bản của một đầu đề trên [$3 $1].\nBạn có thể xem các phiên bản khác của đầu đề này tại [$4 trang lịch sử] của nó.",
+ "right-flow-hide": "Ẩn các chủ đề và bài đăng Flow",
+ "right-flow-lock": "Khóa chủ đề Flow",
+ "right-flow-delete": "Xóa chủ đề và bài đăng Flow",
+ "right-flow-edit-post": "Sửa bài đăng Flow của người dùng khác",
+ "right-flow-suppress": "Đàn áp phiên bản Flow",
+ "flow-terms-of-use-new-topic": "Với việc bấm “{{int:flow-newtopic-save}}”, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-terms-of-use-reply": "Với việc bấm “{{int:flow-reply-submit}}”, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-terms-of-use-edit": "Với việc lưu các thay đổi của bạn, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-anon-warning": "Bạn chưa đăng nhập. Để được ghi công với tên mình thay vì một địa chỉ IP, hãy [$1 đăng nhập] hoặc [$2 mở tài khoản mới].",
+ "flow-cancel-warning": "Bạn đã nhập văn bản vào biểu mẫu này. Bạn có chắc chắn muốn bác bỏ nó?",
+ "flow-topic-first-heading": "Chủ đề trên $1",
+ "flow-topic-html-title": "$1 trên $2",
+ "flow-topic-count": "Chủ đề ($1)",
+ "flow-load-more": "Tải tiếp",
+ "flow-no-more-fwd": "Không có chủ đề cũ hơn",
+ "flow-add-topic": "Thêm chủ đề",
+ "flow-newest-topics": "Các chủ đề mới nhất",
+ "flow-recent-topics": "Các chủ đề có hoạt động gần đây",
+ "flow-sorting-tooltip-newest": "Bạn đang xem các chủ đề mới nhất ở trên. Nhấn chuột để thay đổi kiểu sắp xếp.",
+ "flow-toggle-small-topics": "Chuyển qua chế độ chủ đề nhỏ",
+ "flow-toggle-topics": "Chuyển qua chế độ chỉ có chủ đề",
+ "flow-toggle-topics-posts": "Chuyển qua chế độ chủ đề và bài đăng",
+ "flow-terms-of-use-summarize": "Với việc bấm “{{int:flow-summarize-topic-submit}}”, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-terms-of-use-lock-topic": "Với việc bấm “{{int:flow-lock-topic-submit}}”, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-terms-of-use-unlock-topic": "Với việc bấm “{{int:flow-unlock-topic-submit}}”, bạn chấp nhận các điều khoản sử dụng của wiki này.",
+ "flow-whatlinkshere-post": "từ một [$1 bài đăng]",
+ "flow-whatlinkshere-header": "từ [$1 đầu đề]",
+ "flow": "Flow",
+ "flow-special-desc": "Trang đặc biệt này đổi hướng đến luồng làm việc hoặc bài đăng Flow ứng với UUID.",
+ "flow-special-type": "Kiểu",
+ "flow-special-type-post": "Bài đăng",
+ "flow-special-type-workflow": "Luồng làm việc",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "Không tìm thấy nội dung ứng với kiểu và UUID.",
+ "flow-spam-confirmedit-form": "Xin vui lòng giải hình CAPTCHA bên dưới để xác nhận rằng bạn là con người: $1",
+ "flow-preview-warning": "Bạn đang xem một bản xem trước. Bấm “{{int:flow-newtopic-save}}” để đăng bài, hoặc bấm “{{int:flow-preview-return-edit-post}}” để tiếp tục viết.",
+ "flow-preview-return-edit-post": "Sửa tiếp",
+ "flow-anonymous": "Vô danh",
+ "flow-embedding-unsupported": "Chưa có thể nhúng thảo luận vào trang khác.",
+ "mw-ui-unsubmitted-confirm": "Bạn có thay đổi chưa được lưu trên trang này. Bạn có chắc chắn muốn rời trang và mất các thay đổi của bạn?",
+ "flow-post-undo-hide": "hoàn ẩn",
+ "flow-post-undo-delete": "hoàn xóa",
+ "flow-post-undo-suppress": "hoàn giấu",
+ "flow-topic-undo-hide": "hoàn ẩn",
+ "flow-topic-undo-delete": "hoàn xóa",
+ "flow-topic-undo-suppress": "hoàn giấu",
+ "flow-importer-lqt-converted-template": "Trang LQT chuyển đổi qua Flow",
+ "flow-importer-lqt-converted-archive-template": "Lưu trữ trang LQT chuyển đổi",
+ "flow-importer-wt-converted-template": "Trang thảo luận mã wiki chuyển đổi qua Flow",
+ "flow-importer-wt-converted-archive-template": "Lưu trữ trang thảo luận mã wiki chuyển đổi",
+ "apihelp-flow+undo-edit-header-description": "Lấy lại thông tin cần thiết để hoàn tác chỉnh sửa tiêu đề.",
+ "apihelp-flow+undo-edit-topic-summary-description": "Lấy lại thông tin cần thiết để hoàn tác chỉnh sửa tóm tắt bài viết.",
+ "flow-edited": "Đã sửa đổi",
+ "flow-edited-by": "Sửa đổi bởi $1",
+ "flow-lqt-redirect-reason": "Đang đổi hướng bài đăng LiquidThreads cũ đến bài đăng Flow đã được chuyển đổi",
+ "flow-talk-conversion-move-reason": "Chuyển đổi thảo luận dưới dạng mã wiki đến Flow từ $1",
+ "flow-talk-conversion-archive-edit-reason": "Chuyển đổi thảo luận dưới dạng mã wiki đến Flow",
+ "flow-previous-diff": "← Sửa đổi cũ",
+ "flow-next-diff": "Sửa đổi sau →",
+ "flow-undo": "Hoàn tác",
+ "flow-undo-edit-content": "Các sửa đổi có thể được lùi lại. Xin hãy kiểm tra phần so sánh bên dưới để xác nhận lại những gì bạn muốn làm, sau đó lưu thay đổi ở dưới để hoàn tất việc hoàn tác các sửa đổi.",
+ "flow-undo-edit-failure": "Sửa đổi không thể phục hồi vì đã có những sửa đổi mới ở sau.",
+ "group-flow-bot": "Bot Flow",
+ "group-flow-bot-member": "bot Flow",
+ "grouppage-flow-bot": "Project:Bot Flow"
+}
diff --git a/Flow/i18n/vo.json b/Flow/i18n/vo.json
new file mode 100644
index 00000000..3d073577
--- /dev/null
+++ b/Flow/i18n/vo.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Malafaya"
+ ]
+ },
+ "flow-previous-diff": "← Redakam vönädikum",
+ "flow-next-diff": "Redakam nulikum →"
+}
diff --git a/Flow/i18n/yi.json b/Flow/i18n/yi.json
new file mode 100644
index 00000000..4d968e5a
--- /dev/null
+++ b/Flow/i18n/yi.json
@@ -0,0 +1,57 @@
+{
+ "@metadata": {
+ "authors": [
+ "פוילישער"
+ ]
+ },
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|האט אויסגעמעקט}} א [$4 פאסט] אויף \"[[$3|$5]]\" אויף [[$6]]",
+ "flow-user-moderated": "מאדערירטער באניצער",
+ "flow-edit-header-link": "רעדאקטירט קעפל",
+ "flow-post-actions": "אַקציעס",
+ "flow-topic-actions": "אַקציעס",
+ "flow-cancel": "אַנולירן",
+ "flow-newtopic-title-placeholder": "נײַע טעמע",
+ "flow-newtopic-content-placeholder": "אײַנגעבן א נײַע מעלדונג צו \"$1\"",
+ "flow-newtopic-header": "צולייגן א נײַע טעמע",
+ "flow-newtopic-save": "צושטעלן טעמע",
+ "flow-newtopic-start-placeholder": "אנהייבן א נײַע טעמע",
+ "flow-reply-topic-placeholder": "{{GENDER:$1|קאמענטירן}} אויף \"$2\"",
+ "flow-reply-submit": "{{GENDER:$1|ענטפערן}}",
+ "flow-reply-link": "{{GENDER:$1|ענטפערן}}",
+ "flow-thank-link": "{{GENDER:$1|דאַנקען}}",
+ "flow-post-action-view": "פערמאנענטער לינק",
+ "flow-post-action-suppress-post": "אונטערדריקן",
+ "flow-post-action-edit-post": "רעדאַקטירן",
+ "flow-post-action-edit-post-submit": "אויפהיטן ענדערונגען",
+ "flow-topic-action-view": "פערמאנענטער לינק",
+ "flow-topic-action-watchlist": "אויפֿפאַסונג ליסטע",
+ "flow-topic-action-edit-title": "רעדאקטירן טיטל",
+ "flow-topic-action-history": "היסטאריע",
+ "flow-error-delete-failure": "אויסמעקן דעם אביעקט אדורכגעפאלן.",
+ "flow-error-hide-failure": "באהאלטן דעם אביעקט אדורכגעפאלן.",
+ "flow-error-restore-failure": "צוריקשטעלן דעם אביעקט אדורכגעפאלן.",
+ "flow-edit-header-submit": "אויפהיטן קעפל.",
+ "flow-edit-title-submit": "ענדערן טיטל",
+ "flow-edit-post-submit": "איינגעבן ענדערונגען",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|האט געשאפן}} די טעמע \"[$3 $4]\".",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|האט געענדערט}} דעם טעמע טיטל פון \"$5\" צו \"[$3 $4]\".",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|האט באשאפן}} דאס קעפל.",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|האט רעדאקטירט}} דאס קעפל.",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|האט באהאלטן}} א [$4 הערה] אויף \"$6\" (<em>$5</em>).",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|האט באהאלטן}} א [$4 הערה] וועגן \"$6\" (<em>$5</em>).",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|האט צוריקגעשטעלט}} א [$4 הערה] וועגן \"$6\" (<em>$5</em>).",
+ "flow-topic-history": "\"$1\" טעמע היסטאריע",
+ "flow-history-day": "הײַנט",
+ "flow-comment-restored": "צוריקגעשטעלט הערה",
+ "flow-comment-deleted": "אויסגעמעקט הערה",
+ "flow-comment-hidden": "באהאלטענע הערה",
+ "flow-comment-moderated": "מאדערירטע הערה",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 און $5 {{PLURAL:$6|אן אנדערער|אנדערע}} {{GENDER:$1|האבן זיך אנגערופֿן}} דעם '''$3'''.",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 {{GENDER:$1|האט געשאפן}} א נײַע טעמע </span> אויף '''$3'''.",
+ "echo-category-title-flow-discussion": "פֿלוסן",
+ "flow-link-topic": "טעמע",
+ "flow-link-history": "היסטאריע",
+ "flow-moderation-confirm-undelete-post": "מבטל זיין אויסמעקן",
+ "flow-moderation-confirm-undelete-topic": "מבטל זיין אויסמעקן",
+ "flow-undo-your-text": "אייער טעקסט"
+}
diff --git a/Flow/i18n/zh-hans.json b/Flow/i18n/zh-hans.json
new file mode 100644
index 00000000..0b768674
--- /dev/null
+++ b/Flow/i18n/zh-hans.json
@@ -0,0 +1,527 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dreamism",
+ "Hzy980512",
+ "Linxue9786",
+ "Liuxinyu970226",
+ "Mys 721tx",
+ "Qiyue2001",
+ "Stieizc",
+ "TianyinLee",
+ "Yfdyh000",
+ "Xiaomingyan",
+ "乌拉跨氪",
+ "Hudafu",
+ "Mywood",
+ "Impersonator 1",
+ "Duolaimi",
+ "御坂美琴",
+ "Shizhao"
+ ]
+ },
+ "enableflow": "启用Flow",
+ "flow-desc": "工作流管理系统",
+ "flow-talk-taken-over": "该讨论页正在使用[https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow]。",
+ "flow-talk-username": "Flow讨论页管理器",
+ "log-name-flow": "Flow活动日志",
+ "logentry-delete-flow-delete-post": "$1{{GENDER:$2|删除}}在[[$6]]中“[[$3|$5]]”的一个[$4 帖子]",
+ "logentry-delete-flow-restore-post": "$1{{GENDER:$2|还原}}在[[$6]]中“[[$3|$5]]”的一个[$4 帖子]",
+ "logentry-suppress-flow-suppress-post": "$1{{GENDER:$2|禁止}}在[[$6]]中“[[$3|$5]]”的一个[$4 帖子]",
+ "logentry-suppress-flow-restore-post": "$1{{GENDER:$2|删除}}在[[$6]]中“[[$3|$5]]”的一个[$4 帖子]",
+ "logentry-delete-flow-delete-topic": "$1{{GENDER:$2|删除}}在[[$6]]中的话题“[[$3|$5]]”",
+ "logentry-delete-flow-restore-topic": "$1{{GENDER:$2|还原}}在[[$6]]中的话题“[[$3|$5]]”",
+ "logentry-suppress-flow-suppress-topic": "$1{{GENDER:$2|禁止}}在[[$6]]中的话题“[[$3|$5]]”",
+ "logentry-suppress-flow-restore-topic": "$1{{GENDER:$2|删除}}在[[$6]]中的话题“[[$3|$5]]”",
+ "logentry-import-lqt-to-flow-topic": "在[[$3]]上的[[$1|$2]]已从LiquidThreads导入至Flow",
+ "flow-user-moderated": "限制用户",
+ "flow-board-header-browse-topics-link": "浏览话题",
+ "flow-edit-header-link": "编辑页眉",
+ "flow-post-moderated-toggle-hide-show": "显示被$2{{GENDER:$1|隐藏}}的评论",
+ "flow-post-moderated-toggle-delete-show": "显示被$2{{GENDER:$1|删除}}的评论",
+ "flow-post-moderated-toggle-suppress-show": "显示被$2{{GENDER:$1|禁止}}的评论",
+ "flow-post-moderated-toggle-hide-hide": "隐藏被$2{{GENDER:$1|隐藏}}的评论",
+ "flow-post-moderated-toggle-delete-hide": "隐藏被$2{{GENDER:$1|删除}}的评论",
+ "flow-post-moderated-toggle-suppress-hide": "隐藏被$2{{GENDER:$1|禁止}}的评论",
+ "flow-topic-moderated-reason-prefix": "原因:",
+ "flow-hide-post-content": "此评论已被$1{{GENDER:$1|隐藏}}([$2 历史])",
+ "flow-hide-title-content": "此话题已被$1{{GENDER:$1|隐藏}}",
+ "flow-lock-title-content": "此话题已被$1{{GENDER:$1|锁定}}",
+ "flow-hide-header-content": "被$2{{GENDER:$1|隐藏}}",
+ "flow-delete-post-content": "此评论已被$1{{GENDER:$1|删除}}([$2 历史])",
+ "flow-delete-title-content": "此话题已被$1{{GENDER:$1|删除}}",
+ "flow-delete-header-content": "被$2{{GENDER:$1|删除}}",
+ "flow-suppress-post-content": "此评论已被$1{{GENDER:$1|禁止}}([$2 历史])",
+ "flow-suppress-title-content": "此话题已被$1{{GENDER:$1|禁止}}",
+ "flow-suppress-header-content": "被$2{{GENDER:$1|禁止}}",
+ "flow-suppress-usertext": "<em>用户名已被禁止</em>",
+ "flow-post-actions": "操作",
+ "flow-topic-actions": "操作",
+ "flow-cancel": "取消",
+ "flow-preview": "预览",
+ "flow-show-change": "显示更改",
+ "flow-last-modified-by": "$1最后{{GENDER:$1|修改}}",
+ "flow-stub-post-content": "''由于一个技术错误,此帖子无法被恢复。''",
+ "flow-newtopic-title-placeholder": "新话题",
+ "flow-newtopic-content-placeholder": "发布新消息至“$1”",
+ "flow-newtopic-header": "添加新话题",
+ "flow-newtopic-save": "添加话题",
+ "flow-newtopic-start-placeholder": "发起新话题",
+ "flow-newtopic-first-heading": "在$1发起新话题",
+ "flow-summarize-topic-placeholder": "请总结此讨论",
+ "flow-reply-topic-placeholder": "对“$2”的{{GENDER:$1|评论}}",
+ "flow-reply-topic-title-placeholder": "回复“$1”",
+ "flow-reply-submit": "{{GENDER:$1|回复}}",
+ "flow-reply-link": "{{GENDER:$1|回复}}",
+ "flow-thank-link": "{{GENDER:$1|感谢}}",
+ "flow-lock-link": "{{GENDER:$1|锁定}}",
+ "flow-thank-link-title": "公开感谢发帖人",
+ "flow-history-action-suppress-post": "禁止",
+ "flow-history-action-delete-post": "删除",
+ "flow-history-action-hide-post": "隐藏",
+ "flow-history-action-unsuppress-post": "解禁",
+ "flow-history-action-undelete-post": "还原",
+ "flow-history-action-unhide-post": "显示",
+ "flow-history-action-restore-post": "恢复",
+ "flow-history-action-lock-topic": "锁定",
+ "flow-history-action-unlock-topic": "解锁",
+ "flow-post-edited": "帖子被$1于$2{{GENDER:$1|编辑}}",
+ "flow-post-action-view": "固定链接",
+ "flow-post-action-post-history": "历史",
+ "flow-post-action-suppress-post": "禁止",
+ "flow-post-action-delete-post": "删除",
+ "flow-post-action-hide-post": "隐藏",
+ "flow-post-action-edit-post": "编辑",
+ "flow-post-action-edit-post-submit": "保存更改",
+ "flow-post-action-unsuppress-post": "解禁",
+ "flow-post-action-undelete-post": "还原",
+ "flow-post-action-unhide-post": "显示",
+ "flow-post-action-restore-post": "恢复",
+ "flow-post-action-undo-moderation": "撤销",
+ "flow-topic-action-view": "固定链接",
+ "flow-topic-action-watchlist": "监视列表",
+ "flow-topic-action-edit-title": "编辑标题",
+ "flow-topic-action-history": "历史",
+ "flow-topic-action-hide-topic": "隐藏话题",
+ "flow-topic-action-delete-topic": "删除话题",
+ "flow-topic-action-lock-topic": "锁定话题",
+ "flow-topic-action-unlock-topic": "解锁话题",
+ "flow-topic-action-summarize-topic": "摘要",
+ "flow-topic-action-resummarize-topic": "编辑话题摘要",
+ "flow-topic-action-suppress-topic": "禁止话题",
+ "flow-topic-action-unhide-topic": "显示话题",
+ "flow-topic-action-undelete-topic": "还原话题",
+ "flow-topic-action-unsuppress-topic": "解禁话题",
+ "flow-topic-action-restore-topic": "恢复话题",
+ "flow-topic-action-undo-moderation": "撤销",
+ "flow-topic-notification-subscribe-title": "此话题已加入了{{GENDER:$1|您}}的监视列表。",
+ "flow-topic-notification-subscribe-description": "此话题如有任何活动,{{GENDER:$1|您}}将收到通知。",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|您已}}订阅此讨论版。",
+ "flow-board-notification-subscribe-description": "当此版发起新话题时,{{GENDER:$1|您}}将收到通知。",
+ "flow-error-http": "与服务器联系时出错。",
+ "flow-error-other": "出现意外的错误。",
+ "flow-error-external": "出现一个错误。<br />收到的错误信息:$1",
+ "flow-error-edit-restricted": "您无权编辑此帖。",
+ "flow-error-topic-is-locked": "本话题已锁定,不再接受新回复。",
+ "flow-error-lock-moderated-post": "您不能锁定被限制的帖子。",
+ "flow-error-external-multi": "出错。<br />$1",
+ "flow-error-missing-content": "帖子无内容。只能保存有内容的帖子。",
+ "flow-error-missing-summary": "摘要没有内容。内容需要保存为一段摘要。",
+ "flow-error-missing-title": "此话题没有标题。必须有标题才能保存一个话题。",
+ "flow-error-parsoid-failure": "由于Parsoid故障无法解析内容。",
+ "flow-error-missing-replyto": "没有提供“回复至”参数。此参数在“回复”操作中是必须的。",
+ "flow-error-invalid-replyto": "“回复至”参数无效。找不到指定的帖子。",
+ "flow-error-delete-failure": "删除此项失败。",
+ "flow-error-hide-failure": "隐藏此项失败。",
+ "flow-error-missing-postId": "没有指定“回复至”参数。需要此参数才能操控一个帖子。",
+ "flow-error-invalid-postId": "“回复至”参数无效。找不到指定帖子($1)。",
+ "flow-error-restore-failure": "恢复此项失败。",
+ "flow-error-invalid-moderation-state": "用于参数“moderationState”的无效值被提交到了Flow API。",
+ "flow-error-invalid-moderation-reason": "请提供限制操作的原因。",
+ "flow-error-not-allowed": "没有足够权限执行此操作。",
+ "flow-error-not-allowed-hide": "此话题已被隐藏。",
+ "flow-error-not-allowed-reply-to-hide-topic": "您不能回复,因为此话题已被隐藏。",
+ "flow-error-not-allowed-delete": "此话题已被删除。",
+ "flow-error-not-allowed-reply-to-delete-topic": "您不能回复,因为此话题已被删除。",
+ "flow-error-not-allowed-suppress": "此话题已被删除。",
+ "flow-error-not-allowed-reply-to-suppress-topic": "您不能回复,因为此话题已被删除。",
+ "flow-error-not-allowed-hide-extract": "此话题已被隐藏。以下是话题的隐藏日志以供参考。",
+ "flow-error-not-allowed-delete-extract": "此话题已被删除。以下是该话题的删除日志以供参考。",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "您不能回复,因为此话题已被删除。下面提供该话题的删除日志以便参考。",
+ "flow-error-not-allowed-suppress-extract": "此话题已被删除。下面提供该话题的删除日志以便参考。",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "您不能回复,因为此话题已被禁止。下面提供该话题的监督日志以便参考。",
+ "flow-error-title-too-long": "话题标题需小于$1字节。",
+ "flow-error-no-existing-workflow": "该工作流尚未存在。",
+ "flow-error-not-a-post": "话题标题无法保存为帖子。",
+ "flow-error-missing-header-content": "页眉没有内容。必须输入内容才能保存页眉。",
+ "flow-error-missing-prev-revision-identifier": "上一版本标识符丢失。",
+ "flow-error-prev-revision-mismatch": "另一位用户已于几秒钟前编辑了此帖子。{{GENDER:$3|您}}确信继续重写最近更新?",
+ "flow-error-prev-revision-does-not-exist": "无法找到上一版本。",
+ "flow-error-core-topic-deletion": "要删除一个话题,在Flow板块或[$1 话题页]上使用...菜单。不要直接访问话题的action=delete。",
+ "flow-error-default": "出现错误。",
+ "flow-error-invalid-input": "提供了无效的正在载入的flow内容的值。",
+ "flow-error-invalid-title": "提供了无效的页面标题。",
+ "flow-error-fail-load-history": "载入历史内容失败。",
+ "flow-error-missing-revision": "无法找到需要加载Flow内容的版本。",
+ "flow-error-fail-commit": "未能保存Flow内容。",
+ "flow-error-insufficient-permission": "没有足够的权限访问内容。",
+ "flow-error-revision-comparison": "版本差异操作只能在同一帖子中的两个版本中进行。",
+ "flow-error-missing-topic-title": "找不到当前工作流的话题标题。",
+ "flow-error-missing-metadata": "无法找到此修订所需的元数据。",
+ "flow-error-fail-load-data": "未能加载所请求的数据。",
+ "flow-error-invalid-workflow": "找不到请求的工作流。",
+ "flow-error-process-data": "处理您的请求中的数据时出错。",
+ "flow-error-process-wikitext": "处理 HTML/维基文本 转换时出错。",
+ "flow-error-no-index": "未能找到索引来执行数据搜索。",
+ "flow-error-no-render": "无法识别指定操作。",
+ "flow-error-no-commit": "无法保存指定操作。",
+ "flow-error-fetch-after-lock": "请求新数据时发生错误,不过锁定/解锁操作已成功。错误消息:$1",
+ "flow-error-content-too-long": "内容太长。扩张后内容限于$1字节。",
+ "flow-error-move": "目前暂不支持移动讨论板块。",
+ "flow-error-invalid-topic-uuid-title": "错误标题",
+ "flow-error-invalid-topic-uuid": "请求的页面标题无效。Topic名字空间的页面由Flow自动创建。",
+ "flow-error-unknown-workflow-id-title": "未知话题",
+ "flow-error-unknown-workflow-id": "请求的话题不存在。",
+ "flow-edit-header-placeholder": "描述此讨论板块",
+ "flow-edit-header-submit": "保存页眉",
+ "flow-edit-header-submit-overwrite": "覆盖页眉",
+ "flow-summarize-topic-submit": "摘要",
+ "flow-summarize-topic-submit-overwrite": "覆盖摘要",
+ "flow-lock-topic-submit": "锁定话题",
+ "flow-lock-topic-submit-overwrite": "覆盖锁定话题摘要",
+ "flow-unlock-topic-submit": "解锁话题",
+ "flow-unlock-topic-submit-overwrite": "覆盖解锁话题摘要",
+ "flow-edit-title-submit": "更改标题",
+ "flow-edit-title-submit-overwrite": "覆盖标题",
+ "flow-edit-post-submit": "提交更改",
+ "flow-edit-post-submit-overwrite": "覆盖更改",
+ "flow-rev-message-edit-post": "$1{{GENDER:$2|编辑}}“$4”的一个[$3 评论]。",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|编辑了}}一个帖子",
+ "flow-rev-message-reply": "$1[$3 {{GENDER:$2|评论}}]“$4”(<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1个评论</strong>被添加",
+ "flow-rev-message-new-post": "$1{{GENDER:$2|已创建}}话题“[$3 $4]”",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|已创建}}新话题",
+ "flow-rev-message-edit-title": "$1{{GENDER:$2|已修改}}话题标题自“$5”至“[$3 $4]”",
+ "flow-rev-message-create-header": "$1{{GENDER:$2|已创建}}页眉",
+ "flow-rev-message-edit-header": "$1{{GENDER:$2|已编辑}}页眉",
+ "flow-rev-message-create-topic-summary": "$1{{GENDER:$2|已创建}}$3的话题摘要",
+ "flow-rev-message-edit-topic-summary": "$1{{GENDER:$2|已编辑}}$3的话题摘要",
+ "flow-rev-message-hid-post": "$1{{GENDER:$2|已隐藏}}“$6”的一个[$4 评论](<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1{{GENDER:$2|已删除}}“$6”的一个[$4 评论](<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1在“$6”{{GENDER:$2|已禁止}}一个[$4 评论](<em>$5</em>)。",
+ "flow-rev-message-restored-post": "$1{{GENDER:$2|已还原}}“$6”的一个[$4 评论](<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1{{GENDER:$2|已隐藏}}“$6”的一个[$4 话题](<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1{{GENDER:$2|已删除}}“$6”的一个[$4 话题](<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1{{GENDER:$2|已禁止}}[$4 话题]“$6”(<em>$5</em>)。",
+ "flow-rev-message-locked-topic": "$1{{GENDER:$2|已锁定}}[$4 话题]$6(<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1{{GENDER:$2|已还原}}“$6”的一个[$4 话题](<em>$5</em>)",
+ "flow-rc-topic-of-board": "$2上的$1",
+ "flow-board-history": "“$1”的历史",
+ "flow-board-history-empty": "此版块目前没有历史记录。",
+ "flow-topic-history": "“$1”的话题历史",
+ "flow-post-history": "“{{GENDER:$2|$2}}的评论”帖子历史",
+ "flow-history-last4": "过去4小时",
+ "flow-history-day": "今天",
+ "flow-history-week": "上周",
+ "flow-history-pages-topic": "显示于[$1 “$2”板]",
+ "flow-history-pages-post": "出现在[$1 $2]",
+ "flow-topic-comments": "{{PLURAL:$1|$1个评论|0={{GENDER:$2|抢沙发}}!}}",
+ "flow-comment-restored": "已还原的评论",
+ "flow-comment-deleted": "已删除的评论",
+ "flow-comment-hidden": "已隐藏的评论",
+ "flow-comment-moderated": "已限制的评论",
+ "flow-last-modified": "有关$1的最后修改时间",
+ "flow-workflow": "工作流",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1回答了'''$4'''。",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1和$5位{{PLURAL:$6|其他}}用户回答了'''$3'''。",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1{{GENDER:$1|编辑了}}您在[[$3|$4]]的<span class=\"plainlinks\">[$5 帖子]</span>。",
+ "flow-notification-edit-bundle": "$1和$5名{{PLURAL:$6|其他}}用户于“$2”在“$3”{{GENDER:$1|编辑了}}一个<span class=\"plainlinks\">[$4 帖子]</span>。",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1在'''$3'''{{GENDER:$1|创建了}}一个新话题。",
+ "flow-notification-newtopic-bundle": "'''<span class=\"plainlinks\">[$3 $2]</span>'''上的{{PLURAL:$1|$1|250=超过250}}个新话题",
+ "flow-notification-rename": "<span class=\"plainlinks\">[$2 $3]</span>的标题已被$1在[[$5|$6]]{{GENDER:$1|更改}}为“$4”。",
+ "flow-notification-mention": "$1于“$4”在{{GENDER:$1|他|她|他们}}的“$3”的<span class=\"plainlinks\">[$2 帖子]</span>中提到了{{GENDER:$5|您}}。",
+ "flow-notification-link-text-view-post": "浏览帖子",
+ "flow-notification-link-text-view-topic": "查看话题",
+ "flow-notification-reply-email-subject": "$3上的$2",
+ "flow-notification-reply-email-batch-body": "$1在“$3”{{GENDER:$1|回复了}}“$2”",
+ "flow-notification-reply-email-batch-bundle-body": "$1和$4名{{PLURAL:$5|其他}}用户在“$3”{{GENDER:$1|回答了}}“$2”",
+ "flow-notification-mention-email-subject": "$1在“$2”提及了{{GENDER:$3|您}}",
+ "flow-notification-mention-email-batch-body": "$1于“$2”在“$3”在{{GENDER:$1|他|她|他们}}的帖子里提及了{{GENDER:$4|您}}",
+ "flow-notification-edit-email-subject": "$1编辑了一个帖子",
+ "flow-notification-edit-email-batch-body": "$1编辑了“$3”上话题“$2”中的一个帖子",
+ "flow-notification-edit-email-batch-bundle-body": "$1和{{PLURAL:$5|其他}}$4位用户于“$3”在“$2”编辑了一个帖子",
+ "flow-notification-rename-email-subject": "$1重命名了您的话题",
+ "flow-notification-rename-email-batch-body": "$1将您在“$4”的话题“$2”重命名为“$3”",
+ "flow-notification-newtopic-email-subject": "$1在“$2”创建了新话题",
+ "flow-notification-newtopic-email-batch-body": "$1在$3创建了一个新话题,标题为“$2”",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "在讨论版发生有关我的动作时通知我。",
+ "flow-link-post": "帖子",
+ "flow-link-topic": "话题",
+ "flow-link-history": "历史",
+ "flow-link-post-revision": "帖子版本",
+ "flow-link-topic-revision": "话题版本",
+ "flow-link-header-revision": "页眉版本",
+ "flow-link-summary-revision": "摘要版本",
+ "flow-moderation-title-suppress-post": "禁止帖子么?",
+ "flow-moderation-title-delete-post": "删除帖子吗?",
+ "flow-moderation-title-hide-post": "隐藏帖子吗?",
+ "flow-moderation-title-unsuppress-post": "解禁帖子么?",
+ "flow-moderation-title-undelete-post": "还原帖子么?",
+ "flow-moderation-title-unhide-post": "显示帖子么?",
+ "flow-moderation-placeholder-suppress-post": "请{{GENDER:$3|说明}}您为何禁止这个帖子。",
+ "flow-moderation-placeholder-delete-post": "请{{GENDER:$3|说明}}您为何删除这个帖子。",
+ "flow-moderation-placeholder-hide-post": "请{{GENDER:$3|说明}}您为何隐藏这个帖子。",
+ "flow-moderation-placeholder-unsuppress-post": "请{{GENDER:$3|说明}}您为何解禁这个帖子。",
+ "flow-moderation-placeholder-undelete-post": "请{{GENDER:$3|说明}}您为还原这个帖子。",
+ "flow-moderation-placeholder-unhide-post": "请{{GENDER:$3|说明}}您为何显示这个帖子。",
+ "flow-moderation-confirm-suppress-post": "禁止",
+ "flow-moderation-confirm-delete-post": "删除",
+ "flow-moderation-confirm-hide-post": "隐藏",
+ "flow-moderation-confirm-unsuppress-post": "解禁",
+ "flow-moderation-confirm-undelete-post": "还原",
+ "flow-moderation-confirm-unhide-post": "显示",
+ "flow-moderation-confirm-suppress-topic": "禁止",
+ "flow-moderation-confirm-delete-topic": "删除",
+ "flow-moderation-confirm-hide-topic": "隐藏",
+ "flow-moderation-confirm-lock-topic": "锁定",
+ "flow-moderation-confirm-unsuppress-topic": "解禁",
+ "flow-moderation-confirm-undelete-topic": "还原",
+ "flow-moderation-confirm-unhide-topic": "显示",
+ "flow-moderation-confirm-unlock-topic": "解锁",
+ "flow-moderation-confirmation-suppress-post": "此帖子已被禁止。{{GENDER:$2|考虑}}让$1为此帖子提供反馈。",
+ "flow-moderation-confirmation-delete-post": "此帖子已删除。{{GENDER:$2|考虑}}为此帖子提供$1反馈。",
+ "flow-moderation-confirmation-hide-post": "此帖子已隐藏。{{GENDER:$2|考虑}}为此帖子提供$1反馈。",
+ "flow-moderation-confirmation-unsuppress-post": "您已解禁上述帖子。",
+ "flow-moderation-confirmation-undelete-post": "您已还原上述帖子。",
+ "flow-moderation-confirmation-unhide-post": "您已显示上述帖子。",
+ "flow-moderation-confirmation-suppress-topic": "此话题已被禁止。",
+ "flow-moderation-confirmation-delete-topic": "此话题已被删除。",
+ "flow-moderation-confirmation-hide-topic": "此话题已被隐藏。",
+ "flow-moderation-confirmation-unsuppress-topic": "您已解禁此话题。",
+ "flow-moderation-confirmation-undelete-topic": "您已还原此话题。",
+ "flow-moderation-confirmation-unhide-topic": "您已显示此话题。",
+ "flow-moderation-title-suppress-topic": "要禁止话题吗?",
+ "flow-moderation-title-delete-topic": "要删除话题吗?",
+ "flow-moderation-title-hide-topic": "要隐藏话题吗?",
+ "flow-moderation-title-unsuppress-topic": "要解禁话题吗?",
+ "flow-moderation-title-undelete-topic": "要还原话题吗?",
+ "flow-moderation-title-unhide-topic": "要显示话题吗?",
+ "flow-moderation-placeholder-suppress-topic": "请{{GENDER:$3|说明}}您为何要禁止此话题。",
+ "flow-moderation-placeholder-delete-topic": "请{{GENDER:$3|说明}}您为何要删除此话题。",
+ "flow-moderation-placeholder-hide-topic": "请{{GENDER:$3|说明}}您为何要隐藏此话题。",
+ "flow-moderation-placeholder-lock-topic": "请{{GENDER:$3|说明}}您为何要锁定此话题。",
+ "flow-moderation-placeholder-unsuppress-topic": "请{{GENDER:$3|说明}}您为何要解禁此话题。",
+ "flow-moderation-placeholder-undelete-topic": "请{{GENDER:$3|说明}}您为何还原此话题。",
+ "flow-moderation-placeholder-unhide-topic": "请{{GENDER:$3|说明}}您为何显示此话题。",
+ "flow-moderation-placeholder-unlock-topic": "请{{GENDER:$3|说明}}您为何要解禁此话题。",
+ "flow-topic-permalink-warning": "本话题发起于[$2 $1]",
+ "flow-topic-permalink-warning-user-board": "本话题已在[$2 {{GENDER:$1|$1}}的讨论版]发起",
+ "flow-revision-permalink-warning-post": "这是此帖子某一版本的固定链接。本版本来自$1。您可参见[$5 与之前版本的不同],或在[$4 历史页面]查看其他版本。",
+ "flow-revision-permalink-warning-post-first": "这是此帖子第一个版本的固定链接。您可通过[$4 历史页面]查看其它版本。",
+ "flow-revision-permalink-warning-postsummary": "这是此帖子摘要的某一版本的固定链接。版本来自$1。您可参见[$5 与之前版本的不同],或在[$4 历史页面]查看其他版本。",
+ "flow-revision-permalink-warning-postsummary-first": "这是此帖子摘要的第一个版本的固定链接。您可通过[$4 历史页面]查看其它版本。",
+ "flow-revision-permalink-warning-header": "这是一个指向某个版本的页眉的固定链接。版本号从$1中提取。您可以在[$3 这里]看到与上一个版本的不同,或者在[$2 历史记录]中查看其他版本。",
+ "flow-revision-permalink-warning-header-first": "这是此页眉的第一个版本的固定链接。您可通过[$2 历史页面]查看其他版本。",
+ "flow-compare-revisions-revision-header": "版本由{{GENDER:$2|$2}}从$1生成",
+ "flow-compare-revisions-header-post": "此页面显示由$3于[$4 $1]在话题“[$5 $2]”发布帖子的两个版本之间的{{GENDER:$3|更改}}。您可在它的[$6 历史页面]查看此帖子的其它版本。",
+ "flow-compare-revisions-header-postsummary": "本页面显示在[$3 $1]的帖子“[$4 $2]”摘要两个版本之间的更改。您可点击[$5 历史页面]查看此帖子的其他版本。",
+ "flow-compare-revisions-header-header": "此页面显示[$3 $1]上的页眉两个版本间的{{GENDER:$2|更改}}。您可通过它的[$4 历史页面]查看其它版本。",
+ "action-flow-create-board": "在任意位置创建Flow版块",
+ "right-flow-create-board": "在任意位置创建Flow版块",
+ "right-flow-hide": "隐藏Flow话题和帖子",
+ "right-flow-lock": "锁定Flow话题",
+ "right-flow-delete": "删除Flow话题和帖子",
+ "right-flow-edit-post": "编辑其它用户的Flow帖子",
+ "right-flow-suppress": "禁止Flow修订",
+ "flow-terms-of-use-new-topic": "点击“{{int:flow-newtopic-save}}”,即表示您认同本wiki的使用条款。",
+ "flow-terms-of-use-reply": "点击“{{int:flow-reply-submit}}”,即表示您认同本wiki的使用条款。",
+ "flow-terms-of-use-edit": "保存您的更改,即表示您认同本wiki的使用条款。",
+ "flow-anon-warning": "您尚未登录。要收到带您名字而不是您IP地址的署名,您可以[$1 登录]或[$2 注册一个账户]。",
+ "flow-cancel-warning": "您在此表单中输入了文字。您确定要放弃它?",
+ "flow-topic-first-heading": "在$1的话题",
+ "flow-topic-html-title": "$2上的$1",
+ "flow-topic-count": "话题($1)",
+ "flow-load-more": "载入更多",
+ "flow-no-more-fwd": "没有更早的话题",
+ "flow-add-topic": "添加话题",
+ "flow-newest-topics": "最新话题",
+ "flow-recent-topics": "最近活跃的话题",
+ "flow-sorting-tooltip-newest": "{{GENDER:|您}}正在首选阅读最新话题。点此获取更多排序选项。",
+ "flow-sorting-tooltip-recent": "{{GENDER:|您}}正在首选阅读最新活动。点此获取更多排序选项。",
+ "flow-toggle-small-topics": "切换为小话题视图",
+ "flow-toggle-topics": "切换为仅话题视图",
+ "flow-toggle-topics-posts": "切换为话题和帖子视图",
+ "flow-terms-of-use-summarize": "点击“{{int:flow-summarize-topic-submit}}”,即表示您认同本wiki的使用条款。",
+ "flow-terms-of-use-lock-topic": "点击“{{int:flow-lock-topic-submit}}”,即表示您认同本wiki的使用条款。",
+ "flow-terms-of-use-unlock-topic": "点击“{{int:flow-unlock-topic-submit}}”,即表示您认同本wiki的使用条款。",
+ "flow-whatlinkshere-post": "来自一个[$1 帖子]",
+ "flow-whatlinkshere-header": "来自此[$1 页眉]",
+ "flow": "Flow",
+ "flow-special-desc": "此特殊页面重定向至Flow工作流或指定UUID的Flow帖子。",
+ "flow-special-type": "类型",
+ "flow-special-type-post": "帖子",
+ "flow-special-type-workflow": "工作流",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "无法找到与指定类型和UUID匹配的内容。",
+ "flow-special-enableflow-legend": "在一个新页面启用Flow",
+ "flow-special-enableflow-page": "要启用Flow的页面",
+ "flow-special-enableflow-header": "Flow板块的最初页眉(wiki文本)",
+ "flow-special-enableflow-board-already-exists": "在[[$1]]已存在Flow板块。",
+ "flow-special-enableflow-invalid-title": "提供的页面不是一个有效的页面标题",
+ "flow-special-enableflow-page-already-exists": "在[[$1]]已存在一个非Flow页面。如果您仍然希望在此创建Flow板块,请将现有页面移动至一个存档,删除重定向,之后再次使用Special:EnableFlow。在页眉处包含存档名称。",
+ "flow-special-enableflow-confirmation": "您已成功在[[$1]]创建一个Flow板块。",
+ "flow-spam-confirmedit-form": "请输入下面的验证码已确认您是真人:$1",
+ "flow-preview-warning": "您在查看一个预览。点击“{{int:flow-newtopic-save}}”以发布,或点击“{{int:flow-preview-return-edit-post}}”以继续编辑。",
+ "flow-preview-return-edit-post": "继续编辑",
+ "flow-anonymous": "匿名",
+ "flow-embedding-unsupported": "讨论尚不能嵌入。",
+ "mw-ui-unsubmitted-confirm": "您在此页有未提交的更新。您确信不保存您的更改离开?",
+ "flow-post-undo-hide": "撤销隐藏",
+ "flow-post-undo-delete": "撤销删除",
+ "flow-post-undo-suppress": "撤销禁止",
+ "flow-topic-undo-hide": "撤销隐藏",
+ "flow-topic-undo-delete": "撤销删除",
+ "flow-topic-undo-suppress": "撤销禁止",
+ "flow-importer-lqt-moved-thread-template": "LQT移动了帖子存档,并转换成Flow",
+ "flow-importer-lqt-converted-template": "LQT页面已转换为Flow",
+ "flow-importer-lqt-converted-archive-template": "已转换的LQT页面的存档",
+ "flow-importer-wt-converted-template": "从Wiki文本讨论页转换为Flow页面",
+ "flow-importer-wt-converted-archive-template": "来自已转换的wiki文本讨论页的存档",
+ "flow-importer-lqt-suppressed-user-template": "此修订是由一位被监督隐藏的用户从LiquidThreads导入的。它已被重新指定为当前用户。",
+ "apihelp-flow-description": "允许对Flow页面的操作。",
+ "apihelp-flow-param-submodule": "要调用的Flow子模块。",
+ "apihelp-flow-param-page": "要进行操作的页面。",
+ "apihelp-flow-param-render": "将此设置成输出渲染时包含特定讨论块的一些。",
+ "apihelp-flow-example-1": "编辑“[[Talk:Sandbox]]”的页眉",
+ "apihelp-flow+close-open-topic-description": "由于[[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]]而弃用。",
+ "apihelp-flow+close-open-topic-param-moderationState": "提交话题的情形,要么锁定要么未锁定。",
+ "apihelp-flow+close-open-topic-param-reason": "锁定或解锁话题的原因。",
+ "apihelp-flow+edit-header-description": "编辑板块的页眉。",
+ "apihelp-flow+edit-header-param-prev_revision": "当前页眉修订的版本ID,以检查编辑冲突。",
+ "apihelp-flow+edit-header-param-content": "用于页眉的内容。",
+ "apihelp-flow+edit-header-param-format": "页眉的格式(wiki文本|html)",
+ "apihelp-flow+edit-header-example-1": "编辑[[Talk:Sandbox]]的页眉",
+ "apihelp-flow+edit-header-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+edit-post-description": "编辑帖子的内容。",
+ "apihelp-flow+edit-post-param-postId": "帖子ID。",
+ "apihelp-flow+edit-post-param-prev_revision": "当前帖子修订的版本ID,以检查编辑冲突。",
+ "apihelp-flow+edit-post-param-content": "帖子的内容。",
+ "apihelp-flow+edit-post-param-format": "贴子内容的格式(wiki文本|html)",
+ "apihelp-flow+edit-post-example-1": "编辑[[Topic:S2tycnas4hcucw8w]]的帖子",
+ "apihelp-flow+edit-post-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+edit-title-description": "编辑话题的标题。",
+ "apihelp-flow+edit-title-param-prev_revision": "当前标题修订的版本ID,以检查编辑冲突。",
+ "apihelp-flow+edit-title-param-content": "标题的内容。",
+ "apihelp-flow+edit-title-example-1": "编辑[[Topic:S2tycnas4hcucw8w]]的标题",
+ "apihelp-flow+edit-title-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+edit-topic-summary-description": "编辑一个话题的摘要内容。",
+ "apihelp-flow+edit-topic-summary-param-prev_revision": "当前话题摘要修订的版本ID(如果有),以检查编辑冲突。",
+ "apihelp-flow+edit-topic-summary-param-summary": "编辑摘要的内容。",
+ "apihelp-flow+edit-topic-summary-param-format": "摘要格式(wiki文本|html)",
+ "apihelp-flow+edit-topic-summary-example-1": "编辑[[Topic:S2tycnas4hcucw8w]]的摘要",
+ "apihelp-flow+edit-topic-summary-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+lock-topic-description": "锁定或解锁一个Flow话题。",
+ "apihelp-flow+lock-topic-param-moderationState": "提交话题的情形,要么锁定要么未锁定。",
+ "apihelp-flow+lock-topic-param-reason": "锁定或解锁此话题的原因。",
+ "apihelp-flow+lock-topic-example-1": "锁定[[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+lock-topic-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+moderate-post-description": "限制一个Flow帖子。",
+ "apihelp-flow+moderate-post-param-moderationState": "限制等级。",
+ "apihelp-flow+moderate-post-param-reason": "限制的原因。",
+ "apihelp-flow+moderate-post-param-postId": "要限制的帖子ID。",
+ "apihelp-flow+moderate-post-example-1": "删除话题[[Topic:S2tycnas4hcucw8w]]的帖子",
+ "apihelp-flow+moderate-post-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+moderate-topic-description": "限制一个Flow帖子。",
+ "apihelp-flow+moderate-topic-param-moderationState": "限制等级。",
+ "apihelp-flow+moderate-topic-param-reason": "限制的原因。",
+ "apihelp-flow+moderate-topic-example-1": "删除话题[[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+moderate-topic-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+new-topic-description": "在指定的工作流中创建一个新的Flow话题。",
+ "apihelp-flow+new-topic-param-topic": "用于新话题标题的文字。",
+ "apihelp-flow+new-topic-param-content": "用于话题的最初回复的内容。",
+ "apihelp-flow+new-topic-param-format": "新话题首次回复的格式(wiki文本|html)",
+ "apihelp-flow+new-topic-example-1": "在[[Talk:Sandbox]]创建一个新话题",
+ "apihelp-flow+new-topic-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+reply-description": "回复一个帖子。",
+ "apihelp-flow+reply-param-replyTo": "要回复的ID。",
+ "apihelp-flow+reply-param-content": "新帖子的内容。",
+ "apihelp-flow+reply-param-format": "新贴子的格式(wiki文本|html)",
+ "apihelp-flow+reply-example-1": "回复[[Topic:S2tycnas4hcucw8w]]的帖子",
+ "apihelp-flow+reply-param-metadataonly": "是否只包含有关新内容的元数据,除此之外均予以排除",
+ "apihelp-flow+view-header-description": "查看板块页眉。",
+ "apihelp-flow+view-header-param-contentFormat": "返回内容的格式。",
+ "apihelp-flow+view-header-param-revId": "加载此修订版本,而不是最新的。",
+ "apihelp-flow+view-header-example-1": "获取[[Talk:Sandbox]]的页眉作为wiki文本",
+ "apihelp-flow+view-post-description": "查看帖子。",
+ "apihelp-flow+view-post-param-postId": "要查看的帖子ID。",
+ "apihelp-flow+view-post-param-contentFormat": "返回内容的格式。",
+ "apihelp-flow+view-post-example-1": "获取[[Topic:S2tycnas4hcucw8w]]上帖子的内容作为wiki文本",
+ "apihelp-flow+view-topic-description": "查看话题。",
+ "apihelp-flow+view-topic-example-1": "查看[[Topic:S2tycnas4hcucw8w]]",
+ "apihelp-flow+view-topic-summary-description": "查看话题摘要。",
+ "apihelp-flow+view-topic-summary-param-contentFormat": "返回内容的格式。",
+ "apihelp-flow+view-topic-summary-param-revId": "加载此修订版本,而不是最新的。",
+ "apihelp-flow+view-topic-summary-example-1": "查看[[Topic:S2tycnas4hcucw8w]]的摘要作为wiki文本",
+ "apihelp-flow+view-topiclist-description": "查看话题列表。",
+ "apihelp-flow+view-topiclist-param-offset-dir": "排序话题的方向。",
+ "apihelp-flow+view-topiclist-param-sortby": "话题排序选项。",
+ "apihelp-flow+view-topiclist-param-savesortby": "保存排序方式选项,如果设置。",
+ "apihelp-flow+view-topiclist-param-offset-id": "开始获取话题的抵消值(UUID格式)。",
+ "apihelp-flow+view-topiclist-param-offset": "获取话题的起始偏移值。",
+ "apihelp-flow+view-topiclist-param-limit": "要取得的话题数量。",
+ "apihelp-flow+view-topiclist-param-render": "渲染话题为HTML。",
+ "apihelp-flow+view-topiclist-example-1": "[[Talk:Sandbox]]的话题列表",
+ "apihelp-flow-parsoid-utils-description": "在wiki文本和HTML之间互相转换文本。",
+ "apihelp-flow-parsoid-utils-param-from": "要转换格式的内容。",
+ "apihelp-flow-parsoid-utils-param-to": "格式转换的目标内容。",
+ "apihelp-flow-parsoid-utils-param-content": "要转换的内容。",
+ "apihelp-flow-parsoid-utils-param-title": "页面标题。不能与$1pageid一起使用。",
+ "apihelp-flow-parsoid-utils-param-pageid": "页面ID。不能与$1title一起使用。",
+ "apihelp-flow-parsoid-utils-example-1": "将wiki文本<nowiki>'''lorem''' ''blah''</nowiki>转换为HTML",
+ "apihelp-query+flowinfo-description": "获取有关页面的基本Flow信息。",
+ "apihelp-query+flowinfo-example-1": "获取有关[[Talk:Sandbox]]、[[Main Page]]和[[Talk:Flow]]的Flow信息",
+ "apihelp-flow+undo-edit-header-description": "检索撤销页眉编辑必需要的信息。",
+ "apihelp-flow+undo-edit-header-param-startId": "要开始撤销的修订ID。",
+ "apihelp-flow+undo-edit-header-param-endId": "要结束撤销的修订ID。",
+ "apihelp-flow+undo-edit-header-example-1": "检索有关在[[Talk:Sandbox]]撤销一次页眉编辑的信息",
+ "apihelp-flow+undo-edit-post-description": "检索撤销帖子编辑必需要的信息。",
+ "apihelp-flow+undo-edit-post-param-postId": "要回退的帖子ID。",
+ "apihelp-flow+undo-edit-post-param-startId": "要开始撤销的修订ID。",
+ "apihelp-flow+undo-edit-post-param-endId": "要结束撤销的修订ID。",
+ "apihelp-flow+undo-edit-post-example-1": "取得有关在一次话题中撤销一次帖子编辑的信息。",
+ "apihelp-flow+undo-edit-topic-summary-description": "检索撤销话题摘要编辑必需要的信息。",
+ "apihelp-flow+undo-edit-topic-summary-param-startId": "要开始撤销的修订ID。",
+ "apihelp-flow+undo-edit-topic-summary-param-endId": "要结束撤销的修订ID。",
+ "apihelp-flow+undo-edit-topic-summary-example-1": "取得有关撤销在特定话题中一次话题摘要的编辑的信息",
+ "flow-edited": "编辑于",
+ "flow-edited-by": "由$1编辑",
+ "flow-lqt-redirect-reason": "重定向停用的LiquidThreads帖子至转换的Flow帖子",
+ "flow-talk-conversion-move-reason": "将$1从wiki文本讨论页转换为Flow页面",
+ "flow-talk-conversion-archive-edit-reason": "Wiki文本讨论页至Flow转换",
+ "flow-previous-diff": "←上一编辑",
+ "flow-next-diff": "下一编辑→",
+ "flow-undo": "撤销",
+ "flow-undo-latest-revision": "最新版本",
+ "flow-undo-your-text": "您的文字",
+ "flow-undo-edit-header": "编辑页眉",
+ "flow-undo-edit-topic-summary": "编辑话题摘要",
+ "flow-undo-edit-post": "编辑帖子",
+ "flow-undo-edit-content": "该编辑可以被撤销。请检查下面的对比以核实你想要撤销的内容,然后保存下面的更改以完成撤销。",
+ "flow-undo-edit-failure": "因存在冲突的中间编辑,本编辑不能撤销。",
+ "group-flow-bot": "Flow机器人",
+ "group-flow-bot-member": "Flow机器人",
+ "grouppage-flow-bot": "Project:Flow机器人",
+ "flow-ve-mention-context-item-label": "提及",
+ "flow-ve-mention-inspector-title": "提及",
+ "flow-ve-mention-inspector-remove-label": "移除",
+ "flow-ve-mention-tool-title": "提及一位用户",
+ "flow-ve-mention-template": "ping",
+ "flow-ve-mention-inspector-invalid-user": "用户名“$1”未注册。",
+ "flow-wikitext-editor-help": "Wiki文本$1。",
+ "flow-wikitext-editor-help-and-preview": "Wiki文本$1并且您随时都可以$2。",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|使用标记]]",
+ "flow-wikitext-editor-help-preview-the-result": "预览结果",
+ "flow-wikitext-switch-editor-tooltip": "切换为可视化编辑器",
+ "flow-ve-switch-editor-tool-title": "切换为Wiki文本编辑器"
+}
diff --git a/Flow/i18n/zh-hant.json b/Flow/i18n/zh-hant.json
new file mode 100644
index 00000000..bcd1f6c6
--- /dev/null
+++ b/Flow/i18n/zh-hant.json
@@ -0,0 +1,399 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cwlin0416",
+ "EagerLin",
+ "Liuxinyu970226",
+ "Mywood",
+ "Impersonator 1",
+ "LNDDYL"
+ ]
+ },
+ "enableflow": "啟用 Flow",
+ "flow-desc": "工作流程管理系統",
+ "flow-talk-taken-over": "此對話頁面使用 [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow]。",
+ "flow-talk-username": "Flow 對話頁面管理員",
+ "log-name-flow": "Flow 活躍日誌",
+ "logentry-delete-flow-delete-post": "$1 {{GENDER:$2|已刪除}}位於 [[$6]] 中 \"[[$3|$5]]\" 的一篇 [$4 貼文]",
+ "logentry-delete-flow-restore-post": "$1 {{GENDER:$2|已還原}} 位於 [[$6]] 中 \"[[$3|$5]]\" 的一篇 [$4 貼文]",
+ "logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|已封鎖}}位於 [[$6]] 中 \"[[$3|$5]]\" 的一篇 [$4 貼文]",
+ "logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|已刪除}} 位於 [[$6]] 中 \"[[$3|$5]]\" 的一篇 [$4 貼文]",
+ "logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|已刪除}}位於 [[$6]] 中的主題 \"[[$3|$5]]\"",
+ "logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|已還原}}位於 [[$6]] 中的主題 \"[[$3|$5]]\"",
+ "logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|已封鎖}}位於 [[$6]] 中的主題 \"[[$3|$5]]\"",
+ "logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|已刪除}}位於 [[$6]] 中的主題 \"[[$3|$5]]\"",
+ "logentry-import-lqt-to-flow-topic": "位於 [[$3]] 上的 [[$1|$2]] 已從 LiquidThreads 匯入至 Flow",
+ "flow-user-moderated": "受管制的使用者",
+ "flow-board-header-browse-topics-link": "瀏覽主題",
+ "flow-edit-header-link": "編輯頁首",
+ "flow-post-moderated-toggle-hide-show": "顯示已由 $2 {{GENDER:$1|隱藏}}的評論",
+ "flow-post-moderated-toggle-delete-show": "顯示已由 $2 {{GENDER:$1|刪除}}的評論",
+ "flow-post-moderated-toggle-suppress-show": "顯示已由 $2 {{GENDER:$1|封鎖}}的評論",
+ "flow-post-moderated-toggle-hide-hide": "隱藏已由 $2 {{GENDER:$1|隱藏}}的評論",
+ "flow-post-moderated-toggle-delete-hide": "隱藏已由 $2 {{GENDER:$1|刪除}}的評論",
+ "flow-post-moderated-toggle-suppress-hide": "隱藏已由 $2 {{GENDER:$1|封鎖}}的評論",
+ "flow-topic-moderated-reason-prefix": "原因:",
+ "flow-hide-post-content": "此評論已由 $1 {{GENDER:$1|隱藏}} ([$2 歷史])",
+ "flow-hide-title-content": "此主題已由 $1 {{GENDER:$1|隱藏}}",
+ "flow-lock-title-content": "此主題已由 $1 {{GENDER:$1|關閉}}",
+ "flow-hide-header-content": "已由 $2 {{GENDER:$1|隱藏}}",
+ "flow-delete-post-content": "此評論已由 $1 {{GENDER:$1|刪除}} ([$2 歷史])",
+ "flow-delete-title-content": "此主題已由 $1 {{GENDER:$1|刪除}}",
+ "flow-delete-header-content": "已由 $2 {{GENDER:$1|刪除}}",
+ "flow-suppress-post-content": "此評論已由 $1{{GENDER:$1|封鎖}} ([$2 歷史])",
+ "flow-suppress-title-content": "此主題已由 $1 {{GENDER:$1|封鎖}}",
+ "flow-suppress-header-content": "已由 $2 {{GENDER:$1|封鎖}}",
+ "flow-suppress-usertext": "<em>已禁止顯示使用者名稱</em>",
+ "flow-post-actions": "操作",
+ "flow-topic-actions": "操作",
+ "flow-cancel": "取消",
+ "flow-preview": "預覽",
+ "flow-show-change": "顯示變更",
+ "flow-last-modified-by": "最後由 $1 {{GENDER:$1|修改}}",
+ "flow-stub-post-content": "''由於技術問題,無法檢索這篇貼文。''",
+ "flow-newtopic-title-placeholder": "新主題",
+ "flow-newtopic-content-placeholder": "發佈一則新訊息至 \"$1\"",
+ "flow-newtopic-header": "新增新主題",
+ "flow-newtopic-save": "新增主題",
+ "flow-newtopic-start-placeholder": "發起新主題",
+ "flow-newtopic-first-heading": "發起新主題於 $1",
+ "flow-summarize-topic-placeholder": "請為為此討論摘要說明",
+ "flow-reply-topic-placeholder": "於 \"$2\" 的{{GENDER:$1|評論}}",
+ "flow-reply-topic-title-placeholder": "回覆至 \"$1\"",
+ "flow-reply-submit": "{{GENDER:$1|回覆}}",
+ "flow-reply-link": "{{GENDER:$1|回覆}}",
+ "flow-thank-link": "{{GENDER:$1|感謝}}",
+ "flow-lock-link": "{{GENDER:$1|鎖定}}",
+ "flow-thank-link-title": "公開感謝發佈者",
+ "flow-history-action-suppress-post": "封鎖",
+ "flow-history-action-delete-post": "刪除",
+ "flow-history-action-hide-post": "隱藏",
+ "flow-history-action-unsuppress-post": "解除封鎖",
+ "flow-history-action-undelete-post": "取消刪除",
+ "flow-history-action-unhide-post": "取消隱藏",
+ "flow-history-action-restore-post": "還原",
+ "flow-history-action-lock-topic": "鎖定",
+ "flow-history-action-unlock-topic": "解除封鎖",
+ "flow-post-edited": "貼文已由 $1 於 $2 {{GENDER:$1|編輯}}",
+ "flow-post-action-view": "靜態連結",
+ "flow-post-action-post-history": "歷史記錄",
+ "flow-post-action-suppress-post": "封鎖",
+ "flow-post-action-delete-post": "刪除",
+ "flow-post-action-hide-post": "隱藏",
+ "flow-post-action-edit-post": "編輯",
+ "flow-post-action-edit-post-submit": "儲存變更",
+ "flow-post-action-unsuppress-post": "解除封鎖",
+ "flow-post-action-undelete-post": "取消刪除",
+ "flow-post-action-unhide-post": "取消隱藏",
+ "flow-post-action-restore-post": "還原",
+ "flow-post-action-undo-moderation": "撤銷",
+ "flow-topic-action-view": "靜態連結",
+ "flow-topic-action-watchlist": "監視清單",
+ "flow-topic-action-edit-title": "編輯標題",
+ "flow-topic-action-history": "歷史記錄",
+ "flow-topic-action-hide-topic": "隱藏主題",
+ "flow-topic-action-delete-topic": "刪除主題",
+ "flow-topic-action-lock-topic": "鎖定主題",
+ "flow-topic-action-unlock-topic": "解鎖主題",
+ "flow-topic-action-summarize-topic": "摘要",
+ "flow-topic-action-resummarize-topic": "編輯主題摘要",
+ "flow-topic-action-suppress-topic": "封鎖主題",
+ "flow-topic-action-unhide-topic": "取消隱藏主題",
+ "flow-topic-action-undelete-topic": "取消刪除主題",
+ "flow-topic-action-unsuppress-topic": "取消封鎖主題",
+ "flow-topic-action-restore-topic": "還原主題",
+ "flow-topic-action-undo-moderation": "還原",
+ "flow-topic-notification-subscribe-title": "此主題已新增至{{GENDER:$1|您的}}監視清單。",
+ "flow-topic-notification-subscribe-description": "當此主題有任何的活動時,{{GENDER:$1|您}}將會收到通知。",
+ "flow-board-notification-subscribe-title": "{{GENDER:$1|您}}已經訂閱此討論板!",
+ "flow-board-notification-subscribe-description": "當此討論板建立新主題時{{GENDER:$1|您}}將會收到通知。",
+ "flow-error-http": "與伺服器連線時發生錯誤。",
+ "flow-error-other": "發生未預期的錯誤。",
+ "flow-error-external": "發生錯誤。<br />錯誤訊息為:$1",
+ "flow-error-edit-restricted": "您不被允許編輯此貼文。",
+ "flow-error-topic-is-locked": "此主題已被鎖定,無法進行進一步的活動。",
+ "flow-error-lock-moderated-post": "您無法鎖定受管制的貼文。",
+ "flow-error-external-multi": "發生錯誤。<br />$1",
+ "flow-error-missing-content": "貼文沒有內容。 您需要填寫內容以儲存貼文。",
+ "flow-error-missing-summary": "摘要沒有內容。 您需要填寫內容以儲存摘要。",
+ "flow-error-missing-title": "主題沒有標題。 您需要填寫標題以儲存主題。",
+ "flow-error-parsoid-failure": "因 Parsoid 錯誤,無法解析內容。",
+ "flow-error-missing-replyto": "未提供「回覆至」參數。「回覆」動作需要使用此參數。",
+ "flow-error-invalid-replyto": "「回覆至」參數無效。 查無指定的貼文。",
+ "flow-error-delete-failure": "此項目刪除失敗。",
+ "flow-error-hide-failure": "此項目隱藏失敗。",
+ "flow-error-missing-postId": "未提供 \"postId\" 參數。 處理貼文需要使用此參數。",
+ "flow-error-invalid-postId": "\"postId\" 參數無效。 查無指定的貼文 ($1)。",
+ "flow-error-restore-failure": "此項目還原失敗。",
+ "flow-error-invalid-moderation-state": "提供的 moderationState 參數無效。",
+ "flow-error-invalid-moderation-reason": "請說明限制操作的原因。",
+ "flow-error-not-allowed": "無足夠的權限執行此操作。",
+ "flow-error-not-allowed-hide": "此主題已被隱藏。",
+ "flow-error-not-allowed-reply-to-hide-topic": "您無法回覆,因為此主題已被隱藏。",
+ "flow-error-not-allowed-delete": "此主題已被刪除。",
+ "flow-error-not-allowed-reply-to-delete-topic": "您無法回覆,因為此主題已被刪除。",
+ "flow-error-not-allowed-suppress": "此主題已被刪除。",
+ "flow-error-not-allowed-reply-to-suppress-topic": "您無法回覆,因為此主題已被刪除。",
+ "flow-error-not-allowed-hide-extract": "此主題已被隱藏。 於下方提供該主題的隱藏日誌做為參考。",
+ "flow-error-not-allowed-delete-extract": "此主題已被刪除。 於下方提供該主題的刪除日誌做為參考。",
+ "flow-error-not-allowed-reply-to-delete-topic-extract": "您無法回覆,因為此主題已被刪除。 於下方提供該主題的刪除日誌做為參考。",
+ "flow-error-not-allowed-suppress-extract": "此主題已被刪除。 於下方提供該主題的刪除日誌做為參考。",
+ "flow-error-not-allowed-reply-to-suppress-topic-extract": "您無法回覆,因為此主題已被封鎖。 於下方提供該主題的封鎖日誌做為參考。",
+ "flow-error-title-too-long": "主題標題已限制在 $1 {{PLURAL:$1|位元組|位元組}}內。",
+ "flow-error-no-existing-workflow": "此工作流程尚不存在。",
+ "flow-error-not-a-post": "主題標題無法被儲存為貼文。",
+ "flow-error-missing-header-content": "頁首沒有內容。 您需要填寫內容以儲存頁首。",
+ "flow-error-missing-prev-revision-identifier": "遺失前次修訂的識別碼。",
+ "flow-error-prev-revision-mismatch": "另一位使用者於幾秒鐘前才編輯此貼文。{{GENDER:$3|您}}確定要覆蓋此最近變更?",
+ "flow-error-prev-revision-does-not-exist": "找不到前次修訂。",
+ "flow-error-default": "發生錯誤。",
+ "flow-error-invalid-input": "提供用來讀取 Flow 內容的數值無效。",
+ "flow-error-invalid-title": "提供的頁面標題無效。",
+ "flow-error-fail-load-history": "載入歷史內容失敗。",
+ "flow-error-missing-revision": "查無修訂以讀取 Flow 內容。",
+ "flow-error-fail-commit": "儲存 Flow 內容失敗。",
+ "flow-error-insufficient-permission": "權限不足,無法存取內容。",
+ "flow-error-revision-comparison": "比較差異的操作僅可於相同貼文中的兩個修訂間使用。",
+ "flow-error-missing-topic-title": "目前的工作流程中查無該主題標題。",
+ "flow-error-fail-load-data": "讀取請求的資料失敗。",
+ "flow-error-invalid-workflow": "查無請求的工作流程。",
+ "flow-error-process-data": "處理您請求的資料時發生錯誤。",
+ "flow-error-process-wikitext": "處理 HTML/WikiText 轉換時發生錯誤。",
+ "flow-error-no-index": "查詢用來執行資料搜尋的索引失敗。",
+ "flow-error-no-render": "無法辨識指定的動作。",
+ "flow-error-no-commit": "無法儲存指定的動作。",
+ "flow-error-fetch-after-lock": "雖然鎖定/解除鎖定操作已成功,但請求新資料時發生錯誤,錯誤訊息為:$1",
+ "flow-error-content-too-long": "內容過大,內容展開後的限制為 $1 {{PLURAL:$1|位元組|位元組}}。",
+ "flow-error-move": "目前不支援移動討論板。",
+ "flow-error-invalid-topic-uuid-title": "無效的標題",
+ "flow-error-invalid-topic-uuid": "請求的頁面表題無效。 在主題命名空間中的頁面會由 Flow 自動建立。",
+ "flow-error-unknown-workflow-id-title": "不明主題",
+ "flow-error-unknown-workflow-id": "請求的主題不存在。",
+ "flow-edit-header-placeholder": "請描述此討論板",
+ "flow-edit-header-submit": "儲存頁首",
+ "flow-edit-header-submit-overwrite": "覆寫頁首",
+ "flow-summarize-topic-submit": "摘要",
+ "flow-summarize-topic-submit-overwrite": "覆寫摘要",
+ "flow-lock-topic-submit": "鎖定主題",
+ "flow-lock-topic-submit-overwrite": "覆寫鎖定主題摘要",
+ "flow-unlock-topic-submit": "解除鎖定主題",
+ "flow-unlock-topic-submit-overwrite": "覆寫解除鎖定主題摘要",
+ "flow-edit-title-submit": "更改標題",
+ "flow-edit-title-submit-overwrite": "覆寫標題",
+ "flow-edit-post-submit": "送出變更",
+ "flow-edit-post-submit-overwrite": "覆寫變更",
+ "flow-rev-message-edit-post": "$1{{GENDER:$2|編輯}}於 \"$4\" 之[$3 評論]",
+ "flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|已編輯}}一篇貼文",
+ "flow-rev-message-reply": "$1 [$3 {{GENDER:$2|已評論}}] 於 \"$4\" (<em>$5</em>)",
+ "flow-rev-message-reply-bundle": "<strong>$1 筆{{PLURAL:$1|評論|評論}}</strong> {{PLURAL:$1|已新增|已新增}}",
+ "flow-rev-message-new-post": "$1 {{GENDER:$2|已建立}}主題 \"[$3 $4]\"",
+ "flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|已建立}}新主題",
+ "flow-rev-message-edit-title": "$1 {{GENDER:$2|已更改}}主題標題自 \"$5\" 至 \"[$3 $4]\"",
+ "flow-rev-message-create-header": "$1 {{GENDER:$2|己建立}}頁首",
+ "flow-rev-message-edit-header": "$1 {{GENDER:$2|已編輯}}標頭",
+ "flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|已建立}}於 $3 的主題摘要",
+ "flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|已編輯}}於 $3 的主題摘要",
+ "flow-rev-message-hid-post": "$1 {{GENDER:$2|已隱藏}}於 \"$6\" 的一筆 [$4 評論] (<em>$5</em>)",
+ "flow-rev-message-deleted-post": "$1 {{GENDER:$2|已刪除}}於 \"$6\" 的一筆 [$4 評論] (<em>$5</em>)",
+ "flow-rev-message-suppressed-post": "$1 {{GENDER:$2|已封鎖}}於 \"$6\" 的一筆 [$4 評論] (<em>$5</em>)",
+ "flow-rev-message-restored-post": "$1 {{GENDER:$2|已還原}}於 \"$6\" 的一筆 [$4 評論] (<em>$5</em>)",
+ "flow-rev-message-hid-topic": "$1 {{GENDER:$2|已隱藏}}於 \"$6\" 的 [$4 主題] (<em>$5</em>)",
+ "flow-rev-message-deleted-topic": "$1 {{GENDER:$2|已刪除}}於 \"$6\" 的 [$4 主題] (<em>$5</em>)",
+ "flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|已封鎖}}於 \"$6\" 的 [$4 主題] (<em>$5</em>)",
+ "flow-rev-message-locked-topic": "$1 {{GENDER:$2|已鎖定}}於 \"$6\" 的 [$4 主題] (<em>$5</em>)",
+ "flow-rev-message-restored-topic": "$1 {{GENDER:$2|已還原}}於 \"$6\" 的 [$4 主題] (<em>$5</em>)",
+ "flow-rc-topic-of-board": "於 $2 之 $1",
+ "flow-board-history": "\"$1\" 歷史",
+ "flow-board-history-empty": "此討論板目前沒有歷史記錄。",
+ "flow-topic-history": "\"$1\" 主題歷史",
+ "flow-post-history": "\"{{GENDER:$2|$2}} 的評論\" 貼文歷史",
+ "flow-history-last4": "最近 4 小時",
+ "flow-history-day": "今天",
+ "flow-history-week": "上週",
+ "flow-history-pages-topic": "於 [$1 \"$2\" 討論板] 中顯示",
+ "flow-history-pages-post": "於 [$1 $2] 中顯示",
+ "flow-topic-comments": "{{PLURAL:$1|$1 則評論|0={{GENDER:$2|發表第一則}}評論!}}",
+ "flow-comment-restored": "已還原的評論",
+ "flow-comment-deleted": "已刪除的評論",
+ "flow-comment-hidden": "隱藏的評論",
+ "flow-comment-moderated": "受管制的評論",
+ "flow-last-modified": "上次修改於 $1",
+ "flow-workflow": "工作流程",
+ "flow-notification-reply": "<span class=\"plainlinks mw-echo-title-heading\">[$5 $2]</span><br />$1 已於 '''$4''' {{GENDER:$1|回應}}。",
+ "flow-notification-reply-bundle": "<span class=\"plainlinks mw-echo-title-heading\">[$4 $2]</span><br />$1 與{{PLURAL:$6|其他}} $5 個人已於 '''$3''' {{GENDER:$1|回應}}。",
+ "flow-notification-edit": "<span class=\"plainlinks mw-echo-title-heading\">[$6 $2]</span><br />$1 {{GENDER:$1|已編輯}}您在 [[$3|$4]] 的 <span class=\"plainlinks\">[$5 貼文]</span>。",
+ "flow-notification-edit-bundle": "$1 與{{PLURAL:$6|其他}} $5 個人{{GENDER:$1|已編輯}}於 \"$3\" 中 \"$2\" 的 <span class=\"plainlinks\">[$4 貼文]</span>。",
+ "flow-notification-newtopic": "<span class=\"mw-echo-title-heading plainlinks\">[$5 $4]</span><br />$1 於 '''$3''' {{GENDER:$1|建立了}}一個新話題。",
+ "flow-notification-newtopic-bundle": "於 '''<span class=\"plainlinks\">[$3 $2]</span>''' 中有 {{PLURAL:$1|$1|250=250+}} 則新{{PLURAL:$1|主題}}",
+ "flow-notification-rename": "$1 {{GENDER:$1|已更改}}於 [[$5|$6]] 中 <span class=\"plainlinks\">[$2 $3]</span> 的標題為 \"$4\"。",
+ "flow-notification-mention": "$1 {{GENDER:$1|已提到}}{{GENDER:$5|您}}於 \"$4\" 中 \"$3\" {{GENDER:$1|他|她|他們}}的 <span class=\"plainlinks\">[$2 貼文]</span>。",
+ "flow-notification-link-text-view-post": "檢視貼文",
+ "flow-notification-link-text-view-topic": "檢視主題",
+ "flow-notification-reply-email-subject": "$2 於 $3",
+ "flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|已回覆}}在 \"$3\" 中的 \"$2\" 主題",
+ "flow-notification-reply-email-batch-bundle-body": "$1 與其他 $4 {{PLURAL:$5|個人}}{{GENDER:$1|已回覆}}在 \"$3\" 中的 \"$2\" 主題",
+ "flow-notification-mention-email-subject": "$1 {{GENDER:$1|已提到}}{{GENDER:$3|您}},在 \"$2\" 中",
+ "flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|已提到}}{{GENDER:$4|您}},在 \"$3\" 中 \"$2\" {{GENDER:$1|他|她|他們}}的貼文",
+ "flow-notification-edit-email-subject": "$1 {{GENDER:$1|已編輯}}一篇留言",
+ "flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|已編輯}}在 \"$3\" 中 \"$2\" 的一篇貼文",
+ "flow-notification-edit-email-batch-bundle-body": "$1 與其他 $4 {{PLURAL:$5|個人}} {{GENDER:$1|已編輯}}在 \"$3\" 中 \"$2\" 的一篇貼文",
+ "flow-notification-rename-email-subject": "$1 {{GENDER:$1|已重新命名}}您的主題",
+ "flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|已重新命名}}您在 \"$4\" 中的主題 \"$2\" 為 \"$3\"",
+ "flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|已建立}}在 \"$2\" 中的新主題",
+ "flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|已建立}}在 $3 中的新主題使用標題 \"$2\"",
+ "echo-category-title-flow-discussion": "Flow",
+ "echo-pref-tooltip-flow-discussion": "通知我,當有與我相關的動作發生在 Flow 時。",
+ "flow-link-post": "發佈",
+ "flow-link-topic": "主題",
+ "flow-link-history": "歷史",
+ "flow-link-post-revision": "貼文修訂",
+ "flow-link-topic-revision": "主題修訂",
+ "flow-link-header-revision": "頁首修訂",
+ "flow-link-summary-revision": "摘要修訂",
+ "flow-moderation-title-suppress-post": "封鎖貼文?",
+ "flow-moderation-title-delete-post": "刪除貼文?",
+ "flow-moderation-title-hide-post": "隱藏貼文?",
+ "flow-moderation-title-unsuppress-post": "解除封鎖貼文?",
+ "flow-moderation-title-undelete-post": "取消刪除貼文?",
+ "flow-moderation-title-unhide-post": "取消隱藏貼文?",
+ "flow-moderation-placeholder-suppress-post": "請{{GENDER:$3|說明}}您為何要封鎖此貼文。",
+ "flow-moderation-placeholder-delete-post": "請{{GENDER:$3|說明}}您為何要刪除此貼文。",
+ "flow-moderation-placeholder-hide-post": "請{{GENDER:$3|說明}}您為何要隱藏此貼文。",
+ "flow-moderation-placeholder-unsuppress-post": "請{{GENDER:$3|說明}}您為何要解除封鎖此貼文。",
+ "flow-moderation-placeholder-undelete-post": "請{{GENDER:$3|說明}}您為何要取消刪除此貼文。",
+ "flow-moderation-placeholder-unhide-post": "請{{GENDER:$3|說明}}您為何要取消隱藏此貼文。",
+ "flow-moderation-confirm-suppress-post": "封鎖",
+ "flow-moderation-confirm-delete-post": "刪除",
+ "flow-moderation-confirm-hide-post": "隱藏",
+ "flow-moderation-confirm-unsuppress-post": "解除封鎖",
+ "flow-moderation-confirm-undelete-post": "取消刪除",
+ "flow-moderation-confirm-unhide-post": "取消隱藏",
+ "flow-moderation-confirm-suppress-topic": "封鎖",
+ "flow-moderation-confirm-delete-topic": "刪除",
+ "flow-moderation-confirm-hide-topic": "隱藏",
+ "flow-moderation-confirm-lock-topic": "鎖定",
+ "flow-moderation-confirm-unsuppress-topic": "解除封鎖",
+ "flow-moderation-confirm-undelete-topic": "取消刪除",
+ "flow-moderation-confirm-unhide-topic": "取消隱藏",
+ "flow-moderation-confirm-unlock-topic": "解除鎖定",
+ "flow-moderation-confirmation-suppress-post": "已成功封鎖貼文。\n{{GENDER:$2|請考慮}}讓 $1 對此主題提供意見回饋。",
+ "flow-moderation-confirmation-delete-post": "已成功刪除貼文。\n{{GENDER:$2|請考慮}}讓 $1 對此主題提供意見回饋。",
+ "flow-moderation-confirmation-hide-post": "已成功隱藏留言。\n{{GENDER:$2|請考慮}}讓 $1 對此主題提供意見回饋。",
+ "flow-moderation-confirmation-unsuppress-post": "您已成功解除封鎖以上貼文。",
+ "flow-moderation-confirmation-undelete-post": "您已成功取消刪除以上留言。",
+ "flow-moderation-confirmation-unhide-post": "您已成功取消隱藏以上貼文。",
+ "flow-moderation-confirmation-suppress-topic": "已成功封鎖此主題。",
+ "flow-moderation-confirmation-delete-topic": "已成功刪除此主題。",
+ "flow-moderation-confirmation-hide-topic": "已成功隱藏此主題。",
+ "flow-moderation-confirmation-unsuppress-topic": "您已成功解除封鎖此主題。",
+ "flow-moderation-confirmation-undelete-topic": "您已成功取消刪除此主題。",
+ "flow-moderation-confirmation-unhide-topic": "您已成功取消隱藏此主題。",
+ "flow-moderation-title-suppress-topic": "封鎖主題?",
+ "flow-moderation-title-delete-topic": "刪除主題?",
+ "flow-moderation-title-hide-topic": "隱藏主題?",
+ "flow-moderation-title-unsuppress-topic": "解除封鎖主題?",
+ "flow-moderation-title-undelete-topic": "取消刪除主題?",
+ "flow-moderation-title-unhide-topic": "取消隱藏主題?",
+ "flow-moderation-placeholder-suppress-topic": "請{{GENDER:$3|說明}}您為何要封鎖此主題。",
+ "flow-moderation-placeholder-delete-topic": "請{{GENDER:$3|說明}}您為何要刪除此主題。",
+ "flow-moderation-placeholder-hide-topic": "請{{GENDER:$3|說明}}您為何要隱藏此主題。",
+ "flow-moderation-placeholder-lock-topic": "請{{GENDER:$3|說明}}您為何要鎖定此主題。",
+ "flow-moderation-placeholder-unsuppress-topic": "請{{GENDER:$3|說明}}您為何要解除封鎖此主題。",
+ "flow-moderation-placeholder-undelete-topic": "請{{GENDER:$3|說明}}您為何要取消刪除此主題。",
+ "flow-moderation-placeholder-unhide-topic": "請{{GENDER:$3|說明}}您為何要取消隱藏此主題。",
+ "flow-moderation-placeholder-unlock-topic": "請{{GENDER:$3|說明}}您為何要解除鎖定此主題。",
+ "flow-topic-permalink-warning": "此主題發起於 [$2 $1]",
+ "flow-topic-permalink-warning-user-board": "此主題發起於 [$2 {{GENDER:$1|$1}} 的討論板]",
+ "flow-revision-permalink-warning-post": "此頁為此貼文單一版本的靜態連結。\n此版本來自 $1。\n您可以查閱 [與先前版本的 $5 個差異],或於 [$4 貼文歷史頁面] 檢視其他的版本。",
+ "flow-revision-permalink-warning-post-first": "此頁為此貼文第一個版本的靜態連結。\n您可以於 [$4 貼文歷史頁面] 檢視之後的版本。",
+ "flow-revision-permalink-warning-postsummary": "此頁為此貼文摘要單一版本的靜態連結。\n此版本來自 $1。\n您可以查閱 [與先前版本的 $5 個差異],或於 [$4 貼文歷史頁面] 檢視其他的版本。",
+ "flow-revision-permalink-warning-postsummary-first": "此頁為此貼文摘要第一個版本的靜態連結。\n您可以於 [$4 貼文歷史頁面] 檢視之後的版本。",
+ "flow-revision-permalink-warning-header": "此頁為頁首單一版本的靜態連結。\n此版本來自 $1。\n您可以查看 [與先前版本的 $3 個差異],或於 [$2 討論板歷史頁面] 檢視其他的版本。",
+ "flow-revision-permalink-warning-header-first": "此頁為標題第一個版本的靜態連結。\n您可以於 [$2 留言板歷史頁面] 檢視之後的版本。",
+ "flow-compare-revisions-revision-header": "版本由 {{GENDER:$2|$2}} 完成於 $1",
+ "flow-compare-revisions-header-post": "此頁面顯示由 $3 於 [$4 $1] 發表的主題 \"[$5 $2]\" 兩個版本之間的變更。\n您可以於 [$6 歷史頁面] 查閱此貼文的其他版本。",
+ "flow-compare-revisions-header-postsummary": "此頁面顯示於 [$3 $1] 的文章 \"[$4 $2]\" 中文章摘要兩個版本之間的變更。\n您可以於 [$5 歷史頁面] 查看此文章的其他版本。",
+ "flow-compare-revisions-header-header": "此頁面顯示於 [$3 $1] 中頁首兩個版本之間的{{GENDER:$2|變更}}。\n您可以於 [$4 歷史頁面] 查看該標題的其他版本。",
+ "action-flow-create-board": "在任何位置建立 Flow 討論板",
+ "right-flow-create-board": "在任何位置建立 Flow 討論板",
+ "right-flow-hide": "隱藏 Flow 主題與貼文",
+ "right-flow-lock": "鎖定 Flow 主題",
+ "right-flow-delete": "刪除 Flow 主題和貼文",
+ "right-flow-edit-post": "由其他使用者編輯 Flow 貼文",
+ "right-flow-suppress": "封鎖 Flow 修訂",
+ "flow-terms-of-use-new-topic": "點選 \"{{int:flow-newtopic-save}}\",代表您同意此 Wiki 的使用條款。",
+ "flow-terms-of-use-reply": "點選 \"{{int:flow-reply-submit}}\",代表您同意此 Wiki 的使用條款。",
+ "flow-terms-of-use-edit": "透過儲存您的更改,您同意此 wiki 之使用條款。",
+ "flow-anon-warning": "您尚未登入。 要收到使用您的名字的署名而非 IP 位置,您可以 [$1 登入] 或 [$2 建立帳號]。",
+ "flow-cancel-warning": "您已於此表單輸入內容,您確定要放棄內容?",
+ "flow-topic-first-heading": "於 $1 的主題",
+ "flow-topic-html-title": "$1 於 $2",
+ "flow-topic-count": "主題 ($1)",
+ "flow-load-more": "讀取更多",
+ "flow-no-more-fwd": "沒有更舊的主題",
+ "flow-add-topic": "新增主題",
+ "flow-newest-topics": "最新主題",
+ "flow-recent-topics": "最近活動的主題",
+ "flow-sorting-tooltip-newest": "{{GENDER:|您}}目前的排序方式為最新的主題優先,點選此處以使用更多的排序選項。",
+ "flow-toggle-small-topics": "切換成檢視主題摘要",
+ "flow-toggle-topics": "切換成只檢視主題",
+ "flow-toggle-topics-posts": "切換成主題與文章視圖",
+ "flow-terms-of-use-summarize": "點選 \"{{int:flow-summarize-topic-submit}}\",代表您同意此 Wiki 的使用條款。",
+ "flow-terms-of-use-lock-topic": "點選 \"{{int:flow-lock-topic-submit}}\",代表您同意此 Wiki 的使用條款。",
+ "flow-terms-of-use-unlock-topic": "點選 \"{{int:flow-unlock-topic-submit}}\",代表您同意此 Wiki 的使用條款。",
+ "flow-whatlinkshere-post": "來自一筆 [$1 貼文]",
+ "flow-whatlinkshere-header": "來自 [$1 頁首]",
+ "flow": "Flow",
+ "flow-special-desc": "此特殊頁面會重新導向至指定 UUID 的 Flow 工作流程或 Flow 貼文。",
+ "flow-special-type": "類型",
+ "flow-special-type-post": "發佈",
+ "flow-special-type-workflow": "工作流程",
+ "flow-special-uuid": "UUID",
+ "flow-special-invalid-uuid": "查無符合該類型與 UUID 的內容。",
+ "flow-special-enableflow-header": "Flow 討論板的頁首 (Wikitext)",
+ "flow-spam-confirmedit-form": "請回答以下驗証碼來確認您不是機器人:$1",
+ "flow-preview-warning": "您正在預覽畫面。 點選 \"{{int:flow-newtopic-save}}\" 來發佈貼文,或點選\"{{int:flow-preview-return-edit-post}}\" 繼續編輯。",
+ "flow-preview-return-edit-post": "繼續編輯",
+ "flow-anonymous": "匿名",
+ "flow-embedding-unsupported": "無法嵌入討論。",
+ "mw-ui-unsubmitted-confirm": "您在此頁面有未送出的變更,您確定要離開此頁面並放棄您的修改?",
+ "flow-post-undo-hide": "還原隱藏",
+ "flow-post-undo-delete": "還原刪除",
+ "flow-post-undo-suppress": "還原封鎖",
+ "flow-topic-undo-hide": "還原隱藏",
+ "flow-topic-undo-delete": "還原刪除",
+ "flow-topic-undo-suppress": "還原封鎖",
+ "apihelp-flow-example-1": "編輯 \"[[Talk:Sandbox]]\" 的頁首",
+ "apihelp-flow+edit-header-description": "編輯討論板的頁首。",
+ "apihelp-flow+edit-header-param-format": "頁首格式 (wikitext|html)。",
+ "apihelp-flow+edit-post-description": "編輯一篇貼文的內容。",
+ "apihelp-flow+edit-post-param-format": "貼文內容的格式 (wikitext|html)",
+ "apihelp-flow+reply-description": "回覆一篇貼文。",
+ "apihelp-flow+reply-param-content": "新貼文的內容。",
+ "apihelp-flow+reply-example-1": "於 [[Topic:S2tycnas4hcucw8w]] 回覆一篇貼文",
+ "apihelp-flow+view-header-description": "檢視討論板的頁首。",
+ "apihelp-flow+view-post-description": "檢視一篇貼文。",
+ "flow-edited": "已編輯於",
+ "flow-edited-by": "已編輯由 $1",
+ "flow-previous-diff": "← 較舊編輯",
+ "flow-next-diff": "較新編輯 →",
+ "flow-undo": "還原",
+ "flow-undo-latest-revision": "最新修訂",
+ "flow-undo-your-text": "您的文字",
+ "flow-undo-edit-header": "正在編輯頁首",
+ "flow-undo-edit-topic-summary": "正在編輯主題摘要",
+ "flow-undo-edit-post": "正在編輯貼文",
+ "group-flow-bot": "Flow 機器人",
+ "group-flow-bot-member": "Flow 機器人",
+ "flow-ve-mention-context-item-label": "提到",
+ "flow-ve-mention-inspector-title": "提到",
+ "flow-ve-mention-inspector-remove-label": "移除",
+ "flow-ve-mention-tool-title": "提到一位使用者",
+ "flow-ve-mention-inspector-invalid-user": "使用者名稱 '$1' 未註冊。",
+ "flow-wikitext-editor-help": "Wikitext $1。",
+ "flow-wikitext-editor-help-and-preview": "Wikitext $1 且您可以隨時 $2。",
+ "flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|使用標籤]]",
+ "flow-wikitext-editor-help-preview-the-result": "預覽結果",
+ "flow-wikitext-switch-editor-tooltip": "切換至 VisualEditor",
+ "flow-ve-switch-editor-tool-title": "切換至 Wikitext 編輯器"
+}
diff --git a/Flow/includes/Actions/Action.php b/Flow/includes/Actions/Action.php
new file mode 100644
index 00000000..d027bebf
--- /dev/null
+++ b/Flow/includes/Actions/Action.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Flow\Actions;
+
+use Action;
+use Article;
+use ErrorPageError;
+use Flow\Container;
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+use Flow\View;
+use Flow\WorkflowLoaderFactory;
+use IContextSource;
+use OutputPage;
+use Page;
+use Title;
+use WebRequest;
+use WikiPage;
+
+class FlowAction extends Action {
+ /**
+ * @var string
+ */
+ protected $actionName;
+
+ /**
+ * @param Page $page
+ * @param IContextSource $source
+ * @param string $actionName
+ */
+ public function __construct( Page $page, IContextSource $source, $actionName ) {
+ parent::__construct( $page, $source );
+ $this->actionName = $actionName;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return $this->actionName;
+ }
+
+ public function show() {
+ $this->showForAction( $this->getName() );
+ }
+
+ /**
+ * @param string $action
+ * @param OutputPage|null $output
+ * @throws ErrorPageError
+ * @throws FlowException
+ */
+ public function showForAction( $action, OutputPage $output = null ) {
+ $container = Container::getContainer();
+ $occupationController = \FlowHooks::getOccupationController();
+
+ if ( $output === null ) {
+ $output = $this->context->getOutput();
+ }
+
+ // Check if this is actually a Flow page.
+ if ( ! $this->page instanceof WikiPage && ! $this->page instanceof Article ) {
+ throw new ErrorPageError( 'nosuchaction', 'flow-action-unsupported' );
+ }
+
+ $title = $this->page->getTitle();
+ if ( ! $occupationController->isTalkpageOccupied( $title ) ) {
+ throw new ErrorPageError( 'nosuchaction', 'flow-action-unsupported' );
+ }
+
+ // @todo much of this seems to duplicate BoardContent::getParserOutput
+ $view = new View(
+ $container['url_generator'],
+ $container['lightncandy'],
+ $output,
+ $container['flow_actions']
+ );
+
+ $request = $this->context->getRequest();
+
+ // BC for urls pre july 2014 with workflow query parameter
+ $redirect = $this->getRedirectUrl( $request, $title );
+ if ( $redirect ) {
+ $output->redirect( $redirect );
+ return;
+ }
+
+ $action = $request->getVal( 'action', 'view' );
+ try {
+ /** @var WorkflowLoaderFactory $factory */
+ $factory = $container['factory.loader.workflow'];
+ $loader = $factory->createWorkflowLoader( $title );
+
+ if ( $title->getNamespace() === NS_TOPIC && $loader->getWorkflow()->getType() !== 'topic' ) {
+ // @todo better error handling
+ throw new FlowException( 'Invalid title: uuid is not a topic' );
+ }
+
+ if ( !$loader->getWorkflow()->isNew() ) {
+ // Workflow currently exists, make sure a revision also exists
+ $occupationController->ensureFlowRevision( $this->page, $loader->getWorkflow() );
+ }
+
+ $view->show( $loader, $action );
+ } catch( FlowException $e ) {
+ $e->setOutput( $output );
+ throw $e;
+ }
+ }
+
+
+ /**
+ * Flow used to output some permalink urls with workflow ids in them. Each
+ * workflow now has its own page, so those have been deprecated. This checks
+ * a web request for the old workflow parameter and returns a url to redirect
+ * to if necessary.
+ *
+ * @param WebRequest $request
+ * @param Title $title
+ * @return string URL to redirect to or blank string for no redirect
+ */
+ protected function getRedirectUrl( WebRequest $request, Title $title ) {
+ $workflowId = UUID::create( strtolower( $request->getVal( 'workflow' ) ) ?: null );
+ if ( !$workflowId ) {
+ return '';
+ }
+
+ /** @var ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+ /** @var Workflow $workflow */
+ $workflow = $storage->get( 'Workflow', $workflowId );
+
+ // The uuid points to a non-existant workflow
+ if ( !$workflow ) {
+ return '';
+ }
+
+ // The uuid points to the current page
+ $redirTitle = $workflow->getArticleTitle();
+ if ( $redirTitle->equals( $title ) ) {
+ return '';
+ }
+
+ // We need to redirect
+ return $redirTitle->getLinkURL();
+ }
+}
diff --git a/Flow/includes/Actions/EditAction.php b/Flow/includes/Actions/EditAction.php
new file mode 100644
index 00000000..0ec9ece5
--- /dev/null
+++ b/Flow/includes/Actions/EditAction.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Flow\Actions;
+
+use IContextSource;
+use Page;
+use Title;
+
+class EditAction extends FlowAction {
+
+ function __construct( Page $page, IContextSource $context ) {
+ parent::__construct( $page, $context, 'edit' );
+ }
+
+ /**
+ * Flow doesn't support edit action, redirect to the title instead
+ */
+ public function show() {
+ $title = $this->context->getTitle();
+
+ // There should always be a title since Flow page
+ // is detected by title or namespace, adding this
+ // to prevent some werid cases
+ if ( !$title ) {
+ $title = Title::newMainPage();
+ }
+
+ $this->context->getOutput()->redirect( $title->getFullURL() );
+ }
+
+}
diff --git a/Flow/includes/Actions/PurgeAction.php b/Flow/includes/Actions/PurgeAction.php
new file mode 100644
index 00000000..d6ceeb94
--- /dev/null
+++ b/Flow/includes/Actions/PurgeAction.php
@@ -0,0 +1,197 @@
+<?php
+
+namespace Flow\Actions;
+
+use BagOStuff;
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Data\Pager\Pager;
+use Flow\Formatter\TopicListQuery;
+use Flow\Model\TopicListEntry;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\WorkflowLoaderFactory;
+use HashBagOStuff;
+
+/**
+ * Extends the core Purge action to additionally purge flow specific cache
+ * keys related to the requested title.
+ */
+class PurgeAction extends \PurgeAction {
+ /**
+ * @var BagOStuff
+ */
+ protected $realMemcache;
+
+ /**
+ * @var BagOStuff
+ */
+ protected $hashBag;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onSubmit( $data ) {
+ // Replace $c['memcache'] with a hash bag o stuff. This will look to the
+ // application layer like an empty cache, and as such it will populate this
+ // empty cache with all the cache keys required to reload this page.
+ // We then extract the complete list of keys updated from this hash bag o stuff
+ // and delete them from the real memcache.
+ // The container must be reset prior to this because the TitleSquidURLs hook
+ // will initialize memcache before this is run when UseSquid is enabled.
+ Container::reset();
+ $container = Container::getContainer();
+ $container->extend( 'memcache', function( $memcache, $c ) {
+ $c['memcache.purge_backup'] = $memcache;
+ return new HashBagOStuff;
+ } );
+ $this->hashBag = $container['memcache'];
+ $this->realMemcache = $container['memcache.purge_backup'];
+
+ if ( !parent::onSubmit( array() ) ) {
+ return false;
+ }
+
+ /** @var WorkflowLoaderFactory $loader */
+ $loader = $container['factory.loader.workflow'];
+ $workflow = $loader
+ ->createWorkflowLoader( $this->page->getTitle() )
+ ->getWorkflow();
+
+ switch( $workflow->getType() ) {
+ case 'discussion':
+ $this->fetchDiscussion( $workflow );
+ break;
+
+ case 'topic':
+ $this->fetchTopics( array( $workflow->getId()->getAlphadecimal() => $workflow->getId() ) );
+ break;
+
+ default:
+ throw new \MWException( 'Unknown workflow type: ' . $workflow->getType() );
+ }
+
+ // delete all the keys we just visited
+ $this->purgeCache();
+
+ return true;
+ }
+
+ /**
+ * Load the header and topics from the requested discussion. Does not return
+ * anything, the goal here is to populate $this->hashBag.
+ *
+ * @param Workflow $workflow
+ */
+ protected function fetchDiscussion( Workflow $workflow ) {
+ $results = array();
+ $pagers = array();
+ /** @var ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+
+ // 'newest' sort order
+ $pagers[] = new Pager(
+ $storage->getStorage( 'TopicListEntry' ),
+ array( 'topic_list_id' => $workflow->getId() ),
+ array( 'pager-limit' => 499 )
+ );
+
+ // 'updated' sort order
+ $pagers[] = new Pager(
+ $storage->getStorage( 'TopicListEntry' ),
+ array( 'topic_list_id' => $workflow->getId() ),
+ array(
+ 'pager-limit' => 499,
+ 'sort' => 'workflow_last_update_timestamp',
+ 'order' => 'desc',
+ )
+ );
+
+ // Based on Header::init.
+ $storage->find(
+ 'Header',
+ array( 'rev_type_id' => $workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ foreach ( $pagers as $pager ) {
+ foreach ( $pager->getPage()->getResults() as $entry ) {
+ // use array key to de-duplicate
+ $results[$entry->getId()->getAlphadecimal()] = $entry->getId();
+ }
+ }
+
+ $this->fetchTopics( $results );
+
+ // purge the board history
+ $storage->find(
+ 'BoardHistoryEntry',
+ array( 'topic_list_id' => $workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 499 )
+ );
+ }
+
+
+ /**
+ * Load the requested topics. Does not return anything, the goal
+ * here is to populate $this->hashBag.
+ *
+ * @param UUID[] $results
+ */
+ protected function fetchTopics( array $results ) {
+ // purge the revisions that make up the topic
+ /** @var TopicListQuery $query */
+ $query = Container::get( 'query.topiclist' );
+ $query->getResults( $results );
+
+ // Purge the history
+ $queries = array();
+ foreach ( $results as $id ) {
+ $queries[] = array( 'topic_root_id' => $id );
+ }
+ Container::get( 'storage' )->findMulti(
+ 'TopicHistoryEntry',
+ $queries,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 499 )
+ );
+ }
+
+ /**
+ * Purge all keys written to $this->hashBag that match our cache prefix key.
+ */
+ protected function purgeCache() {
+ $prefix = $this->cacheKeyPrefix();
+ $reflProp = new \ReflectionProperty( $this->hashBag, 'bag' );
+ $reflProp->setAccessible( true );
+ // Loop through all the cache keys we just populated and find the ones
+ // that contain our prefix.
+ $keys = array_filter(
+ array_keys( $reflProp->getValue( $this->hashBag ) ),
+ function( $key ) use( $prefix ) {
+ if ( strpos( $key, $prefix ) === 0 ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ );
+
+ foreach ( $keys as $key ) {
+ $this->realMemcache->delete( $key );
+ }
+
+ return true;
+ }
+
+ /**
+ * @return string The id of the database being cached
+ */
+ protected function cacheKeyPrefix() {
+ global $wgFlowDefaultWikiDb;
+ if ( $wgFlowDefaultWikiDb === false ) {
+ return wfWikiId();
+ } else {
+ return $wgFlowDefaultWikiDb;
+ }
+ }
+}
diff --git a/Flow/includes/Actions/ViewAction.php b/Flow/includes/Actions/ViewAction.php
new file mode 100644
index 00000000..6637d98d
--- /dev/null
+++ b/Flow/includes/Actions/ViewAction.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Flow\Actions;
+
+use IContextSource;
+use Page;
+use Title;
+
+class ViewAction extends FlowAction {
+ function __construct( Page $page, IContextSource $context ) {
+ parent::__construct( $page, $context, 'view' );
+ }
+
+ public function showForAction( $action, OutputPage $output = null ) {
+ parent::showForAction( $action, $output );
+
+ $title = $this->context->getTitle();
+ $this->context->getUser()->clearNotification( $title );
+
+ if ( $output === null ) {
+ $output = $this->context->getOutput();
+ }
+ $output->addCategoryLinks( $this->getCategories( $title ) );
+
+ }
+
+ protected function getCategories( Title $title ) {
+ $id = $title->getArticleId();
+ if ( !$id ) {
+ return array();
+ }
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $res = $dbr->select(
+ /* from */ 'categorylinks',
+ /* select */ array( 'cl_to', 'cl_sortkey' ),
+ /* conditions */ array( 'cl_from' => $id ),
+ __METHOD__
+ );
+
+ $categories = array();
+ foreach ( $res as $row ) {
+ $categories[$row->cl_to] = $row->cl_sortkey;
+ }
+
+ return $categories;
+ }
+}
diff --git a/Flow/includes/Api/ApiFlow.php b/Flow/includes/Api/ApiFlow.php
new file mode 100644
index 00000000..65a93045
--- /dev/null
+++ b/Flow/includes/Api/ApiFlow.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+use ApiMain;
+use ApiModuleManager;
+use Flow\Container;
+use Title;
+
+class ApiFlow extends ApiBase {
+
+ /** @var ApiModuleManager $moduleManager */
+ private $moduleManager;
+
+ private static $modules = array(
+ // POST
+ 'new-topic' => 'Flow\Api\ApiFlowNewTopic',
+ 'edit-header' => 'Flow\Api\ApiFlowEditHeader',
+ 'edit-post' => 'Flow\Api\ApiFlowEditPost',
+ 'edit-topic-summary' => 'Flow\Api\ApiFlowEditTopicSummary',
+ 'reply' => 'Flow\Api\ApiFlowReply',
+ 'moderate-post' => 'Flow\Api\ApiFlowModeratePost',
+ 'moderate-topic' => 'Flow\Api\ApiFlowModerateTopic',
+ 'edit-title' => 'Flow\Api\ApiFlowEditTitle',
+ 'lock-topic' => 'Flow\Api\ApiFlowLockTopic',
+ 'close-open-topic' => 'Flow\Api\ApiFlowLockTopic', // BC: has been renamed to lock-topic
+
+ // GET
+ // action 'view' exists in Topic.php & TopicList.php, for topic, post &
+ // topiclist - we'll want to know topic-/post- or topiclist-view ;)
+ 'view-topiclist' => 'Flow\Api\ApiFlowViewTopicList',
+ 'view-post' => 'Flow\Api\ApiFlowViewPost',
+ 'view-topic' => 'Flow\Api\ApiFlowViewTopic',
+ 'view-header' => 'Flow\Api\ApiFlowViewHeader',
+ 'view-topic-summary' => 'Flow\Api\ApiFlowViewTopicSummary',
+ );
+
+ public function __construct( $main, $action ) {
+ parent::__construct( $main, $action );
+ $this->moduleManager = new ApiModuleManager( $this );
+ $this->moduleManager->addModules( self::$modules, 'submodule' );
+ }
+
+ public function getModuleManager() {
+ return $this->moduleManager;
+ }
+
+ public function execute() {
+ // To avoid API warning, register the parameter used to bust browser cache
+ $this->getMain()->getVal( '_' );
+
+ $params = $this->extractRequestParams();
+ /** @var $module ApiFlowBase */
+ $module = $this->moduleManager->getModule( $params['submodule'], 'submodule' );
+
+ // The checks for POST and tokens are the same as ApiMain.php
+ $wasPosted = $this->getRequest()->wasPosted();
+ if ( !$wasPosted && $module->mustBePosted() ) {
+ $this->dieUsageMsg( array( 'mustbeposted', $params['submodule'] ) );
+ }
+
+ if ( $module->needsToken() ) {
+ if ( !isset( $params['token'] ) ) {
+ $this->dieUsageMsg( array( 'missingparam', 'token' ) );
+ }
+
+ if ( is_callable( array( $module, 'validateToken' ) ) ) {
+ if ( !$module->validateToken( $params['token'], $params ) ) {
+ $this->dieUsageMsg( 'sessionfailure' );
+ }
+ } else {
+ if ( !$this->getUser()->matchEditToken(
+ $params['token'],
+ $module->getTokenSalt(),
+ $this->getRequest() )
+ ) {
+ $this->dieUsageMsg( 'sessionfailure' );
+ }
+ }
+ }
+
+ $module->extractRequestParams();
+ $module->profileIn();
+ $module->setPage( $this->getPage( $params ) );
+ $module->doRender( $params['render'] );
+ $module->execute();
+ wfRunHooks( 'APIFlowAfterExecute', array( $module ) );
+ $module->profileOut();
+ }
+
+ /**
+ * @param array $params
+ * @return Title
+ */
+ protected function getPage( $params ) {
+ $page = Title::newFromText( $params['page'] );
+ if ( !$page ) {
+ $this->dieUsage( 'Invalid page provided', 'invalid-page' );
+ }
+ /** @var \Flow\TalkpageManager $controller */
+ $controller = Container::get( 'occupation_controller' );
+ if ( !$controller->isTalkpageOccupied( $page ) ) {
+ // just check for permissions, nothing else to do. if the commit
+ // is successful the OccupationListener will see the new revision
+ // and put the flow board in place.
+ if ( !$controller->allowCreation( $page, $this->getUser() ) ) {
+ $this->dieUsage( 'Page provided does not have Flow enabled', 'invalid-page' );
+ }
+ }
+
+ return $page;
+ }
+
+ public function getAllowedParams() {
+ $mainParams = $this->getMain()->getAllowedParams();
+ if ( $mainParams['action'][ApiBase::PARAM_TYPE] === 'submodule' ) {
+ $submodulesType = 'submodule';
+ } else {
+ /** @todo Remove this case once support for older MediaWiki is dropped */
+ $submodulesType = $this->moduleManager->getNames( 'submodule' );
+ }
+
+ return array(
+ 'submodule' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => $submodulesType,
+ ),
+ 'page' => array(
+ ApiBase::PARAM_REQUIRED => true
+ ),
+ 'token' => '',
+ 'render' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ),
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Allows actions to be taken on Flow pages.';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'submodule' => 'The Flow submodule to invoke',
+ 'page' => 'The page to take the action on',
+ 'token' => 'An edit token',
+ 'render' => 'Set this to something to include a block-specific rendering in the output',
+ );
+ }
+
+ /**
+ * Override the parent to generate help messages for all available query modules.
+ * @return string
+ */
+ public function makeHelpMsg() {
+
+ // Use parent to make default message for the query module
+ $msg = parent::makeHelpMsg();
+
+ $querySeparator = str_repeat( '--- ', 12 );
+ $moduleSeparator = str_repeat( '*** ', 14 );
+ $msg .= "\n$querySeparator Flow: Submodules $querySeparator\n\n";
+ $msg .= $this->makeHelpMsgHelper( 'submodule' );
+ $msg .= "\n\n$moduleSeparator Modules: continuation $moduleSeparator\n\n";
+
+ return $msg;
+ }
+
+ /**
+ * For all modules of a given group, generate help messages and join them together
+ * @param string $group Module group
+ * @return string
+ */
+ private function makeHelpMsgHelper( $group ) {
+ $moduleDescriptions = array();
+
+ $moduleNames = $this->moduleManager->getNames( $group );
+ sort( $moduleNames );
+ foreach ( $moduleNames as $name ) {
+ /**
+ * @var $module ApiFlowBase
+ */
+ $module = $this->moduleManager->getModule( $name );
+
+ $msg = ApiMain::makeHelpMsgHeader( $module, $group );
+ $msg2 = $module->makeHelpMsg();
+ if ( $msg2 !== false ) {
+ $msg .= $msg2;
+ }
+ $moduleDescriptions[] = $msg;
+ }
+
+ return implode( "\n", $moduleDescriptions );
+ }
+
+ public function getHelpUrls() {
+ return array(
+ 'https://www.mediawiki.org/wiki/Extension:Flow/API',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=edit-header&page=Talk:Sandbox&ehprev_revision=???&ehcontent=Nice%20to&20meet%20you',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=edit-header&page=Talk:Sandbox&ehprev_revision=???&ehcontent=Nice%20to&20meet%20you'
+ => 'apihelp-flow-example-1',
+ );
+ }
+
+ public function mustBePosted() {
+ return false;
+ }
+
+ public function needsToken() {
+ return false;
+ }
+
+ public function getTokenSalt() {
+ return false;
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowBase.php b/Flow/includes/Api/ApiFlowBase.php
new file mode 100644
index 00000000..5acf68ad
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowBase.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+use Flow\Block\Block;
+use Flow\Container;
+use Flow\Model\AbstractRevision;
+use Flow\TalkpageManager;
+use Flow\WorkflowLoader;
+use Flow\WorkflowLoaderFactory;
+use Title;
+
+abstract class ApiFlowBase extends ApiBase {
+
+ /** @var WorkflowLoader $loader */
+ protected $loader;
+
+ /** @var Title $page */
+ protected $page;
+
+ /** @var bool $render */
+ protected $render;
+
+ /** @var ApiFlow $apiFlow */
+ protected $apiFlow;
+
+ /**
+ * @param ApiFlow $api
+ * @param string $modName
+ * @param string $prefix
+ */
+ public function __construct( $api, $modName, $prefix = '' ) {
+ $this->apiFlow = $api;
+ parent::__construct( $api->getMain(), $modName, $prefix );
+ }
+
+ /**
+ * @return array
+ */
+ abstract protected function getBlockParams();
+
+ public function doRender( $do = null ) {
+ return wfSetVar( $this->render, $do );
+ }
+
+ /**
+ * @param Title $page
+ */
+ public function setPage( Title $page ) {
+ $this->page = $page;
+ }
+
+ /*
+ * Return the name of the flow action
+ * @return string
+ */
+ abstract protected function getAction();
+
+ /**
+ * @return WorkflowLoader
+ */
+ protected function getLoader() {
+ if ( $this->loader === null ) {
+ /** @var WorkflowLoaderFactory $factory */
+ $factory = Container::get( 'factory.loader.workflow' );
+ $this->loader = $factory->createWorkflowLoader( $this->page );
+ }
+
+ return $this->loader;
+ }
+
+ /**
+ * @return TalkpageManager
+ */
+ protected function getOccupationController() {
+ return Container::get( 'occupation_controller' );
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getModerationStates() {
+ return array(
+ AbstractRevision::MODERATED_DELETED,
+ AbstractRevision::MODERATED_HIDDEN,
+ AbstractRevision::MODERATED_SUPPRESSED,
+ // aliases for AbstractRevision::MODERATED_NONE
+ 'restore', 'unhide', 'undelete', 'unsuppress',
+ );
+ }
+
+ /**
+ * Kill the request if errors were encountered.
+ * Only the first error will be output:
+ * * dieUsage only outputs one error - we could add more as $extraData, but
+ * that would mean we'd have to check for flow-specific errors differently
+ * * most of our code just quits on the first error that's encountered, so
+ * outputting all encountered errors might still not cover everything
+ * that's wrong with the request
+ *
+ * @param Block[] $blocks
+ */
+ protected function processError( $blocks ) {
+ foreach( $blocks as $block ) {
+ if ( $block->hasErrors() ) {
+ $errors = $block->getErrors();
+
+ foreach( $errors as $key ) {
+ $this->dieUsage(
+ $block->getErrorMessage( $key )->parse(),
+ $key,
+ 200,
+ // additional info for this message (e.g. to be used to
+ // enable recovery from error, like returning the most
+ // recent revision ID to re-submit content in the case
+ // of edit conflict)
+ array( $key => $block->getErrorExtra( $key ) )
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Override prefix on CSRF token so the same code can be reused for
+ * all modules. This is a *TEMPORARY* hack please remove as soon as
+ * unprefixed tokens are working correctly again (bug 70099).
+ *
+ * @param string $paramName
+ * @return string
+ */
+ public function encodeParamName( $paramName ) {
+ return $paramName === 'token'
+ ? $paramName
+ : parent::encodeParamName( $paramName );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getHelpUrls() {
+ return array(
+ 'https://www.mediawiki.org/wiki/Extension:Flow/API#' . $this->getAction(),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTokenSalt() {
+ return '';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getParent() {
+ return $this->apiFlow;
+ }
+
+}
diff --git a/Flow/includes/Api/ApiFlowBaseGet.php b/Flow/includes/Api/ApiFlowBaseGet.php
new file mode 100644
index 00000000..8b7651dc
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowBaseGet.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Flow\Api;
+
+use Flow\Block\Block;
+use Flow\Model\Anchor;
+use Flow\Model\UUID;
+use Message;
+
+abstract class ApiFlowBaseGet extends ApiFlowBase {
+ public function execute() {
+ $loader = $this->getLoader();
+ $blocks = $loader->getBlocks();
+ $context = $this->getContext();
+ $action = $this->getAction();
+ $passedParams = $this->getBlockParams();
+
+ $output = array( $action => array(
+ 'result' => array(),
+ 'status' => 'ok',
+ ) );
+
+ /** @var Block $block */
+ foreach( $blocks as $block ) {
+ $block->init( $context, $action );
+
+ if ( $block->canRender( $action ) ) {
+ $blockParams = array();
+ if ( isset( $passedParams[$block->getName()] ) ) {
+ $blockParams = $passedParams[$block->getName()];
+ }
+
+ $output[$action]['result'][$block->getName()] = $block->renderApi( $blockParams );
+ }
+ }
+
+ // See if any of the blocks generated an error (in which case the
+ // request will terminate with an the error message)
+ $this->processError( $blocks );
+
+ // If nothing could render, we'll consider that an error (at least some
+ // block should've been able to render a GET request)
+ if ( !$output[$action]['result'] ) {
+ $this->dieUsage(
+ wfMessage( 'flow-error-no-render' )->parse(),
+ 'no-render',
+ 200,
+ array()
+ );
+ }
+
+ $blocks = array_keys($output[$action]['result']);
+ $this->getResult()->setIndexedTagName( $blocks, 'block' );
+
+ // Required until php5.4 which has the JsonSerializable interface
+ array_walk_recursive( $output, function( &$value ) {
+ if ( $value instanceof Anchor ) {
+ $value = $value->toArray();
+ } elseif ( $value instanceof Message ) {
+ $value = $value->text();
+ } elseif ( $value instanceof UUID ) {
+ $value = $value->getAlphadecimal();
+ }
+ } );
+
+ $this->getResult()->addValue( null, $this->apiFlow->getModuleName(), $output );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function mustBePosted() {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function needsToken() {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTokenSalt() {
+ return false;
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowBasePost.php b/Flow/includes/Api/ApiFlowBasePost.php
new file mode 100644
index 00000000..d448987b
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowBasePost.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+use Flow\Model\Anchor;
+use Flow\Model\UUID;
+use Message;
+
+abstract class ApiFlowBasePost extends ApiFlowBase {
+ public function execute() {
+ $loader = $this->getLoader();
+ $blocks = $loader->getBlocks();
+ /** @var \Flow\Model\Workflow $workflow */
+ $workflow = $loader->getWorkflow();
+ $action = $this->getAction();
+
+ $result = $this->getResult();
+ $params = $this->getBlockParams();
+ $blocksToCommit = $loader->handleSubmit(
+ $this->getContext(),
+ $action,
+ $params
+ );
+
+ // See if any of the blocks generated an error (in which case the
+ // request will terminate with an the error message)
+ $this->processError( $blocks );
+
+ // If nothing was committed, we'll consider that an error (at least some
+ // block should've been able to process the POST request)
+ if ( !count( $blocksToCommit ) ) {
+ $this->dieUsage(
+ wfMessage( 'flow-error-no-commit' )->parse(),
+ 'no-commit',
+ 200,
+ array()
+ );
+ }
+
+ $commitMetadata = $loader->commit( $blocksToCommit );
+ $savedBlocks = array();
+ $result->setIndexedTagName( $savedBlocks, 'block' );
+
+ foreach( $blocksToCommit as $block ) {
+ $savedBlocks[] = $block->getName();
+ }
+
+ $output = array( $action => array(
+ 'status' => 'ok',
+ 'workflow' => $workflow->isNew() ? '' : $workflow->getId()->getAlphadecimal(),
+ 'committed' => $commitMetadata,
+ ) );
+
+ // User frontends need this data, but bots do not. When they
+ // pass metadataonly=1 we will skip this data and return a slimmer
+ // response in a shorter timeframe.
+ if ( !$this->getParameter( 'metadataonly' ) ) {
+ $output[$action]['result'] = array();
+ foreach( $blocksToCommit as $block ) {
+ // Always return parsed text to client after successful submission?
+ // @Todo - hacky, maybe have contentformat in the request to overwrite
+ // requiredWikitext
+ $block->unsetRequiresWikitext( $action );
+ $output[$action]['result'][$block->getName()] = $block->renderApi( $params[$block->getName()] );
+ }
+ }
+
+ // required until php5.4 which has the JsonSerializable interface
+ array_walk_recursive( $output, function( &$value ) {
+ if ( $value instanceof Anchor ) {
+ $value = $value->toArray();
+ } elseif ( $value instanceof Message ) {
+ $value = $value->text();
+ } elseif ( $value instanceof UUID ) {
+ $value = $value->getAlphadecimal();
+ }
+ } );
+
+ $this->getResult()->addValue( null, $this->apiFlow->getModuleName(), $output );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'metadataonly' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_REQUIRED => false,
+ ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function mustBePosted() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isWriteMode() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTokenSalt() {
+ return '';
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowEditHeader.php b/Flow/includes/Api/ApiFlowEditHeader.php
new file mode 100644
index 00000000..36db50c2
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowEditHeader.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowEditHeader extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'eh' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'header' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'edit-header';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'prev_revision' => array(
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'format' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'prev_revision' => 'Revision id of the current header revision to check for edit conflicts',
+ 'content' => 'Content for header',
+ 'format' => 'Format of the content (wikitext|html)',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Edits a topic\'s header';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=edit-header&page=Talk:Sandbox&ehprev_revision=???&ehcontent=Nice%20to&20meet%20you&ehformat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=edit-header&page=Talk:Sandbox&ehprev_revision=???&ehcontent=Nice%20to&20meet%20you&ehformat=wikitext'
+ => 'apihelp-flow+edit-header-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowEditPost.php b/Flow/includes/Api/ApiFlowEditPost.php
new file mode 100644
index 00000000..ff8982ff
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowEditPost.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowEditPost extends ApiFlowBasePost {
+
+ public function __construct( $api ) {
+ parent::__construct( $api, 'edit-post', 'ep' );
+ }
+
+ protected function getAction() {
+ return 'edit-post';
+ }
+
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'postId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'prev_revision' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'format' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'postId' => 'Post ID',
+ 'prev_revision' => 'Revision id of the current post revision to check for edit conflicts',
+ 'content' => 'Content for post',
+ 'format' => 'Format of the content (wikitext|html)',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Edits a post\'s content';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=edit-post&page=Topic:S2tycnas4hcucw8w&eppostId=???&epprev_revision=???&epcontent=Nice%20to&20meet%20you&epformat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=edit-post&page=Topic:S2tycnas4hcucw8w&eppostId=???&epprev_revision=???&epcontent=Nice%20to&20meet%20you&epformat=wikitext'
+ => 'apihelp-flow+edit-post-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowEditTitle.php b/Flow/includes/Api/ApiFlowEditTitle.php
new file mode 100644
index 00000000..0c1c3527
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowEditTitle.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowEditTitle extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'et' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'edit-title';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'prev_revision' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'prev_revision' => 'Revision id of the current title revision to check for edit conflicts',
+ 'content' => 'Content for title',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Edits a topic\'s title';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=edit-title&page=Topic:S2tycnas4hcucw8w&etprev_revision=???&etcontent=Nice%20to&20meet%20you',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=edit-title&page=Topic:S2tycnas4hcucw8w&etprev_revision=???&ehtcontent=Nice%20to&20meet%20you'
+ => 'apihelp-flow+edit-title-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowEditTopicSummary.php b/Flow/includes/Api/ApiFlowEditTopicSummary.php
new file mode 100644
index 00000000..ca8d6a67
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowEditTopicSummary.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowEditTopicSummary extends ApiFlowBasePost {
+
+ public function __construct( $api ) {
+ parent::__construct( $api, 'edit-topic-summary', 'ets' );
+ }
+
+ protected function getAction() {
+ return 'edit-topic-summary';
+ }
+
+ protected function getBlockParams() {
+ return array(
+ 'topicsummary' => $this->extractRequestParams(),
+ 'topic' => array(),
+ );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'prev_revision' => null,
+ 'summary' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'format' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'prev_revision' => 'Revision id of the current topic summary revision to check for edit conflicts. Null for a new topic summary revision',
+ 'summary' => 'Content for the summary',
+ 'format' => 'Format of the summary (wikitext|html)',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Edits a topic summary\'s content';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=edit-topic-summary&page=Topic:S2tycnas4hcucw8w&wetsprev_revision=???&etssummary=Nice%20to&20meet%20you&etsformat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=edit-topic-summary&page=Topic:S2tycnas4hcucw8w&wetsprev_revision=???&etssummary=Nice%20to&20meet%20you&etsformat=wikitext'
+ => 'apihelp-flow+edit-topic-summary-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowLockTopic.php b/Flow/includes/Api/ApiFlowLockTopic.php
new file mode 100644
index 00000000..3942ee54
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowLockTopic.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+use Flow\Model\AbstractRevision;
+
+class ApiFlowLockTopic extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'cot' );
+ }
+
+ protected function getBlockParams() {
+ $params = $this->extractRequestParams();
+ return array(
+ 'topic' => $params,
+ );
+ }
+
+ protected function getAction() {
+ return 'lock-topic';
+ }
+
+ public function isDeprecated() {
+ return $this->getModuleName() === 'close-open-topic';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'moderationState' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => array(
+ AbstractRevision::MODERATED_LOCKED, 'unlock',
+ 'close', 'reopen' // BC: now replaced by lock & unlock
+ ),
+ ),
+ 'reason' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => 'string',
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'moderationState' => "State to put topic in, either locked or unlocked",
+ 'reason' => 'Reason for locking or unlocking the topic',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Lock or unlock a Flow topic';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=lock-topic&page=Topic:S2tycnas4hcucw8w&cotmoderationState=lock&cotsummary=Ahhhh',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=lock-topic&page=Topic:S2tycnas4hcucw8w&cotmoderationState=lock&cotsummary=Ahhhh'
+ => 'apihelp-flow+lock-topic-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowModeratePost.php b/Flow/includes/Api/ApiFlowModeratePost.php
new file mode 100644
index 00000000..89e9acf2
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowModeratePost.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowModeratePost extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'mp' );
+ }
+
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'moderate-post';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'moderationState' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => $this->getModerationStates(),
+ ),
+ 'reason' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'postId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'moderationState' => 'What level to moderate at',
+ 'reason' => 'Reason for moderation',
+ 'postId' => 'Id of post to moderate',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Moderates a Flow post';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=moderate-post&page=Topic:S2tycnas4hcucw8w&mppostId=050f30e34c87beebcd54080027630f57&mpmoderationState=delete&mpreason=Ahhhh',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=moderate-post&page=Topic:S2tycnas4hcucw8w&mppostId=050f30e34c87beebcd54080027630f57&mpmoderationState=delete&mpreason=Ahhhh'
+ => 'apihelp-flow+moderate-post-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowModerateTopic.php b/Flow/includes/Api/ApiFlowModerateTopic.php
new file mode 100644
index 00000000..a2c58365
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowModerateTopic.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowModerateTopic extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'mt' );
+ }
+
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'moderate-topic';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'moderationState' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => $this->getModerationStates(),
+ ),
+ 'reason' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'moderationState' => 'What level to moderate at',
+ 'reason' => 'Reason for moderation',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Moderates a Flow topic';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=moderate-topic&page=Topic:S2tycnas4hcucw8w&mtmoderationState=delete&mtreason=Ahhhh',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=moderate-topic&page=Topic:S2tycnas4hcucw8w&mtmoderationState=delete&mtreason=Ahhhh'
+ => 'apihelp-flow+moderate-topic-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowNewTopic.php b/Flow/includes/Api/ApiFlowNewTopic.php
new file mode 100644
index 00000000..25bb2b50
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowNewTopic.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowNewTopic extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'nt' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topiclist' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'new-topic';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'topic' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'format' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'topic' => 'Text for new topic title',
+ 'content' => 'Content for the topic\'s initial reply',
+ 'format' => 'Format of the content (wikitext|html)',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Creates a new Flow topic on the given page, or workflow';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=new-topic&page=Talk:Sandbox&nttopic=Hi&ntcontent=Nice%20to&20meet%20you&ntformat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=new-topic&page=Talk:Sandbox&nttopic=Hi&ntcontent=Nice%20to&20meet%20you&ntformat=wikitext'
+ => 'apihelp-flow+new-topic-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowReply.php b/Flow/includes/Api/ApiFlowReply.php
new file mode 100644
index 00000000..3e8c8927
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowReply.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowReply extends ApiFlowBasePost {
+
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'rep' );
+ }
+
+ /**
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'reply';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'replyTo' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'format' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'replyTo' => 'Post ID to reply to',
+ 'content' => 'Content for new post',
+ 'format' => 'Format of the content (wikitext|html)',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Replies to a post';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=reply&page=Topic:S2tycnas4hcucw8w&repreplyTo=050e554490c2b269143b080027630f57&repcontent=Nice%20to&20meet%20you&repformat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=reply&page=Topic:S2tycnas4hcucw8w&repreplyTo=050e554490c2b269143b080027630f57&repcontent=Nice%20to&20meet%20you&repformat=wikitext'
+ => 'apihelp-flow+reply-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowUndoEditHeader.php b/Flow/includes/Api/ApiFlowUndoEditHeader.php
new file mode 100644
index 00000000..b07b4ce9
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowUndoEditHeader.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowUndoEditHeader extends ApiFlowBasePost {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'ueh' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'header' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'undo-edit-header';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'startId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'endId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=undo-edit-header&page=Talk:Sandbox&uehstartId=???&uehendId=???'
+ => 'apihelp-flow+undo-edit-header-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowUndoEditPost.php b/Flow/includes/Api/ApiFlowUndoEditPost.php
new file mode 100644
index 00000000..f10206da
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowUndoEditPost.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowUndoEditPost extends ApiFlowBasePost {
+ public function __construct( $api ) {
+ parent::__construct( $api, 'undo-edit-post', 'uep' );
+ }
+
+ protected function getAction() {
+ return 'undo-edit-post';
+ }
+
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'postId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'startId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'endId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=undo-edit-post&page=Topic:S2tycnas4hcucw8w&uaepostId=???&uaestartId=???&uaeendId=???'
+ => 'apihelp-flow+undo-edit-post-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowUndoEditTopicSummary.php b/Flow/includes/Api/ApiFlowUndoEditTopicSummary.php
new file mode 100644
index 00000000..b510d927
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowUndoEditTopicSummary.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowUndoEditTopicSummary extends ApiFlowBasePost {
+ public function __construct( $api ) {
+ parent::__construct( $api, 'edit-topic-summary', 'uets' );
+ }
+
+ protected function getAction() {
+ return 'undo-edit-topic-summary';
+ }
+
+ protected function getBlockParams() {
+ return array(
+ 'topicsummary' => $this->extractRequestParams(),
+ 'topic' => array(),
+ );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'startId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'endId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ ) + parent::getAllowedParams();
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=undo-edit-topic-summary&page=Topic:S2tycnas4hcucw8w&uetsstartId=???&uetsendId=???'
+ => 'apihelp-flow+undo-edit-topic-summary-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowViewHeader.php b/Flow/includes/Api/ApiFlowViewHeader.php
new file mode 100644
index 00000000..e51dca20
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowViewHeader.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowViewHeader extends ApiFlowBaseGet {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'vh' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'header' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'view-header';
+ }
+
+ public function getAllowedParams() {
+ global $wgFlowContentFormat;
+
+ return array(
+ 'contentFormat' => array(
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ApiBase::PARAM_DFLT => $wgFlowContentFormat,
+ ),
+ 'revId' => null,
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'contentFormat' => 'Format to return the content in',
+ 'revId' => 'load a specific revision if provided, otherwise, load the most recent',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'View a board header';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=view-header&page=Talk:Sandbox&vhcontentFormat=wikitext&revId=',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=view-header&page=Talk:Sandbox&vhcontentFormat=wikitext&revId='
+ => 'apihelp-flow+view-header-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowViewPost.php b/Flow/includes/Api/ApiFlowViewPost.php
new file mode 100644
index 00000000..10a8a132
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowViewPost.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowViewPost extends ApiFlowBaseGet {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'vp' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ *
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'view-post';
+ }
+
+ public function getAllowedParams() {
+ global $wgFlowContentFormat;
+
+ return array(
+ 'postId' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'contentFormat' => array(
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ApiBase::PARAM_DFLT => $wgFlowContentFormat,
+ ),
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'postId' => 'Id of the post to view',
+ 'contentFormat' => 'Format to return the content in',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'View a post';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=view-post&page=Topic:S2tycnas4hcucw8w&vppostId=???&vpcontentFormat=wikitext',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=view-post&page=Topic:S2tycnas4hcucw8w&vppostId=???&vpcontentFormat=wikitext'
+ => 'apihelp-flow+view-post-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowViewTopic.php b/Flow/includes/Api/ApiFlowViewTopic.php
new file mode 100644
index 00000000..c02227eb
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowViewTopic.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Flow\Api;
+
+class ApiFlowViewTopic extends ApiFlowBaseGet {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'vt' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ *
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topic' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'view-topic';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'View a topic';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=view-topic&page=Topic:S2tycnas4hcucw8w',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=view-topic&page=Topic:S2tycnas4hcucw8w'
+ => 'apihelp-flow+view-topic-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowViewTopicList.php b/Flow/includes/Api/ApiFlowViewTopicList.php
new file mode 100644
index 00000000..2080eb89
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowViewTopicList.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowViewTopicList extends ApiFlowBaseGet {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'vtl' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ *
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topiclist' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'view-topiclist';
+ }
+
+ public function getAllowedParams() {
+ global $wgFlowDefaultLimit, $wgFlowMaxLimit;
+
+ return array(
+ 'offset-dir' => array(
+ ApiBase::PARAM_TYPE => array( 'fwd', 'rev' ),
+ ApiBase::PARAM_DFLT => 'fwd',
+ ),
+ 'sortby' => array(
+ ApiBase::PARAM_TYPE => array( 'newest', 'updated', 'user' ),
+ ApiBase::PARAM_DFLT => 'user',
+ ),
+ 'savesortby' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ),
+ 'offset-id' => array(
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => false,
+ ),
+ 'offset' => array(
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => false,
+ ),
+ 'include-offset' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ),
+ 'limit' => array(
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => $wgFlowDefaultLimit,
+ ApiBase::PARAM_MAX => $wgFlowMaxLimit,
+ ApiBase::PARAM_MAX2 => $wgFlowMaxLimit,
+ ),
+ // @todo: I assume render parameter will soon be removed, after
+ // frontend rewrite
+ 'render' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ),
+ 'toconly' => array(
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ),
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'offset-dir' => 'Direction to get topics for',
+ 'sortby' => 'Sorting option of the topics',
+ 'savesortby' => 'Save sortby option, if set',
+ 'offset-id' => 'Offset value (in UUID format) to start fetching topics at',
+ 'offset' => 'Offset value to start fetching topics at',
+ 'include-offset' => 'Includes the offset item in the results as well',
+ 'limit' => 'Number of topics to fetch',
+ 'render' => 'Renders (in HTML) the topics, if set',
+ 'toconly' => 'Whether to respond with only the information required for the TOC',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'View a list of topics';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=view-topiclist&page=Talk:Sandbox',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=view-topiclist&page=Talk:Sandbox'
+ => 'apihelp-flow+view-topiclist-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiFlowViewTopicSummary.php b/Flow/includes/Api/ApiFlowViewTopicSummary.php
new file mode 100644
index 00000000..02b2de6c
--- /dev/null
+++ b/Flow/includes/Api/ApiFlowViewTopicSummary.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+
+class ApiFlowViewTopicSummary extends ApiFlowBaseGet {
+ public function __construct( $api, $modName ) {
+ parent::__construct( $api, $modName, 'vts' );
+ }
+
+ /**
+ * Taken from ext.flow.base.js
+ * @return array
+ */
+ protected function getBlockParams() {
+ return array( 'topicsummary' => $this->extractRequestParams() );
+ }
+
+ protected function getAction() {
+ return 'view-topic-summary';
+ }
+
+ public function getAllowedParams() {
+ global $wgFlowContentFormat;
+
+ return array(
+ 'contentFormat' => array(
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ApiBase::PARAM_DFLT => $wgFlowContentFormat,
+ ),
+ 'revId' => null,
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ return array(
+ 'contentFormat' => 'Format to return the content in',
+ 'revId' => 'load a specific revision if provided, otherwise, load the most recent',
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'View a topic summary';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=flow&submodule=view-topic-summary&page=Topic:S2tycnas4hcucw8w&vtscontentFormat=wikitext&revId=',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=flow&submodule=view-topic-summary&page=Topic:S2tycnas4hcucw8w&vtscontentFormat=wikitext&revId='
+ => 'apihelp-flow+view-topic-summary-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiParsoidUtilsFlow.php b/Flow/includes/Api/ApiParsoidUtilsFlow.php
new file mode 100644
index 00000000..d16ccc59
--- /dev/null
+++ b/Flow/includes/Api/ApiParsoidUtilsFlow.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiBase;
+use Flow\Container;
+use Flow\Parsoid\ContentFixer;
+use Flow\Parsoid\Utils;
+use Flow\Exception\WikitextException;
+
+class ApiParsoidUtilsFlow extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $page = $this->getTitleOrPageId( $params );
+
+ try {
+ $content = Utils::convert( $params['from'], $params['to'], $params['content'], $page->getTitle() );
+ } catch ( WikitextException $e ) {
+ $code = $e->getErrorCode();
+ $this->dieUsage( $this->msg( $code )->inContentLanguage()->useDatabase( false )->plain(), $code,
+ $e->getStatusCode(), array( 'detail' => $e->getMessage() ) );
+ return; // helps static analysis know execution does not continue past self::dieUsage
+ }
+
+ $result = array(
+ 'format' => $params['to'],
+ 'content' => $content,
+ );
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'from' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ 'to' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => array( 'html', 'wikitext' ),
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'title' => null,
+ 'pageid' => array(
+ ApiBase::PARAM_ISMULTI => false,
+ ApiBase::PARAM_TYPE => 'integer'
+ ),
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getParamDescription() {
+ $p = $this->getModulePrefix();
+ return array(
+ 'from' => 'Format of content tossed in',
+ 'to' => 'Format to convert content to',
+ 'content' => 'Content to be converted',
+ 'title' => "Title of the page. Cannot be used together with {$p}pageid",
+ 'pageid' => "ID of the page. Cannot be used together with {$p}title",
+ );
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Convert text from/to wikitext/html';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ "api.php?action=flow-parsoid-utils&from=wikitext&to=html&content='''lorem'''+''blah''&title=Main_Page",
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ "action=flow-parsoid-utils&from=wikitext&to=html&content='''lorem'''+''blah''&title=Main_Page"
+ => 'apihelp-flow-parsoid-utils-example-1',
+ );
+ }
+}
diff --git a/Flow/includes/Api/ApiQueryPropFlowInfo.php b/Flow/includes/Api/ApiQueryPropFlowInfo.php
new file mode 100644
index 00000000..699257c3
--- /dev/null
+++ b/Flow/includes/Api/ApiQueryPropFlowInfo.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Flow\Api;
+
+use ApiQueryBase;
+use Flow\Container;
+use Title;
+
+class ApiQueryPropFlowInfo extends ApiQueryBase {
+
+ public function __construct( $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'fli' );
+ }
+
+ public function execute() {
+ $pageSet = $this->getPageSet();
+ /** @var Title $title */
+ foreach ( $pageSet->getGoodTitles() as $pageid => $title ) {
+ $pageInfo = $this->getPageInfo( $title );
+ $this->addPageSubItems( $pageid, $pageInfo );
+ }
+ }
+
+ /**
+ * In the future we can add more Flow related info here
+ * @param Title $title
+ * @return array
+ */
+ protected function getPageInfo( Title $title ) {
+ /** @var \Flow\TalkpageManager $manager */
+ $manager = Container::get( 'occupation_controller' );
+ $result = array( 'flow' => array() );
+ if ( $manager->isTalkpageOccupied( $title ) ) {
+ $result['flow']['enabled'] = '';
+ }
+
+ return $result;
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getDescription() {
+ return 'Get basic Flow information about a page including whether Flow is enabled on them';
+ }
+
+ /**
+ * @deprecated since MediaWiki core 1.25
+ */
+ public function getExamples() {
+ return array(
+ 'api.php?action=query&prop=flowinfo&titles=Talk:Sandbox|Main_Page|Talk:Flow',
+ );
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return array(
+ 'action=query&prop=flowinfo&titles=Talk:Sandbox|Main_Page|Talk:Flow'
+ => 'apihelp-query+flowinfo-example-1',
+ );
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Extension:Flow/API#action.3Dquery.26prop.3Dflowinfo';
+ }
+
+}
diff --git a/Flow/includes/Block/Block.php b/Flow/includes/Block/Block.php
new file mode 100644
index 00000000..b0c503a7
--- /dev/null
+++ b/Flow/includes/Block/Block.php
@@ -0,0 +1,370 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Exception\InvalidInputException;
+use Flow\FlowActions;
+use Flow\Model\AbstractRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\RevisionActionPermissions;
+use Flow\SpamFilter\Controller as SpamFilterController;
+use IContextSource;
+
+interface Block {
+ /**
+ * @param IContextSource $context
+ * @param string $action
+ */
+ function init( IContextSource $context, $action );
+
+ /**
+ * Perform validation of data model
+ *
+ * @param array $data
+ * @return boolean True if data model is valid
+ */
+ function onSubmit( array $data );
+
+ /**
+ * Write updates to storage
+ */
+ function commit();
+
+ /**
+ * Render the API output of this Block.
+ * Templating is provided for convenience
+ *
+ * @param array $options
+ * @return array
+ */
+ function renderApi( array $options );
+
+ /**
+ * @return string Unique name among all blocks on an object
+ */
+ function getName();
+
+ /**
+ * @return UUID
+ */
+ function getWorkflowId();
+
+ /**
+ * Returns an array of all error types encountered in this block. The values
+ * in the returned array can be used to pass to getErrorMessage() or
+ * getErrorExtra() to respectively fetch the specific error message or
+ * additional details.
+ *
+ * @return array
+ */
+ function getErrors();
+
+ /**
+ * Checks if any errors have occurred in the block (no argument), or if a
+ * specific error has occurred (argument being the error type)
+ *
+ * @param string[optional] $type
+ * @return bool
+ */
+ function hasErrors( $type = null );
+
+ /**
+ * Returns true if the block can render the requested action, or false
+ * otherwise.
+ *
+ * @param string $action
+ * @return bool
+ */
+ public function canRender( $action );
+
+ /**
+ * @param string $action
+ */
+ public function unsetRequiresWikitext( $action );
+}
+
+abstract class AbstractBlock implements Block {
+
+ /** @var Workflow */
+ protected $workflow;
+ /** @var ManagerGroup */
+ protected $storage;
+
+ /** @var IContextSource */
+ protected $context;
+ /** @var array|null */
+ protected $submitted = null;
+ /** @var array */
+ protected $errors = array();
+
+ /**
+ * @var string|null The commitable action being submitted, or null
+ * for read-only actions.
+ */
+ protected $action;
+
+ /** @var RevisionActionPermissions */
+ protected $permissions;
+
+ /**
+ * A list of supported post actions
+ * @var array
+ */
+ protected $supportedPostActions = array();
+
+ /**
+ * A list of supported get actions
+ * @var array
+ */
+ protected $supportedGetActions = array();
+
+ /** @var array */
+ protected $requiresWikitext = array();
+
+ /**
+ * Templates for each view actions
+ * @var array
+ */
+ protected $templates = array();
+
+ public function __construct( Workflow $workflow, ManagerGroup $storage ) {
+ $this->workflow = $workflow;
+ $this->storage = $storage;
+ }
+
+ /**
+ * Called by $this->onSubmit to populate $this->errors based
+ * on $this->action and $this->submitted.
+ */
+ abstract protected function validate();
+
+ // This method exists in the Block interface and as such cannot be abstract
+ // until php 5.3.9, but MediaWiki requires PHP version 5.3.2 or later (and
+ // some of our test machines are on 5.3.3).
+ //abstract public function commit();
+
+ /**
+ * @var IContextSource $context
+ * @var string $action
+ */
+ public function init( IContextSource $context, $action ) {
+ $this->context = $context;
+ $this->action = $action;
+ // @todo not guaranteed that $this->permissions->getUser() === $context->getUser();
+ $this->permissions = Container::get( 'permissions' );
+ }
+
+ /**
+ * @return IContextSource
+ */
+ public function getContext() {
+ return $this->context;
+ }
+
+ /**
+ * Returns true if the block can submit the requested action, or false
+ * otherwise.
+ *
+ * @param string $action
+ * @return bool
+ */
+ public function canSubmit( $action ) {
+ return in_array( $this->getActionName( $action ), $this->supportedPostActions );
+ }
+
+ /**
+ * Returns true if the block can render the requested action, or false
+ * otherwise.
+ *
+ * @param string $action
+ * @return bool
+ */
+ public function canRender( $action ) {
+ return
+ // GET actions can be rendered
+ in_array( $this->getActionName( $action ), $this->supportedGetActions ) ||
+ // POST actions are usually redirected to 'view' after successfully
+ // completing the request, but can also be rendered (e.g. to show
+ // error message after unsuccessful submission)
+ $this->canSubmit( $action );
+ }
+
+ /**
+ * Get the template name for a specific action or an array of template
+ * for all possible view actions in this block
+ *
+ * @param string|null
+ * @return string|array
+ * @throws InvalidInputException
+ */
+ public function getTemplate( $action = null ) {
+ if ( $action === null ) {
+ return $this->templates;
+ }
+ if ( !isset( $this->templates[$action] ) ) {
+ throw new InvalidInputException( 'Template is not defined for action: ' . $action, 'invalid-input' );
+ }
+ return $this->templates[$action];
+ }
+
+ /**
+ * @param array $data
+ * @return bool|null true when accepted, false when not accepted.
+ * null when this action does not support submission.
+ */
+ public function onSubmit( array $data ) {
+ if ( !$this->canSubmit( $this->action ) ) {
+ return null;
+ }
+
+ $this->submitted = $data;
+ $this->validate();
+
+ return !$this->hasErrors();
+ }
+
+ public function wasSubmitted() {
+ return $this->submitted !== null;
+ }
+
+ /**
+ * Checks if any errors have occurred in the block (no argument), or if a
+ * specific error has occurred (argument being the error type)
+ *
+ * @param string[optional] $type
+ * @return bool
+ */
+ public function hasErrors( $type = null ) {
+ if ( $type === null ) {
+ return (bool) $this->errors;
+ }
+ return isset( $this->errors[$type] );
+ }
+
+ /**
+ * Returns an array of all error types encountered in this block. The values
+ * in the returned array can be used to pass to getErrorMessage() or
+ * getErrorExtra() to respectively fetch the specific error message or
+ * additional details.
+ *
+ * @return array
+ */
+ public function getErrors() {
+ return array_keys( $this->errors );
+ }
+
+ /**
+ * @param $type
+ * @return \Message
+ */
+ public function getErrorMessage( $type ) {
+ return isset( $this->errors[$type]['message'] ) ? $this->errors[$type]['message'] : null;
+ }
+
+ /**
+ * @param $type
+ * @return mixed
+ */
+ public function getErrorExtra( $type ) {
+ return isset( $this->errors[$type]['extra'] ) ? $this->errors[$type]['extra'] : null;
+ }
+
+ /**
+ * @param string $type
+ * @param \Message $message
+ * @param mixed[optional] $extra
+ */
+ public function addError( $type, \Message $message, $extra = null ) {
+ $this->errors[$type] = array(
+ 'message' => $message,
+ 'extra' => $extra,
+ );
+ }
+
+ public function getWorkflow() {
+ return $this->workflow;
+ }
+
+ public function getWorkflowId() {
+ return $this->workflow->getId();
+ }
+
+ public function getStorage() {
+ return $this->storage;
+ }
+
+ /**
+ * Given a certain action name, this returns the valid action name. This is
+ * meant for BC compatibility with renamed actions.
+ *
+ * @param string $action
+ * @return string
+ */
+ public function getActionName( $action ) {
+ // BC for renamed actions
+ /** @var FlowActions $actions */
+ $actions = Container::get( 'flow_actions' );
+ $alias = $actions->getValue( $action );
+ if ( is_string( $alias ) ) {
+ // All proper actions return arrays, but aliases return a string
+ $action = $alias;
+ }
+
+ return $action;
+ }
+
+ /**
+ * Run through AbuseFilter and friends.
+ * @todo Having to call spamFilter in each place that creates a revision
+ * is error-prone.
+ *
+ * @param AbstractRevision|null $old null when $new is first revision
+ * @param AbstractRevision $new
+ * @return boolean True when content is allowed by spam filter
+ */
+ protected function checkSpamFilters( AbstractRevision $old = null, AbstractRevision $new ) {
+ /** @var SpamFilterController $spamFilter */
+ $spamFilter = Container::get( 'controller.spamfilter' );
+ $status = $spamFilter->validate( $this->context, $new, $old, $this->workflow->getArticleTitle() );
+ if ( $status->isOK() ) {
+ return true;
+ }
+
+ $this->addError( 'spamfilter', $status->getMessage() );
+ return false;
+ }
+
+ /**
+ * @return string The new edit token
+ */
+ public function getEditToken() {
+ return $this->context->getUser()->getEditToken();
+ }
+
+ /**
+ * @param string $action
+ */
+ public function unsetRequiresWikitext( $action ) {
+ $key = array_search( $action, $this->requiresWikitext );
+ if ( $key !== false ) {
+ unset( $this->requiresWikitext[$key] );
+ }
+ }
+
+ /**
+ * @param \OutputPage $out
+ */
+ public function setPageTitle( \OutputPage $out ) {
+ if ( $out->getPageTitle() ) {
+ // Don't override page title if another block has already set it.
+ // If this should *really* be done, the specific block extending
+ // this AbstractBlock should just implement this itself ;)
+ return;
+ }
+
+ $out->setPageTitle( $this->workflow->getArticleTitle()->getFullText() );
+ }
+}
diff --git a/Flow/includes/Block/BoardHistory.php b/Flow/includes/Block/BoardHistory.php
new file mode 100644
index 00000000..2ff81ca1
--- /dev/null
+++ b/Flow/includes/Block/BoardHistory.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Data\Pager\HistoryPager;
+use Flow\Exception\DataModelException;
+use Flow\Formatter\BoardHistoryQuery;
+use Flow\Formatter\RevisionFormatter;
+use Flow\Model\UUID;
+
+class BoardHistoryBlock extends AbstractBlock {
+ protected $supportedGetActions = array( 'history' );
+
+ // @Todo - fill in the template names
+ protected $templates = array(
+ 'history' => '',
+ );
+
+ /**
+ * Board history is read-only block which should not invoke write action
+ */
+ public function validate() {
+ throw new DataModelException( __CLASS__ . ' should not invoke validate()', 'process-data' );
+ }
+
+ /**
+ * Board history is read-only block which should not invoke write action
+ */
+ public function commit() {
+ throw new DataModelException( __CLASS__ . ' should not invoke commit()', 'process-data' );
+ }
+
+ public function renderApi( array $options ) {
+ global $wgRequest;
+
+ if ( $this->workflow->isNew() ) {
+ return array(
+ 'type' => $this->getName(),
+ 'revisions' => array(),
+ 'links' => array(
+ ),
+ );
+ }
+
+ /** @var BoardHistoryQuery $query */
+ $query = Container::get( 'query.board-history' );
+ /** @var RevisionFormatter $formatter */
+ $formatter = Container::get( 'formatter.revision' );
+ $formatter->setIncludeHistoryProperties( true );
+
+ list( $limit, /* $offset */ ) = $wgRequest->getLimitOffset();
+ // don't use offset from getLimitOffset - that assumes an int, which our
+ // UUIDs are not
+ $offset = $wgRequest->getText( 'offset' );
+ $offset = $offset ? UUID::create( $offset ) : null;
+
+ $pager = new HistoryPager( $query, $this->workflow->getId() );
+ $pager->setLimit( $limit );
+ $pager->setOffset( $offset );
+ $pager->doQuery();
+ $history = $pager->getResult();
+
+ $revisions = array();
+ foreach ( $history as $row ) {
+ $serialized = $formatter->formatApi( $row, $this->context );
+ if ( $serialized ) {
+ $revisions[$serialized['revisionId']] = $serialized;
+ }
+ }
+
+ return array(
+ 'type' => $this->getName(),
+ 'revisions' => $revisions,
+ 'navbar' => $pager->getNavigationBar(),
+ 'links' => array(
+ ),
+ );
+ }
+
+ public function getName() {
+ return 'board-history';
+ }
+}
diff --git a/Flow/includes/Block/Header.php b/Flow/includes/Block/Header.php
new file mode 100644
index 00000000..05292e3c
--- /dev/null
+++ b/Flow/includes/Block/Header.php
@@ -0,0 +1,329 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Exception\InvalidActionException;
+use Flow\Exception\InvalidInputException;
+use Flow\Formatter\HeaderViewQuery;
+use Flow\Formatter\RevisionDiffViewFormatter;
+use Flow\Formatter\RevisionViewFormatter;
+use Flow\Formatter\FormatterRow;
+use Flow\Model\Header;
+use Flow\Model\UUID;
+use Flow\RevisionActionPermissions;
+use Flow\UrlGenerator;
+use IContextSource;
+
+class HeaderBlock extends AbstractBlock {
+ /**
+ * @var Header|null
+ */
+ protected $header;
+
+ /**
+ * New revision created via submission.
+ *
+ * @var Header|null
+ */
+ protected $newRevision;
+
+ /**
+ * @var boolean
+ */
+ protected $needCreate = false;
+
+ /**
+ * @var string[]
+ */
+ protected $supportedPostActions = array( 'edit-header', 'undo-edit-header' );
+
+ /**
+ * @var string[]
+ */
+ protected $requiresWikitext = array( 'edit-header', 'compare-header-revisions' );
+
+ /**
+ * @var string[]
+ */
+ protected $supportedGetActions = array( 'view', 'compare-header-revisions', 'edit-header', 'view-header', 'undo-edit-header' );
+
+ // @Todo - fill in the template names
+ protected $templates = array(
+ 'view' => '',
+ 'compare-header-revisions' => 'diff_view',
+ 'edit-header' => 'edit',
+ 'undo-edit-header' => 'undo_edit',
+ 'view-header' => 'single_view',
+ );
+
+ /**
+ * @var RevisionActionPermissions Allows or denies actions to be performed
+ */
+ protected $permissions;
+
+ public function init( IContextSource $context, $action ) {
+ parent::init( $context, $action );
+
+ // Basic initialisation done -- now, load data if applicable
+ if ( $this->workflow->isNew() ) {
+ $this->needCreate = true;
+ return;
+ }
+
+ // Get the latest revision attached to this workflow
+ $found = $this->storage->find(
+ 'Header',
+ array( 'rev_type_id' => $this->workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ if ( $found ) {
+ $this->header = reset( $found );
+ }
+ }
+
+ protected function validate() {
+ // @todo some sort of restriction along the lines of article protection
+ // @todo this is superseeded by SubmissionHandler::onSubmit checking
+ // Title::userCan() ?
+ if ( !$this->context->getUser()->isAllowed( 'edit' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ if ( !isset( $this->submitted['content'] ) ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-header-content' ) );
+ }
+
+ if ( $this->header ) {
+ $this->validateNextRevision();
+ } else {
+ // simpler case
+ $this->validateFirstRevision();
+ }
+ }
+
+ protected function validateNextRevision() {
+ if ( !$this->permissions->isAllowed( $this->header, 'edit-header' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+
+ if ( empty( $this->submitted['prev_revision'] ) ) {
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
+ } elseif ( $this->header->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
+ // This is a reasonably effective way to ensure prev revision matches, but for guarantees against race
+ // conditions there also exists a unique index on rev_prev_revision in mysql, meaning if someone else inserts against the
+ // parent we and the submitter think is the latest, our insert will fail.
+ // TODO: Catch whatever exception happens there, make sure the most recent revision is the one in the cache before
+ // handing user back to specific dialog indicating race condition
+ $this->addError(
+ 'prev_revision',
+ $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
+ $this->submitted['prev_revision'],
+ $this->header->getRevisionId()->getAlphadecimal(),
+ $this->context->getUser()->getName()
+ ),
+ array( 'revision_id' => $this->header->getRevisionId()->getAlphadecimal() ) // save current revision ID
+ );
+ }
+
+ // this isn't really part of validate, but we want the error-rendering template to see the users edited header
+ $this->newRevision = $this->header->newNextRevision(
+ $this->context->getUser(),
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'edit-header',
+ $this->workflow->getArticleTitle()
+ );
+
+ if ( !$this->checkSpamFilters( $this->header, $this->newRevision ) ) {
+ return;
+ }
+
+ }
+
+ protected function validateFirstRevision() {
+ if ( !$this->permissions->isAllowed( null, 'create-header' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ if ( isset( $this->submitted['prev_revision'] ) && $this->submitted['prev_revision'] ) {
+ // User submitted a previous revision, but we couldn't find one. This is likely
+ // an internal error and not a user error, consider better handling
+ // is this even worth checking?
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-prev-revision-does-not-exist' ) );
+ return;
+ }
+
+ $this->newRevision = Header::create(
+ $this->workflow,
+ $this->context->getUser(),
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'create-header'
+ );
+
+ if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
+ return;
+ }
+ }
+
+ public function needCreate() {
+ return $this->needCreate;
+ }
+
+ public function commit() {
+ switch( $this->action ) {
+ case 'edit-header':
+ $this->storage->put( $this->newRevision, array(
+ 'workflow' => $this->workflow,
+ ) );
+ // Reload $this->header for renderApi() after save
+ $this->header = $this->newRevision;
+ return array(
+ 'header-revision-id' => $this->newRevision->getRevisionId(),
+ );
+
+ default:
+ throw new InvalidActionException( 'Unrecognized commit action', 'invalid-action' );
+ }
+ }
+
+ public function renderApi( array $options ) {
+ $output = array(
+ 'type' => $this->getName(),
+ 'editToken' => $this->getEditToken(),
+ );
+
+ switch ( $this->action ) {
+ case 'view':
+ case 'edit-header':
+ $output += $this->renderRevisionApi();
+ break;
+
+ case 'undo-edit-header':
+ $output = $this->renderUndoApi( $options ) + $output;
+ break;
+
+ case 'view-header':
+ if ( isset( $options['revId'] ) && $options['revId'] ) {
+ $output += $this->renderSingleViewApi( $options['revId'] );
+ } else {
+ if ( isset( $options['contentFormat'] ) && $options['contentFormat'] === 'wikitext' ) {
+ $this->requiresWikitext[] = 'view-header';
+ }
+ $output += $this->renderRevisionApi();
+ }
+ break;
+
+ case 'compare-header-revisions':
+ $output += $this->renderDiffviewApi( $options );
+ break;
+
+ }
+
+ if ( $this->wasSubmitted() ) {
+ $output += array(
+ 'submitted' => $this->submitted,
+ 'errors' => $this->errors,
+ );
+ } else {
+ $output += array(
+ 'submitted' => array(),
+ 'errors' => array()
+ );
+ }
+
+ return $output;
+ }
+
+ // @Todo - duplicated logic in other diff view block
+ protected function renderDiffviewApi( array $options ) {
+ if ( !isset( $options['newRevision'] ) ) {
+ throw new InvalidInputException( 'A revision must be provided for comparison', 'revision-comparison' );
+ }
+ $oldRevision = null;
+ if ( isset( $options['oldRevision'] ) ) {
+ $oldRevision = $options['newRevision'];
+ }
+ /** @var HeaderViewQuery $query */
+ $query = Container::get( 'query.header.view' );
+ list( $new, $old ) = $query->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );
+ /** @var RevisionDiffViewFormatter $formatter */
+ $formatter = Container::get( 'formatter.revision.diff.view' );
+
+ return array(
+ 'revision' => $formatter->formatApi( $new, $old, $this->context )
+ );
+ }
+
+ // @Todo - duplicated logic in other single view block
+ protected function renderSingleViewApi( $revId ) {
+ /** @var HeaderViewQuery $query */
+ $query = Container::get( 'query.header.view' );
+ $row = $query->getSingleViewResult( $revId );
+ /** @var RevisionViewFormatter $formatter */
+ $formatter = Container::get( 'formatter.revisionview' );
+
+ return array(
+ 'revision' => $formatter->formatApi( $row, $this->context )
+ );
+ }
+
+ protected function renderRevisionApi() {
+ $output = array();
+ if ( $this->header === null ) {
+ /** @var UrlGenerator $urlGenerator */
+ $urlGenerator = Container::get( 'url_generator' );
+ $output['revision'] = array(
+ // @todo
+ 'actions' => array(
+ 'edit' => $urlGenerator
+ ->createHeaderAction( $this->workflow->getArticleTitle() ),
+ ),
+ 'links' => array(
+ ),
+ );
+ } else {
+ $row = new FormatterRow;
+ $row->workflow = $this->workflow;
+ $row->revision = $this->header;
+ $row->currentRevision = $this->header;
+
+ $serializer = Container::get( 'formatter.revision' );
+ if ( false !== array_search( $this->action, $this->requiresWikitext ) ) {
+ $serializer->setContentFormat( 'wikitext' );
+ }
+
+ $output['revision'] = $serializer->formatApi( $row, $this->context );
+ }
+ return $output;
+ }
+
+ protected function renderUndoApi( array $options ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No header exists to undo' );
+ }
+
+ if ( !isset( $options['startId'], $options['endId'] ) ) {
+ throw new InvalidInputException( 'Both startId and endId must be provided' );
+ }
+
+ /** @var RevisionViewQuery */
+ $query = Container::get( 'query.header.view' );
+ $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
+ if ( !$rows ) {
+ throw new InvalidInputException( 'Could not load revision to undo' );
+ }
+
+ $serializer = Container::get( 'formatter.undoedit' );
+ return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
+ }
+
+ public function getName() {
+ return 'header';
+ }
+}
diff --git a/Flow/includes/Block/Topic.php b/Flow/includes/Block/Topic.php
new file mode 100644
index 00000000..5f60708f
--- /dev/null
+++ b/Flow/includes/Block/Topic.php
@@ -0,0 +1,973 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Data\Pager\HistoryPager;
+use Flow\Exception\FailCommitException;
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidActionException;
+use Flow\Exception\InvalidDataException;
+use Flow\Exception\InvalidInputException;
+use Flow\Exception\PermissionException;
+use Flow\Formatter\PostHistoryQuery;
+use Flow\Formatter\RevisionViewQuery;
+use Flow\Formatter\TopicHistoryQuery;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Repository\RootPostLoader;
+use Message;
+
+class TopicBlock extends AbstractBlock {
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $root;
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $topicTitle;
+
+ /**
+ * @var RootPostLoader|null
+ */
+ protected $rootLoader;
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $newRevision;
+
+ /**
+ * @var array
+ */
+ protected $requestedPost = array();
+
+ /**
+ * @var array Map of data to be passed on as
+ * commit metadata for event handlers
+ */
+ protected $extraCommitMetadata = array();
+
+ protected $supportedPostActions = array(
+ // Standard editing
+ 'edit-post', 'reply',
+ // Moderation
+ 'moderate-topic',
+ 'moderate-post',
+ // lock or unlock topic
+ 'lock-topic',
+ // Other stuff
+ 'edit-title',
+ 'undo-edit-post',
+ // psuedo-action, we don't do anything but we return
+ // information about the topic in the api response
+ 'edit-topic-summary',
+ );
+
+ protected $supportedGetActions = array(
+ 'reply', 'view', 'history', 'edit-post', 'edit-title', 'compare-post-revisions', 'single-view',
+ 'view-topic', 'view-post', 'undo-edit-post',
+ 'moderate-topic', 'moderate-post', 'lock-topic',
+ );
+
+ // @Todo - fill in the template names
+ protected $templates = array(
+ 'single-view' => 'single_view',
+ 'view' => '',
+ 'reply' => '',
+ 'history' => 'history',
+ 'edit-post' => '',
+ 'undo-edit-post' => 'undo_edit',
+ 'edit-title' => 'edit_title',
+ 'compare-post-revisions' => 'diff_view',
+ 'moderate-topic' => 'moderate_topic',
+ 'moderate-post' => 'moderate_post',
+ 'lock-topic' => 'lock',
+ );
+
+ protected $requiresWikitext = array( 'edit-post', 'edit-title', 'lock-topic' );
+
+ public function __construct( Workflow $workflow, ManagerGroup $storage, $root ) {
+ parent::__construct( $workflow, $storage );
+ if ( $root instanceof PostRevision ) {
+ $this->root = $root;
+ } elseif ( $root instanceof RootPostLoader ) {
+ $this->rootLoader = $root;
+ } else {
+ throw new InvalidInputException(
+ 'Expected PostRevision or RootPostLoader, received: ' . is_object( $root ) ? get_class( $root ) : gettype( $root ), 'invalid-input'
+ );
+ }
+ }
+
+ protected function validate() {
+ $topicTitle = $this->loadTopicTitle();
+ if ( !$topicTitle ) {
+ // permissions issue, self::loadTopicTitle should have added appropriate
+ // error messages already.
+ return;
+ }
+
+ // If the topic is locked, the only allowed action is to unlock it
+ if (
+ $topicTitle->isLocked()
+ && (
+ $this->action !== 'lock-topic'
+ || !in_array( $this->submitted['moderationState'], array( 'unlock', /* BC for unlock: */ 'reopen' ) )
+ )
+ ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-topic-is-locked' ) );
+ }
+
+ switch( $this->action ) {
+ case 'edit-title':
+ $this->validateEditTitle();
+ break;
+
+ case 'reply':
+ $this->validateReply();
+ break;
+
+ case 'moderate-topic':
+ case 'lock-topic':
+ $this->validateModerateTopic();
+ break;
+
+ case 'moderate-post':
+ $this->validateModeratePost();
+ break;
+
+ case 'restore-post':
+ // @todo still necessary?
+ $this->validateModeratePost();
+ break;
+
+ case 'undo-edit-post':
+ case 'edit-post':
+ $this->validateEditPost();
+ break;
+
+ case 'edit-topic-summary':
+ // pseudo-action does not do anything, only includes data in api response
+ break;
+
+ default:
+ throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' );
+ }
+ }
+
+ protected function validateEditTitle() {
+ if ( $this->workflow->isNew() ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-no-existing-workflow' ) );
+ return;
+ }
+ if ( !isset( $this->submitted['content'] ) || !is_string( $this->submitted['content'] ) ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
+ return;
+ }
+ $this->submitted['content'] = trim( $this->submitted['content'] );
+ $len = mb_strlen( $this->submitted['content'] );
+ if ( $len === 0 ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
+ return;
+ }
+ if ( $len > PostRevision::MAX_TOPIC_LENGTH ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) );
+ return;
+ }
+ if ( empty( $this->submitted['prev_revision'] ) ) {
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
+ return;
+ }
+ $topicTitle = $this->loadTopicTitle();
+ if ( !$topicTitle ) {
+ return;
+ }
+ if ( !$this->permissions->isAllowed( $topicTitle, 'edit-title' ) ) {
+ $this->addError( 'permissions', $this->getDisallowedErrorMessage( $topicTitle ) );
+ return;
+ }
+ if ( $topicTitle->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
+ // This is a reasonably effective way to ensure prev revision matches, but for guarantees against race
+ // conditions there also exists a unique index on rev_prev_revision in mysql, meaning if someone else inserts against the
+ // parent we and the submitter think is the latest, our insert will fail.
+ // TODO: Catch whatever exception happens there, make sure the most recent revision is the one in the cache before
+ // handing user back to specific dialog indicating race condition
+ $this->addError(
+ 'prev_revision',
+ $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
+ $this->submitted['prev_revision'],
+ $topicTitle->getRevisionId()->getAlphadecimal(),
+ $this->context->getUser()->getName()
+ ),
+ array( 'revision_id' => $topicTitle->getRevisionId()->getAlphadecimal() ) // save current revision ID
+ );
+ return;
+ }
+
+ $this->newRevision = $topicTitle->newNextRevision(
+ $this->context->getUser(),
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'edit-title',
+ $this->workflow->getArticleTitle()
+ );
+ if ( !$this->checkSpamFilters( $topicTitle, $this->newRevision ) ) {
+ return;
+ }
+ }
+
+ protected function validateReply() {
+ if ( trim( $this->submitted['content'] ) === '' ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
+ return;
+ }
+ if ( !isset( $this->submitted['replyTo'] ) ) {
+ $this->addError( 'replyTo', $this->context->msg( 'flow-error-missing-replyto' ) );
+ return;
+ }
+
+ $post = $this->loadRequestedPost( $this->submitted['replyTo'] );
+ if ( !$post ) {
+ return; // loadRequestedPost adds its own errors
+ }
+ if ( !$this->permissions->isAllowed( $post, 'reply' ) ) {
+ $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
+ return;
+ }
+ $this->newRevision = $post->reply(
+ $this->workflow,
+ $this->context->getUser(),
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext'
+ );
+ if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
+ return;
+ }
+
+ $this->extraCommitMetadata['reply-to'] = $post;
+ }
+
+ protected function validateModerateTopic() {
+ $root = $this->loadRootPost();
+ if ( !$root ) {
+ return;
+ }
+
+ $this->doModerate( $root );
+ }
+
+ protected function validateModeratePost() {
+ if ( empty( $this->submitted['postId'] ) ) {
+ $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
+ return;
+ }
+
+ $post = $this->loadRequestedPost( $this->submitted['postId'] );
+ if ( !$post ) {
+ // loadRequestedPost added its own messages to $this->errors;
+ return;
+ }
+ if ( $post->isTopicTitle() ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-not-a-post' ) );
+ return;
+ }
+ $this->doModerate( $post );
+ }
+
+ protected function doModerate( PostRevision $post ) {
+ if (
+ $this->submitted['moderationState'] === AbstractRevision::MODERATED_LOCKED
+ && $post->isModerated()
+ ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-lock-moderated-post' ) );
+ return;
+ }
+
+ // Moderation state supplied in request parameters
+ $moderationState = isset( $this->submitted['moderationState'] )
+ ? $this->submitted['moderationState']
+ : null;
+
+ // $moderationState should be a string like 'restore', 'suppress', etc. The exact strings allowed
+ // are checked below with $post->isValidModerationState(), but this is checked first otherwise
+ // a blank string would restore a post(due to AbstractRevision::MODERATED_NONE === '').
+ if ( ! $moderationState ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
+ return;
+ }
+
+ /*
+ * BC: 'suppress' used to be called 'censor', 'lock' was 'close' &
+ * 'unlock' was 'reopen'
+ */
+ $bc = array(
+ 'censor' => AbstractRevision::MODERATED_SUPPRESSED,
+ 'close' => AbstractRevision::MODERATED_LOCKED,
+ 'reopen' => 'un' . AbstractRevision::MODERATED_LOCKED
+ );
+ $moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $moderationState );
+
+ // these all just mean set to no moderation, it returns a post to unmoderated status
+ $allowedRestoreAliases = array( 'unlock', 'unhide', 'undelete', 'unsuppress', /* BC for unlock: */ 'reopen' );
+ if ( in_array( $moderationState, $allowedRestoreAliases ) ) {
+ $moderationState = 'restore';
+ }
+ // By allowing the moderationState to be sourced from $this->submitted['moderationState']
+ // we no longer have a unique action name for use with the permissions system. This rebuilds
+ // an action name. e.x. restore-post, restore-topic, suppress-topic, etc.
+ $action = $moderationState . ( $post->isTopicTitle() ? "-topic" : "-post" );
+
+ if ( $moderationState === 'restore' ) {
+ $newState = AbstractRevision::MODERATED_NONE;
+ } else {
+ $newState = $moderationState;
+ }
+
+ if ( ! $post->isValidModerationState( $newState ) ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
+ return;
+ }
+ if ( !$this->permissions->isAllowed( $post, $action ) ) {
+ $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
+ return;
+ }
+
+ if ( trim( $this->submitted['reason'] ) === '' ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-reason' ) );
+ return;
+ }
+
+ $reason = $this->submitted['reason'];
+
+ $this->newRevision = $post->moderate( $this->context->getUser(), $newState, $action, $reason );
+ if ( !$this->newRevision ) {
+ $this->addError( 'moderate', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ }
+
+ protected function validateEditPost() {
+ if ( empty( $this->submitted['postId'] ) ) {
+ $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
+ return;
+ }
+ if ( trim( $this->submitted['content'] ) === '' ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
+ return;
+ }
+ if ( empty( $this->submitted['prev_revision'] ) ) {
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
+ return;
+ }
+ $post = $this->loadRequestedPost( $this->submitted['postId'] );
+ if ( !$post ) {
+ return;
+ }
+ if ( !$this->permissions->isAllowed( $post, 'edit-post' ) ) {
+ $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
+ return;
+ }
+ if ( $post->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
+ // This is a reasonably effective way to ensure prev revision
+ // matches, but for guarantees against race conditions there
+ // also exists a unique index on rev_prev_revision in mysql,
+ // meaning if someone else inserts against the parent we and
+ // the submitter think is the latest, our insert will fail.
+ //
+ // TODO: Catch whatever exception happens there, make sure the
+ // most recent revision is the one in the cache before handing
+ // user back to specific dialog indicating race condition
+ $this->addError(
+ 'prev_revision',
+ $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
+ $this->submitted['prev_revision'],
+ $post->getRevisionId()->getAlphadecimal(),
+ $this->context->getUser()->getName()
+ ),
+ array( 'revision_id' => $post->getRevisionId()->getAlphadecimal() ) // save current revision ID
+ );
+ return;
+ }
+
+ $this->newRevision = $post->newNextRevision(
+ $this->context->getUser(),
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'edit-post',
+ $this->workflow->getArticleTitle()
+ );
+ if ( !$this->checkSpamFilters( $post, $this->newRevision ) ) {
+ return;
+ }
+ }
+
+ public function commit() {
+
+ switch( $this->action ) {
+ case 'edit-topic-summary':
+ // pseudo-action does not do anything, only includes data in api response
+ return array();
+
+ case 'reply':
+ case 'moderate-topic':
+ case 'lock-topic':
+ case 'restore-post':
+ case 'moderate-post':
+ case 'edit-title':
+ case 'undo-edit-post':
+ case 'edit-post':
+ if ( $this->newRevision === null ) {
+ throw new FailCommitException( 'Attempt to save null revision', 'fail-commit' );
+ }
+
+
+ $metadata = $this->extraCommitMetadata + array(
+ 'workflow' => $this->workflow,
+ 'topic-title' => $this->loadTopicTitle(),
+ );
+ if ( !$metadata['topic-title'] instanceof PostRevision ) {
+ // permissions failure, should never have gotten this far
+ throw new PermissionException( 'Not Allowed', 'insufficient-permission' );
+ }
+ if ( $this->newRevision->getPostId()->equals( $metadata['topic-title']->getPostId() ) ) {
+ // When performing actions against the topic-title self::loadTopicTitle
+ // returns the previous revision.
+ $metadata['topic-title'] = $this->newRevision;
+ }
+
+ $this->storage->put( $this->newRevision, $metadata );
+ $this->workflow->updateLastModified( $this->newRevision->getRevisionId() );
+ $this->storage->put( $this->workflow, $metadata );
+ $newRevision = $this->newRevision;
+
+ // If no context was loaded render the post in isolation
+ // @todo make more explicit
+ try {
+ $newRevision->getChildren();
+ } catch ( \MWException $e ) {
+ $newRevision->setChildren( array() );
+ }
+
+ $returnMetadata = array(
+ 'post-revision-id' => $this->newRevision->getRevisionId(),
+ );
+ if ( $this->newRevision->isFirstRevision() ) {
+ $returnMetadata['post-id'] = $this->newRevision->getPostId();
+ }
+
+ return $returnMetadata;
+
+ default:
+ throw new InvalidActionException( "Unknown commit action: {$this->action}", 'invalid-action' );
+ }
+ }
+
+ public function renderApi( array $options ) {
+ $output = array( 'type' => $this->getName() );
+
+ $topic = $this->loadTopicTitle( $this->action === 'history' ? 'history' : 'view' );
+ if ( !$topic ) {
+ return $output + $this->finalizeApiOutput($options);
+ }
+
+ // there's probably some OO way to turn this stack of if/else into
+ // something nicer. Consider better ways before extending this with
+ // more conditionals
+ if ( $this->action === 'history' ) {
+ // single post history or full topic?
+ if ( isset( $options['postId'] ) ) {
+ // singular post history
+ $output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ) );
+ } else {
+ // post history for full topic
+ $output += $this->renderTopicHistoryApi( $options );
+ }
+ } elseif ( $this->action === 'single-view' ) {
+ if ( isset( $options['revId'] ) ) {
+ $revId = $options['revId'];
+ } else {
+ throw new InvalidInputException( 'A revision must be provided', 'invalid-input' );
+ }
+ $output += $this->renderSingleViewApi( $revId );
+ } elseif ( $this->action === 'lock-topic' ) {
+ // Treat topic as a post, only the post + summary are needed
+ $result = $this->renderPostApi( $options, $this->workflow->getId() );
+ $topicId = $result['roots'][0];
+ $revisionId = $result['posts'][$topicId][0];
+ $output += $result['revisions'][$revisionId];
+ } elseif ( $this->action === 'compare-post-revisions' ) {
+ $output += $this->renderDiffViewApi( $options );
+ } elseif ( $this->action === 'undo-edit-post' ) {
+ $output += $this->renderUndoApi( $options );
+ } elseif ( $this->shouldRenderTopicApi( $options ) ) {
+ // view full topic
+ $output += $this->renderTopicApi( $options );
+ } else {
+ // view single post, possibly specific revision
+ // @todo this isn't valid for the topic title
+ $output += $this->renderPostApi( $options );
+ }
+
+ return $output + $this->finalizeApiOutput($options);
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ protected function finalizeApiOutput( $options ) {
+ if ( $this->wasSubmitted() ) {
+ // Failed actions, like reply, end up here
+ return array(
+ 'submitted' => $this->submitted,
+ 'errors' => $this->errors,
+ );
+ } else {
+ return array(
+ 'submitted' => $options,
+ 'errors' => $this->errors,
+ );
+ }
+ }
+
+ protected function shouldRenderTopicApi( array $options ) {
+ switch( $this->action ) {
+ // Any actions require rerendering the whole topic
+ case 'edit-post':
+ case 'moderate-post':
+ case 'restore-post':
+ case 'reply':
+ case 'moderate-topic':
+ return true;
+
+ // View actions
+ case 'view-topic':
+ return true;
+ case 'view-post':
+ return false;
+ case 'view':
+ return !isset( $options['postId'] ) && !isset( $options['revId'] );
+ }
+
+ return true;
+ }
+
+ // @Todo - duplicated logic in other diff view block
+ protected function renderDiffViewApi( array $options ) {
+ if ( !isset( $options['newRevision'] ) ) {
+ throw new InvalidInputException( 'A revision must be provided for comparison', 'revision-comparison' );
+ }
+ $oldRevision = null;
+ if ( isset( $options['oldRevision'] ) ) {
+ $oldRevision = $options['oldRevision'];
+ }
+ list( $new, $old ) = Container::get( 'query.post.view' )->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );
+
+ return array(
+ 'revision' => Container::get( 'formatter.revision.diff.view' )->formatApi( $new, $old, $this->context )
+ );
+ }
+
+ // @Todo - duplicated logic in other single view block
+ protected function renderSingleViewApi( $revId ) {
+ $row = Container::get( 'query.post.view' )->getSingleViewResult( $revId );
+
+ return array(
+ 'revision' => Container::get( 'formatter.revisionview' )->formatApi( $row, $this->context )
+ );
+ }
+
+ protected function renderTopicApi( array $options, $workflowId = '' ) {
+ $serializer = Container::get( 'formatter.topic' );
+ if ( !$workflowId ) {
+ if ( $this->workflow->isNew() ) {
+ return $serializer->buildEmptyResult( $this->workflow );
+ }
+ $workflowId = $this->workflow->getId();
+ }
+
+ if ( $this->submitted !== null ) {
+ $options += $this->submitted;
+ }
+ if ( !empty( $options['revId'] ) &&
+ false !== array_search( $this->action, $this->requiresWikitext )
+ ) {
+ // In the topic level responses we only want to force a single revision
+ // to wikitext, not the entire thing.
+ $uuid = UUID::create( $options['revId'] );
+ if ( $uuid ) {
+ $serializer->setContentFormat( 'wikitext', $uuid );
+ }
+ }
+
+ return $serializer->formatApi(
+ $this->workflow,
+ Container::get( 'query.topiclist' )->getResults( array( $workflowId ) ),
+ $this->context
+ );
+ }
+
+ /**
+ * @todo Any failed action performed against a single revisions ends up here.
+ * To generate forms with validation errors in the non-javascript renders we
+ * need to add something to this output, but not sure what yet
+ */
+ protected function renderPostApi( array $options, $postId = '' ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No posts can exist for non-existent topic' );
+ }
+
+ if ( !$postId ) {
+ if ( isset( $options['postId'] ) ) {
+ $postId = $options['postId'];
+ } elseif( $this->newRevision ) {
+ // API results after a reply will have no $postId (ID is not yet
+ // known when the reply is submitted) so we'll grab it from the
+ // newly added revision
+ $postId = $this->newRevision->getPostId();
+ }
+ }
+
+ $row = Container::get( 'query.singlepost' )->getResult( UUID::create( $postId ) );
+ $serializer = $this->getRevisionFormatter();
+ if ( isset( $options['contentFormat'] ) ) {
+ $serializer->setContentFormat( $options['contentFormat'] );
+ }
+ $serialized = $serializer->formatApi( $row, $this->context );
+ if ( !$serialized ) {
+ return null;
+ }
+
+ return array(
+ 'roots' => array( $serialized['postId'] ),
+ 'posts' => array(
+ $serialized['postId'] => array( $serialized['revisionId'] ),
+ ),
+ 'revisions' => array(
+ $serialized['revisionId'] => $serialized,
+ )
+ );
+ }
+
+ protected function renderUndoApi( array $options ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No posts can exist for non-existent topic' );
+ }
+
+ if ( !isset( $options['startId'], $options['endId'] ) ) {
+ throw new InvalidInputException( 'Both startId and endId must be provided' );
+ }
+
+ /** @var RevisionViewQuery */
+ $query = Container::get( 'query.post.view' );
+ $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
+ if ( !$rows ) {
+ throw new InvalidInputException( 'Could not load revision to undo' );
+ }
+
+ $serializer = Container::get( 'formatter.undoedit' );
+ return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
+ }
+
+ protected function getRevisionFormatter() {
+ $serializer = Container::get( 'formatter.revision' );
+ if ( false !== array_search( $this->action, $this->requiresWikitext ) ) {
+ $serializer->setContentFormat( 'wikitext' );
+ }
+
+ return $serializer;
+ }
+
+ protected function renderTopicHistoryApi( array $options ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No topic history can exist for non-existent topic' );
+ }
+ return $this->processHistoryResult( Container::get( 'query.topic.history' ), $this->workflow->getId(), $options );
+ }
+
+ protected function renderPostHistoryApi( array $options, UUID $postId ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No post history can exist for non-existent topic' );
+ }
+ return $this->processHistoryResult( Container::get( 'query.post.history' ), $postId, $options );
+ }
+
+ /**
+ * Process the history result for either topic or post
+ *
+ * @param TopicHistoryQuery|PostHistoryQuery $query
+ * @param UUID $uuid
+ * @param array $options
+ * @return array
+ */
+ protected function processHistoryResult( /* TopicHistoryQuery|PostHistoryQuery */ $query, UUID $uuid, $options ) {
+ global $wgRequest;
+
+ $serializer = $this->getRevisionFormatter();
+ if ( isset( $options['contentFormat'] ) ) {
+ $serializer->setContentFormat( $options['contentFormat'] );
+ }
+ $serializer->setIncludeHistoryProperties( true );
+
+ list( $limit, /* $offset */ ) = $wgRequest->getLimitOffset();
+ // don't use offset from getLimitOffset - that assumes an int, which our
+ // UUIDs are not
+ $offset = $wgRequest->getText( 'offset' );
+ $offset = $offset ? UUID::create( $offset ) : null;
+
+ $pager = new HistoryPager( $query, $uuid );
+ $pager->setLimit( $limit );
+ $pager->setOffset( $offset );
+ $pager->doQuery();
+ $history = $pager->getResult();
+
+ $revisions = array();
+ foreach ( $history as $row ) {
+ $serialized = $serializer->formatApi( $row, $this->context );
+ // if the user is not allowed to see this row it will return empty
+ if ( $serialized ) {
+ $revisions[$serialized['revisionId']] = $serialized;
+ }
+ }
+
+ return array(
+ 'revisions' => $revisions,
+ 'navbar' => $pager->getNavigationBar(),
+ );
+ }
+
+ /**
+ * @return PostRevision|null
+ */
+ public function loadRootPost() {
+ if ( $this->root !== null ) {
+ return $this->root;
+ }
+
+ $rootPost = $this->rootLoader->get( $this->workflow->getId() );
+
+ if ( $this->permissions->isAllowed( $rootPost, 'view' ) ) {
+ // topicTitle is same as root, difference is root has children populated to full depth
+ return $this->topicTitle = $this->root = $rootPost;
+ }
+
+ $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );
+
+ return null;
+ }
+
+ /**
+ * @param string $action Permissions action to require to return revision
+ * @return AbstractRevision|null
+ * @throws InvalidDataException
+ */
+ public function loadTopicTitle( $action = 'view' ) {
+ if ( $this->workflow->isNew() ) {
+ throw new InvalidDataException( 'New workflows do not have any related content', 'missing-topic-title' );
+ }
+
+ if ( $this->topicTitle === null ) {
+ $found = $this->storage->find(
+ 'PostRevision',
+ array( 'rev_type_id' => $this->workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( !$found ) {
+ throw new InvalidDataException( 'Every workflow must have an associated topic title', 'missing-topic-title' );
+ }
+ $this->topicTitle = reset( $found );
+
+ // this method loads only title, nothing else; otherwise, you're
+ // looking for loadRootPost
+ $this->topicTitle->setChildren( array() );
+ $this->topicTitle->setDepth( 0 );
+ $this->topicTitle->setRootPost( $this->topicTitle );
+ }
+
+ if ( !$this->permissions->isAllowed( $this->topicTitle, $action ) ) {
+ $this->addError( 'permissions', $this->getDisallowedErrorMessage( $this->topicTitle ) );
+ return null;
+ }
+
+ return $this->topicTitle;
+ }
+
+ /**
+ * @todo Move this to AbstractBlock and use for summary/header/etc.
+ * @param AbstractRevision $revision
+ * @return Message
+ */
+ protected function getDisallowedErrorMessage( AbstractRevision $revision ) {
+ if ( in_array( $this->action, array( 'moderate-topic', 'moderate-post' ) ) ) {
+ /*
+ * When failing to moderate an already moderated action (like
+ * undo), show the more general "you have insufficient
+ * permissions for this action" message, rather than the
+ * specialized "this topic is <hidden|deleted|suppressed>" msg.
+ */
+ return $this->context->msg( 'flow-error-not-allowed' );
+ }
+
+ $state = $revision->getModerationState();
+
+ // display simple message
+ // i18n messages:
+ // flow-error-not-allowed-hide,
+ // flow-error-not-allowed-reply-to-hide-topic
+ // flow-error-not-allowed-delete
+ // flow-error-not-allowed-reply-to-delete-topic
+ // flow-error-not-allowed-suppress
+ // flow-error-not-allowed-reply-to-suppress-topic
+ if ( $revision instanceof PostRevision ) {
+ $type = $revision->isTopicTitle() ? 'topic' : 'post';
+ } else {
+ $type = $revision->getRevisionType();
+ }
+
+ // Show a snippet of the relevant log entry if available.
+ if ( \LogPage::isLogType( $state ) ) {
+ // check if user has sufficient permissions to see log
+ $logPage = new \LogPage( $state );
+ if ( $this->context->getUser()->isAllowed( $logPage->getRestriction() ) ) {
+ // LogEventsList::showLogExtract will write to OutputPage, but we
+ // actually just want that text, to write it ourselves wherever we want,
+ // so let's create an OutputPage object to then get the content from.
+ $rc = new \RequestContext();
+ $output = $rc->getOutput();
+
+ // get log extract
+ $entries = \LogEventsList::showLogExtract(
+ $output,
+ array( $state ),
+ $this->workflow->getArticleTitle()->getPrefixedText(),
+ '',
+ array(
+ 'lim' => 10,
+ 'showIfEmpty' => false,
+ // i18n messages:
+ // flow-error-not-allowed-delete-extract
+ // flow-error-not-allowed-reply-to-delete-topic-extract
+ // flow-error-not-allowed-suppress-extract
+ // flow-error-not-allowed-reply-to-suppress-topic-extract
+ 'msgKey' => array(
+ "flow-error-not-allowed-{$this->action}-to-$state-$type",
+ "flow-error-not-allowed-$state-extract",
+ )
+ )
+ );
+
+ // check if there were any log extracts
+ if ( $entries ) {
+ $message = new \RawMessage( '$1' );
+ return $message->rawParams( $output->getHTML() );
+ }
+ }
+ }
+
+ return $this->context->msg( array(
+ // set of keys to try in order
+ "flow-error-not-allowed-{$this->action}-to-$state-$type",
+ "flow-error-not-allowed-$state",
+ "flow-error-not-allowed"
+ ) );
+ }
+
+ /**
+ * Loads the post referenced by $postId. Returns null when:
+ * $postId does not belong to the workflow
+ * The user does not have view access to the topic title
+ * The user does not have view access to the referenced post
+ * All these conditions add a relevant error message to $this->errors when returning null
+ *
+ * @param UUID|string $postId The post being requested
+ * @return PostRevision|null
+ */
+ protected function loadRequestedPost( $postId ) {
+ if ( !$postId instanceof UUID ) {
+ $postId = UUID::create( $postId );
+ }
+
+ if ( $this->rootLoader === null ) {
+ // Since there is no root loader the full tree is already loaded
+ $topicTitle = $root = $this->loadRootPost();
+ if ( !$topicTitle ) {
+ return null;
+ }
+ $post = $root->getDescendant( $postId );
+ if ( $post === null ) {
+ // The requested postId is not a member of the current workflow
+ $this->addError( 'post', $this->context->msg( 'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
+ return null;
+ }
+ } else {
+ // Load the post and its root
+ $found = $this->rootLoader->getWithRoot( $postId );
+ if ( !$found['post'] || !$found['root'] || !$found['root']->getPostId()->equals( $this->workflow->getId() ) ) {
+ $this->addError( 'post', $this->context->msg( 'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
+ return null;
+ }
+ $this->topicTitle = $topicTitle = $found['root'];
+ $post = $found['post'];
+
+ // using the path to the root post, we can know the post's depth
+ $rootPath = $this->rootLoader->getTreeRepo()->findRootPath( $postId );
+ $post->setDepth( count( $rootPath ) - 1 );
+ $post->setRootPost( $found['root'] );
+ }
+
+ if ( $this->permissions->isAllowed( $topicTitle, 'view' )
+ && $this->permissions->isAllowed( $post, 'view' ) ) {
+ return $post;
+ }
+
+ $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );
+ return null;
+ }
+
+ // The prefix used for form data$pos
+ public function getName() {
+ return 'topic';
+ }
+
+ /**
+ * @param \OutputPage $out
+ *
+ * @todo Provide more informative page title for actions other than view,
+ * e.g. "Hide post in <TITLE>", "Unlock <TITLE>", etc.
+ */
+ public function setPageTitle( \OutputPage $out ) {
+ $topic = $this->loadTopicTitle( $this->action === 'history' ? 'history' : 'view' );
+ if ( !$topic ) {
+ return;
+ }
+
+ $title = $this->workflow->getOwnerTitle();
+ $out->setPageTitle( $out->msg( 'flow-topic-first-heading', $title->getPrefixedText() ) );
+ if ( $this->permissions->isAllowed( $topic, 'view' ) ) {
+ if ( $this->action === 'undo-edit-post' ) {
+ $key = 'flow-undo-edit-post';
+ } else {
+ $key = 'flow-topic-html-title';
+ }
+ $out->setHtmlTitle( $out->msg( $key, array(
+ // This must be a rawParam to not expand {{foo}} in the title, it must
+ // not be htmlspecialchar'd because OutputPage::setHtmlTitle handles that.
+ Message::rawParam( $topic->getContent( 'wikitext' ) ),
+ $title->getPrefixedText()
+ ) ) );
+ } else {
+ $out->setHtmlTitle( $title->getPrefixedText() );
+ }
+ $out->setSubtitle( '&lt; ' . \Linker::link( $title ) );
+ }
+}
diff --git a/Flow/includes/Block/TopicList.php b/Flow/includes/Block/TopicList.php
new file mode 100644
index 00000000..085f2e76
--- /dev/null
+++ b/Flow/includes/Block/TopicList.php
@@ -0,0 +1,435 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Data\Pager\Pager;
+use Flow\Data\Pager\PagerPage;
+use Flow\Exception\FlowException;
+use Flow\Formatter\TocTopicListFormatter;
+use Flow\Formatter\TopicListFormatter;
+use Flow\Formatter\TopicListQuery;
+use Flow\Model\PostRevision;
+use Flow\Model\TopicListEntry;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Exception\FailCommitException;
+
+class TopicListBlock extends AbstractBlock {
+
+ /**
+ * @var array
+ */
+ protected $supportedPostActions = array( 'new-topic' );
+
+ /**
+ * @var array
+ */
+ protected $supportedGetActions = array( 'view', 'view-topiclist' );
+
+ // @Todo - fill in the template names
+ protected $templates = array(
+ 'view' => '',
+ 'new-topic' => 'newtopic',
+ );
+
+ /**
+ * @var Workflow|null
+ */
+ protected $topicWorkflow;
+
+ /**
+ * @var TopicListEntry|null
+ */
+ protected $topicListEntry;
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $topicTitle;
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $firstPost;
+
+ /**
+ * @var array
+ *
+ * Associative array mapping topic ID (in alphadecimal form) to PostRevision for the topic root.
+ */
+ protected $topicRootRevisionCache = array();
+
+ protected function validate() {
+ // for now, new topic is considered a new post; perhaps some day topic creation should get it's own permissions?
+ if ( !$this->permissions->isAllowed( null, 'new-post' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ if ( !isset( $this->submitted['topic'] ) || !is_string( $this->submitted['topic'] ) ) {
+ $this->addError( 'topic', $this->context->msg( 'flow-error-missing-title' ) );
+ return;
+ }
+ $this->submitted['topic'] = trim( $this->submitted['topic'] );
+ if ( strlen( $this->submitted['topic'] ) === 0 ) {
+ $this->addError( 'topic', $this->context->msg( 'flow-error-missing-title' ) );
+ return;
+ }
+ if ( mb_strlen( $this->submitted['topic'] ) > PostRevision::MAX_TOPIC_LENGTH ) {
+ $this->addError( 'topic', $this->context->msg( 'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) );
+ return;
+ }
+
+ if ( trim( $this->submitted['content'] ) === '' ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
+ return;
+ }
+
+ // creates Workflow, Revision & TopicListEntry objects to be inserted into storage
+ list( $this->topicWorkflow, $this->topicListEntry, $this->topicTitle, $this->firstPost ) = $this->create();
+
+ if ( !$this->checkSpamFilters( null, $this->topicTitle ) ) {
+ return;
+ }
+ if ( $this->firstPost && !$this->checkSpamFilters( null, $this->firstPost ) ) {
+ return;
+ }
+ }
+
+ /**
+ * Creates the objects about to be inserted into storage:
+ * * $this->topicWorkflow
+ * * $this->topicListEntry
+ * * $this->topicTitle
+ * * $this->firstPost
+ *
+ * @throws \MWException
+ * @throws \Flow\Exception\FailCommitException
+ * @return array Array of [$topicWorkflow, $topicListEntry, $topicTitle, $firstPost]
+ */
+ protected function create() {
+ $title = $this->workflow->getArticleTitle();
+ $user = $this->context->getUser();
+ $topicWorkflow = Workflow::create( 'topic', $title );
+ $topicListEntry = TopicListEntry::create( $this->workflow, $topicWorkflow );
+ $topicTitle = PostRevision::create(
+ $topicWorkflow,
+ $user,
+ $this->submitted['topic'],
+ // topic title is never parsed
+ 'wikitext'
+ );
+
+ $firstPost = null;
+ if ( !empty( $this->submitted['content'] ) ) {
+ $firstPost = $topicTitle->reply(
+ $topicWorkflow,
+ $user,
+ $this->submitted['content'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext'
+ );
+ $topicTitle->setChildren( array( $firstPost ) );
+ }
+
+ return array( $topicWorkflow, $topicListEntry, $topicTitle, $firstPost );
+ }
+
+ public function commit() {
+ if ( $this->action !== 'new-topic' ) {
+ throw new FailCommitException( 'Unknown commit action', 'fail-commit' );
+ }
+
+ $storage = $this->storage;
+ $metadata = array(
+ 'workflow' => $this->topicWorkflow,
+ 'board-workflow' => $this->workflow,
+ 'topic-title' => $this->topicTitle,
+ 'first-post' => $this->firstPost,
+ );
+
+ $storage->put( $this->topicWorkflow, $metadata );
+ $storage->put( $this->topicListEntry, $metadata );
+ $storage->put( $this->topicTitle, $metadata );
+ if ( $this->firstPost !== null ) {
+ $storage->put( $this->firstPost, $metadata + array(
+ 'reply-to' => $this->topicTitle
+ ) );
+ }
+
+ $output = array(
+ 'topic-page' => $this->topicWorkflow->getArticleTitle()->getPrefixedText(),
+ 'topic-id' => $this->topicTitle->getPostId(),
+ 'topic-revision-id' => $this->topicTitle->getRevisionId(),
+ 'post-id' => $this->firstPost ? $this->firstPost->getPostId() : null,
+ 'post-revision-id' => $this->firstPost ? $this->firstPost->getRevisionId() : null,
+ );
+
+ return $output;
+ }
+
+ public function renderApi( array $options ) {
+ $options = $this->preloadTexts( $options );
+
+ $response = array(
+ 'submitted' => $this->wasSubmitted() ? $this->submitted : $options,
+ 'errors' => $this->errors,
+ );
+
+ // Repeating the default until we use the API for everything (bug 72659)
+ // Also, if this is removed other APIs (i.e. ApiFlowNewTopic) may need
+ // to be adjusted if they trigger a rendering of this block.
+ $isTocOnly = isset( $options['toconly'] ) ? $options['toconly'] : false;
+
+ if ( $isTocOnly ) {
+ /** @var TocTopicListFormatter $serializer */
+ $serializer = Container::get( 'formatter.topiclist.toc' );
+ } else {
+ /** @var TopicListFormatter $serializer */
+ $serializer = Container::get( 'formatter.topiclist' );
+ }
+
+ // @todo remove the 'api' => true, its always api
+ $findOptions = $this->getFindOptions( $options + array( 'api' => true ) );
+
+ // include the current sortby option. Note that when 'user' is either
+ // submitted or defaulted to this is the resulting sort. ex: newest
+ $response['sortby'] = $findOptions['sortby'];
+
+ if ( $this->workflow->isNew() ) {
+ return $response + $serializer->buildEmptyResult( $this->workflow );
+ }
+
+ $page = $this->getPage( $findOptions );
+ $workflowIds = array();
+ /** @var TopicListEntry $topicListEntry */
+ foreach ( $page->getResults() as $topicListEntry ) {
+ $workflowIds[] = $topicListEntry->getId();
+ }
+
+ $workflows = $this->storage->getMulti( 'Workflow', $workflowIds );
+
+ if ( $isTocOnly ) {
+ // We don't need any further data, so we skip the TopicListQuery.
+
+ $topicRootRevisionsByWorkflowId = array();
+ $workflowsByWorkflowId = array();
+
+ foreach ( $workflows as $workflow ) {
+ $alphaWorkflowId = $workflow->getId()->getAlphadecimal();
+ $topicRootRevisionsByWorkflowId[$alphaWorkflowId] = $this->topicRootRevisionCache[$alphaWorkflowId];
+ $workflowsByWorkflowId[$alphaWorkflowId] = $workflow;
+ }
+
+ return $response + $serializer->formatApi( $this->workflow, $topicRootRevisionsByWorkflowId, $workflowsByWorkflowId, $page );
+ }
+
+ /** @var TopicListQuery $query */
+ $query = Container::get( 'query.topiclist' );
+ $found = $query->getResults( $page->getResults() );
+ wfDebugLog( 'FlowDebug', 'Rendering topiclist for ids: ' . implode( ', ', array_map( function( UUID $id ) {
+ return $id->getAlphadecimal();
+ }, $workflowIds ) ) );
+
+ return $response + $serializer->formatApi( $this->workflow, $workflows, $found, $page, $this->context );
+ }
+
+ /**
+ * Transforms preload params into proper options we can assign to template.
+ *
+ * @param array $options
+ * @return array
+ * @throws \MWException
+ */
+ protected function preloadTexts( $options ) {
+ if ( isset( $options['preload'] ) ) {
+ $title = \Title::newFromText( $options['preload'] );
+ $page = \WikiPage::factory( $title );
+ if ( $page->isRedirect() ) {
+ $title = $page->getRedirectTarget();
+ $page = \WikiPage::factory( $title );
+ }
+
+ if ( $page->exists() ) {
+ $content = $page->getContent( \Revision::RAW );
+ $options['content'] = $content->serialize();
+ }
+ }
+
+ if ( isset( $options['preloadtitle'] ) ) {
+ $options['topic'] = $options['preloadtitle'];
+ }
+
+ return $options;
+ }
+
+ public function getName() {
+ return 'topiclist';
+ }
+
+ protected function getLimit( array $options ) {
+ global $wgFlowDefaultLimit, $wgFlowMaxLimit;
+ $limit = $wgFlowDefaultLimit;
+ if ( isset( $options['limit'] ) ) {
+ $requestedLimit = intval( $options['limit'] );
+ $limit = min( $requestedLimit, $wgFlowMaxLimit );
+ $limit = max( 0, $limit );
+ }
+
+ return $limit;
+ }
+
+ protected function getFindOptions( array $requestOptions ) {
+ $findOptions = array();
+
+ // Compute offset/limit
+ $limit = $this->getLimit( $requestOptions );
+
+ // @todo Once we migrate View.php to use the API directly
+ // all defaults will be handled by API and not here.
+ $requestOptions += array(
+ 'include-offset' => false,
+ 'offset-id' => false,
+ 'offset-dir' => 'fwd',
+ 'offset' => false,
+ 'api' => true,
+ 'sortby' => 'user',
+ 'savesortby' => false,
+ );
+
+ $user = $this->context->getUser();
+ if ( strlen( $requestOptions['sortby'] ) === 0 ) {
+ $requestOptions['sortby'] = 'user';
+ }
+ // the sortby option in $findOptions is not directly used for querying,
+ // but is needed by the pager to generate appropriate pagination links.
+ if ( $requestOptions['sortby'] === 'user' ) {
+ $requestOptions['sortby'] = $user->getOption( 'flow-topiclist-sortby' );
+ }
+ switch( $requestOptions['sortby'] ) {
+ case 'updated':
+ $findOptions = array(
+ 'sortby' => 'updated',
+ 'sort' => 'workflow_last_update_timestamp',
+ 'order' => 'desc',
+ ) + $findOptions;
+
+ if ( $requestOptions['offset-id'] ) {
+ throw new FlowException( 'The `updated` sort order does not allow the `offset-id` parameter. Please use `offset`.' );
+ }
+ break;
+
+ case 'newest':
+ default:
+ $findOptions = array(
+ 'sortby' => 'newest',
+ 'sort' => 'topic_id',
+ 'order' => 'desc',
+ ) + $findOptions;
+
+ if ( $requestOptions['offset'] ) {
+ throw new FlowException( 'The `newest` sort order does not allow the `offset` parameter. Please use `offset-id`.' );
+ }
+ }
+
+ if ( $requestOptions['offset-id'] ) {
+ $findOptions['pager-offset'] = UUID::create( $requestOptions['offset-id'] );
+ } elseif ( $requestOptions['offset'] ) {
+ $findOptions['pager-offset'] = intval( $requestOptions['offset'] );
+ }
+
+ if ( $requestOptions['offset-dir'] ) {
+ $findOptions['pager-dir'] = $requestOptions['offset-dir'];
+ }
+
+ if ( $requestOptions['include-offset'] ) {
+ $findOptions['pager-include-offset'] = $requestOptions['include-offset'];
+ }
+
+ if ( $requestOptions['api'] ) {
+ $findOptions['offset-elastic'] = false;
+ }
+
+ $findOptions['pager-limit'] = $limit;
+
+ if (
+ $requestOptions['savesortby']
+ && !$user->isAnon()
+ && $user->getOption( 'flow-topiclist-sortby' ) != $findOptions['sortby']
+ ) {
+ $user->setOption( 'flow-topiclist-sortby', $findOptions['sortby'] );
+ $user->saveSettings();
+ }
+
+ return $findOptions;
+ }
+
+ /**
+ * Gets a set of workflow IDs
+ * This filters result to only include unmoderated and locked topics.
+ *
+ * Also populates topicRootRevisionCache with a mapping from topic ID to the
+ * PostRevision for the topic root.
+ *
+ * @param array $findOptions
+ * @return PagerPage
+ */
+ protected function getPage( array $findOptions ) {
+ $pager = new Pager(
+ $this->storage->getStorage( 'TopicListEntry' ),
+ array( 'topic_list_id' => $this->workflow->getId() ),
+ $findOptions
+ );
+
+ $postStorage = $this->storage->getStorage( 'PostRevision' );
+
+ // Work around lack of $this in closures until we can use PHP 5.4+ features.
+ $topicRootRevisionCache =& $this->topicRootRevisionCache;
+
+ return $pager->getPage( function( array $found ) use ( $postStorage, &$topicRootRevisionCache ) {
+ $queries = array();
+ /** @var TopicListEntry[] $found */
+ foreach ( $found as $entry ) {
+ $queries[] = array( 'rev_type_id' => $entry->getId() );
+ }
+ $posts = $postStorage->findMulti( $queries, array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => 1,
+ ) );
+ $allowed = array();
+ foreach ( $posts as $queryResult ) {
+ $post = reset( $queryResult );
+ if ( !$post->isModerated() || $post->isLocked() ) {
+ $allowed[$post->getPostId()->getAlphadecimal()] = $post;
+ }
+ }
+ foreach ( $found as $idx => $entry ) {
+ if ( isset( $allowed[$entry->getId()->getAlphadecimal()] ) ) {
+ $topicRootRevisionCache[$entry->getId()->getAlphadecimal()] = $allowed[$entry->getId()->getAlphadecimal()];
+ } else {
+ unset( $found[$idx] );
+ }
+ }
+
+ return $found;
+ } );
+ }
+
+ /**
+ * @param \OutputPage $out
+ */
+ public function setPageTitle( \OutputPage $out ) {
+ if ( $this->action !== 'new-topic' ) {
+ // Only new-topic should override page title, rest should default
+ parent::setPageTitle( $out );
+ return;
+ }
+
+ $title = $this->workflow->getOwnerTitle();
+ $message = $out->msg( 'flow-newtopic-first-heading', $title->getPrefixedText() );
+ $out->setPageTitle( $message );
+ $out->setHtmlTitle( $message );
+ $out->setSubtitle( '&lt; ' . \Linker::link( $title ) );
+ }
+}
diff --git a/Flow/includes/Block/TopicSummary.php b/Flow/includes/Block/TopicSummary.php
new file mode 100644
index 00000000..1f47a0a6
--- /dev/null
+++ b/Flow/includes/Block/TopicSummary.php
@@ -0,0 +1,378 @@
+<?php
+
+namespace Flow\Block;
+
+use Flow\Container;
+use Flow\Exception\FailCommitException;
+use Flow\Exception\InvalidActionException;
+use Flow\Exception\InvalidDataException;
+use Flow\Exception\InvalidInputException;
+use Flow\Formatter\FormatterRow;
+use Flow\Formatter\PostSummaryViewQuery;
+use Flow\Formatter\PostSummaryQuery;
+use Flow\Formatter\RevisionViewFormatter;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\UUID;
+use IContextSource;
+use Message;
+
+class TopicSummaryBlock extends AbstractBlock {
+ /**
+ * @var PostSummary|null
+ */
+ protected $topicSummary;
+
+ /**
+ * @var FormatterRow
+ */
+ protected $formatterRow;
+
+ /**
+ * @var PostSummary|null
+ */
+ protected $nextRevision;
+
+ /**
+ * @var PostRevision|null
+ */
+ protected $topicTitle;
+
+ /**
+ * @var string[]
+ */
+ protected $supportedPostActions = array( 'edit-topic-summary', 'undo-edit-topic-summary' );
+
+ /**
+ * @var string[]
+ */
+ protected $supportedGetActions = array( 'view-topic-summary', 'compare-postsummary-revisions', 'edit-topic-summary', 'undo-edit-topic-summary' );
+
+ /**
+ * @var string[]
+ */
+ protected $requiresWikitext = array( 'edit-topic-summary', 'undo-edit-topic-summary' );
+
+ protected $templates = array(
+ 'view-topic-summary' => 'single_view',
+ 'compare-postsummary-revisions' => 'diff_view',
+ 'edit-topic-summary' => 'edit',
+ 'undo-edit-topic-summary' => 'undo_edit',
+ );
+
+ /**
+ * @param IContextSource $context
+ * @param string $action
+ */
+ public function init( IContextSource $context, $action ) {
+ parent::init( $context, $action );
+
+ if ( !$this->workflow->isNew() ) {
+ /** @var PostSummaryQuery $query */
+ $query = Container::get( 'query.postsummary' );
+ $this->formatterRow = $query->getResult( $this->workflow->getId() );
+ if ( $this->formatterRow ) {
+ $this->topicSummary = $this->formatterRow->revision;
+ }
+ }
+ }
+
+ /**
+ * Validate data before commiting change
+ */
+ public function validate() {
+ switch( $this->action ) {
+ case 'undo-edit-topic-summary':
+ case 'edit-topic-summary':
+ $this->validateTopicSummary();
+ break;
+
+ default:
+ throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' );
+ }
+ }
+
+ /**
+ * Validate topic summary
+ *
+ * @throws InvalidDataException
+ */
+ protected function validateTopicSummary() {
+ if ( !isset( $this->submitted['summary'] ) || !is_string( $this->submitted['summary'] ) ) {
+ $this->addError( 'content', $this->context->msg( 'flow-error-missing-summary' ) );
+ return;
+ }
+
+ if ( $this->workflow->isNew() ) {
+ throw new InvalidDataException( 'Topic summary can only be added to an existing topic', 'missing-topic-title' );
+ }
+
+ // Create topic summary
+ if ( !$this->topicSummary ) {
+ if ( !$this->permissions->isAllowed( null, 'create-topic-summary' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ // new summary should not have a previous revision
+ if ( !empty( $this->submitted['prev_revision'] ) ) {
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-prev-revision-does-not-exist' ) );
+ return;
+ }
+
+ $this->nextRevision = PostSummary::create(
+ $this->workflow->getArticleTitle(),
+ $this->findTopicTitle(),
+ $this->context->getUser(),
+ $this->submitted['summary'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'create-topic-summary'
+ );
+ // Edit topic summary
+ } else {
+ if ( !$this->permissions->isAllowed( $this->topicSummary, 'edit-topic-summary' ) ) {
+ $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
+ return;
+ }
+ // Check the previous revision to catch possible edit conflict
+ if ( empty( $this->submitted['prev_revision'] ) ) {
+ $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
+ return;
+ } elseif ( $this->topicSummary->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
+ $this->addError(
+ 'prev_revision',
+ $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
+ $this->submitted['prev_revision'],
+ $this->topicSummary->getRevisionId()->getAlphadecimal(),
+ $this->context->getUser()->getName()
+ ),
+ array( 'revision_id' => $this->topicSummary->getRevisionId()->getAlphadecimal() )
+ );
+ return;
+ }
+
+ $this->nextRevision = $this->topicSummary->newNextRevision(
+ $this->context->getUser(),
+ $this->submitted['summary'],
+ // default to wikitext when not specified, for old API requests
+ isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
+ 'edit-topic-summary',
+ $this->workflow->getArticleTitle()
+ );
+ }
+
+ if ( !$this->checkSpamFilters( $this->topicSummary, $this->nextRevision ) ) {
+ return;
+ }
+ }
+
+ /**
+ * Find the topic title for the summary
+ *
+ * @throws InvalidDataException
+ * @return PostRevision
+ */
+ public function findTopicTitle() {
+ if ( $this->topicTitle ) {
+ return $this->topicTitle;
+ }
+ $found = $this->storage->find(
+ 'PostRevision',
+ array( 'rev_type_id' => $this->workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( !$found ) {
+ throw new InvalidDataException( 'Every workflow must have an associated topic title', 'missing-topic-title' );
+ }
+ return $this->topicTitle = reset( $found );
+ }
+
+ /**
+ * Save topic summary
+ *
+ * @throws FailCommitException
+ */
+ protected function saveTopicSummary() {
+ if ( !$this->nextRevision ) {
+ throw new FailCommitException( 'Attempt to save summary on null revision', 'fail-commit' );
+ }
+
+ $this->storage->put( $this->nextRevision, array(
+ 'workflow' => $this->workflow,
+ ) );
+ // Reload the $this->formatterRow for renderApi() after save
+ $this->formatterRow = new FormatterRow();
+ $this->formatterRow->revision = $this->nextRevision;
+ $this->formatterRow->previousRevision = $this->topicSummary;
+ $this->formatterRow->currentRevision = $this->nextRevision;
+ $this->formatterRow->workflow = $this->workflow;
+ $this->topicSummary = $this->nextRevision;
+
+ return array(
+ 'summary-revision-id' => $this->nextRevision->getRevisionId(),
+ );
+ }
+
+ /**
+ * Save change for any valid committed action
+ *
+ * @throws InvalidActionException
+ */
+ public function commit() {
+ switch( $this->action ) {
+ case 'undo-edit-topic-summary':
+ case 'edit-topic-summary':
+ return $this->saveTopicSummary();
+ break;
+
+ default:
+ throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' );
+ }
+ }
+
+ /**
+ * Render the data for API request
+ *
+ * @param array $options
+ * @return array
+ * @throws InvalidInputException
+ */
+ public function renderApi( array $options ) {
+ $output = array( 'type' => $this->getName() );
+
+ if ( $this->wasSubmitted() ) {
+ $output += array(
+ 'submitted' => $this->submitted,
+ 'errors' => $this->errors,
+ );
+ } else {
+ $output += array(
+ 'submitted' => array(),
+ 'errors' => array(),
+ );
+ }
+
+ switch ( $this->action ) {
+ case 'view-topic-summary':
+ // @Todo - duplicated logic in other single view block
+ if ( isset( $options['revId'] ) && $options['revId'] ) {
+ /** @var PostSummaryViewQuery $query */
+ $query = Container::get( 'query.postsummary.view' );
+ $row = $query->getSingleViewResult( $options['revId'] );
+ /** @var RevisionViewFormatter $formatter */
+ $formatter = Container::get( 'formatter.revisionview' );
+ $output['revision'] = $formatter->formatApi( $row, $this->context );
+ } else {
+ if ( isset( $options['contentFormat'] ) && $options['contentFormat'] === 'wikitext' ) {
+ $this->requiresWikitext[] = 'view-topic-summary';
+ }
+ $output += $this->renderNewestTopicSummary();
+ }
+ break;
+ case 'edit-topic-summary':
+ $output += $this->renderNewestTopicSummary();
+ break;
+ case 'undo-edit-topic-summary':
+ $output = $this->renderUndoApi( $options ) + $output;
+ break;
+ case 'compare-postsummary-revisions':
+ // @Todo - duplicated logic in other diff view block
+ if ( !isset( $options['newRevision'] ) ) {
+ throw new InvalidInputException( 'A revision must be provided for comparison', 'revision-comparison' );
+ }
+ $oldRevision = null;
+ if ( isset( $options['oldRevision'] ) ) {
+ $oldRevision = $options['newRevision'];
+ }
+ list( $new, $old ) = Container::get( 'query.postsummary.view' )->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );
+ $output['revision'] = Container::get( 'formatter.revision.diff.view' )->formatApi( $new, $old, $this->context );
+ break;
+ }
+
+ return $output;
+ }
+
+ protected function renderNewestTopicSummary() {
+ $output = array();
+ $formatter = Container::get( 'formatter.revision' );
+
+ if ( in_array( $this->action, $this->requiresWikitext ) ) {
+ $formatter->setContentFormat( 'wikitext' );
+ }
+ if ( $this->formatterRow ) {
+ $output['revision'] = $formatter->formatApi(
+ $this->formatterRow,
+ $this->context
+ );
+ } else {
+ $urlGenerator = Container::get( 'url_generator' );
+ $title = $this->workflow->getArticleTitle();
+ $workflowId = $this->workflow->getId();
+ $output['revision'] = array(
+ 'actions' => array(
+ 'summarize' => $urlGenerator->editTopicSummaryAction(
+ $title,
+ $workflowId
+ )
+ ),
+ 'links' => array(
+ 'topic' => $urlGenerator->topicLink(
+ $title,
+ $workflowId
+ )
+ )
+ );
+ }
+ return $output;
+ }
+
+ protected function renderUndoApi( array $options ) {
+ if ( $this->workflow->isNew() ) {
+ throw new FlowException( 'No header exists to undo' );
+ }
+
+ if ( !isset( $options['startId'], $options['endId'] ) ) {
+ throw new InvalidInputException( 'Both startId and endId must be provided' );
+ }
+
+ /** @var RevisionViewQuery */
+ $query = Container::get( 'query.postsummary.view' );
+ $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
+ if ( !$rows ) {
+ throw new InvalidInputException( 'Could not load revision to undo' );
+ }
+
+ $serializer = Container::get( 'formatter.undoedit' );
+ return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
+ }
+
+ public function getName() {
+ return 'topicsummary';
+ }
+
+ /**
+ * @param \OutputPage $out
+ */
+ public function setPageTitle( \OutputPage $out ) {
+ $topic = $this->findTopicTitle();
+ $title = $this->workflow->getOwnerTitle();
+ $out->setPageTitle( $out->msg( 'flow-topic-first-heading', $title->getPrefixedText() ) );
+ if ( $this->permissions->isAllowed( $topic, 'view' ) ) {
+ if ( $this->action === 'undo-edit-topic-summary' ) {
+ $key = 'flow-undo-edit-topic-summary';
+ } else {
+ $key = 'flow-topic-html-title';
+ }
+ $out->setHtmlTitle( $out->msg( $key, array(
+ // This must be a rawParam to not expand {{foo}} in the title, it must
+ // not be htmlspecialchar'd because OutputPage::setHtmlTitle handles that.
+ Message::rawParam( $topic->getContent( 'wikitext' ) ),
+ $title->getPrefixedText()
+ ) ) );
+ } else {
+ $out->setHtmlTitle( $title->getPrefixedText() );
+ }
+
+ $out->setSubtitle( '&lt; ' . \Linker::link( $title ) );
+ }
+}
diff --git a/Flow/includes/BlockFactory.php b/Flow/includes/BlockFactory.php
new file mode 100644
index 00000000..a61aa990
--- /dev/null
+++ b/Flow/includes/BlockFactory.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Flow;
+
+use Flow\Block\AbstractBlock;
+use Flow\Block\HeaderBlock;
+use Flow\Block\TopicBlock;
+use Flow\Block\TopicListBlock;
+use Flow\Block\TopicSummaryBlock;
+use Flow\Block\BoardHistoryBlock;
+use Flow\Model\Workflow;
+use Flow\Data\ManagerGroup;
+use Flow\Exception\InvalidInputException;
+use Flow\Exception\InvalidDataException;
+use Flow\Repository\RootPostLoader;
+
+class BlockFactory {
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var RootPostLoader
+ */
+ protected $rootPostLoader;
+
+ public function __construct(
+ ManagerGroup $storage,
+ RootPostLoader $rootPostLoader
+ ) {
+ $this->storage = $storage;
+ $this->rootPostLoader = $rootPostLoader;
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @return AbstractBlock[]
+ * @throws InvalidInputException When the workflow type is unrecognized
+ * @throws InvalidDataException When multiple blocks share the same name
+ */
+ public function createBlocks( Workflow $workflow ) {
+ switch( $workflow->getType() ) {
+ case 'discussion':
+ $blocks = array(
+ new HeaderBlock( $workflow, $this->storage ),
+ new TopicListBlock( $workflow, $this->storage ),
+ new BoardHistoryBlock( $workflow, $this->storage ),
+ );
+ break;
+
+ case 'topic':
+ $blocks = array(
+ new TopicBlock( $workflow, $this->storage, $this->rootPostLoader ),
+ new TopicSummaryBlock( $workflow, $this->storage ),
+ );
+ break;
+
+ default:
+ throw new InvalidInputException( 'Not Implemented', 'invalid-definition' );
+ break;
+ }
+
+ $return = array();
+ /** @var AbstractBlock[] $blocks */
+ foreach ( $blocks as $block ) {
+ if ( isset( $return[$block->getName()] ) ) {
+ throw new InvalidDataException( 'Multiple blocks with same name is not yet supported', 'fail-load-data' );
+ }
+ $return[$block->getName()] = $block;
+ }
+
+ return $return;
+ }
+}
diff --git a/Flow/includes/Collection/AbstractCollection.php b/Flow/includes/Collection/AbstractCollection.php
new file mode 100644
index 00000000..4db16dcd
--- /dev/null
+++ b/Flow/includes/Collection/AbstractCollection.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Flow\Collection;
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Data\ObjectManager;
+use Flow\Exception\InvalidDataException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Title;
+
+abstract class AbstractCollection {
+ /**
+ * Id of the collection object.
+ *
+ * @var UUID
+ */
+ protected $uuid;
+
+ /**
+ * @var \Flow\Data\ObjectManager[]
+ */
+ protected $storage = array();
+
+ /**
+ * Array of revisions for this object.
+ *
+ * @var AbstractRevision[]
+ */
+ protected $revisions = array();
+
+ /**
+ * @var Workflow
+ */
+ protected $workflow;
+
+ /**
+ * Returns the revision class name for this specific object (e.g. Header,
+ * PostRevision)
+ *
+ * @return string
+ */
+ abstract public function getRevisionClass();
+
+ /**
+ * Returns the id of the workflow this collection is associated with.
+ *
+ * @return UUID
+ */
+ abstract public function getWorkflowId();
+
+ /**
+ * Use the static methods to load an object from a given revision.
+ *
+ * @see AbstractCollection::newFromId
+ * @see AbstractCollection::newFromRevision
+ * @see AbstractCollection::newFromRevisionId
+ *
+ * @param UUID $uuid
+ */
+ protected function __construct( UUID $uuid ) {
+ $this->uuid = $uuid ;
+ }
+
+ /**
+ * Instantiate a new object based on its id.
+ *
+ * @param UUID $uuid
+ * @return AbstractCollection
+ */
+ public static function newFromId( UUID $uuid ) {
+ return new static( $uuid );
+ }
+
+ /**
+ * Instantiate a new object based off of an AbstractRevision object.
+ *
+ * @param AbstractRevision $revision
+ * @return AbstractCollection
+ */
+ public static function newFromRevision( AbstractRevision $revision ) {
+ return static::newFromId( $revision->getCollectionId() );
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getId() {
+ return $this->uuid;
+ }
+
+ /**
+ * @param string|null $class Storage class - defaults to getRevisionClass()
+ * @return ObjectManager
+ */
+ public function getStorage( $class = null ) {
+ if ( !$class ) {
+ $class = $this->getRevisionClass();
+ }
+
+ if ( !isset( $this->storage[$class] ) ) {
+ /** @var ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+ $this->storage[$class] = $storage->getStorage( $class );
+ }
+
+ return $this->storage[$class];
+ }
+
+ /**
+ * Returns all revisions.
+ *
+ * @return AbstractRevision[] Array of AbstractRevision
+ * @throws InvalidDataException When no revisions can be found
+ */
+ public function getAllRevisions() {
+ if ( !$this->revisions ) {
+ /** @var AbstractRevision[] $revisions */
+ $revisions = $this->getStorage()->find(
+ array( 'rev_type_id' => $this->uuid ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC' )
+ );
+
+ if ( !$revisions ) {
+ throw new InvalidDataException( 'Revisions for ' . $this->uuid->getAlphadecimal() . ' could not be found', 'invalid-revision-id' );
+ }
+
+ foreach ( $revisions as $revision ) {
+ $this->revisions[$revision->getRevisionId()->getAlphadecimal()] = $revision;
+ }
+ }
+
+ return $this->revisions;
+ }
+
+ /**
+ * Returns the revision with the given id.
+ *
+ * @param UUID $uuid
+ * @return AbstractRevision|null null if there is no such revision
+ */
+ public function getRevision( UUID $uuid ) {
+ // make sure all revisions have been loaded
+ $this->getAllRevisions();
+
+ if ( !isset( $this->revisions[$uuid->getAlphadecimal()] ) ) {
+ return null;
+ }
+
+ // find requested id, based on given revision
+ return $this->revisions[$uuid->getAlphadecimal()];
+ }
+
+ /**
+ * Returns the oldest revision.
+ *
+ * @return AbstractRevision
+ */
+ public function getFirstRevision() {
+ $revisions = $this->getAllRevisions();
+ return array_pop( $revisions );
+ }
+
+ /**
+ * Returns the most recent revision.
+ *
+ * @return AbstractRevision
+ */
+ public function getLastRevision() {
+ $revisions = $this->getAllRevisions();
+ return array_shift( $revisions );
+ }
+
+ /**
+ * Given a certain revision, returns the previous revision.
+ *
+ * @param AbstractRevision $revision
+ * @return AbstractRevision|null null if there is no previous revision
+ */
+ public function getPrevRevision( AbstractRevision $revision ) {
+ $previousRevisionId = $revision->getPrevRevisionId();
+ if ( !$previousRevisionId ) {
+ return null;
+ }
+
+ return $this->getRevision( $previousRevisionId );
+ }
+
+ /**
+ * Given a certain revision, returns the next revision.
+ *
+ * @param AbstractRevision $revision
+ * @return AbstractRevision|null null if there is no next revision
+ */
+ public function getNextRevision( AbstractRevision $revision ) {
+ // make sure all revisions have been loaded
+ $this->getAllRevisions();
+
+ // find requested id, based on given revision
+ $ids = array_keys( $this->revisions );
+ $current = array_search( $revision->getRevisionId()->getAlphadecimal(), $ids );
+ $next = $current - 1;
+
+ if ( $next < 0 ) {
+ return null;
+ }
+
+ return $this->getRevision( UUID::create( $ids[$next] ) );
+ }
+
+ /**
+ * Returns the Title object this revision is associated with.
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->getWorkflow()->getArticleTitle();
+ }
+
+ /**
+ * Returns the workflow object this collection is associated with.
+ *
+ * @return Workflow
+ * @throws InvalidDataException
+ */
+ public function getWorkflow() {
+ if ( !$this->workflow ) {
+ $uuid = $this->getWorkflowId();
+
+ $this->workflow = $this->getStorage( 'Flow\\Model\\Workflow' )->get( $uuid );
+ if ( !$this->workflow ) {
+ throw new InvalidDataException( 'Invalid workflow: ' . $uuid->getAlphadecimal(), 'invalid-workflow' );
+ }
+ }
+
+ return $this->workflow;
+ }
+}
diff --git a/Flow/includes/Collection/CollectionCache.php b/Flow/includes/Collection/CollectionCache.php
new file mode 100644
index 00000000..b05b95b8
--- /dev/null
+++ b/Flow/includes/Collection/CollectionCache.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Flow\Collection;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Model\AbstractRevision;
+use MapCacheLRU;
+
+/**
+ * Cache any useful collection data. Listens to lifecycle events for
+ * insert/update/remove to keep the internal cache up to date and reduce
+ * requests deeper into the stack.
+ */
+class CollectionCache implements LifecycleHandler {
+
+ /**
+ * Max to cache collection's last revision
+ */
+ const LAST_REV_CACHE_MAX = 50;
+
+ /**
+ * The last revision for a collection
+ *
+ * @var MapCacheLRU
+ */
+ protected $lastRevCache;
+
+ /**
+ * Initialize any cache holder in here
+ */
+ public function __construct() {
+ $this->lastRevCache = new MapCacheLRU( self::LAST_REV_CACHE_MAX );
+ }
+
+ /**
+ * Get the last revision of a collection that the requested revision belongs to
+ * @param AbstractRevision $revision current revision
+ * @return AbstractRevision the last revision
+ */
+ public function getLastRevisionFor( AbstractRevision $revision ) {
+ $key = $this->getLastRevCacheKey( $revision );
+ $lastRevision = $this->lastRevCache->get( $key );
+ if ( $lastRevision === null ) {
+ $lastRevision = $revision->getCollection()->getLastRevision();
+ $this->lastRevCache->set( $key, $lastRevision );
+ }
+
+ return $lastRevision;
+ }
+
+ /**
+ * Cache key for last revision
+ *
+ * @param AbstractRevision $revision
+ * @return string
+ */
+ protected function getLastRevCacheKey( AbstractRevision $revision ) {
+ return $revision->getCollectionId()->getAlphadecimal() . '-' . $revision->getRevisionType() . '-last-rev';
+ }
+
+ public function onAfterLoad( $object, array $row ) {}
+
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ if ( $object instanceof AbstractRevision ) {
+ $this->lastRevCache->clear( $this->getLastRevCacheKey( $object ) );
+ }
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ if ( $object instanceof AbstractRevision ) {
+ $this->lastRevCache->clear( $this->getLastRevCacheKey( $object ) );
+ }
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ if ( $object instanceof AbstractRevision ) {
+ $this->lastRevCache->clear( $this->getLastRevCacheKey( $object ) );
+ }
+ }
+}
diff --git a/Flow/includes/Collection/HeaderCollection.php b/Flow/includes/Collection/HeaderCollection.php
new file mode 100644
index 00000000..01e2d43b
--- /dev/null
+++ b/Flow/includes/Collection/HeaderCollection.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Flow\Collection;
+
+class HeaderCollection extends LocalCacheAbstractCollection {
+ public function getRevisionClass() {
+ return 'Flow\\Model\\Header';
+ }
+
+ public function getWorkflowId() {
+ return $this->getId();
+ }
+}
diff --git a/Flow/includes/Collection/LocalCacheAbstractCollection.php b/Flow/includes/Collection/LocalCacheAbstractCollection.php
new file mode 100644
index 00000000..4d958c77
--- /dev/null
+++ b/Flow/includes/Collection/LocalCacheAbstractCollection.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Flow\Collection;
+
+use Flow\Exception\InvalidDataException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\UUID;
+
+/**
+ * LocalBufferedCache saves all data that has been requested in an internal
+ * cache (in memory, per request). This provides the opportunity of (trying to)
+ * be smart about what results we fetch.
+ * The class extends the default AbstractCollection to make sure not all
+ * revisions are loaded unless we really need them. It could very well be that
+ * perhaps 5 recent revisions have already been loaded in other parts of the
+ * code, and we only need the 3rd most recent, in which case we shouldn't
+ * try to fetch all of them.
+ */
+abstract class LocalCacheAbstractCollection extends AbstractCollection {
+ /**
+ * Returns all revisions.
+ *
+ * @return AbstractRevision[]
+ */
+ public function getAllRevisions() {
+ // if we have not yet loaded everything, just clear what we have and
+ // fetch from cache
+ if ( !$this->loaded() ) {
+ $this->revisions = array();
+ }
+
+ return parent::getAllRevisions();
+ }
+
+ /**
+ * Returns the revision with the given id.
+ *
+ * @param UUID $uuid
+ * @return AbstractRevision|null null if there is no such revision
+ */
+ public function getRevision( UUID $uuid ) {
+ // check if fetching last already res
+ if ( isset( $this->revisions[$uuid->getAlphadecimal() ] ) ) {
+ return $this->revisions[$uuid->getAlphadecimal() ];
+ }
+
+ /*
+ * The strategy here is to avoid having to call getAllRevisions(), which
+ * is most likely to have to load (fresh) data that is not yet in
+ * LocalBufferedCache's internal cache.
+ * To do so, we'll build the $this->revisions array by hand. Starting at
+ * the most recent revision and going up 1 revision at a time, checking
+ * if it is already in LocalBufferedCache's cache.
+ * If, however, we can't find the requested revisions (or one of the
+ * revisions on our way to the requested revision) in the internal cache
+ * of LocalBufferedCache, we'll just bail and load all revisions after
+ * all: if we do have to fetch data, might as well do it all in 1 go!
+ */
+ while ( !$this->loaded() ) {
+ // fetch current oldest revision
+ $oldest = $this->getOldestLoaded();
+
+ // fetch that one's preceding revision id
+ $previousId = $oldest->getPrevRevisionId();
+
+ // check if it's in local storage already
+ if ( $previousId && $this->getStorage()->got( $previousId ) ) {
+ $revision = $this->getStorage()->get( $previousId );
+
+ // add this revision to revisions array
+ $this->revisions[$previousId->getAlphadecimal()] = $revision;
+
+ // stop iterating if we've found the one we wanted
+ if ( $uuid->equals( $previousId ) ) {
+ break;
+ }
+ } else {
+ // revision not found in local storage: load all revisions
+ $this->getAllRevisions();
+ break;
+ }
+ }
+
+ if ( !isset( $this->revisions[$uuid->getAlphadecimal()] ) ) {
+ return null;
+ }
+
+ return $this->revisions[$uuid->getAlphadecimal()];
+ }
+
+ /**
+ * Returns the most recent revision.
+ *
+ * @return AbstractRevision
+ * @throws InvalidDataException When no revision can be located
+ */
+ public function getLastRevision() {
+ // if $revisions is not empty, it will always have the last revision,
+ // at the beginning of the array
+ if ( $this->revisions ) {
+ return reset( $this->revisions );
+ }
+
+ $attributes = array( 'rev_type_id' => $this->uuid );
+ $options = array( 'sort' => 'rev_id', 'limit' => 1, 'order' => 'DESC' );
+
+ if ( $this->getStorage()->found( $attributes, $options ) ) {
+ // if last revision is already known in local cache, fetch it
+ $revision = $this->getStorage()->find( $attributes, $options );
+ if ( !$revision ) {
+ throw new InvalidDataException( 'Last revision for ' . $this->uuid->getAlphadecimal() . ' could not be found', 'invalid-revision-id' );
+ }
+ $revision = reset( $revision );
+ $this->revisions[$revision->getRevisionId()->getAlphadecimal()] = $revision;
+ return $revision;
+
+ } else {
+ // otherwise, might as well fetch all previous revisions while we're at
+ // it - saves roundtrips to cache/db
+ $this->getAllRevisions();
+ return reset( $this->revisions );
+ }
+ }
+
+ /**
+ * Given a certain revision, returns the next revision.
+ *
+ * @param AbstractRevision $revision
+ * @return AbstractRevision|null null if there is no next revision
+ */
+ public function getNextRevision( AbstractRevision $revision ) {
+ // make sure the given revision is loaded
+ $this->getRevision( $revision->getRevisionId() );
+
+ // find requested id, based on given revision
+ $ids = array_keys( $this->revisions );
+ $current = array_search( $revision->getRevisionId()->getAlphadecimal(), $ids );
+ $next = $current - 1;
+
+ if ( $next < 0 ) {
+ return null;
+ }
+
+ return $this->getRevision( UUID::create( $ids[$next] ) );
+ }
+
+
+ /**
+ * Returns true if all revisions have been loaded into $this->revisions.
+ *
+ * @return bool
+ */
+ public function loaded() {
+ $first = end( $this->revisions );
+ return $first && $first->getPrevRevisionId() === null;
+ }
+
+ /**
+ * Returns the oldest revision that has already been fetched via this class.
+ *
+ * @return AbstractRevision
+ */
+ public function getOldestLoaded() {
+ if ( !$this->revisions ) {
+ return $this->getLastRevision();
+ }
+
+ return end( $this->revisions );
+ }
+}
diff --git a/Flow/includes/Collection/PostCollection.php b/Flow/includes/Collection/PostCollection.php
new file mode 100644
index 00000000..fef59ce3
--- /dev/null
+++ b/Flow/includes/Collection/PostCollection.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Flow\Collection;
+
+use Flow\Container;
+use Flow\Model\UUID;
+
+class PostCollection extends LocalCacheAbstractCollection {
+ /**
+ * @var UUID
+ */
+ protected $rootId;
+
+ public function getRevisionClass() {
+ return 'Flow\\Model\\PostRevision';
+ }
+
+ /**
+ * @return UUID
+ * @throws \Flow\Exception\DataModelException
+ */
+ public function getWorkflowId() {
+ // the root post (topic title) has the same id as the workflow
+ if ( !$this->rootId ) {
+ /** @var \Flow\Repository\TreeRepository $treeRepo */
+ $treeRepo = Container::get( 'repository.tree' );
+ $this->rootId = $treeRepo->findRoot( $this->getId() );
+ }
+
+ return $this->rootId;
+ }
+
+ /**
+ * Returns the topic title collection this post is associated with.
+ *
+ * @return PostCollection
+ */
+ public function getRoot() {
+ return static::newFromId( $this->getWorkflowId() );
+ }
+}
diff --git a/Flow/includes/Collection/PostSummaryCollection.php b/Flow/includes/Collection/PostSummaryCollection.php
new file mode 100644
index 00000000..d42a869c
--- /dev/null
+++ b/Flow/includes/Collection/PostSummaryCollection.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Flow\Collection;
+
+use Flow\Container;
+use Flow\Model\UUID;
+
+class PostSummaryCollection extends LocalCacheAbstractCollection {
+ /**
+ * @var UUID
+ */
+ protected $rootId;
+
+ public function getRevisionClass() {
+ return 'Flow\\Model\\PostSummary';
+ }
+
+ public function getWorkflowId() {
+ // the root post (topic title) has the same id as the workflow
+ if ( !$this->rootId ) {
+ /** @var \Flow\Repository\TreeRepository $treeRepo */
+ $treeRepo = Container::get( 'repository.tree' );
+ $this->rootId = $treeRepo->findRoot( $this->getId() );
+ }
+
+ return $this->rootId;
+ }
+
+ /**
+ * Get the post collection for this summary
+ * @return PostCollection
+ */
+ public function getPost() {
+ return PostCollection::newFromId( $this->uuid );
+ }
+}
diff --git a/Flow/includes/Container.php b/Flow/includes/Container.php
new file mode 100644
index 00000000..5c596e1f
--- /dev/null
+++ b/Flow/includes/Container.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow;
+
+class Container extends \Pimple\Container {
+ static private $container;
+
+ /**
+ * Get a Flow Container
+ * IMPORTANT: If you are using this function, consider if you can achieve
+ * your objectives by passing values from an existing, accessible
+ * container object instead.
+ * If you use this function outside a Flow entry point (such as a hook,
+ * special page or API module), there is a good chance that your code
+ * requires refactoring
+ *
+ * @return Container
+ */
+ public static function getContainer() {
+ if ( self::$container === null ) {
+ if ( defined( 'MW_PHPUNIT_TEST' ) ) {
+ $file = 'container-test.php';
+ } else {
+ $file = 'container.php';
+ }
+ self::$container = include __DIR__ . "/../$file";
+ }
+ return self::$container;
+ }
+
+ /**
+ * Reset the container, do not use during a normal request. This is
+ * only for unit tests that need a fresh container.
+ */
+ public static function reset() {
+ self::$container = null;
+ }
+
+ /**
+ * Get a specific item from the Flow Container.
+ * This should only be used from entry points (hooks and such) into flow from mediawiki core.
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public static function get( $name ) {
+ $container = self::getContainer();
+ return $container[$name];
+ }
+}
diff --git a/Flow/includes/Content/BoardContent.php b/Flow/includes/Content/BoardContent.php
new file mode 100644
index 00000000..7d72b609
--- /dev/null
+++ b/Flow/includes/Content/BoardContent.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Flow\Content;
+
+use DerivativeContext;
+use FauxRequest;
+use Flow\Container;
+use Flow\LinksTableUpdater;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\View;
+use Flow\WorkflowLoaderFactory;
+use MWException;
+use OutputPage;
+use ParserOptions;
+use ParserOutput;
+use RequestContext;
+use Title;
+
+class BoardContent extends \AbstractContent {
+ /** @var Workflow|UUID|null */
+ protected $workflow;
+
+ public function __construct( $contentModel = CONTENT_MODEL_FLOW_BOARD, $workflow = null ) {
+ parent::__construct( CONTENT_MODEL_FLOW_BOARD );
+
+ // Allowed ways of loading a Workflow
+ if ( ! (
+ $workflow === null ||
+ $workflow instanceof UUID ||
+ $workflow instanceof Workflow
+ ) ) {
+ throw new MWException( "Invalid argument for 'workflow' parameter." );
+ }
+
+ if (
+ $workflow instanceof UUID ||
+ ( $workflow instanceof Workflow && !$workflow->isNew() )
+ ) {
+ $this->workflow = $workflow;
+ }
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return string A string representing the content in a way useful for
+ * building a full text search index. If no useful representation exists,
+ * this method returns an empty string.
+ *
+ * @todo Test that this actually works
+ * @todo Make sure this also works with LuceneSearch / WikiSearch
+ */
+ public function getTextForSearchIndex() {
+ return '';
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return string The wikitext to include when another page includes this
+ * content, or false if the content is not includable in a wikitext page.
+ *
+ * @todo Allow native handling, bypassing wikitext representation, like
+ * for includable special pages.
+ * @todo Allow transclusion into other content models than Wikitext!
+ * @todo Used in WikiPage and MessageCache to get message text. Not so
+ * nice. What should we use instead?!
+ */
+ public function getWikitextForTransclusion() {
+ return '<span class="error">' . wfMessage( 'flow-embedding-unsupported' )->plain() . '</span>';
+ }
+
+ /**
+ * Returns a textual representation of the content suitable for use in edit
+ * summaries and log messages.
+ *
+ * @since 1.21
+ *
+ * @param int $maxLength Maximum length of the summary text.
+ *
+ * @return string The summary text.
+ */
+ public function getTextForSummary( $maxLength = 250 ) {
+ return '[Flow board ' . $this->getWorkflowId()->getAlphaDecimal() . ']';
+ }
+
+ /**
+ * Returns native representation of the data. Interpretation depends on
+ * the data model used, as given by getDataModel().
+ *
+ * @since 1.21
+ *
+ * @return UUID|null The native representation of the content. Could be a
+ * string, a nested array structure, an object, a binary blob...
+ * anything, really.
+ *
+ * @note Caller must be aware of content model!
+ */
+ public function getNativeData() {
+ return $this->getWorkflowId();
+ }
+
+ /**
+ * Returns the content's nominal size in bogo-bytes.
+ *
+ * @return int
+ */
+ public function getSize() {
+ return 1;
+ }
+
+ /**
+ * Return a copy of this Content object. The following must be true for the
+ * object returned:
+ *
+ * if $copy = $original->copy()
+ *
+ * - get_class($original) === get_class($copy)
+ * - $original->getModel() === $copy->getModel()
+ * - $original->equals( $copy )
+ *
+ * If and only if the Content object is immutable, the copy() method can and
+ * should return $this. That is, $copy === $original may be true, but only
+ * for immutable content objects.
+ *
+ * @since 1.21
+ *
+ * @return Content A copy of this object
+ */
+ public function copy() {
+ return $this;
+ }
+
+ /**
+ * Returns true if this content is countable as a "real" wiki page, provided
+ * that it's also in a countable location (e.g. a current revision in the
+ * main namespace).
+ *
+ * @since 1.21
+ *
+ * @param bool $hasLinks If it is known whether this content contains
+ * links, provide this information here, to avoid redundant parsing to
+ * find out.
+ *
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null ) {
+ return true;
+ }
+
+ /**
+ * Parse the Content object and generate a ParserOutput from the result.
+ * $result->getText() can be used to obtain the generated HTML. If no HTML
+ * is needed, $generateHtml can be set to false; in that case,
+ * $result->getText() may return null.
+ *
+ * @note To control which options are used in the cache key for the
+ * generated parser output, implementations of this method
+ * may call ParserOutput::recordOption() on the output object.
+ *
+ * @param Title $title The page title to use as a context for rendering.
+ * @param int $revId Optional revision ID being rendered.
+ * @param ParserOptions $options Any parser options.
+ * @param bool $generateHtml Whether to generate HTML (default: true). If false,
+ * the result of calling getText() on the ParserOutput object returned by
+ * this method is undefined.
+ *
+ * @since 1.21
+ *
+ * @return ParserOutput
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true )
+ {
+ $parserOutput = new ParserOutput();
+ $parserOutput->updateCacheExpiry( 0 );
+
+ if ( $generateHtml ) {
+ // Set up a derivative context (which inherits the current request)
+ // to hold the output modules + text
+ $childContext = new DerivativeContext( RequestContext::getMain() );
+ $childContext->setOutput( new OutputPage( $childContext ) );
+ $childContext->setRequest( new FauxRequest );
+
+ // Create a View set up to output to our derivative context
+ $view = new View(
+ Container::get( 'url_generator' ),
+ Container::get( 'lightncandy' ),
+ $childContext->getOutput(),
+ Container::get( 'flow_actions' )
+ );
+
+ // Load workflow and run View.
+ /** @var WorkflowLoaderFactory $factory */
+ $factory = Container::get('factory.loader.workflow');
+ $loader = $factory->createWorkflowLoader( $title, $this->getWorkflowId() );
+ $view->show( $loader, 'view' );
+
+ // Extract data from derivative context
+ $parserOutput->setText( $childContext->getOutput()->getHTML() );
+ $parserOutput->addModules( $childContext->getOutput()->getModules() );
+ $parserOutput->addModuleStyles( $childContext->getOutput()->getModuleStyles() );
+ $parserOutput->addModuleScripts( $childContext->getOutput()->getModuleScripts() );
+ }
+
+ /** @var LinksTableUpdater $updater */
+ $updater = Container::get( 'reference.updater.links-tables' );
+ $updater->mutateParserOutput( $title, $parserOutput );
+
+ return $parserOutput;
+ }
+
+ public function getWorkflowId() {
+ if ( $this->workflow instanceof UUID ) {
+ return $this->workflow;
+ } elseif ( $this->workflow instanceof Workflow ) {
+ return $this->workflow->getId();
+ } elseif ( $this->workflow === null ) {
+ return null;
+ } else {
+ throw new MWException( "Unknown Workflow specifier" );
+ }
+ }
+}
diff --git a/Flow/includes/Content/BoardContentHandler.php b/Flow/includes/Content/BoardContentHandler.php
new file mode 100644
index 00000000..a028251d
--- /dev/null
+++ b/Flow/includes/Content/BoardContentHandler.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Flow\Content;
+
+use Flow\Actions\FlowAction;
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\UUID;
+use FormatJson;
+use IContextSource;
+use MWException;
+use Page;
+
+class BoardContentHandler extends \ContentHandler {
+ public function __construct( $modelId ) {
+ if ( $modelId !== CONTENT_MODEL_FLOW_BOARD ) {
+ throw new MWException( __CLASS__." initialised for invalid content model" );
+ }
+
+ parent::__construct( CONTENT_MODEL_FLOW_BOARD, array( CONTENT_FORMAT_JSON ) );
+ }
+
+ public function isSupportedFormat( $format ) {
+ // Necessary for backwards-compatability where
+ // the format "json" was used
+ if ( $format === 'json' ) {
+ $format = CONTENT_FORMAT_JSON;
+ }
+
+ return parent::isSupportedFormat( $format );
+ }
+
+ /**
+ * Serializes a Content object of the type supported by this ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @param \Content $content The Content object to serialize
+ * @param string|null $format The desired serialization format
+ * @return string Serialized form of the content
+ * @throws MWException
+ */
+ public function serializeContent( \Content $content, $format = null ) {
+ if ( ! $content instanceof BoardContent ) {
+ throw new MWException( "Expected a BoardContent object, got a " . get_class( $content ) );
+ }
+
+ $info = array();
+
+ if ( $content->getWorkflowId() ) {
+ $info['flow-workflow'] = $content->getWorkflowId()->getAlphaDecimal();
+ }
+
+ return FormatJson::encode( $info );
+ }
+
+ /**
+ * Unserializes a Content object of the type supported by this ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @param string $blob Serialized form of the content
+ * @param string $format The format used for serialization
+ *
+ * @return Content The Content object created by deserializing $blob
+ */
+ public function unserializeContent( $blob, $format = null ) {
+ $info = FormatJson::decode( $blob, true );
+ $uuid = null;
+
+ if ( ! $info ) {
+ // For transition from wikitext-type pages
+ // Make a plain content object and then when we get a chance
+ // we can insert a proper object.
+ return $this->makeEmptyContent();
+ } elseif ( isset( $info['flow-workflow'] ) ) {
+ $uuid = UUID::create( $info['flow-workflow'] );
+ }
+
+ return new BoardContent( CONTENT_MODEL_FLOW_BOARD, $uuid );
+ }
+
+ /**
+ * Creates an empty Content object of the type supported by this
+ * ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @return Content
+ */
+ public function makeEmptyContent() {
+ return new BoardContent;
+ }
+
+ /**
+ * Don't let people turn random pages into
+ * Flow ones until we want them to.
+ *
+ * @param \Title $title
+ * @return bool
+ */
+ public function canBeUsedOn( \Title $title ) {
+ /** @var \Flow\TalkpageManager $manager */
+ $manager = Container::get( 'occupation_controller' );
+ return $manager->canBeUsedOn( $title );
+ }
+
+ /**
+ * Returns overrides for action handlers.
+ * Classes listed here will be used instead of the default one when
+ * (and only when) $wgActions[$action] === true. This allows subclasses
+ * to override the default action handlers.
+ *
+ * @since 1.21
+ *
+ * @return array Always an empty array.
+ */
+ public function getActionOverrides() {
+ /** @var FlowActions $actions */
+ $actions = Container::get( 'flow_actions' );
+ $output = array();
+
+ foreach( $actions->getActions() as $action ) {
+ $actionData = $actions->getValue( $action );
+ if ( !is_array( $actionData ) ) {
+ continue;
+ }
+
+ if ( !isset( $actionData['handler-class'] ) ) {
+ continue;
+ }
+
+ if ( $actionData['handler-class'] === 'Flow\Actions\FlowAction' ) {
+ $output[$action] = function( Page $page, IContextSource $source ) use ( $action ) {
+ return new FlowAction( $page, $source, $action );
+ };
+ } else {
+ $output[$action] = $actionData['handler-class'];
+ }
+ }
+
+ // Flow has its own handlling for action=edit
+ $output['edit'] = 'Flow\Actions\EditAction';
+
+ return $output;
+ }
+}
diff --git a/Flow/includes/Content/Content.php b/Flow/includes/Content/Content.php
new file mode 100644
index 00000000..7f8d387f
--- /dev/null
+++ b/Flow/includes/Content/Content.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Flow\Content;
+
+use Flow\Exception\FlowException;
+use Flow\WorkflowLoaderFactory;
+use Article;
+use ContentHandler;
+use Flow\Container;
+use Title;
+
+abstract class Content {
+ static function onGetDefaultModel( Title $title, &$model ) {
+ $occupationController = \FlowHooks::getOccupationController();
+
+ if ( $occupationController->isTalkpageOccupied( $title, false ) ) {
+ $model = CONTENT_MODEL_FLOW_BOARD;
+
+ return false;
+ }
+
+ return true;
+ }
+
+ static function onShowMissingArticle( Article $article ) {
+ if ( $article->getPage()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
+ return true;
+ }
+
+ if ( $article->getTitle()->getNamespace() === NS_TOPIC ) {
+ // @todo pretty message about invalid workflow
+ throw new FlowException( 'Non-existent topic' );
+ }
+
+ $emptyContent = ContentHandler::getForModelID( CONTENT_MODEL_FLOW_BOARD )->makeEmptyContent();
+
+ $parserOutput = $emptyContent->getParserOutput( $article->getTitle() );
+ $article->getContext()->getOutput()->addParserOutput( $parserOutput );
+
+ return false;
+ }
+
+ static function onFetchContentObject( Article &$article, \Content &$contentObject = null ) {
+ if ( $contentObject === null ) {
+ return true;
+ }
+
+ $occupationController = \FlowHooks::getOccupationController();
+ $title = $article->getTitle();
+
+ if ( $occupationController->isTalkpageOccupied( $title ) ) {
+ /** @var WorkflowLoaderFactory $factory */
+ $factory = Container::get( 'factory.loader.workflow' );
+ $loader = $factory->createWorkflowLoader( $title );
+
+ $newRev = $occupationController->ensureFlowRevision( $article, $loader->getWorkflow() );
+
+ if ( $newRev ) {
+ /** @noinspection PhpUndefinedFieldInspection */
+ $article->getPage()->mRevision = $newRev;
+ /** @noinspection PhpUndefinedFieldInspection */
+ $article->getPage()->mContentObject = $newRev->getContent();
+ $contentObject = $newRev->getContent();
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Flow/includes/Data/BagOStuff/BufferedBagOStuff.php b/Flow/includes/Data/BagOStuff/BufferedBagOStuff.php
new file mode 100644
index 00000000..0110cd33
--- /dev/null
+++ b/Flow/includes/Data/BagOStuff/BufferedBagOStuff.php
@@ -0,0 +1,415 @@
+<?php
+
+namespace Flow\Data\BagOStuff;
+
+use BagOStuff;
+use HashBagOStuff;
+
+/**
+ * This class will serve as a local buffer to the real cache.
+ *
+ * It will pass reads on to the real cache, but defer writes. This makes it
+ * possible to not do any cache updates until we can guarantee it's safe (e.g.
+ * until we successfully commit everything to real storage)
+ *
+ * There will be some trickery to make sure that, after we've made changes to
+ * cache (but that are still deferred), we don't read from the real cache
+ * anymore, but instead serve the in-memory equivalent that we'll be writing to
+ * real cache when all goes well.
+ */
+class BufferedBagOStuff extends HashBagOStuff {
+ /**
+ * The real cache we'll eventually want to store to.
+ *
+ * @var BagOStuff
+ */
+ protected $cache;
+
+ /**
+ * Deferred updates to be committed to real cache.
+ *
+ * @var array
+ */
+ protected $buffer = array();
+
+ /**
+ * We'll return stub CAS tokens in order to reliably replay the CAS actions
+ * later on. This will hold a map of stub token => value at that time.
+ *
+ * @see cas()
+ * @var array
+ */
+ protected $casTokens = array();
+
+ /**
+ * Whether or not to defer updates.
+ *
+ * @var bool
+ */
+ protected $transaction = false;
+
+ /**
+ * Array of keys we've written to. They'll briefly be stored here after
+ * being committed, until all other writes in the transaction have been
+ * committed. This way, if a later write fails, we can invalidate previous
+ * updates based on those keys we wrote to.
+ *
+ * @var array
+ */
+ protected $committed = array();
+
+ /**
+ * @param BagOStuff $cache
+ */
+ public function __construct( BagOStuff $cache ) {
+ $this->cache = $cache;
+ parent::__construct();
+ }
+
+ /**
+ * We only want expire to check if the key is expired. Parent expire will
+ * delete any such keys, but we want to keep them around, because otherwise
+ * we won't be able to discern between "deleted from buffered cache" and
+ * "not available in local cache, so let's get it from real cache."
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function expire( $key ) {
+ $expiry = $this->bag[$key][1];
+
+ if ( $expiry == 0 || $expiry > time() ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $casToken [optional]
+ * @return bool|mixed
+ */
+ public function get( $key, &$casToken = null ) {
+ if ( !isset( $this->bag[$key] ) ) {
+ // Unknown in local cache = fetch from source cache
+ $value = $this->cache->get( $key, $casToken );
+ } else {
+ $value = parent::get( $key, $casToken );
+ }
+
+ // $casToken will be unreliable to the deferred updates so generate
+ // a custom one and keep the associated value around.
+ // Read more details in PHPDoc for function cas().
+ // uniqid is ok here. Doesn't really have to be unique across
+ // servers, just has to be unique every time it's called in this
+ // one particular request - which it is.
+ $casToken = uniqid();
+ $this->casTokens[$casToken] = serialize( $value );
+
+ return $value;
+ }
+
+ /**
+ * @param array $keys
+ * @return array
+ */
+ public function getMulti( array $keys ) {
+ $values = array();
+
+ // Retrieve all that we can from local cache
+ foreach ( $keys as $key ) {
+ $result = parent::get( $key );
+
+ // If we found a real result, no need to go request if from real cache
+ if ( $result !== false ) {
+ $values[$key] = $result;
+ unset( $keys[$key] );
+ }
+ }
+
+ // Fetch the rest from real cache
+ if ( $keys ) {
+ $result = $this->cache->getMulti( $keys );
+
+ // While most of the BagOStuff implementations return an empty array
+ // on not found from getMulti the memcached bag returns false
+ $result = $result ?: array();
+
+ $values += $result;
+ }
+
+ // The memcached BagOStuff returns only existing keys, but the redis
+ // BagOStuff puts a false for all keys it doesn't find. Resolve that
+ // inconsistency here by filtering all false values
+ return array_filter( $values, function( $value ) {
+ return $value !== false;
+ } );
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return bool
+ */
+ public function set( $key, $value, $exptime = 0 ) {
+ $this->defer( array( $this->cache, __FUNCTION__ ), func_get_args(), $key );
+
+ // Store the value in memory, so that when we ask for it again later in
+ // this same request, we get the value we just set
+ return parent::set( $key, $value, $exptime );
+ }
+
+ /**
+ * @param array $data
+ * @param int $exptime
+ * @return bool
+ */
+ public function setMulti( array $data, $exptime = 0 ) {
+ $this->defer( array( $this->cache, __FUNCTION__ ), func_get_args(), array_keys( $data ) );
+
+ $success = true;
+
+ foreach ( $data as $key => $value ) {
+ // Store the values in memory, so that when we ask for it again later in
+ // this same request, we get the value we just set
+ if ( !parent::set( $key, $value, $exptime ) ) {
+ $success = false;
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return bool
+ */
+ public function add( $key, $value, $exptime = 0 ) {
+ if ( $this->get( $key ) === false ) {
+ $this->defer( array( $this->cache, __FUNCTION__ ), func_get_args(), $key );
+
+ // Store the values in memory, so that when we ask for it again later in
+ // this same request, we get the value we just set
+ return parent::set( $key, $value, $exptime );
+ }
+
+ return false;
+ }
+
+ /**
+ * Since our CAS is deferred, the CAS token we got from our original
+ * get() will likely not be valid by the time we want to store it to
+ * the real cache. Imagine this scenario:
+ * * a value is fetched from (real) cache
+ * * an new value key is CAS'ed (into temp cache - real CAS is deferred)
+ * * this key's value is fetched again (this time from temp cache)
+ * * and a new value is CAS'ed again (into temp cache...)
+ *
+ * In this scenario, when we finally want to replay the write actions
+ * onto the real cache, the first 3 actions would likely work fine.
+ * The last (second CAS) however would not, since it never got a real
+ * updated $casToken from the real cache.
+ *
+ * To work around this problem, all get() calls will return a unique
+ * CAS token and store the value-at-that-time associated with that
+ * token. All we have to do when we want to write the data to real cache
+ * is, right before was CAS for real, get the value & (real) cas token
+ * from storage & compare that value to the one we had stored. If that
+ * checks out, we can safely resume the CAS with the real token we just
+ * received.
+ *
+ * Should a deferred CAS fail, however, we'll delete the key in cache
+ * since it's no longer reliable.
+ *
+ * @param mixed $casToken
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return bool
+ */
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ $that = $this;
+ $cache = $this->cache;
+ $originalValue = isset( $this->casTokens[$casToken] ) ? $this->casTokens[$casToken] : null;
+
+ /**
+ * @param mixed $casToken
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return bool
+ */
+ $cas = function ( $casToken, $key, $value, $exptime = 0 )
+ use ( $that, $cache, $originalValue )
+ {
+ // Check if given (local) CAS token was known
+ if ( $originalValue === null ) {
+ return false;
+ }
+
+ // Fetch data from real cache, getting new valid CAS token
+ $current = $cache->get( $key, $casToken );
+
+ // Check if the value we just read from real cache is still the same
+ // as the one we saved when doing the original fetch
+ if ( serialize( $current ) === $originalValue ) {
+ /*
+ * Note that all BagOStuff::cas implementations are protected!
+ * We can still call it from here because this class too extends
+ * from BagOStuff, where the cas method is defined. PHP will
+ * allow us access because "because the implementation specific
+ * details are already known."
+ */
+
+ // Everything still checked out, let's CAS the value for real now
+ return $that->immediateCasInternal( $casToken, $key, $value, $exptime );
+ }
+
+ return false;
+ };
+
+ // CAS value to local cache/memory
+ $success = false;
+ if ( serialize( $this->get( $key ) ) === $originalValue ) {
+ $success = parent::set( $key, $value, $exptime );
+ }
+
+ // Only schedule the CAS to be performed on real cache if it was OK on
+ // local cache
+ if ( $success ) {
+ $this->defer( $cas, func_get_args(), $key );
+ }
+
+ return $success;
+ }
+
+ /**
+ * Internal function to let closures access protected cache object methods
+ *
+ * @param mixed $casToken
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return type
+ */
+ public function immediateCasInternal( $casToken, $key, $value, $exptime ) {
+ return $this->cache->cas( $casToken, $key, $value, $exptime );
+ }
+
+ /**
+ * @param string $key
+ * @param int $time
+ * @return bool
+ */
+ public function delete( $key, $time = 0 ) {
+ $cache = $this->cache;
+
+ /**
+ * We'll use the return value of all buffered writes to check if they
+ * should be "rolled back" (which means deleting the keys to prevent
+ * corruption).
+ *
+ * delete() can return false if the delete was issued on a non-existing
+ * key. That is no corruption of data, though (the requested action
+ * actually succeeded: the key is done). Instead, make this callback
+ * always return true, regardless of whether or not the key existed.
+ *
+ * @param string $key
+ * @param int $time
+ * @return bool
+ */
+ $delete = function ( $key, $time = 0 ) use ( $cache ) {
+ $cache->delete( $key, $time );
+ return true;
+ };
+
+ $this->defer( $delete, func_get_args(), $key );
+
+ // Check the current value to see if is currently exists, so we can
+ // properly return true/false as would be expected from other BagOStuff
+ $value = $this->get( $key );
+
+ // To make sure that subsequent get() calls for this key don't return
+ // a value (it's supposed to be deleted), we'll make it is expired in
+ // our temporary bag.
+ parent::set( $key, '', -1 );
+
+ return $value !== false;
+ }
+
+ /**
+ * Initiate a transaction: this will defer all writes to real cache until
+ * commit() is called.
+ */
+ public function begin() {
+ $this->transaction = true;
+ }
+
+ /**
+ * Commits all deferred updates to real cache.
+ *
+ * @return bool
+ */
+ public function commit() {
+ foreach ( $this->buffer as $update ) {
+ $success = call_user_func_array( $update[0], $update[1] );
+
+ // Store keys that data has been written to (so we can rollback)
+ $this->committed += array_flip( $update[2] );
+
+ // If we failed to commit data at any point, roll back
+ if ( !$success ) {
+ $this->rollback();
+ return false;
+ }
+ }
+
+ $this->clearLocal();
+ $this->transaction = false;
+
+ return true;
+ }
+
+ /**
+ * Roll back all scheduled changes.
+ */
+ public function rollback() {
+ // Delete all those keys from cache, they may be corrupt
+ foreach ( $this->committed as $key => $nop ) {
+ $this->cache->delete( $key );
+ }
+
+ // Always clear local cache values when something went wrong
+ $this->bag = array();
+ $this->clearLocal();
+
+ $this->transaction = false;
+ }
+
+ /**
+ * @param callable $callback
+ * @param array $arguments
+ * @param string|string[] $key Key(s) being written to
+ */
+ protected function defer( $callback, $arguments, $key ) {
+ // Keys can be either 1 single string or array of multiple keys
+ $keys = (array) $key;
+
+ $this->buffer[] = array( $callback, $arguments, $keys );
+
+ // persist to real cache immediately, if we're not in a "transaction"
+ if ( !$this->transaction ) {
+ $this->commit();
+ }
+ }
+
+ protected function clearLocal() {
+ $this->bag = array();
+ $this->buffer = array();
+ $this->committed = array();
+ }
+}
diff --git a/Flow/includes/Data/BagOStuff/LocalBufferedBagOStuff.php b/Flow/includes/Data/BagOStuff/LocalBufferedBagOStuff.php
new file mode 100644
index 00000000..f9386c24
--- /dev/null
+++ b/Flow/includes/Data/BagOStuff/LocalBufferedBagOStuff.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Flow\Data\BagOStuff;
+
+/**
+ * Handles duplicate requests for the same data by keeping them in memory for
+ * the rest of this request.
+ */
+class LocalBufferedBagOStuff extends BufferedBagOStuff {
+ /**
+ * Returns true if the data is in own "storage" already, or false if it will
+ * need to be fetched from external cache.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function has( $key ) {
+ return array_key_exists( $key, $this->bag );
+ }
+
+ /**
+ * @param string $key
+ * @param null $casToken
+ * @return bool|mixed
+ */
+ public function get( $key, &$casToken = null ) {
+ $value = parent::get( $key, $casToken );
+ $this->bag[$key] = array( $value, 0 );
+ return $value;
+ }
+
+ /**
+ * @param array $keys
+ * @return array
+ */
+ public function getMulti( array $keys ) {
+ $values = parent::getMulti( $keys );
+
+ foreach ( $values as $key => $value ) {
+ $this->bag[$key] = array( $value, 0 );
+ }
+
+ return $values;
+ }
+
+ protected function clearLocal() {
+ // contrary to BufferedBagOStuff, don't clear $this->bag
+ $this->buffer = array();
+ $this->committed = array();
+ }
+}
diff --git a/Flow/includes/Data/BufferedCache.php b/Flow/includes/Data/BufferedCache.php
new file mode 100644
index 00000000..94cf5eec
--- /dev/null
+++ b/Flow/includes/Data/BufferedCache.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Flow\Data;
+
+use Flow\Data\BagOStuff\BufferedBagOStuff;
+use Closure;
+
+/**
+ * This class will emulate a BagOStuff, but with a fixed expiry time for all
+ * writes. All methods will be passed on to the BagOStuff in constructor.
+ * Preserves any BagOStuff semantics for the most common methods.
+ */
+class BufferedCache {
+ /**
+ * @var BufferedBagOStuff
+ */
+ protected $cache;
+
+ /**
+ * @var int
+ */
+ protected $exptime = 0;
+
+ /**
+ * @param BufferedBagOStuff $cache The cache implementation to back this buffer with
+ * @param int $exptime The default length of time to cache data. 0 for LRU.
+ */
+ public function __construct( BufferedBagOStuff $cache, $exptime = 0 ) {
+ $this->exptime = $exptime;
+ $this->cache = $cache;
+ }
+
+ /**
+ * @param string $key
+ * @param null $casToken
+ * @return mixed
+ */
+ public function get( $key, &$casToken = null ) {
+ return $this->cache->get( $key, $casToken );
+ }
+
+ /**
+ * @param array $keys
+ * @return array
+ */
+ public function getMulti( array $keys ) {
+ return $this->cache->getMulti( $keys );
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function set( $key, $value ) {
+ return $this->cache->set( $key, $value, $this->exptime );
+ }
+
+ /**
+ * @param array $data
+ * @return bool
+ */
+ public function setMulti( array $data ) {
+ return $this->cache->setMulti( $data, $this->exptime );
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function add( $key, $value ) {
+ return $this->cache->add( $key, $value, $this->exptime );
+ }
+
+ /**
+ * @param string $key
+ * @param int $time
+ * @return bool
+ */
+ public function delete( $key, $time = 0 ) {
+ return $this->cache->delete( $key, $time );
+ }
+
+ /**
+ * @param string $key
+ * @param Closure $callback
+ * @param int $attempts
+ * @return bool
+ */
+ public function merge( $key, Closure $callback, $attempts = 10 ) {
+ return $this->cache->merge( $key, $callback, $this->exptime, $attempts );
+ }
+
+ /**
+ * Initiate a transaction: this will defer all writes to real cache until
+ * commit() is called.
+ */
+ public function begin() {
+ $this->cache->begin();
+ }
+
+ /**
+ * Commits all deferred updates to real cache.
+ *
+ * @return bool
+ */
+ public function commit() {
+ return $this->cache->commit();
+ }
+
+ /**
+ * Roll back all scheduled changes.
+ */
+ public function rollback() {
+ $this->cache->rollback();
+ }
+
+ /**
+ * Catches all other method calls & passes them on to the real cache.
+ *
+ * @param string $name
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call( $name, array $arguments ) {
+ return call_user_func_array( array( $this->cache, $name ), $arguments );
+ }
+}
diff --git a/Flow/includes/Data/Compactor.php b/Flow/includes/Data/Compactor.php
new file mode 100644
index 00000000..464498e6
--- /dev/null
+++ b/Flow/includes/Data/Compactor.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Flow\Data;
+
+/**
+ * Compact rows before writing to cache, expand when receiving back
+ * Still returns arrays, just removes unnecessary values.
+ */
+interface Compactor {
+ /**
+ * @param array $row A data model row to strip unnecessary data from
+ * @return array Only the values in $row that will be written to the cache
+ */
+ public function compactRow( array $row );
+
+ /**
+ * @param array $rows Multiple data model rows to strip unnecesssary data from
+ * @return array The provided rows now containing only the values the will be written to cache
+ */
+ public function compactRows( array $rows );
+
+ /**
+ * Repopulate BagOStuff::multiGet results with any values removed in self::compactRow
+ *
+ * @param array $cached The multi-dimensional array results of BagOStuff::multiGet
+ * @param array $keyToQuery An array mapping memcache-key to the values used to generate that cache key
+ * @return array The cached content from memcache along with any data stripped in self::compactRow
+ */
+ public function expandCacheResult( array $cached, array $keyToQuery );
+}
diff --git a/Flow/includes/Data/Compactor/FeatureCompactor.php b/Flow/includes/Data/Compactor/FeatureCompactor.php
new file mode 100644
index 00000000..426ee3a0
--- /dev/null
+++ b/Flow/includes/Data/Compactor/FeatureCompactor.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Flow\Data\Compactor;
+
+use Flow\Data\Compactor;
+use Flow\Exception\DataModelException;
+
+/**
+ * Removes the feature fields from stored array since its duplicating the cache key values
+ * Re-adds them when retrieving from cache.
+ */
+class FeatureCompactor implements Compactor {
+ /**
+ * @var string[]
+ */
+ protected $indexed;
+
+ /**
+ * @param string[] $indexedColumns
+ */
+ public function __construct( array $indexedColumns ) {
+ $this->indexed = $indexedColumns;
+ }
+
+ /**
+ * The indexed values are always available when querying, this strips
+ * the duplicated data.
+ *
+ * @param array $row
+ * @return array
+ * @throws DataModelException
+ */
+ public function compactRow( array $row ) {
+ foreach ( $this->indexed as $key ) {
+ unset( $row[$key] );
+ }
+ foreach ( $row as $foo ) {
+ if ( $foo !== null && !is_scalar( $foo ) ) {
+ throw new DataModelException( 'Attempted to compact row containing objects, must be scalar values: ' . print_r( $foo, true ), 'process-data' );
+ }
+ }
+ return $row;
+ }
+
+ /**
+ * @param array $rows
+ * @return array
+ */
+ public function compactRows( array $rows ) {
+ return array_map( array( $this, 'compactRow' ), $rows );
+ }
+
+ /**
+ * The $cached array is three dimensional. Each top level key is a cache key
+ * and contains an array of rows. Each row is an array representing a single data model.
+ *
+ * $cached = array( $cacheKey => array( array( 'rev_id' => 123, ... ), ... ), ... )
+ *
+ * The $keyToQuery array maps from cache key to the values that were used to build the cache key.
+ * These values are re-added to the results found in memcache.
+ *
+ * @param array $cached Array of results from BagOStuff::multiGet each containg a list of rows
+ * @param array $keyToQuery Map from key in $cached to the values used to generate that key
+ * @return array The $cached array with the queried values merged in
+ * @throws DataModelException
+ */
+ public function expandCacheResult( array $cached, array $keyToQuery ) {
+ foreach ( $cached as $key => $rows ) {
+ $query = $keyToQuery[$key];
+ foreach ( $query as $foo ) {
+ if ( $foo !== null && !is_scalar( $foo ) ) {
+ throw new DataModelException( 'Query values to merge with cache contains objects, should be scalar values: ' . print_r( $foo, true ), 'process-data' );
+ }
+ }
+ foreach ( $rows as $k => $row ) {
+ foreach ( $row as $foo ) {
+ if ( $foo !== null && !is_scalar( $foo ) ) {
+ throw new DataModelException( 'Result from cache contains objects, should be scalar values: ' . print_r( $foo, true ), 'process-data' );
+ }
+ }
+ $cached[$key][$k] += $query;
+ }
+ }
+
+ return $cached;
+ }
+}
diff --git a/Flow/includes/Data/Compactor/ShallowCompactor.php b/Flow/includes/Data/Compactor/ShallowCompactor.php
new file mode 100644
index 00000000..7f0f2011
--- /dev/null
+++ b/Flow/includes/Data/Compactor/ShallowCompactor.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Flow\Data\Compactor;
+
+use Flow\Data\Compactor;
+use Flow\Data\Index\UniqueFeatureIndex;
+use Flow\Data\Utils\ResultDuplicator;
+
+/**
+ * Backs an index with a UniqueFeatureIndex. This index will store only the primary key
+ * values from the unique index, and on retrieval from cache will materialize the primary key
+ * values into full rows from the unique index.
+ */
+class ShallowCompactor implements Compactor {
+ /**
+ * @var Compactor
+ */
+ protected $inner;
+
+ /**
+ * @var UniqueFeatureIndex
+ */
+ protected $shallow;
+
+ /**
+ * @var string[]
+ */
+ protected $sort;
+
+ /**
+ * @param Compactor $inner
+ * @param UniqueFeatureIndex $shallow
+ * @param string[] $sortedColumns
+ */
+ public function __construct( Compactor $inner, UniqueFeatureIndex $shallow, array $sortedColumns ) {
+ $this->inner = $inner;
+ $this->shallow = $shallow;
+ $this->sort = $sortedColumns;
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ public function compactRow( array $row ) {
+ $keys = array_merge( $this->shallow->getPrimaryKeyColumns(), $this->sort );
+ $extra = array_diff( array_keys( $row ), $keys );
+ foreach ( $extra as $key ) {
+ unset( $row[$key] );
+ }
+ return $this->inner->compactRow( $row );
+ }
+
+ /**
+ * @param array $rows
+ * @return array
+ */
+ public function compactRows( array $rows ) {
+ return array_map( array( $this, 'compactRow' ), $rows );
+ }
+
+ /**
+ * @return UniqueFeatureIndex
+ */
+ public function getShallow() {
+ return $this->shallow;
+ }
+
+ /**
+ * @param array $cached
+ * @param array $keyToQuery
+ * @return ResultDuplicator
+ */
+ public function getResultDuplicator( array $cached, array $keyToQuery ) {
+ $results = $this->inner->expandCacheResult( $cached, $keyToQuery );
+ // Allows us to flatten $results into a single $query array, then
+ // rebuild final return value in same structure and order as $results.
+ $duplicator = new ResultDuplicator( $this->shallow->getPrimaryKeyColumns(), 2 );
+ foreach ( $results as $i => $rows ) {
+ foreach ( $rows as $j => $row ) {
+ $duplicator->add( $row, array( $i, $j ) );
+ }
+ }
+
+ return $duplicator;
+ }
+
+ /**
+ * @param array $cached
+ * @param array $keyToQuery
+ * @return array
+ */
+ public function expandCacheResult( array $cached, array $keyToQuery ) {
+ $duplicator = $this->getResultDuplicator( $cached, $keyToQuery );
+ $queries = $duplicator->getUniqueQueries();
+ $innerResult = $this->shallow->findMulti( $queries );
+ foreach ( $innerResult as $rows ) {
+ // __construct guaranteed the shallow backing index is a unique, so $first is only result
+ $first = reset( $rows );
+ $duplicator->merge( $first, $first );
+ }
+
+ return $duplicator->getResult();
+ }
+}
diff --git a/Flow/includes/Data/Index.php b/Flow/includes/Data/Index.php
new file mode 100644
index 00000000..ed05bb29
--- /dev/null
+++ b/Flow/includes/Data/Index.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Flow\Data;
+
+/**
+ * Indexes store one or more values bucketed by exact key/value combinations.
+ */
+interface Index extends LifecycleHandler {
+ /**
+ * Find data models matching the provided equality condition.
+ *
+ * @param array $keys A map of k,v pairs to find via equality condition
+ * @param array[optional] $options Options to use
+ * @return array|false Cached subset of data model rows matching the
+ * equality conditions provided in $keys.
+ */
+ function find( array $keys, array $options = array() );
+
+ /**
+ * Batch together multiple calls to self::find with minimal network round trips.
+ *
+ * @param array $queries An array of arrays in the form of $keys parameter of self::find
+ * @param array[optional] $options Options to use
+ * @return array|false Array of arrays in same order as $queries representing batched result set.
+ */
+ function findMulti( array $queries, array $options = array() );
+
+ /**
+ * Returns a boolean true/false if the find()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $attributes Attributes to find()
+ * @param array[optional] $options Options to find()
+ * @return bool
+ */
+ public function found( array $attributes, array $options = array() );
+
+ /**
+ * Returns a boolean true/false if the findMulti()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $attributes Attributes to find()
+ * @param array[optional] $options Options to find()
+ * @return bool
+ */
+ public function foundMulti( array $attributes, array $options = array() );
+
+ /**
+ * @return integer Maximum number of items in a single index value
+ */
+ function getLimit();
+
+ /**
+ * Rows are first sorted based on the first term of the result, then ties
+ * are broken by evaluating the second term and so on.
+ *
+ * @todo choose a default sort instead of false?
+ * @return array|false Columns to sort on
+ */
+ function getSort();
+
+ /**
+ * Query options are not supported at the query level, the index always
+ * returns the same value for the same key/value combination. Depending on what
+ * the query stores it may contain the answers to various options, which will require
+ * post-processing by the caller.
+ *
+ * @param array $keys
+ * @param array $options
+ * @return boolean Can the index locate a result for this keys and options pair
+ */
+ function canAnswer( array $keys, array $options );
+
+ /**
+ * @param array $row
+ * @param string $offset
+ * @return integer An integer less than, equal to, or greater than zero
+ * if $row is considered to be respectively less than, equal to, or
+ * greater than $offset
+ */
+ function compareRowToOffset( array $row, $offset );
+
+ /**
+ * @param object $object
+ * @param array $row
+ */
+ function cachePurge( $object, array $row );
+}
diff --git a/Flow/includes/Data/Index/BoardHistoryIndex.php b/Flow/includes/Data/Index/BoardHistoryIndex.php
new file mode 100644
index 00000000..9767f322
--- /dev/null
+++ b/Flow/includes/Data/Index/BoardHistoryIndex.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Flow\Data\Index;
+
+use Flow\Data\BufferedCache;
+use Flow\Data\ObjectManager;
+use Flow\Data\Storage\BoardHistoryStorage;
+use Flow\Exception\DataModelException;
+use Flow\Exception\InvalidInputException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Header;
+use Flow\Model\PostSummary;
+use Flow\Model\PostRevision;
+use Flow\Model\TopicListEntry;
+use Flow\Model\Workflow;
+
+/**
+ * Keeps a list of revision ids relevant to the board history bucketed
+ * by the owning TopicList id (board workflow).
+ *
+ * Can be used with Header, PostRevision and PostSummary ObjectMapper's
+ */
+class BoardHistoryIndex extends TopKIndex {
+
+ /**
+ * @var ObjectManager Manager for the TopicListEntry model
+ */
+ protected $om;
+
+ public function __construct(
+ BufferedCache $cache,
+ BoardHistoryStorage $storage,
+ $prefix,
+ array $indexed,
+ array $options = array(),
+ ObjectManager $om
+ ) {
+ if ( $indexed !== array( 'topic_list_id' ) ) {
+ throw new DataModelException( __CLASS__ . ' is hardcoded to only index topic_list_id: ' . print_r( $indexed, true ), 'process-data' );
+ }
+ parent::__construct( $cache, $storage, $prefix, $indexed, $options );
+ $this->om = $om;
+ }
+
+ public function findMulti( array $queries, array $options = array() ) {
+ if ( count( $queries ) > 1 ) {
+ // why?
+ throw new DataModelException( __METHOD__ . ' expects only one value in $queries', 'process-data' );
+ }
+ return parent::findMulti( $queries, $options );
+ }
+
+ /**
+ * @param array $queries
+ * @return array
+ */
+ public function backingStoreFindMulti( array $queries ) {
+ return $this->storage->findMulti(
+ $queries,
+ $this->queryOptions()
+ ) ?: array();
+ }
+
+ /**
+ * @param Header|PostRevision $object
+ * @param string[] $row
+ */
+ public function cachePurge( $object, array $row ) {
+ $row['topic_list_id'] = $this->findTopicListId( $object, $row, array() );
+ parent::cachePurge( $object, $row );
+ }
+
+ /**
+ * @param Header|PostRevision $object
+ * @param string[] $new
+ * @param array $metadata
+ */
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ $new['topic_list_id'] = $this->findTopicListId( $object, $new, $metadata );
+ parent::onAfterInsert( $object, $new, $metadata );
+ }
+
+ /**
+ * @param Header|PostRevision $object
+ * @param string[] $old
+ * @param string[] $new
+ * @param array $metadata
+ */
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ $new['topic_list_id'] = $old['topic_list_id'] = $this->findTopicListId( $object, $new, $metadata );
+ parent::onAfterUpdate( $object, $old, $new, $metadata );
+ }
+
+ /**
+ * @param Header|PostRevision $object
+ * @param string[] $old
+ * @param array $metadata
+ */
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ $old['topic_list_id'] = $this->findTopicListId( $object, $old, $metadata );
+ parent::onAfterRemove( $object, $old, $metadata );
+ }
+
+ /**
+ * Find a topic list id related to an abstract revision
+ *
+ * @param AbstractRevision $object
+ * @param string[] $row
+ * @param array $metadata
+ * @return string Alphadecimal uid of the related board
+ * @throws InvalidInputException When $object is not a Header, PostRevision or
+ * PostSummary instance.
+ * @throws DataModelException When the related id cannot be located
+ */
+ protected function findTopicListId( AbstractRevision $object, array $row, array $metadata ) {
+ if ( $object instanceof Header ) {
+ return $row['rev_type_id'];
+ }
+
+ if ( isset( $metadata['workflow'] ) && $metadata['workflow'] instanceof Workflow ) {
+ $topicId = $metadata['workflow']->getId();
+ } else {
+ if ( $object instanceof PostRevision ) {
+ $post = $object;
+ } elseif ( $object instanceof PostSummary ) {
+ $post = $object->getCollection()->getPost()->getLastRevision();
+ } else {
+ throw new InvalidInputException( 'Unexpected object type: ' . get_class( $object ) );
+ }
+ $topicId = $post->getRootPost()->getPostId();
+ }
+
+ $found = $this->om->find( array( 'topic_id' => $topicId ) );
+ if ( !$found ) {
+ throw new DataModelException(
+ "No topic list contains topic " . $topicId->getAlphadecimal() .
+ ", called for revision " . $object->getRevisionId()->getAlphadecimal()
+ );
+ }
+
+ /** @var TopicListEntry $topicListEntry */
+ $topicListEntry = reset( $found );
+ return $topicListEntry->getListId()->getAlphadecimal();
+ }
+}
diff --git a/Flow/includes/Data/Index/FeatureIndex.php b/Flow/includes/Data/Index/FeatureIndex.php
new file mode 100644
index 00000000..8d56cfde
--- /dev/null
+++ b/Flow/includes/Data/Index/FeatureIndex.php
@@ -0,0 +1,680 @@
+<?php
+
+namespace Flow\Data\Index;
+
+use Flow\Container;
+use Flow\Data\BufferedCache;
+use Flow\Data\Compactor;
+use Flow\Data\Compactor\FeatureCompactor;
+use Flow\Data\Compactor\ShallowCompactor;
+use Flow\Data\Index;
+use Flow\Data\ObjectManager;
+use Flow\Data\ObjectStorage;
+use Flow\Model\UUID;
+use FormatJson;
+use Flow\Exception\DataModelException;
+
+/**
+ * Index objects with equal features($indexedColumns) into the same buckets.
+ */
+abstract class FeatureIndex implements Index {
+
+ /**
+ * @var BufferedCache
+ */
+ protected $cache;
+
+ /**
+ * @var ObjectStorage
+ */
+ protected $storage;
+
+ /**
+ * @var string
+ */
+ protected $prefix;
+
+ /**
+ * @var Compactor
+ */
+ protected $rowCompactor;
+
+ /**
+ * @var string[]
+ */
+ protected $indexed;
+
+ /**
+ * @var string[] The indexed columns in alphabetical order. This is
+ * ordered so that cache keys can be generated in a stable manner.
+ */
+ protected $indexedOrdered;
+
+ /**
+ * @var array
+ */
+ protected $options;
+
+ /**
+ * {@inheritDoc}
+ *
+ * This exists in the Index interface and as such can't be abstract
+ * until php 5.3.9, but some of our test machines are on 5.3.3. It
+ * is included here to a complete list of unimplemented methods are seen
+ * by looking at just this class.
+ */
+ //abstract public function getLimit();
+
+ /**
+ * @return array The options used for querying self::$storage
+ */
+ abstract public function queryOptions();
+
+ /**
+ * @todo this doesn't need to be abstract
+ * @param array $values The current contents of a single feature bucket
+ * @return array $values trimmed to respect self::getLimit()
+ */
+ abstract public function limitIndexSize( array $values );
+
+ /**
+ * @todo Could the cache key be passed in instead of $indexed?
+ * @param array $indexed The portion of $row that makes up the cache key
+ * @param array $row A single row of data to add to its related feature bucket
+ */
+ abstract protected function addToIndex( array $indexed, array $row );
+
+ /**
+ * @todo Similar, Could the cache key be passed in instead of $indexed?
+ * @param array $indexed The portion of $row that makes up the cache key
+ * @param array $row A single row of data to remove from its related feature bucket
+ */
+ abstract protected function removeFromIndex( array $indexed, array $row );
+
+ /**
+ * @param BufferedCache $cache
+ * @param ObjectStorage $storage
+ * @param string $prefix Prefix to utilize for all cache keys
+ * @param array $indexedColumns List of columns to index,
+ */
+ public function __construct( BufferedCache $cache, ObjectStorage $storage, $prefix, array $indexedColumns ) {
+ $this->cache = $cache;
+ $this->storage = $storage;
+ $this->prefix = $prefix;
+ $this->rowCompactor = new FeatureCompactor( $indexedColumns );
+ $this->indexed = $indexedColumns;
+ // sort this and ksort in self::cacheKey to always have cache key
+ // fields in same order
+ sort( $indexedColumns );
+ $this->indexedOrdered = $indexedColumns;
+ }
+
+ /**
+ * @return string[] The list of columns to bucket database rows by in
+ * the same order as provided to the constructor.
+ */
+ public function getPrimaryKeyColumns() {
+ return $this->indexed;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function canAnswer( array $featureColumns, array $options ) {
+ sort( $featureColumns );
+ if ( $featureColumns !== $this->indexedOrdered ) {
+ return false;
+ }
+ if ( isset( $options['limit'] ) ) {
+ $max = $options['limit'];
+ if ( isset( $options['offset'] ) ) {
+ $max += $options['offset'];
+ }
+ if ( $max > $this->getLimit() ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Rows are first sorted based on the first term of the result, then ties
+ * are broken by evaluating the second term and so on.
+ *
+ * @return string[]|false The columns to sort by, or false if no sorting is defined
+ */
+ public function getSort() {
+ return isset( $this->options['sort'] ) ? $this->options['sort'] : false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getOrder() {
+ if ( isset( $this->options['order'] ) && strtoupper( $this->options['order'] ) === 'ASC' ) {
+ return 'ASC';
+ } else {
+ return 'DESC';
+ }
+ }
+
+ /**
+ * @param array $rows
+ * @param array $options
+ * @return array [offset, limit]
+ */
+ protected function getOffsetLimit( $rows, $options ) {
+ $limit = isset( $options['limit'] ) ? $options['limit'] : $this->getLimit();
+
+ // not using isset because offset-id could also just be null (in which
+ // case we'll still not want to fallback to 0, because offset-dir may
+ // need to to start from the end of the rows)
+ if ( !array_key_exists( 'offset-id', $options ) ) {
+ $offset = isset( $options['offset'] ) ? $options['offset'] : 0;
+ return array( $offset, $limit );
+ }
+
+ $offsetId = $options['offset-id'];
+ if ( $offsetId instanceof UUID ) {
+ $offsetId = $offsetId->getAlphadecimal();
+ }
+
+ $dir = 'fwd';
+ if (
+ isset( $options['offset-dir'] ) &&
+ $options['offset-dir'] === 'rev'
+ ) {
+ $dir = 'rev';
+ }
+
+ if ( $offsetId === null ) {
+ $offset = $dir === 'fwd' ? 0 : count( $rows ) - $limit;
+ return array( $offset, $limit );
+ }
+
+ $offset = $this->getOffsetFromKey( $rows, $offsetId );
+ $includeOffset = isset( $options['include-offset'] ) && $options['include-offset'];
+ if ( $dir === 'fwd' ) {
+ if ( $includeOffset ) {
+ $startPos = $offset;
+ } else {
+ $startPos = $offset + 1;
+ }
+ } elseif ( $dir === 'rev' ) {
+ $startPos = $offset - $limit;
+ if ( $includeOffset ) {
+ $startPos++;
+ }
+
+ if ( $startPos < 0 ) {
+ if (
+ isset( $options['offset-elastic'] ) &&
+ $options['offset-elastic'] === false
+ ) {
+ // If non-elastic, then reduce the number of items shown commensurately
+ $limit += $startPos;
+ }
+ $startPos = 0;
+ }
+ } else {
+ $startPos = 0;
+ }
+
+ return array( $startPos, $limit );
+ }
+
+ /**
+ * Returns the 0-indexed position of $offsetKey within $rows or throws a
+ * DataModelException if $offsetKey is not contained within $rows
+ *
+ * @todo seems wasteful to pass string offsetKey instead of exploding when it comes in
+ * @param array $rows Current bucket contents
+ * @param string $offsetKey
+ * @return int The position of $offsetKey within $rows
+ * @throws DataModelException When $offsetKey is not found within $rows
+ */
+ protected function getOffsetFromKey( $rows, $offsetKey ) {
+ $rowIndex = 0;
+ $nextInOrder = $this->getOrder() === 'DESC' ? -1 : 1;
+ foreach ( $rows as $row ) {
+ $comparisonValue = $this->compareRowToOffset( $row, $offsetKey );
+ if ( $comparisonValue === 0 || $comparisonValue === $nextInOrder ) {
+ return $rowIndex;
+ }
+ $rowIndex++;
+ }
+
+ throw new DataModelException( 'Unable to find specified offset in query results', 'process-data' );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws DataModelException When the index does not support key offsets due to
+ * having an undefined sort order.
+ */
+ public function compareRowToOffset( array $row, $offset ) {
+ $sortFields = $this->getSort();
+ $splitOffset = explode( '|', $offset );
+ $fieldIndex = 0;
+
+ if ( $sortFields === false ) {
+ throw new DataModelException( 'This Index implementation does not support key offsets', 'process-data' );
+ }
+
+ foreach( $sortFields as $field ) {
+ $valueInRow = $row[$field];
+ $valueInOffset = $splitOffset[$fieldIndex];
+
+ if ( $valueInRow > $valueInOffset ) {
+ return 1;
+ } elseif ( $valueInRow < $valueInOffset ) {
+ return -1;
+ }
+ ++$fieldIndex;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Delete any feature bucket $object would be contained in from the cache
+ *
+ * @param object $object
+ * @param array $row
+ * @throws DataModelException
+ */
+ public function cachePurge( $object, array $row ) {
+ $indexed = ObjectManager::splitFromRow( $row, $this->indexed );
+ if ( !$indexed ) {
+ throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $row ), 'process-data' );
+ }
+ // We don't want to just remove this object from the index, then the index would be incorrect.
+ // We want to delete the bucket that contains this object.
+ $this->cache->delete( $this->cacheKey( $indexed ) );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ $indexed = ObjectManager::splitFromRow( $new , $this->indexed );
+ // is un-indexable a bail-worthy occasion? Probably not but makes debugging easier
+ if ( !$indexed ) {
+ throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $new ), 'process-data' );
+ }
+ $compacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $new, 'alphadecimal' ) );
+ // give implementing index option to create rather than append
+ if ( !$this->maybeCreateIndex( $indexed, $new, $compacted ) ) {
+ // fall back to append
+ $this->addToIndex( $indexed, $compacted );
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ $oldIndexed = ObjectManager::splitFromRow( $old, $this->indexed );
+ $newIndexed = ObjectManager::splitFromRow( $new, $this->indexed );
+ if ( !$oldIndexed ) {
+ throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $oldIndexed ), 'process-data' );
+ }
+ if ( !$newIndexed ) {
+ throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $newIndexed ), 'process-data' );
+ }
+ $oldCompacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $old, 'alphadecimal' ) );
+ $newCompacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $new, 'alphadecimal' ) );
+ if ( ObjectManager::arrayEquals( $oldIndexed, $newIndexed ) ) {
+ if ( ObjectManager::arrayEquals( $oldCompacted, $newCompacted ) ) {
+ // Nothing changed in the index
+ return;
+ }
+ // object representation in feature bucket has changed
+ $this->replaceInIndex( $oldIndexed, $oldCompacted, $newCompacted );
+ } else {
+ // object has moved from one feature bucket to another
+ $this->removeFromIndex( $oldIndexed, $oldCompacted );
+ $this->addToIndex( $newIndexed, $newCompacted );
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ $indexed = ObjectManager::splitFromRow( $old, $this->indexed );
+ if ( !$indexed ) {
+ throw new DataModelException( 'Unindexable row: ' . FormatJson::encode( $old ), 'process-data' );
+ }
+ $this->removeFromIndex( $indexed, $old );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onAfterLoad( $object, array $old ) {
+ // nothing to do
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function find( array $attributes, array $options = array() ) {
+ $results = $this->findMulti( array( $attributes ), $options );
+ return reset( $results );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function findMulti( array $queries, array $options = array() ) {
+ if ( !$queries ) {
+ return array();
+ }
+
+ // get cache keys for all queries
+ $cacheKeys = $this->getCacheKeys( $queries );
+
+ // retrieve from cache (only query duplicate queries once)
+ // $fromCache will be an array containing compacted results as value and
+ // cache keys as key
+ $fromCache = $this->cache->getMulti( array_unique( $cacheKeys ) );
+
+ // figure out what queries were resolved in cache
+ // $keysFromCache will be an array where values are cache keys and keys
+ // are the same index as their corresponding $queries
+ $keysFromCache = array_intersect( $cacheKeys, array_keys( $fromCache ) );
+
+ // filter out all queries that have been resolved from cache and fetch
+ // them from storage
+ // $fromStorage will be an array containing (expanded) results as value
+ // and indexes matching $query as key
+ $storageQueries = array_diff_key( $queries, $keysFromCache );
+ $fromStorage = array();
+ if ( $storageQueries ) {
+ $fromStorage = $this->backingStoreFindMulti( $storageQueries );
+
+ // store the data we've just retrieved to cache
+ foreach ( $fromStorage as $index => $rows ) {
+ $compacted = $this->rowCompactor->compactRows( $rows );
+ $callback = function( \BagOStuff $cache, $key, $value ) use ( $compacted ) {
+ if ( $value !== false ) {
+ // somehow, the data was already cached in the meantime
+ return false;
+ }
+
+ return $compacted;
+ };
+
+ $this->cache->merge( $cacheKeys[$index], $callback );
+ }
+ }
+
+ $results = $fromStorage;
+
+ // $queries may have had duplicates that we've ignored to minimize
+ // cache requests - now re-duplicate values from cache & match the
+ // results against their respective original keys in $queries
+ foreach ( $keysFromCache as $index => $cacheKey ) {
+ $results[$index] = $fromCache[$cacheKey];
+ }
+
+ // now that we have all data, both from cache & backing storage, filter
+ // out all data we don't need
+ $results = $this->filterResults( $results, $options );
+
+ // if we have no data from cache, there's nothing left - quit early
+ if ( !$fromCache ) {
+ return $results;
+ }
+
+ // because we may have combined data from 2 different sources, chances
+ // are the order of the data is no longer in sync with the order
+ // $queries were in - fix that by replacing $queries values with
+ // the corresponding $results value
+ // note that there may be missing results, hence the intersect ;)
+ $order = array_intersect_key( $queries, $results );
+ $results = array_replace( $order, $results );
+
+ $keyToQuery = array();
+ foreach ( $keysFromCache as $index => $key ) {
+ // all redundant data has been stripped, now expand all cache values
+ // (we're only doing this now to avoid expanding redundant data)
+ $fromCache[$key] = $results[$index];
+
+ // to expand rows, we'll need the $query info mapped to the cache
+ // key instead of the $query index
+ if ( !isset( $keyToQuery[$key] ) ) {
+ $keyToQuery[$key] = $queries[$index];
+ $keyToQuery[$key] = UUID::convertUUIDs( $keyToQuery[$key], 'alphadecimal' );
+ }
+ }
+
+ // expand and replace the stubs in $results with complete data
+ $fromCache = $this->rowCompactor->expandCacheResult( $fromCache, $keyToQuery );
+ foreach ( $keysFromCache as $index => $cacheKey ) {
+ $results[$index] = $fromCache[$cacheKey];
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get rid of unneeded, according to the given $options.
+ *
+ * This is used to strip entries before expanding them;
+ * basically, at that point, we may only have a list of ids, which we need
+ * to expand (= fetch from cache) - don't want to do this for more than
+ * what is needed
+ *
+ * @param array $results
+ * @param array[optional] $options
+ * @return array
+ */
+ protected function filterResults( array $results, array $options = array() ) {
+ foreach ( $results as $i => $result ) {
+ list( $offset, $limit ) = $this->getOffsetLimit( $result, $options );
+ $results[$i] = array_slice( $result, $offset, $limit, true );
+ }
+
+ return $results;
+ }
+
+ /**
+ * Returns a boolean true/false if the find()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $attributes Attributes to find()
+ * @param array[optional] $options Options to find()
+ * @return bool
+ */
+ public function found( array $attributes, array $options = array() ) {
+ return $this->foundMulti( array( $attributes ), $options );
+ }
+
+ /**
+ * Returns a boolean true/false if the findMulti()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $queries Queries to findMulti()
+ * @param array[optional] $options Options to findMulti()
+ * @return bool
+ */
+ public function foundMulti( array $queries, array $options = array() ) {
+ if ( !$queries ) {
+ return true;
+ }
+
+ // get cache keys for all queries
+ $cacheKeys = $this->getCacheKeys( $queries );
+
+ // check if cache has a way of identifying what's stored locally
+ if ( !method_exists( $this->cache, 'has' ) ) {
+ return false;
+ }
+
+ // check if keys matching given queries are already known in local cache
+ foreach ( $cacheKeys as $key ) {
+ if ( !$this->cache->has( $key ) ) {
+ return false;
+ }
+ }
+
+ $keyToQuery = array();
+ foreach ( $cacheKeys as $i => $key ) {
+ // These results will be merged into the query results, and as such need binary
+ // uuid's as would be received from storage
+ if ( !isset( $keyToQuery[$key] ) ) {
+ $keyToQuery[$key] = $queries[$i];
+ }
+ }
+
+ // retrieve from cache - this is cheap, it's is local storage
+ $cached = $this->cache->getMulti( $cacheKeys );
+ foreach ( $cached as $i => $result ) {
+ $limit = isset( $options['limit'] ) ? $options['limit'] : $this->getLimit();
+ $cached[$i] = array_splice( $result, 0, $limit );
+ }
+
+ // if we have a shallow compactor, the returned data are PKs of objects
+ // that need to be fetched too
+ if ( $this->rowCompactor instanceof ShallowCompactor ) {
+ // test of the keys to be expanded are already in local cache
+ $duplicator = $this->rowCompactor->getResultDuplicator( $cached, $keyToQuery );
+ $queries = $duplicator->getUniqueQueries();
+ if ( !$this->rowCompactor->getShallow()->foundMulti( $queries ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Build a map from cache key to its index in $queries.
+ *
+ * @param array $queries
+ * @return array Array of [query index => cache key]
+ * @throws DataModelException
+ */
+ protected function getCacheKeys( $queries ) {
+ $idxToKey = array();
+ foreach ( $queries as $idx => $query ) {
+ ksort( $query );
+ if ( array_keys( $query ) !== $this->indexedOrdered ) {
+ throw new DataModelException(
+ 'Cannot answer query for columns: ' . implode( ', ', array_keys( $queries[$idx] ) ), 'process-data'
+ );
+ }
+ $key = $this->cacheKey( $query );
+ $idxToKey[$idx] = $key;
+ }
+
+ return $idxToKey;
+ }
+
+ /**
+ * Query persistent storage for data not found in cache. Note that this
+ * does not use the query options because an individual bucket contents is
+ * based on constructor options, and not query options. Query options merely
+ * change what part of the bucket is returned(or if the query has to fail over
+ * to direct from storage due to being beyond the set of cached values).
+ *
+ * @param array $queries
+ * @return array
+ */
+ protected function backingStoreFindMulti( array $queries ) {
+ // query backing store
+ $options = $this->queryOptions();
+ $stored = $this->storage->findMulti( $queries, $options );
+ $results = array();
+
+ // map store results to cache key
+ foreach ( $stored as $idx => $rows ) {
+ if ( !$rows ) {
+ // Nothing found, should we cache failures as well as success?
+ continue;
+ }
+ $results[$idx] = $rows;
+ unset( $queries[$idx] );
+ }
+
+ if ( count( $queries ) !== 0 ) {
+ // Log something about not finding everything?
+ }
+
+ return $results;
+ }
+
+ /**
+ * Called prior to self::addToIndex only when new objects as inserted. Gives the
+ * opportunity for indexes to create rather than append if this object signifies a new
+ * feature list.
+ *
+ * @todo again, could just pass cache key instead of $indexed?
+ * @param array $indexed The values that make up the cache key
+ * @param array $sourceRow The input database row
+ * @param array $compacted The database row reduced in size for storage within the index
+ * @return boolean True if an index was created, or false if $sourceRow should be merged
+ * into the index via self::addToIndex
+ */
+ protected function maybeCreateIndex( array $indexed, array $sourceRow, array $compacted ) {
+ return false;
+ }
+
+ /**
+ * Called to update a row's data within a feature bucket.
+ *
+ * Note that this naive implementation does two round trips, likely an implementing
+ * class can do this in a single round trip.
+ *
+ * @todo again, could just pass cache key instead of $indexed?
+ * @param array $indexed The values that make up the cache key
+ * @param array $old The database row that was previously retrieved from cache
+ * @param array $new The new version of that replacement row
+ */
+ protected function replaceInIndex( array $indexed, array $old, array $new ) {
+ $this->removeFromIndex( $indexed, $old );
+ $this->addToIndex( $indexed, $new );
+ }
+
+ /**
+ * Generate the cache key representing the attributes
+ * @param array $attributes
+ * @return string
+ */
+ protected function cacheKey( array $attributes ) {
+ foreach( $attributes as $key => $attr ) {
+ if ( $attr instanceof UUID ) {
+ $attributes[$key] = $attr->getAlphadecimal();
+ } elseif ( strlen( $attr ) === UUID::BIN_LEN && substr( $key, -3 ) === '_id' ) {
+ $attributes[$key] = UUID::create( $attr )->getAlphadecimal();
+ }
+ }
+
+ // values in $attributes may not always be in the exact same order,
+ // which would lead to differences in cache key if we don't force that
+ sort( $attributes );
+
+ return wfForeignMemcKey( self::cachedDbId(), '', $this->prefix, implode( ':', $attributes ), Container::get( 'cache.version' ) );
+ }
+
+ /**
+ * @return string The id of the database being cached
+ */
+ static public function cachedDbId() {
+ global $wgFlowDefaultWikiDb;
+ if ( $wgFlowDefaultWikiDb === false ) {
+ return wfWikiId();
+ } else {
+ return $wgFlowDefaultWikiDb;
+ }
+ }
+}
diff --git a/Flow/includes/Data/Index/TopKIndex.php b/Flow/includes/Data/Index/TopKIndex.php
new file mode 100644
index 00000000..b79e93f2
--- /dev/null
+++ b/Flow/includes/Data/Index/TopKIndex.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Flow\Data\Index;
+
+use BagOStuff;
+use Flow\Data\BufferedCache;
+use Flow\Data\ObjectManager;
+use Flow\Data\ObjectStorage;
+use Flow\Data\Compactor\ShallowCompactor;
+use Flow\Data\Utils\SortArrayByKeys;
+use Flow\Exception\InvalidInputException;
+
+/**
+ * Holds the top k items with matching $indexed columns. List is sorted and truncated to specified size.
+ */
+class TopKIndex extends FeatureIndex {
+ /**
+ * @var array
+ */
+ protected $options = array();
+
+ public function __construct( BufferedCache $cache, ObjectStorage $storage, $prefix, array $indexed, array $options = array() ) {
+ if ( empty( $options['sort'] ) ) {
+ throw new InvalidInputException( 'TopKIndex must be sorted', 'invalid-input' );
+ }
+
+ parent::__construct( $cache, $storage, $prefix, $indexed );
+
+ $this->options = $options + array(
+ 'limit' => 500,
+ 'order' => 'DESC',
+ 'create' => function() { return false; },
+ 'shallow' => null,
+ );
+ $this->options['order'] = strtoupper( $this->options['order'] );
+
+ if ( !is_array( $this->options['sort'] ) ) {
+ $this->options['sort'] = array( $this->options['sort'] );
+ }
+ if ( $this->options['shallow'] ) {
+ // TODO: perhaps we shouldn't even get a shallow option, just receive a proper compactor in FeatureIndex::__construct
+ $this->rowCompactor = new ShallowCompactor( $this->rowCompactor, $this->options['shallow'], $this->options['sort'] );
+ }
+ }
+
+ public function canAnswer( array $keys, array $options ) {
+ if ( !parent::canAnswer( $keys, $options ) ) {
+ return false;
+ }
+ if ( isset( $options['sort'], $options['order'] ) ) {
+ return ObjectManager::makeArray( $options['sort'] ) === $this->options['sort']
+ && strtoupper( $options['order'] ) === $this->options['order'];
+ }
+ return true;
+ }
+
+ public function getLimit() {
+ return $this->options['limit'];
+ }
+
+ protected function maybeCreateIndex( array $indexed, array $sourceRow, array $compacted ) {
+ if ( call_user_func( $this->options['create'], $sourceRow ) ) {
+ $this->cache->set( $this->cacheKey( $indexed ), array( $compacted ) );
+ return true;
+ }
+ return false;
+ }
+
+ protected function addToIndex( array $indexed, array $row ) {
+ $self = $this;
+ // If this used redis instead of memcached, could it add to index in position
+ // without retry possibility? need a single number that will properly sort rows.
+ $this->cache->merge(
+ $this->cacheKey( $indexed ),
+ function( BagOStuff $cache, $key, $value ) use( $self, $row ) {
+ if ( $value === false ) {
+ return false;
+ }
+ $idx = array_search( $row, $value );
+ if ( $idx !== false ) {
+ return false; // This row already exists somehow
+ }
+ $retval = $value;
+ $retval[] = $row;
+ $retval = $self->sortIndex( $retval );
+ $retval = $self->limitIndexSize( $retval );
+ if ( $retval === $value ) {
+ // object didn't fit in index
+ return false;
+ } else {
+ return $retval;
+ }
+ }
+ );
+ }
+
+ protected function removeFromIndex( array $indexed, array $row ) {
+ $this->cache->merge(
+ $this->cacheKey( $indexed ),
+ function( BagOStuff $cache, $key, $value ) use( $row ) {
+ if ( $value === false ) {
+ return false;
+ }
+ $idx = array_search( $row, $value );
+ if ( $idx === false ) {
+ return false;
+ }
+ unset( $value[$idx] );
+ return $value;
+ }
+ );
+ }
+
+ protected function replaceInIndex( array $indexed, array $oldRow, array $newRow ) {
+ $self = $this;
+ $this->cache->merge(
+ $this->cacheKey( $indexed ),
+ function( BagOStuff $cache, $key, $value ) use( $self, $oldRow, $newRow ) {
+ if ( $value === false ) {
+ return false;
+ }
+ $retval = $value;
+ $idx = array_search( $oldRow, $retval );
+ if ( $idx !== false ) {
+ unset( $retval[$idx] );
+ }
+ $retval[] = $newRow;
+ $retval = $self->sortIndex( $retval );
+ $retval = $self->limitIndexSize( $retval );
+ if ( $value === $retval ) {
+ // new item didnt fit in index and old item wasnt found in index
+ return false;
+ } else {
+ return $retval;
+ }
+ }
+ );
+ }
+
+ // INTERNAL: in 5.4 it can be protected
+ public function sortIndex( array $values ) {
+ // I dont think this is a valid way to sort a 128bit integer string
+ $callback = new SortArrayByKeys( $this->options['sort'], true );
+ /** @noinspection PhpParamsInspection */
+ usort( $values, $callback );
+ if ( $this->options['order'] === 'DESC' ) {
+ $values = array_reverse( $values );
+ }
+ return $values;
+ }
+
+ // INTERNAL: in 5.4 it can be protected
+ public function limitIndexSize( array $values ) {
+ return array_slice( $values, 0, $this->options['limit'] );
+ }
+
+ // INTERNAL: in 5.4 it can be protected
+ public function queryOptions() {
+ $options = array( 'LIMIT' => $this->options['limit'] );
+
+ $orderBy = array();
+ $order = $this->options['order'];
+ foreach ( $this->options['sort'] as $key ) {
+ $orderBy[] = "$key $order";
+ }
+ $options['ORDER BY'] = $orderBy;
+
+ return $options;
+ }
+}
diff --git a/Flow/includes/Data/Index/TopicHistoryIndex.php b/Flow/includes/Data/Index/TopicHistoryIndex.php
new file mode 100644
index 00000000..01b1659c
--- /dev/null
+++ b/Flow/includes/Data/Index/TopicHistoryIndex.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Flow\Data\Index;
+
+use Flow\Data\BufferedCache;
+use Flow\Data\Storage\TopicHistoryStorage;
+use Flow\Exception\InvalidInputException;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\Workflow;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use MWException;
+
+/**
+ * Slight tweak to the TopKIndex uses additional info from TreeRepository to build the cache
+ */
+class TopicHistoryIndex extends TopKIndex {
+
+ protected $treeRepository;
+
+ public function __construct( BufferedCache $cache, TopicHistoryStorage $storage, TreeRepository $treeRepo, $prefix, array $indexed, array $options = array() ) {
+ if ( $indexed !== array( 'topic_root_id' ) ) {
+ throw new \MWException( __CLASS__ . ' is hardcoded to only index topic_root_id: ' . print_r( $indexed, true ) );
+ }
+ parent::__construct( $cache, $storage, $prefix, $indexed, $options );
+ $this->treeRepository = $treeRepo;
+ }
+
+ /**
+ * @param PostRevision|PostSummary $object
+ * @param array $row
+ */
+ public function cachePurge( $object, array $row ) {
+ $row['topic_root_id'] = $this->findTopicRootId( $object, array() );
+ parent::cachePurge( $object, $row );
+ }
+
+ /**
+ * @param PostRevision|PostSummary $object
+ * @param string[] $new
+ * @param array $metadata
+ */
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ $new['topic_root_id'] = $this->findTopicRootId( $object, $metadata );
+ parent::onAfterInsert( $object, $new, $metadata );
+ }
+
+ /**
+ * @param PostRevision|PostSummary $object
+ * @param string[] $old
+ * @param string[] $new
+ * @param array $metadata
+ */
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ $old['topic_root_id'] = $new['topic_root_id'] = $this->findTopicRootId( $object, $metadata );
+ parent::onAfterUpdate( $object, $old, $new, $metadata );
+ }
+
+ /**
+ * @param PostRevision|PostSummary $object
+ * @param string[] $old
+ * @param array $metadata
+ */
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ $old['topic_root_id'] = $this->findTopicRootId( $object, $metadata );
+ parent::onAfterRemove( $object, $old, $metadata );
+ }
+
+ /**
+ * @param PostRevision|PostSummary $object
+ * @param array $metadata
+ * @return string alphadecimal uuid
+ * @throws InvalidInputException When $object is not PostRevision or PostSummary
+ */
+ protected function findTopicRootId( $object, array $metadata ) {
+ if ( isset( $metadata['workflow'] ) && $metadata['workflow'] instanceof Workflow ) {
+ return $metadata['workflow']->getId();
+ } elseif ( $object instanceof PostRevision ) {
+ return $object->getRootPost()->getPostId()->getAlphadecimal();
+ } elseif ( $object instanceof PostSummary ) {
+ return $object->getCollection()->getWorkflowId()->getAlphadecimal();
+ } else {
+ throw new InvalidInputException( 'Unexpected revision type: ' . get_class( $object ) );
+ }
+ }
+
+ protected function backingStoreFindMulti( array $queries ) {
+ // all queries are for roots( guaranteed by constructor), so anything that falls
+ // through and has to be queried from storage will actually need to be doing a
+ // special condition either joining against flow_tree_node or first collecting the
+ // subtree node lists and then doing a big IN condition
+
+ // This isn't a hot path (should be pre-populated into index) but we still don't want
+ // horrible performance
+
+ $roots = array();
+ foreach ( $queries as $features ) {
+ $roots[] = UUID::create( $features['topic_root_id'] );
+ }
+ $nodeList = $this->treeRepository->fetchSubtreeNodeList( $roots );
+ if ( $nodeList === false ) {
+ // We can't return the existing $retval, that false data would be cached.
+ return array();
+ }
+
+ $descendantQueries = array();
+ foreach ( $queries as $idx => $features ) {
+ /** @var UUID $topicRootId */
+ $topicRootId = UUID::create( $features['topic_root_id'] );
+ $nodes = $nodeList[$topicRootId->getAlphadecimal()];
+ $descendantQueries[$idx] = array(
+ 'rev_type_id' => UUID::convertUUIDs( $nodes ),
+ );
+ }
+
+ $options = $this->queryOptions();
+ $res = $this->storage->findMulti( $descendantQueries, $options );
+ if ( !$res ) {
+ return array();
+ }
+
+ $results = array();
+
+ foreach ( $res as $idx => $rows ) {
+ $results[$idx] = $rows;
+ unset( $queries[$idx] );
+ }
+ if ( $queries ) {
+ // Log something about not finding everything?
+ }
+ return $results;
+ }
+}
diff --git a/Flow/includes/Data/Index/UniqueFeatureIndex.php b/Flow/includes/Data/Index/UniqueFeatureIndex.php
new file mode 100644
index 00000000..caa0d9af
--- /dev/null
+++ b/Flow/includes/Data/Index/UniqueFeatureIndex.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Flow\Data\Index;
+
+use Flow\Exception\DataModelException;
+
+/**
+ * Offers direct lookup of an object via a unique feature(set of properties)
+ * on the object.
+ */
+class UniqueFeatureIndex extends FeatureIndex {
+
+ public function getLimit() {
+ return 1;
+ }
+
+ public function queryOptions() {
+ return array( 'LIMIT' => $this->getLimit() );
+ }
+
+ public function limitIndexSize( array $values ) {
+ if ( count( $values ) > $this->getLimit() ) {
+ throw new DataModelException( 'Unique index should never have more than ' . $this->getLimit() . ' value', 'process-data' );
+ }
+ return $values;
+ }
+
+ protected function addToIndex( array $indexed, array $row ) {
+ $this->cache->set( $this->cacheKey( $indexed ), array( $row ) );
+ }
+
+ protected function removeFromIndex( array $indexed, array $row ) {
+ $this->cache->delete( $this->cacheKey( $indexed ) );
+ }
+
+ protected function replaceInIndex( array $indexed, array $oldRow, array $newRow ) {
+ $this->cache->set( $this->cacheKey( $indexed ), array( $newRow ) );
+ }
+} \ No newline at end of file
diff --git a/Flow/includes/Data/LifecycleHandler.php b/Flow/includes/Data/LifecycleHandler.php
new file mode 100644
index 00000000..b1ee3a86
--- /dev/null
+++ b/Flow/includes/Data/LifecycleHandler.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Flow\Data;
+
+/**
+ * Listeners that receive notifications about the lifecycle of
+ * a domain model.
+ */
+interface LifecycleHandler {
+ function onAfterLoad( $object, array $old );
+ function onAfterInsert( $object, array $new, array $metadata );
+ function onAfterUpdate( $object, array $old, array $new, array $metadata );
+ function onAfterRemove( $object, array $old, array $metadata );
+}
diff --git a/Flow/includes/Data/Listener/DeferredInsertLifecycleHandler.php b/Flow/includes/Data/Listener/DeferredInsertLifecycleHandler.php
new file mode 100644
index 00000000..23332fb9
--- /dev/null
+++ b/Flow/includes/Data/Listener/DeferredInsertLifecycleHandler.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use SplQueue;
+
+class DeferredInsertLifecycleHandler implements LifecycleHandler {
+ /**
+ * @var SplQueue
+ */
+ protected $queue;
+
+ /**
+ * @var LifecycleHandler
+ */
+ protected $nested;
+
+ /**
+ * @param SplQueue $queue
+ * @param LifecycleHandler $nested
+ */
+ public function __construct( SplQueue $queue, LifecycleHandler $nested ) {
+ $this->queue = $queue;
+ $this->nested = $nested;
+ }
+
+ /**
+ * @param object $object
+ * @param array $new
+ * @param array $metadata
+ */
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ $nested = $this->nested;
+ $this->queue->enqueue( function() use ( $nested, $object, $new, $metadata ) {
+ $nested->onAfterInsert( $object, $new, $metadata );
+ } );
+ }
+
+ /**
+ * @param object $object
+ * @param array $old
+ * @param array $new
+ * @param array $metadata
+ */
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ $this->nested->onAfterUpdate( $object, $old, $new, $metadata );
+ }
+
+ /**
+ * @param object $object
+ * @param array $old
+ * @param array $metadata
+ */
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ $this->nested->onAfterRemove( $object, $old, $metadata );
+ }
+
+ /**
+ * @param object $object
+ * @param array $old
+ */
+ public function onAfterLoad( $object, array $old ) {
+ $this->nested->onAfterLoad( $object, $old );
+ }
+}
diff --git a/Flow/includes/Data/Listener/EditCountListener.php b/Flow/includes/Data/Listener/EditCountListener.php
new file mode 100644
index 00000000..e7f40fe4
--- /dev/null
+++ b/Flow/includes/Data/Listener/EditCountListener.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Exception\InvalidDataException;
+use Flow\FlowActions;
+use Flow\Model\AbstractRevision;
+
+class EditCountListener implements LifecycleHandler {
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ public function __construct( FlowActions $actions ) {
+ $this->actions = $actions;
+ }
+
+ public function onAfterLoad( $object, array $old ) {
+ // Nuthin
+ }
+
+ public function onAfterInsert( $revision, array $new, array $metadata ) {
+ if ( !$revision instanceof AbstractRevision ) {
+ throw new InvalidDataException( 'EditCountListener can only attach to AbstractRevision storage');
+ }
+
+ $action = $revision->getChangeType();
+ $increase = $this->actions->getValue( $action, 'editcount' );
+
+ if ( $increase ) {
+ $revision->getUser()->incEditCount();
+ }
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // Nuthin
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ // Nuthin
+ }
+}
diff --git a/Flow/includes/Data/Listener/ModerationLoggingListener.php b/Flow/includes/Data/Listener/ModerationLoggingListener.php
new file mode 100644
index 00000000..a243a966
--- /dev/null
+++ b/Flow/includes/Data/Listener/ModerationLoggingListener.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Log\ModerationLogger;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+
+class ModerationLoggingListener implements LifecycleHandler {
+
+ /**
+ * @var ModerationLogger
+ */
+ protected $moderationLogger;
+
+ function __construct( ModerationLogger $moderationLogger ) {
+ $this->moderationLogger = $moderationLogger;
+ }
+
+ /**
+ * @param PostRevision $object
+ * @param array $row
+ * @param array $metadata (must contain 'workflow' key with a Workflow object)
+ */
+ function onAfterInsert( $object, array $row, array $metadata ) {
+ if ( $object instanceof PostRevision ) {
+ $this->log( $object, $metadata['workflow'] );
+ }
+ }
+
+ function onAfterLoad( $object, array $old ) {
+ // You don't need to see my identification
+ }
+
+ function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // These aren't the droids you're looking for
+ }
+
+ function onAfterRemove( $object, array $old, array $metadata ) {
+ // Move along
+ }
+
+ protected function log( PostRevision $post, Workflow $workflow ) {
+ $moderationChangeTypes = self::getModerationChangeTypes();
+ if ( ! in_array( $post->getChangeType(), $moderationChangeTypes ) ) {
+ // Do nothing for non-moderation actions
+ return;
+ }
+
+ if ( $this->moderationLogger->canLog( $post, $post->getChangeType() ) ) {
+ $workflowId = $workflow->getId();
+
+ $this->moderationLogger->log(
+ $post,
+ $post->getChangeType(),
+ $post->getModeratedReason(),
+ $workflowId
+ );
+ }
+ }
+
+ public static function getModerationChangeTypes() {
+ static $changeTypes = false;
+
+ if ( ! $changeTypes ) {
+ $changeTypes = array();
+ foreach( AbstractRevision::$perms as $perm ) {
+ if ( $perm != '' ) {
+ $changeTypes[] = "{$perm}-topic";
+ $changeTypes[] = "{$perm}-post";
+ }
+ }
+
+ $changeTypes[] = 'restore-topic';
+ $changeTypes[] = 'restore-post';
+ }
+
+ return $changeTypes;
+ }
+}
diff --git a/Flow/includes/Data/Listener/NotificationListener.php b/Flow/includes/Data/Listener/NotificationListener.php
new file mode 100644
index 00000000..6c266d12
--- /dev/null
+++ b/Flow/includes/Data/Listener/NotificationListener.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Exception\InvalidDataException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Workflow;
+use Flow\NotificationController;
+
+class NotificationListener implements LifecycleHandler {
+
+ /**
+ * @var NotificationController
+ */
+ protected $notificationController;
+
+ public function __construct( NotificationController $notificationController ) {
+ $this->notificationController = $notificationController;
+ }
+
+ public function onAfterInsert( $object, array $row, array $metadata ) {
+ if ( !$object instanceof AbstractRevision ) {
+ return;
+ }
+
+ if ( isset( $metadata['imported'] ) && $metadata['imported'] ) {
+ // Don't send any notifications by default for imports
+ return;
+ }
+
+ switch( $row['rev_change_type'] ) {
+ // Actually new-topic @todo rename
+ case 'new-post':
+ if ( !isset(
+ $metadata['board-workflow'],
+ $metadata['workflow'],
+ $metadata['topic-title'],
+ $metadata['first-post']
+ ) ) {
+ throw new InvalidDataException( 'Invalid metadata for revision ' . $object->getRevisionId()->getAlphadecimal(), 'missing-metadata' );
+ }
+
+ $this->notificationController->notifyNewTopic( array(
+ 'board-workflow' => $metadata['board-workflow'],
+ 'topic-workflow' => $metadata['workflow'],
+ 'topic-title' => $metadata['topic-title'],
+ 'first-post' => $metadata['first-post'],
+ ) );
+ break;
+
+ case 'edit-title':
+ $this->notifyPostChange( 'flow-topic-renamed', $object, $metadata );
+ break;
+
+ case 'reply':
+ $this->notifyPostChange( 'flow-post-reply', $object, $metadata, array(
+ 'reply-to' => $metadata['reply-to'],
+ ) );
+ break;
+
+ case 'edit-post':
+ $this->notifyPostChange( 'flow-post-edited', $object, $metadata );
+ break;
+ }
+ }
+
+ /**
+ * @param string $type
+ * @param AbstractRevision $object
+ * @param array $metadata
+ * @param array $params
+ * @throws InvalidDataException
+ */
+ protected function notifyPostChange( $type, $object, $metadata, array $params = array() ) {
+ if ( !isset(
+ $metadata['workflow'],
+ $metadata['topic-title']
+ ) ) {
+ throw new InvalidDataException( 'Invalid metadata for revision ' . $object->getRevisionId()->getAlphadecimal(), 'missing-metadata' );
+ }
+
+ $workflow = $metadata['workflow'];
+ if ( !$workflow instanceof Workflow ) {
+ throw new InvalidDataException( 'Workflow metadata is not a Workflow', 'missing-metadata' );
+ }
+
+ $this->notificationController->notifyPostChange( $type, $params + array(
+ 'revision' => $object,
+ 'title' => $workflow->getOwnerTitle(),
+ 'topic-workflow' => $workflow,
+ 'topic-title' => $metadata['topic-title'],
+ ) );
+ }
+
+ public function onAfterLoad( $object, array $row ) {}
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {}
+ public function onAfterRemove( $object, array $row, array $metadata ) {}
+}
diff --git a/Flow/includes/Data/Listener/OccupationListener.php b/Flow/includes/Data/Listener/OccupationListener.php
new file mode 100644
index 00000000..12c14194
--- /dev/null
+++ b/Flow/includes/Data/Listener/OccupationListener.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Article;
+use Flow\Data\LifecycleHandler;
+use Flow\Exception\FlowException;
+use Flow\Model\Workflow;
+use Flow\OccupationController;
+use SplQueue;
+
+/**
+ * Ensures that a given workflow is occupied. This will be unnecssary
+ * once we deprecate the OccupationController white list.
+ */
+class OccupationListener implements LifecycleHandler {
+ /** @var OccupationController **/
+ protected $occupationController;
+
+ /** @var SplQueue */
+ protected $deferredQueue;
+
+ /** @var string **/
+ protected $defaultType;
+
+ /** @var bool **/
+ protected $enabled = true;
+
+ /**
+ * @param OccupationController $occupationController The OccupationController to occupy the page with.
+ * @param SplQueue $deferredQueue Queue of callbacks to run only if commit succedes
+ * @param string $defaultType The workflow type to look for
+ */
+ public function __construct(
+ OccupationController $occupationController,
+ SplQueue $deferredQueue,
+ $defaultType
+ ) {
+ $this->occupationController = $occupationController;
+ $this->deferredQueue = $deferredQueue;
+ $this->defaultType = $defaultType;
+ }
+
+ /**
+ * Disabling the listener is required if you want to load contributions
+ * or other flow history from pages that were enabled but are not anymore.
+ *
+ * @param bool $enabled
+ */
+ public function setEnabled( $enabled ) {
+ $this->enabled = (bool)$enabled;
+ }
+
+ public function onAfterLoad( $object, array $old ) {
+ if ( !$object instanceof Workflow ) {
+ return;
+ }
+ if ( $object->getType() === $this->defaultType ) {
+ // We don't want to defer the load event, the request
+ // may require this to actually exist to render properly.
+ $this->occupationController->ensureFlowRevision(
+ new Article( $object->getArticleTitle() ),
+ $object
+ );
+ }
+ }
+
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ if ( !$object instanceof Workflow ) {
+ return;
+ }
+
+ if ( isset( $metadata['imported'] ) && $metadata['imported'] ) {
+ $user = $this->occupationController->getTalkpageManager();
+ $this->occupationController->allowCreation( $object->getArticleTitle(), $user );
+ }
+
+ $this->ensureOccupation( $object );
+ }
+
+ protected function ensureOccupation( Workflow $workflow ) {
+ if ( $this->enabled ) {
+ $controller = $this->occupationController;
+ $this->deferredQueue->push( function() use ( $controller, $workflow ) {
+ $controller->ensureFlowRevision(
+ new Article( $workflow->getArticleTitle() ),
+ $workflow
+ );
+ } );
+ }
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // Nothing
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ // Nothing
+ }
+}
diff --git a/Flow/includes/Data/Listener/RecentChangesListener.php b/Flow/includes/Data/Listener/RecentChangesListener.php
new file mode 100644
index 00000000..b8398503
--- /dev/null
+++ b/Flow/includes/Data/Listener/RecentChangesListener.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Closure;
+use Flow\Container;
+use Flow\Data\LifecycleHandler;
+use Flow\Data\Utils\RecentChangeFactory;
+use Flow\FlowActions;
+use Flow\Formatter\IRCLineUrlFormatter;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Workflow;
+use Flow\Repository\UserNameBatch;
+
+/**
+ * Inserts mw recentchange rows for flow AbstractRevision instances.
+ */
+class RecentChangesListener implements LifecycleHandler {
+
+ // Value used in rc_source field of recentchanges to identify flow specific changes
+ const SRC_FLOW = "flow";
+
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ /**
+ * @var UserNameBatch
+ */
+ protected $usernames;
+
+ /**
+ * @var RecentChangeFactory
+ */
+ protected $rcFactory;
+
+ /**
+ * @var IRCLineUrlFormatter
+ */
+ protected $ircFormatter;
+
+ /**
+ * @param FlowActions $actions
+ * @param UserNameBatch $usernames
+ * @param RecentChangeFactory $rcFactory Creates mw RecentChange instances
+ * @param IRCLineUrlFormatter $ircFormatter
+ */
+ public function __construct(
+ FlowActions $actions,
+ UserNameBatch $usernames,
+ RecentChangeFactory $rcFactory,
+ IRCLineUrlFormatter $ircFormatter
+ ) {
+ $this->actions = $actions;
+ $this->usernames = $usernames;
+ $this->rcFactory = $rcFactory;
+ $this->ircFormatter = $ircFormatter;
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // Moderation. Doesn't need to log anything because all moderation also inserts
+ // a new null revision to track who and when.
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ // Deletion. Not kinda-sorta deleted, like 100% GONE. Should never happen.
+ }
+
+ public function onAfterLoad( $object, array $row ) {
+ // nothing to do
+ }
+
+ /**
+ * @param AbstractRevision $revision Revision object
+ * @param array $row Revision row
+ * @param array $metadata;
+ */
+ public function onAfterInsert( $revision, array $row, array $metadata ) {
+ global $wgRCFeeds;
+
+ // No action on imported revisions
+ if ( isset( $metadata['imported'] ) && $metadata['imported'] ) {
+ return;
+ }
+
+ $action = $revision->getChangeType();
+ $revisionId = $revision->getRevisionId()->getAlphadecimal();
+ $timestamp = $revision->getRevisionId()->getTimestamp();
+ /** @var Workflow $workflow */
+ $workflow = $metadata['workflow'];
+
+ if ( !$this->isAllowed( $revision, $action ) ) {
+ return;
+ }
+
+ $title = $this->getRcTitle( $workflow, $revision->getChangeType() );
+ $attribs = array(
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_user' => $row['rev_user_id'],
+ 'rc_user_text' => $this->usernames->get( wfWikiId(), $row['rev_user_id'], $row['rev_user_ip'] ),
+ 'rc_type' => RC_FLOW,
+ 'rc_source' => self::SRC_FLOW,
+ 'rc_minor' => 0,
+ 'rc_bot' => 0, // TODO: is revision by bot
+ 'rc_patrolled' => 0,
+ 'rc_old_len' => $revision->getPreviousContentLength(),
+ 'rc_new_len' => $revision->getContentLength(),
+ 'rc_this_oldid' => 0,
+ 'rc_last_oldid' => 0,
+ 'rc_log_type' => null,
+ 'rc_params' => serialize( array(
+ 'flow-workflow-change' => array(
+ 'action' => $action,
+ 'revision_type' => get_class( $revision ),
+ 'revision' => $revisionId,
+ 'workflow' => $workflow->getId()->getAlphadecimal(),
+ ),
+ ) ),
+ 'rc_cur_id' => 0,
+ 'rc_comment' => '',
+ 'rc_timestamp' => $timestamp,
+ 'rc_deleted' => 0,
+ );
+
+ $rc = $this->rcFactory->newFromRow( (object)$attribs );
+ $rc->save( /* $noudp = */ true ); // Insert into db
+ $feeds = $wgRCFeeds;
+ // Override the IRC formatter with our own formatter
+ foreach ( array_keys( $feeds ) as $name ) {
+ $feeds[$name]['original_formatter'] = $feeds[$name]['formatter'];
+ $feeds[$name]['formatter'] = $this->ircFormatter;
+ }
+ // pre-load the irc formatter which will be triggered via hook
+ $this->ircFormatter->associate( $rc, array(
+ 'revision' => $revision
+ ) + $metadata );
+ // run the feeds/irc/etc external notifications
+ $rc->notifyRCFeeds( $feeds );
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @param string $action
+ * @return \Title
+ */
+ public function getRcTitle( Workflow $workflow, $action ) {
+ if ( $this->actions->getValue( $action, 'rc_title' ) === 'owner' ) {
+ return $workflow->getOwnerTitle();
+ } else {
+ return $workflow->getArticleTitle();
+ }
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @param string $action
+ * @return bool
+ */
+ public function isAllowed( AbstractRevision $revision, $action ) {
+ $allowed = $this->actions->getValue( $action, 'rc_insert' );
+ if ( $allowed instanceof Closure ) {
+ $allowed = $allowed( $revision, $this );
+ }
+
+ return (bool) $allowed;
+ }
+}
diff --git a/Flow/includes/Data/Listener/ReferenceRecorder.php b/Flow/includes/Data/Listener/ReferenceRecorder.php
new file mode 100644
index 00000000..e260d6a3
--- /dev/null
+++ b/Flow/includes/Data/Listener/ReferenceRecorder.php
@@ -0,0 +1,303 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidDataException;
+use Flow\LinksTableUpdater;
+use Flow\Data\LifecycleHandler;
+use Flow\Data\ManagerGroup;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Model\Reference;
+use Flow\Parsoid\ReferenceExtractor;
+use Flow\Repository\TreeRepository;
+
+/**
+ * Listens for new revisions to be inserted. Calculates the difference in
+ * references(URLs, images, etc) between this new version and the previous
+ * revision. Uses calculated difference to update links tables to match the new revision.
+ */
+class ReferenceRecorder implements LifecycleHandler {
+ /**
+ * @var ReferenceExtractor
+ */
+ protected $referenceExtractor;
+
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var LinksTableUpdater
+ */
+ protected $linksTableUpdater;
+
+ /**
+ * @var TreeRepository Used to query for the posts within a topic when moderation
+ * changes the visibility of a topic.
+ */
+ protected $treeRepository;
+
+ public function __construct(
+ ReferenceExtractor $referenceExtractor,
+ LinksTableUpdater $linksTableUpdater,
+ ManagerGroup $storage,
+ TreeRepository $treeRepository
+ ) {
+ $this->referenceExtractor = $referenceExtractor;
+ $this->linksTableUpdater = $linksTableUpdater;
+ $this->storage = $storage;
+ $this->treeRepository = $treeRepository;
+ }
+
+ public function onAfterLoad( $object, array $old ) {
+ // Nuthin
+ }
+
+ public function onAfterInsert( $revision, array $new, array $metadata ) {
+ if ( !isset( $metadata['workflow'] )) {
+ return;
+ }
+ if ( !$revision instanceof AbstractRevision ) {
+ throw new InvalidDataException( 'ReferenceRecorder can only attach to AbstractRevision storage');
+ }
+ $workflow = $metadata['workflow'];
+
+ if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) {
+ list( $added, $removed ) = $this->calculateChangesFromTopic( $workflow, $revision );
+ } else {
+ list( $added, $removed ) = $this->calculateChangesFromExisting( $workflow, $revision );
+ }
+
+ $this->storage->multiPut( $added );
+ $this->storage->multiRemove( $removed );
+
+ // Data updates
+ $this->linksTableUpdater->doUpdate( $workflow );
+ }
+
+ /**
+ * Compares the references contained within $revision against those stored for
+ * that revision. Returns the differences.
+ *
+ * @param Workflow $workflow
+ * @param AbstractRevision $revision
+ * @param PostRevision|null $root
+ * @return array Two nested arrays, first the references that were added and
+ * second the references that were removed.
+ */
+ protected function calculateChangesFromExisting(
+ Workflow $workflow,
+ AbstractRevision $revision,
+ PostRevision $root = null
+ ) {
+ $prevReferences = $this->getExistingReferences(
+ $revision->getRevisionType(),
+ $revision->getCollectionId()
+ );
+ $references = $this->getReferencesFromRevisionContent( $workflow, $revision, $root );
+
+ return $this->referencesDifference( $prevReferences, $references );
+ }
+
+ /**
+ * While topic's themselves are plaintext and do not contain any references,
+ * moderation actions change what references are visible. When transitioning
+ * from or to a generically visible state (unmoderated or locked) the entire
+ * topic + summary needs to be re-evaluated.
+ *
+ * @param Workflow $workflow
+ * @param PostRevision $current Topic revision object that was inserted
+ * @return array Contains two arrays, first the references to add a second
+ * the references to remove
+ * @throws FlowException
+ */
+ protected function calculateChangesFromTopic( Workflow $workflow, PostRevision $current ) {
+ if ( $current->isFirstRevision() ) {
+ return array( array(), array() );
+ }
+ $previous = $this->storage->get( 'PostRevision', $current->getPrevRevisionId() );
+ if ( !$previous ) {
+ throw new FlowException( 'Expcted previous revision of ' . $current->getPrevRevisionId()->getAlphadecimal() );
+ }
+
+ $isHidden = self::isHidden( $current );
+ $wasHidden = self::isHidden( $previous );
+
+ if ( $isHidden === $wasHidden ) {
+ return array( array(), array() );
+ }
+
+ // re-run
+ $revisions = $this->collectTopicRevisions( $workflow );
+ $added = array();
+ $removed = array();
+ foreach ( $revisions as $revision ) {
+ list( $add, $remove ) = $this->calculateChangesFromExisting( $workflow, $revision, $current );
+ $added = array_merge( $added, $add );
+ $removed = array_merge( $removed, $remove );
+ };
+
+ return array( $added, $removed );
+ }
+
+ static protected function isHidden( AbstractRevision $revision ) {
+ return $revision->isModerated() && $revision->getModerationState() !== $revision::MODERATED_LOCKED;
+ }
+
+ /**
+ * Gets all the 'top' revisions within the topic, namely the posts and the
+ * summary. These are used when a topic changes is visibility via moderation
+ * to add or remove the relevant references.
+ *
+ * @param Workflow $workflow
+ * @return AbstractRevision[]
+ */
+ protected function collectTopicRevisions( Workflow $workflow ) {
+ $found = $this->treeRepository->fetchSubtreeNodeList( array( $workflow->getId() ) );
+ $queries = array();
+ foreach ( reset( $found ) as $uuid ) {
+ $queries[] = array( 'rev_type_id' => $uuid );
+ }
+
+ $posts = $this->storage->findMulti(
+ 'PostRevision',
+ $queries,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ // we also need the most recent topic summary if it exists
+ $summaries = $this->storage->find(
+ 'PostSummary',
+ array( 'rev_type_id' => $workflow->getId() ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ $result = $summaries;
+ // we have to unwrap the posts since we used findMulti, it returns
+ // a separate result set for each query
+ foreach ( $posts as $found ) {
+ $result[] = reset( $found );
+ }
+ return $result;
+ }
+
+ /**
+ * Pulls references from a revision's content
+ *
+ * @param Workflow $workflow The Workflow that the revision is attached to.
+ * @param AbstractRevision $revision The Revision to pull references from.
+ * @param PostRevision|null $root
+ * @return Reference[] Array of References.
+ */
+ public function getReferencesFromRevisionContent(
+ Workflow $workflow,
+ AbstractRevision $revision,
+ PostRevision $root = null
+ ) {
+ // Locked is the only moderated state we still collect references for.
+ if ( self::isHidden( $revision ) ) {
+ return array();
+ }
+
+ // If this is attached to a topic we also need to check its permissions
+ if ( $root === null ) {
+ try {
+ if ( $revision instanceof PostRevision && !$revision->isTopicTitle() ) {
+ $root = $revision->getCollection()->getRoot()->getLastRevision();
+ } elseif ( $revision instanceof PostSummary ) {
+ $root = $revision->getCollection()->getPost()->getRoot()->getLastRevision();
+ }
+ } catch ( FlowException $e ) {
+ // Do nothing - we're likely in a unit test where no root can
+ // be resolved because the revision is created on the fly
+ }
+ }
+
+ if ( $root && ( self::isHidden( $root ) ) ) {
+ return array();
+ }
+
+ return $this->referenceExtractor->getReferences(
+ $workflow,
+ $revision->getRevisionType(),
+ $revision->getCollectionId(),
+ $revision->getContent( 'html' )
+ );
+ }
+
+ /**
+ * Retrieves references that are already stored in the database for a given revision
+ *
+ * @param string $revType The value returned from Revision::getRevisionType() for the revision.
+ * @param UUID $objectId The revision's Object ID.
+ * @return Reference[] Array of References.
+ */
+ public function getExistingReferences( $revType, UUID $objectId ) {
+ $prevWikiReferences = $this->storage->find( 'WikiReference', array(
+ 'ref_src_object_type' => $revType,
+ 'ref_src_object_id' => $objectId,
+ ) );
+
+ $prevUrlReferences = $this->storage->find( 'URLReference', array(
+ 'ref_src_object_type' => $revType,
+ 'ref_src_object_id' => $objectId,
+ ) );
+
+ return array_merge( (array) $prevWikiReferences, (array) $prevUrlReferences );
+ }
+
+ /**
+ * Compares two arrays of references
+ *
+ * Would be protected if not for testing.
+ *
+ * @param Reference[] $old The old references.
+ * @param Reference[] $new The new references.
+ * @return array Array with two elements: added and removed references.
+ */
+ public function referencesDifference( array $old, array $new ) {
+ $newReferences = array();
+
+ foreach( $new as $ref ) {
+ $newReferences[$ref->getIdentifier()] = $ref;
+ }
+
+ $oldReferences = array();
+
+ foreach( $old as $ref ) {
+ $oldReferences[$ref->getIdentifier()] = $ref;
+ }
+
+ $addReferences = array();
+
+ foreach( $newReferences as $identifier => $ref ) {
+ if ( ! isset( $oldReferences[$identifier] ) ) {
+ $addReferences[] = $ref;
+ }
+ }
+
+ $removeReferences = array();
+
+ foreach( $oldReferences as $identifier => $ref ) {
+ if ( ! isset( $newReferences[$identifier] ) ) {
+ $removeReferences[] = $ref;
+ }
+ }
+
+ return array( $addReferences, $removeReferences );
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // Nuthin
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ // Nuthin
+ }
+}
diff --git a/Flow/includes/Data/Listener/UrlGenerationListener.php b/Flow/includes/Data/Listener/UrlGenerationListener.php
new file mode 100644
index 00000000..d8ce241b
--- /dev/null
+++ b/Flow/includes/Data/Listener/UrlGenerationListener.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Model\Workflow;
+use Flow\UrlGenerator;
+
+/**
+ * The url generator needs to know about loaded workflow instances so it
+ * can generate urls pointing to the correct pages.
+ */
+class UrlGenerationListener implements LifecycleHandler {
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @param UrlGenerator $urlGenerator
+ */
+ public function __construct( UrlGenerator $urlGenerator ) {
+ $this->urlGenerator = $urlGenerator;
+ }
+
+ public function onAfterLoad( $object, array $old ) {
+ if ( $object instanceof Workflow ) {
+ $this->urlGenerator->withWorkflow( $object );
+ }
+ }
+
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ if ( $object instanceof Workflow ) {
+ $this->urlGenerator->withWorkflow( $object );
+ }
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ // Nothing
+ }
+
+ public function onAfterRemove( $object, array $old, array $metadata ) {
+ // Nothing
+ }
+}
diff --git a/Flow/includes/Data/Listener/UserNameListener.php b/Flow/includes/Data/Listener/UserNameListener.php
new file mode 100644
index 00000000..5d366520
--- /dev/null
+++ b/Flow/includes/Data/Listener/UserNameListener.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Provide usernames filtered by per-wiki ipblocks. Batches together
+ * database requests for multiple usernames when possible.
+ */
+namespace Flow\Data\Listener;
+
+use Flow\Data\LifecycleHandler;
+use Flow\Repository\UserNameBatch;
+
+/**
+ * Listen for loaded objects and pre-load their user id fields into
+ * a batch username loader.
+ */
+class UserNameListener implements LifecycleHandler {
+ protected $batch;
+ protected $keys;
+ protected $wikiKey;
+ protected $wiki;
+
+ /**
+ * @param UserNameBatch $batch
+ * @param array $keys key - a list of keys from storage that contain user ids, value - the wiki for the user id lookup, default to $wiki if null
+ * @param string|null $wiki The wikiid to use when $wikiKey is null. If both are null wfWikiId() is used
+ */
+ public function __construct( UserNameBatch $batch, array $keys, $wiki = null ) {
+ $this->batch = $batch;
+ $this->keys = $keys;
+
+ if ( $wiki === null ) {
+ $this->wiki = wfWikiId();
+ } else {
+ $this->wiki = $wiki;
+ }
+ }
+
+ /**
+ * Load any user ids in $row into the username batch
+ */
+ public function onAfterLoad( $object, array $row ) {
+ foreach ( $this->keys as $userKey => $wikiKey ) {
+ // check if the user id key exists in the data array and
+ // make sure it has a non-zero value
+ if ( isset( $row[$userKey] ) && $row[$userKey] != 0 ) {
+ // the wiki for the user id lookup is specified,
+ // check if it exists in the data array
+ if ( $wikiKey ) {
+ if ( !isset( $row[$wikiKey] ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": could not detect wiki with " . $wikiKey );
+ continue;
+ }
+ $wiki = $row[$wikiKey];
+ // no wiki lookup is specified, default to $this->wiki
+ } else {
+ $wiki = $this->wiki;
+ }
+ $this->batch->add( $wiki, $row[$userKey] );
+ }
+ }
+ }
+
+ public function onAfterInsert( $object, array $new, array $metadata ) {}
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {}
+ public function onAfterRemove( $object, array $old, array $metadata ) {}
+}
diff --git a/Flow/includes/Data/Listener/WatchTopicListener.php b/Flow/includes/Data/Listener/WatchTopicListener.php
new file mode 100644
index 00000000..db93f6a2
--- /dev/null
+++ b/Flow/includes/Data/Listener/WatchTopicListener.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Container;
+use Flow\Data\LifecycleHandler;
+use Flow\Exception\InvalidDataException;
+use Flow\FlowActions;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\WatchedTopicItems;
+use Title;
+use User;
+use WatchedItem;
+
+/**
+ * Auto-watch topics when the user performs one of the actions specified
+ * in the constructor.
+ */
+abstract class AbstractTopicInsertListener implements LifecycleHandler {
+ /**
+ * @param string $changeType
+ * @param Workflow $workflow
+ */
+ abstract protected function onAfterInsertExpectedChange( $changeType, Workflow $workflow );
+
+ public function onAfterInsert( $object, array $row, array $metadata ) {
+ if ( !$object instanceof PostRevision ) {
+ wfWarn( __METHOD__ . ': Object is no PostRevision instance' );
+ return;
+ }
+
+ if ( !isset( $metadata['workflow'] ) ) {
+ wfWarn( __METHOD__ . ': Missing required metadata: workflow' );
+ return;
+ }
+ $workflow = $metadata['workflow'];
+ if ( !$workflow instanceof Workflow ) {
+ throw new InvalidDataException( 'Workflow metadata is not Workflow instance' );
+ }
+
+ if ( $workflow->getType() !== 'topic' ) {
+ wfWarn( __METHOD__ . ': Expected "topic" workflow but received "' . $workflow->getType() . '"' );
+ return;
+ }
+
+ /** @var $title Title */
+ $title = $workflow->getArticleTitle();
+ if ( !$title ) {
+ return;
+ }
+
+ $this->onAfterInsertExpectedChange( $row['rev_change_type'], $metadata['workflow'] );
+ }
+
+ /**
+ * Returns an array of user ids to subscribe to the title.
+ *
+ * @param string $changeType
+ * @param string $watchType Key of the corresponding 'watch' array in FlowActions.php
+ * @param WatchedTopicItems[] $params Params to feed to callback function that will return
+ * an array of users to subscribe
+ * @return User[]
+ */
+ public static function getUsersToSubscribe( $changeType, $watchType, array $params = array() ) {
+ /** @var FlowActions $actions */
+ $actions = Container::get( 'flow_actions' );
+
+ // Find users defined for this action, in FlowActions.php
+ try {
+ $users = $actions->getValue( $changeType, 'watch', $watchType );
+ } catch ( \Exception $e ) {
+ return array();
+ }
+
+ // Null will be returned if nothing is defined for this changeType
+ if ( !$users ) {
+ return array();
+ }
+
+ // Some actions may have more complex logic to determine watching users
+ if ( is_callable( $users ) ) {
+ $users = call_user_func_array( $users, $params );
+ }
+
+ return $users;
+ }
+
+ // do nothing
+ public function onAfterLoad( $object, array $new ) {}
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {}
+ public function onAfterRemove( $object, array $old, array $metadata ) {}
+}
+
+/**
+ * Class to immediately subscribe users to the article title when one of the
+ * actions specified in the constructor is inserted.
+ */
+class ImmediateWatchTopicListener extends AbstractTopicInsertListener {
+ /**
+ * @var WatchedTopicItems
+ */
+ protected $watchedTopicItems;
+
+ /**
+ * @param WatchedTopicItems $watchedTopicItems Helper class for watching titles
+ */
+ public function __construct( WatchedTopicItems $watchedTopicItems ) {
+ $this->watchedTopicItems = $watchedTopicItems;
+ }
+
+ /**
+ * @param string $changeType
+ * @param Workflow $workflow
+ */
+ public function onAfterInsertExpectedChange( $changeType, Workflow $workflow ) {
+ $users = static::getUsersToSubscribe( $changeType, 'immediate', array( $this->watchedTopicItems ) );
+
+ foreach ( $users as $user ) {
+ if ( !$user instanceof User ) {
+ continue;
+ }
+ $title = $workflow->getArticleTitle();
+
+ WatchedItem::fromUserTitle( $user, $title )->addWatch();
+ $this->watchedTopicItems->addOverrideWatched( $title );
+ }
+ }
+
+ /**
+ * @param WatchedTopicItems $watchedTopicItems
+ * @return User[]
+ */
+ public static function getCurrentUser( WatchedTopicItems $watchedTopicItems ) {
+ return array( $watchedTopicItems->getUser() );
+ }
+}
diff --git a/Flow/includes/Data/Listener/WorkflowTopicListListener.php b/Flow/includes/Data/Listener/WorkflowTopicListListener.php
new file mode 100644
index 00000000..b1cff74a
--- /dev/null
+++ b/Flow/includes/Data/Listener/WorkflowTopicListListener.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Flow\Data\Listener;
+
+use Flow\Data\Index\TopKIndex;
+use Flow\Data\LifecycleHandler;
+use Flow\Data\ObjectManager;
+use Flow\Model\TopicListEntry;
+
+/**
+ * Every time an action is performed against something within a workflow
+ * the workflow's last_update_timestamp is updated as well. This listener
+ * passes that updated timestamp along to the topic list last updated index
+ * so that it can reorder any lists this workflow is in.
+ */
+class WorkflowTopicListListener implements LifecycleHandler {
+
+ /**
+ * @param ObjectManager
+ */
+ protected $topicListStorage;
+
+ /**
+ * @param TopKIndex
+ */
+ protected $topicListLastUpdatedIndex;
+
+ /**
+ * @param ObjectManager $topicListStorage
+ * @param TopKIndex $topicListLastUpdatedIndex
+ */
+ public function __construct( ObjectManager $topicListStorage, TopKIndex $topicListLastUpdatedIndex ) {
+ $this->topicListStorage = $topicListStorage;
+ $this->topicListLastUpdatedIndex = $topicListLastUpdatedIndex;
+ }
+
+ /**
+ * @var string
+ * @return TopicListEntry|false
+ */
+ protected function getTopicListEntry( $workflowId ) {
+ $list = $this->topicListStorage->find( array( 'topic_id' => $workflowId ) );
+
+ // One topic maps to only one topic list now
+ if ( $list ) {
+ return reset( $list );
+ } else {
+ return false;
+ }
+ }
+
+ public function onAfterInsert( $object, array $new, array $metadata ) {
+ $entry = $this->getTopicListEntry( $new['workflow_id'] );
+ if ( $entry ) {
+ $row = array(
+ 'workflow_last_update_timestamp' => $new['workflow_last_update_timestamp']
+ ) + TopicListEntry::toStorageRow( $entry );
+ $this->topicListLastUpdatedIndex->onAfterInsert( $entry, $row, $metadata );
+ }
+ }
+
+ public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
+ $entry = $this->getTopicListEntry( $new['workflow_id'] );
+ if ( $entry ) {
+ $row = TopicListEntry::toStorageRow( $entry );
+ $this->topicListLastUpdatedIndex->onAfterUpdate(
+ $entry,
+ array(
+ 'workflow_last_update_timestamp' => $old['workflow_last_update_timestamp']
+ ) + $row,
+ array(
+ 'workflow_last_update_timestamp' => $new['workflow_last_update_timestamp']
+ ) + $row,
+ $metadata
+ );
+ }
+ }
+
+ public function onAfterLoad( $object, array $row ) {}
+ public function onAfterRemove( $object, array $old, array $metadata ) {}
+}
diff --git a/Flow/includes/Data/ManagerGroup.php b/Flow/includes/Data/ManagerGroup.php
new file mode 100644
index 00000000..8f88f3df
--- /dev/null
+++ b/Flow/includes/Data/ManagerGroup.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Flow\Data;
+
+use Flow\Container;
+use Flow\Exception\DataModelException;
+
+/**
+ * A little glue code to allow passing around and manipulating multiple
+ * ObjectManagers more conveniently.
+ */
+class ManagerGroup {
+ /**
+ * @var Container
+ */
+ protected $container;
+
+ /**
+ * @var string[] Map from FQCN or short name to key in container that holds
+ * the relevant ObjectManager
+ */
+ protected $classMap;
+
+ /**
+ * @var string[] List of container keys that have been used
+ */
+ protected $used = array();
+
+ /**
+ * @param Container $container
+ * @param string[] $classMap Map from ObjectManager alias to container key
+ * holding that object manager.
+ */
+ public function __construct( Container $container, array $classMap ) {
+ $this->container = $container;
+ $this->classMap = $classMap;
+ }
+
+ /**
+ * Runs ObjectManager::clear on all managers that have been accessed since
+ * the last clear.
+ */
+ public function clear() {
+ foreach ( array_keys( $this->used ) as $key ) {
+ $this->container[$key]->clear();
+ }
+ $this->used = array();
+ }
+
+ /**
+ * Purge all cached data related to this object
+ *
+ * @param object $object
+ */
+ public function cachePurge( $object ) {
+ $this->getStorage( get_class( $object ) )->cachePurge( $object );
+ }
+
+ /**
+ * @param string $className
+ * @return ObjectManager
+ * @throws DataModelException
+ */
+ public function getStorage( $className ) {
+ if ( !isset( $this->classMap[$className] ) ) {
+ throw new DataModelException( "Request for '$className' is not in classmap: " . implode( ', ', array_keys( $this->classMap ) ), 'process-data' );
+ }
+ $key = $this->classMap[$className];
+ $this->used[$key] = true;
+
+ return $this->container[$key];
+ }
+
+ /**
+ * @param object $object
+ * @param array $metadata
+ * @throws DataModelException
+ */
+ public function put( $object, array $metadata ) {
+ $this->getStorage( get_class( $object ) )->put( $object, $metadata );
+ }
+
+ /**
+ * @param string $method
+ * @param array $objects
+ * @param array $metadata
+ * @throws DataModelException
+ */
+ protected function multiMethod( $method, $objects, array $metadata ) {
+ $itemsByClass = array();
+
+ foreach( $objects as $object ) {
+ $itemsByClass[ get_class( $object ) ][] = $object;
+ }
+
+ foreach( $itemsByClass as $class => $myObjects ) {
+ $this->getStorage( $class )->$method( $myObjects, $metadata );
+ }
+ }
+
+ /**
+ * @param array $objects
+ * @param array $metadata
+ */
+ public function multiPut( $objects, array $metadata = array() ) {
+ $this->multiMethod( 'multiPut', $objects, $metadata );
+ }
+
+ /**
+ * @param array $objects
+ * @param array $metadata
+ */
+ public function multiRemove( $objects, array $metadata = array() ) {
+ $this->multiMethod( 'multiRemove', $objects, $metadata );
+ }
+
+ /**
+ * @param string $method
+ * @param array $args
+ * @return mixed
+ * @throws DataModelException
+ */
+ protected function call( $method, $args ) {
+ $className = array_shift( $args );
+
+ return call_user_func_array(
+ array( $this->getStorage( $className ), $method ),
+ $args
+ );
+ }
+
+ public function get( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getMulti( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+
+ public function find( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+
+ public function findMulti( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+
+ public function found( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+
+ public function foundMulti( /* ... */ ) {
+ return $this->call( __FUNCTION__, func_get_args() );
+ }
+}
diff --git a/Flow/includes/Data/Mapper/BasicObjectMapper.php b/Flow/includes/Data/Mapper/BasicObjectMapper.php
new file mode 100644
index 00000000..4e74abfa
--- /dev/null
+++ b/Flow/includes/Data/Mapper/BasicObjectMapper.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Flow\Data\Mapper;
+
+use Flow\Data\ObjectMapper;
+
+/**
+ * Simplest possible implementation of ObjectMapper delgates
+ * execution to closures passed in the constructor.
+ *
+ * This can be used to keep the mapping logic in static methods
+ * within the model as so:
+ *
+ * $userMapper = new BasicObjectMapper(
+ * array( 'User', 'toStorageRow' ),
+ * array( 'User', 'fromStorageRow' ),
+ * );
+ */
+class BasicObjectMapper implements ObjectMapper {
+ protected $toStorageRow;
+
+ protected $fromStorageRow;
+
+ public function __construct( $toStorageRow, $fromStorageRow ) {
+ $this->toStorageRow = $toStorageRow;
+ $this->fromStorageRow = $fromStorageRow;
+ }
+
+ static public function model( $className ) {
+ return new self( array( $className, 'toStorageRow' ), array( $className, 'fromStorageRow' ) );
+ }
+
+ public function toStorageRow( $object ) {
+ return call_user_func( $this->toStorageRow, $object );
+ }
+
+ public function fromStorageRow( array $row, $object = null ) {
+ return call_user_func( $this->fromStorageRow, $row, $object );
+ }
+
+ public function get( array $pk ) {
+ return null;
+ }
+
+ public function normalizeRow( array $row ) {
+ $object = $this->fromStorageRow( $row );
+ return $this->toStorageRow( $object );
+ }
+
+ public function clear() {
+ // noop
+ }
+}
diff --git a/Flow/includes/Data/Mapper/CachingObjectMapper.php b/Flow/includes/Data/Mapper/CachingObjectMapper.php
new file mode 100644
index 00000000..87a42a9e
--- /dev/null
+++ b/Flow/includes/Data/Mapper/CachingObjectMapper.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Flow\Data\Mapper;
+
+use Flow\Data\ObjectManager;
+use Flow\Data\ObjectMapper;
+use Flow\Data\Utils\MultiDimArray;
+use Flow\Model\UUID;
+use InvalidArgumentException;
+use OutOfBoundsException;
+
+/**
+ * Rows with the same primary key always return the same object
+ * from self::fromStorageRow. This means that if two parts of the
+ * code both load revision 123 they will receive the same object.
+ */
+class CachingObjectMapper implements ObjectMapper {
+ /**
+ * @var callable
+ */
+ protected $toStorageRow;
+
+ /**
+ * @var callable
+ */
+ protected $fromStorageRow;
+
+ /**
+ * @var string[]
+ */
+ protected $primaryKey;
+
+ /**
+ * @var MultiDimArray
+ */
+ protected $loaded;
+
+ /**
+ * @param callable $toStorageRow
+ * @param callable $fromStorageRow
+ * @param string[] $primaryKey
+ */
+ public function __construct( $toStorageRow, $fromStorageRow, array $primaryKey ) {
+ $this->toStorageRow = $toStorageRow;
+ $this->fromStorageRow = $fromStorageRow;
+ ksort( $primaryKey );
+ $this->primaryKey = $primaryKey;
+ $this->clear();
+ }
+
+ /**
+ * @param string $className Fully qualified class name
+ * @param string[] $primaryKey
+ * @return CachingObjectMapper
+ */
+ static public function model( $className, array $primaryKey ) {
+ return new self(
+ array( $className, 'toStorageRow' ),
+ array( $className, 'fromStorageRow' ),
+ $primaryKey
+ );
+ }
+
+ public function toStorageRow( $object ) {
+ $row = call_user_func( $this->toStorageRow, $object );
+ $pk = ObjectManager::splitFromRow( $row, $this->primaryKey );
+ if ( $pk === null ) {
+ // new object may not have pk yet, calling code
+ // should call self::fromStorageRow with $object to load
+ // db assigned pk and store obj in $this->loaded
+ } elseif ( !isset( $this->loaded[$pk] ) ) {
+ // first time this id has been seen
+ $this->loaded[$pk] = $object;
+ } elseif ( $this->loaded[$pk] !== $object ) {
+ // loaded object of this id is not same object
+ $class = get_class( $object );
+ $id = json_encode( $pk );
+ throw new \InvalidArgumentException( "Duplicate '$class' objects for id $id" );
+ }
+ return $row;
+ }
+
+ public function fromStorageRow( array $row, $object = null ) {
+ $pk = ObjectManager::splitFromRow( $row, $this->primaryKey );
+ if ( $pk === null ) {
+ throw new \InvalidArgumentException( 'Storage row has no pk' );
+ } elseif ( !isset( $this->loaded[$pk] ) ) {
+ // unserialize the object
+ return $this->loaded[$pk] = call_user_func( $this->fromStorageRow, $row, $object );
+ } elseif ( $object === null ) {
+ // provide previously loaded object
+ return $this->loaded[$pk];
+ } elseif ( $object !== $this->loaded[$pk] ) {
+ // loaded object of this id is not same object
+ $class = get_class( $object );
+ $id = json_encode( $pk );
+ throw new \InvalidArgumentException( "Duplicate '$class' objects for id $id" );
+ } else {
+ // object was provided, load $row into $object
+ // we already know $this->loaded[$pk] === $object
+ return call_user_func( $this->fromStorageRow, $row, $object );
+ }
+ }
+
+ /**
+ * @param array $primaryKey
+ * @return object|null
+ * @throws InvalidArgumentException
+ */
+ public function get( array $primaryKey ) {
+ $primaryKey = UUID::convertUUIDs( $primaryKey, 'alphadecimal' );
+ ksort( $primaryKey );
+ if ( array_keys( $primaryKey ) !== $this->primaryKey ) {
+ throw new InvalidArgumentException;
+ }
+ try {
+ return $this->loaded[$primaryKey];
+ } catch ( OutOfBoundsException $e ) {
+ return null;
+ }
+ }
+
+ public function normalizeRow( array $row ) {
+ $object = call_user_func( $this->fromStorageRow, $row );
+ return call_user_func( $this->toStorageRow, $object );
+ }
+
+ public function clear() {
+ $this->loaded = new MultiDimArray;
+ }
+}
diff --git a/Flow/includes/Data/ObjectLocator.php b/Flow/includes/Data/ObjectLocator.php
new file mode 100644
index 00000000..1741024b
--- /dev/null
+++ b/Flow/includes/Data/ObjectLocator.php
@@ -0,0 +1,330 @@
+<?php
+
+namespace Flow\Data;
+
+use Flow\Exception\FlowException;
+use Flow\Exception\NoIndexException;
+use Flow\Model\UUID;
+use FormatJson;
+
+/**
+ * Denormalized indexes that are query-only. The indexes used here must
+ * be provided to some ObjectManager as a lifecycleHandler to receive
+ * update events.
+ * Error handling is all wrong, but simplifies prototyping.
+ */
+class ObjectLocator {
+ /*
+ * @var ObjectMapper
+ */
+ protected $mapper;
+
+ /**
+ * @var ObjectStorage
+ */
+ protected $storage;
+
+ /**
+ * @var Index[]
+ */
+ protected $indexes;
+
+ /**
+ * @var LifecycleHandler[]
+ */
+ protected $lifecycleHandlers;
+
+ /**
+ * @param ObjectMapper $mapper
+ * @param ObjectStorage $storage
+ * @param Index[] $indexes
+ * @param LifecycleHandler[] $lifecycleHandlers
+ */
+ public function __construct( ObjectMapper $mapper, ObjectStorage $storage, array $indexes = array(), array $lifecycleHandlers = array() ) {
+ $this->mapper = $mapper;
+ $this->storage = $storage;
+ $this->indexes = $indexes;
+ $this->lifecycleHandlers = array_merge( $indexes, $lifecycleHandlers );
+ }
+
+ public function getMapper() {
+ return $this->mapper;
+ }
+
+ public function find( array $attributes, array $options = array() ) {
+ $result = $this->findMulti( array( $attributes ), $options );
+ return $result ? reset( $result ) : null;
+ }
+
+ /**
+ * All queries must be against the same index. Results are equivalent to
+ * array_map, maintaining order and key relationship between input $queries
+ * and $result.
+ *
+ * @param array $queries
+ * @param array $options
+ * @return array|null null is query failure. empty array is no result. array is success
+ */
+ public function findMulti( array $queries, array $options = array() ) {
+ if ( !$queries ) {
+ return array();
+ }
+
+ $keys = array_keys( reset( $queries ) );
+ if ( isset( $options['sort'] ) && !is_array( $options['sort'] ) ) {
+ $options['sort'] = ObjectManager::makeArray( $options['sort'] );
+ }
+
+ try {
+ $index = $this->getIndexFor( $keys, $options );
+ $res = $index->findMulti( $queries, $options );
+ } catch ( NoIndexException $e ) {
+ if ( array_search( 'topic_root_id', $keys ) ) {
+ wfDebugLog(
+ 'Flow',
+ __METHOD__ . ': '
+ . json_encode( $keys ) . ' : '
+ . json_encode( $options ) . ' : '
+ . json_encode( array_map( 'get_class', $this->indexes ) )
+ );
+ \MWExceptionHandler::logException( $e );
+ } else {
+ wfDebugLog( 'FlowDebug', __METHOD__ . ': ' . $e->getMessage() );
+ }
+ $res = $this->storage->findMulti( $queries, $this->convertToDbOptions( $options ) );
+ }
+
+ if ( $res === null ) {
+ return null;
+ }
+
+ $output = array();
+ foreach( $res as $index => $queryOutput ) {
+ foreach ( $queryOutput as $k => $v ) {
+ if ( $v ) {
+ $output[$index][$k] = $this->load( $v );
+ }
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Returns a boolean true/false if the find()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $attributes Attributes to find()
+ * @param array[optional] $options Options to find()
+ * @return bool
+ */
+ public function found( array $attributes, array $options = array() ) {
+ return $this->foundMulti( array( $attributes ), $options );
+ }
+
+ /**
+ * Returns a boolean true/false if the findMulti()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $queries Queries to findMulti()
+ * @param array[optional] $options Options to findMulti()
+ * @return bool
+ */
+ public function foundMulti( array $queries, array $options = array() ) {
+ if ( !$queries ) {
+ return true;
+ }
+
+ $keys = array_keys( reset( $queries ) );
+ if ( isset( $options['sort'] ) && !is_array( $options['sort'] ) ) {
+ $options['sort'] = ObjectManager::makeArray( $options['sort'] );
+ }
+
+ foreach( $queries as $key => $value ) {
+ $queries[$key] = UUID::convertUUIDs( $value, 'alphadecimal' );
+ }
+
+ try {
+ $index = $this->getIndexFor( $keys, $options );
+ $res = $index->foundMulti( $queries, $options );
+ return $res;
+ } catch ( NoIndexException $e ) {
+ wfDebugLog( 'FlowDebug', __METHOD__ . ': ' . $e->getMessage() );
+ }
+
+ return false;
+ }
+
+ public function getPrimaryKeyColumns() {
+ return $this->storage->getPrimaryKeyColumns();
+ }
+
+ public function get( $id ) {
+ $result = $this->getMulti( array( $id ) );
+ return $result ? reset( $result ) : null;
+ }
+
+ // Just a helper to find by primary key
+ //
+ // Be careful with regards to order on composite primary keys,
+ // must be in same order as provided to the storage implementation.
+ public function getMulti( array $objectIds ) {
+ if ( !$objectIds ) {
+ return array();
+ }
+ $primaryKey = $this->storage->getPrimaryKeyColumns();
+ $queries = array();
+ $retval = null;
+ foreach ( $objectIds as $id ) {
+ // check internal cache
+ $query = array_combine( $primaryKey, ObjectManager::makeArray( $id ) );
+ $obj = $this->mapper->get( $query );
+ if ( $obj === null ) {
+ $queries[] = $query;
+ } else {
+ $retval[] = $obj;
+ }
+ }
+ if ( $queries ) {
+ $res = $this->findMulti( $queries );
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ // primary key is unique, but indexes still return their results as array
+ // to be consistent. undo that for a flat result array
+ $retval[] = reset( $row );
+ }
+ }
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Returns a boolean true/false if the get()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param string|integer $id Id to get()
+ * @return bool
+ */
+ public function got( $id ) {
+ return $this->gotMulti( array( $id ) );
+ }
+
+ /**
+ * Returns a boolean true/false if the getMulti()-operation for the given
+ * attributes has already been resolves and doesn't need to query any
+ * outside cache/database.
+ * Determining if a find() has not yet been resolved may be useful so that
+ * additional data may be loaded at once.
+ *
+ * @param array $objectIds Ids to getMulti()
+ * @return bool
+ */
+ public function gotMulti( array $objectIds ) {
+ if ( !$objectIds ) {
+ return true;
+ }
+
+ $primaryKey = $this->storage->getPrimaryKeyColumns();
+ $queries = array();
+ foreach ( $objectIds as $id ) {
+ $query = array_combine( $primaryKey, ObjectManager::makeArray( $id ) );
+ $query = UUID::convertUUIDs( $query, 'alphadecimal' );
+ if ( !$this->mapper->get( $query ) ) {
+ $queries[] = $query;
+ }
+ }
+
+ if ( $queries && $this->mapper instanceof Mapper\CachingObjectMapper ) {
+ return false;
+ }
+
+ return $this->foundMulti( $queries );
+ }
+
+ public function clear() {
+ // nop, we don't store anything
+ }
+
+ /**
+ * @param array $keys
+ * @param array $options
+ * @return Index
+ * @throws NoIndexException
+ */
+ public function getIndexFor( array $keys, array $options = array() ) {
+ sort( $keys );
+ /** @var Index|null $current */
+ $current = null;
+ foreach ( $this->indexes as $index ) {
+ // @var Index $index
+ if ( !$index->canAnswer( $keys, $options ) ) {
+ continue;
+ }
+
+ // make sure at least some index is picked
+ if ( $current === null ) {
+ $current = $index;
+
+ // Find the smallest matching index
+ } else if ( isset( $options['limit'] ) ) {
+ $current = $index->getLimit() < $current->getLimit() ? $index : $current;
+
+ // if no limit specified, find biggest matching index
+ } else {
+ $current = $index->getLimit() > $current->getLimit() ? $index : $current;
+ }
+ }
+ if ( $current === null ) {
+ $count = count( $this->indexes );
+ throw new NoIndexException(
+ "No index (out of $count) available to answer query for " . implode( ", ", $keys ) .
+ ' with options ' . FormatJson::encode( $options ), 'no-index'
+ );
+ }
+ return $current;
+ }
+
+ protected function load( $row ) {
+ $object = $this->mapper->fromStorageRow( $row );
+ foreach ( $this->lifecycleHandlers as $handler ) {
+ $handler->onAfterLoad( $object, $row );
+ }
+ return $object;
+ }
+
+ /**
+ * Convert index options to db equivalent options
+ */
+ protected function convertToDbOptions( $options ) {
+ $dbOptions = $orderBy = array();
+ $order = '';
+
+ if ( isset( $options['limit'] ) ) {
+ $dbOptions['LIMIT'] = (int)$options['limit'];
+ }
+
+ if ( isset( $options['order'] ) ) {
+ $order = ' ' . $options['order'];
+ }
+ if ( isset( $options['sort'] ) ) {
+ foreach ( $options['sort'] as $val ) {
+ $orderBy[] = $val . $order;
+ }
+ }
+ if ( $orderBy ) {
+ $dbOptions['ORDER BY'] = $orderBy;
+ }
+
+ return $dbOptions;
+ }
+}
diff --git a/Flow/includes/Data/ObjectManager.php b/Flow/includes/Data/ObjectManager.php
new file mode 100644
index 00000000..5b6bf867
--- /dev/null
+++ b/Flow/includes/Data/ObjectManager.php
@@ -0,0 +1,380 @@
+<?php
+
+namespace Flow\Data;
+
+use Flow\Exception\DataModelException;
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+use SplObjectStorage;
+
+/**
+ * ObjectManager orchestrates the storage of a single type of objects.
+ * Where ObjectLocator handles querying, ObjectManager extends that to
+ * add persistence.
+ *
+ * The ObjectManager has two required constructor dependencies:
+ * * An ObjectMapper instance that can convert back and forth from domain
+ * objects to database rows
+ * * An ObjectStorage implementation that implements persistence.
+ *
+ * Additionally there are two optional constructor arguments:
+ * * A set of Index objects that listen to life cycle events and maintain
+ * an up-to date cache of all objects. Individual Index objects typically
+ * answer a single set of query arguments.
+ * * A set of LifecycleHandler implementations that are notified about
+ * insert, update, remove and load events.
+ *
+ * A simple ObjectManager instances might be created as such:
+ *
+ * $om = new Flow\Data\ObjectManager(
+ * Flow\Data\Mapper\BasicObjectMapper::model( 'MyModelClass' ),
+ * new Flow\Data\Storage\BasicDbStorage(
+ * $dbFactory,
+ * 'my_model_table',
+ * array( 'my_primary_key' )
+ * )
+ * );
+ *
+ * Objects of MyModelClass can be stored:
+ *
+ * $om->put( $object );
+ *
+ * Objects can be retrieved via my_primary_key
+ *
+ * $object = $om->get( $pk );
+ *
+ * The object can be updated by calling ObjectManager:put at
+ * any time. If the object is to be deleted:
+ *
+ * $om->remove( $object );
+ *
+ * The data cached in the indexes about this object can be cleared
+ * with:
+ *
+ * $om->cachePurge( $object );
+ *
+ * In addition to the single-use put, get and remove there are also multi
+ * variants named multiPut, mulltiGet and multiRemove. They perform the
+ * same operation as their namesake but with fewer network operations when
+ * dealing with multiple objects of the same type.
+ *
+ * @todo Information about Indexes and LifecycleHandlers
+ */
+class ObjectManager extends ObjectLocator {
+ /**
+ * @var SplObjectStorage $loaded Maps from a php object to the database
+ * row that was used to create it. One use of this is to toggle between
+ * self::insert and self::update when self::put is called.
+ */
+ protected $loaded;
+
+ /**
+ * @param ObjectMapper $mapper Convert to/from database rows/domain objects.
+ * @param ObjectStorage $storage Implements persistence(typically sql)
+ * @param Index[] $indexes Specialized listeners that cache rows and can respond
+ * to queries
+ * @param LifecycleHandler[] $lifecycleHandlers Listeners for insert, update,
+ * remove and load events.
+ */
+ public function __construct(
+ ObjectMapper $mapper,
+ ObjectStorage $storage,
+ array $indexes = array(),
+ array $lifecycleHandlers = array()
+ ) {
+ parent::__construct( $mapper, $storage, $indexes, $lifecycleHandlers );
+
+ // This needs to be SplObjectStorage rather than using spl_object_hash for keys
+ // in a normal array because if the object gets GC'd spl_object_hash can reuse
+ // the value. Stuffing the object as well into SplObjectStorage prevents GC.
+ $this->loaded = new SplObjectStorage;
+ }
+
+ /**
+ * Clear the internal cache of which objects have been loaded so far.
+ *
+ * Objects that were loaded prior to clearing the object manager must
+ * not use self::put until they have been merged via self::merge or
+ * an insert operation will be performed.
+ */
+ public function clear() {
+ $this->loaded = new SplObjectStorage;
+ $this->mapper->clear();
+ }
+
+ /**
+ * Merge an object loaded from outside the object manager for update.
+ * Without merge using self::put will trigger an insert operation.
+ *
+ * @var object $object
+ */
+ public function merge( $object ) {
+ if ( !isset( $this->loaded[$object] ) ) {
+ $this->loaded[$object] = $this->mapper->toStorageRow( $object );
+ }
+ }
+
+ /**
+ * Purge all cached data related to this object.
+ *
+ * @param object $object
+ */
+ public function cachePurge( $object ) {
+ if ( !isset( $this->loaded[$object] ) ) {
+ throw new FlowException( 'Object was not loaded through this object manager, use ObjectManager::merge if necessary' );
+ }
+ $row = $this->loaded[$object];
+ foreach ( $this->indexes as $index ) {
+ $index->cachePurge( $object, $row );
+ }
+ }
+
+ /**
+ * Persist a single object to storage.
+ *
+ * @var object $object
+ * @var array $metadata Additional information about the object for
+ * listeners to operate on.
+ */
+ public function put( $object, array $metadata = array() ) {
+ $this->multiPut( array( $object ), $metadata );
+ }
+
+ /**
+ * Persist multiple objects to storage.
+ *
+ * @var object[] $objects
+ * @var array $metadata Additional information about the object for
+ * listeners to operate on.
+ */
+ public function multiPut( array $objects, array $metadata = array() ) {
+ $updateObjects = array();
+ $insertObjects = array();
+
+ foreach( $objects as $object ) {
+ if ( isset( $this->loaded[$object] ) ) {
+ $updateObjects[] = $object;
+ } else {
+ $insertObjects[] = $object;
+ }
+ }
+
+ if ( count( $updateObjects ) ) {
+ $this->update( $updateObjects, $metadata );
+ }
+
+ if ( count( $insertObjects ) ) {
+ $this->insert( $insertObjects, $metadata );
+ }
+ }
+
+ /**
+ * Remove an object from persistent storage.
+ *
+ * @var object $object
+ * @var array $metadata Additional information about the object for
+ * listeners to operate on.
+ */
+ public function remove( $object, array $metadata = array() ) {
+ if ( !isset( $this->loaded[$object] ) ) {
+ throw new FlowException( 'Object was not loaded through this object manager, use ObjectManager::merge if necessary' );
+ }
+ $old = $this->loaded[$object];
+ $old = $this->mapper->normalizeRow( $old );
+ $this->storage->remove( $old );
+ foreach ( $this->lifecycleHandlers as $handler ) {
+ $handler->onAfterRemove( $object, $old, $metadata );
+ }
+ unset( $this->loaded[$object] );
+ }
+
+ /**
+ * Remove multiple objects from persistent storage.
+ *
+ * @var object[] $objects
+ * @var array $metadata
+ */
+ public function multiRemove( $objects, array $metadata ) {
+ foreach ( $objects as $obj ) {
+ $this->remove( $obj, $metadata );
+ }
+ }
+
+ /**
+ * Return a string value that can be provided to self::find or self::findMulti
+ * as the offset-id option to facilitate pagination.
+ *
+ * @param object $object
+ * @param array $sortFields
+ * @return string
+ */
+ public function serializeOffset( $object, array $sortFields ) {
+ $offsetFields = array();
+ // @todo $row = $this->loaded[$object] ?
+ $row = $this->mapper->toStorageRow( $object );
+ // @todo Why not self::splitFromRow?
+ foreach( $sortFields as $field ) {
+ $value = $row[$field];
+
+ if ( is_string( $value )
+ && strlen( $value ) === UUID::BIN_LEN
+ && substr( $field, -3 ) === '_id'
+ ) {
+ $value = UUID::create( $value );
+ }
+ if ( $value instanceof UUID ) {
+ $value = $value->getAlphadecimal();
+ }
+ $offsetFields[] = $value;
+ }
+
+ return implode( '|', $offsetFields );
+ }
+
+ /**
+ * Insert new objects into storage.
+ *
+ * @param object[] $objects
+ * @param array $metadata
+ */
+ protected function insert( array $objects, array $metadata ) {
+ $rows = array_map( array( $this->mapper, 'toStorageRow' ), $objects );
+ $storedRows = $this->storage->insert( $rows );
+ if ( !$storedRows ) {
+ throw new DataModelException( 'failed insert', 'process-data' );
+ }
+
+ $numObjects = count( $objects );
+ for( $i = 0; $i < $numObjects; ++$i ) {
+ $object = $objects[$i];
+ $stored = $storedRows[$i];
+
+ // Propagate stuff that was added to the row by storage back
+ // into the object. Currently intended for storage URLs etc,
+ // but may in the future also bring in auto-ids and so on.
+ $this->mapper->fromStorageRow( $stored, $object );
+
+ foreach ( $this->lifecycleHandlers as $handler ) {
+ $handler->onAfterInsert( $object, $stored, $metadata );
+ }
+
+ $this->loaded[$object] = $stored;
+ }
+ }
+
+ /**
+ * Update the set of objects representation within storage.
+ *
+ * @param object[] $objects
+ * @param array $metadata
+ */
+ protected function update( array $objects, array $metadata ) {
+ foreach( $objects as $object ) {
+ $this->updateSingle( $object, $metadata );
+ }
+ }
+
+ /**
+ * Update a single objects representation within storage.
+ *
+ * @param object $object
+ * @param array $metadata
+ */
+ protected function updateSingle( $object, array $metadata ) {
+ $old = $this->loaded[$object];
+ $old = $this->mapper->normalizeRow( $old );
+ $new = $this->mapper->toStorageRow( $object );
+ if ( self::arrayEquals( $old, $new ) ) {
+ return;
+ }
+ $this->storage->update( $old, $new );
+ foreach ( $this->lifecycleHandlers as $handler ) {
+ $handler->onAfterUpdate( $object, $old, $new, $metadata );
+ }
+ $this->loaded[$object] = $new;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function load( $row ) {
+ $object = parent::load( $row );
+ $this->loaded[$object] = $row;
+ return $object;
+ }
+
+ /**
+ * Compare two arrays for equality.
+ * @todo why not $x === $y ?
+ *
+ * @param array $old
+ * @param array $new
+ * @return bool
+ */
+ static public function arrayEquals( array $old, array $new ) {
+ return array_diff_assoc( $old, $new ) === array()
+ && array_diff_assoc( $new, $old ) === array();
+ }
+
+ /**
+ * Convert the input argument into an array. This is preferred
+ * over casting with (array)$value because that will cast an
+ * object to an array rather than wrap it.
+ *
+ * @param mixed $input
+ *
+ * @return array
+ */
+ static public function makeArray( $input ) {
+ if ( is_array( $input ) ) {
+ return $input;
+ } else {
+ return array( $input );
+ }
+ }
+
+ /**
+ * Return an array containing all the top level changes between
+ * $old and $new. Expects $old and $new to be representations of
+ * database rows and contain only strings and numbers.
+ *
+ * @param array $old
+ * @param array $new
+ * @return array
+ */
+ static public function calcUpdates( array $old, array $new ) {
+ $updates = array();
+ foreach ( array_keys( $new ) as $key ) {
+ if ( !array_key_exists( $key, $old ) || $old[$key] !== $new[$key] ) {
+ $updates[$key] = $new[$key];
+ }
+ unset( $old[$key] );
+ }
+ // These keys dont exist in $new
+ foreach ( array_keys( $old ) as $key ) {
+ $updates[$key] = null;
+ }
+ return $updates;
+ }
+
+
+ /**
+ * Separate a set of keys from an array. Returns null if not
+ * all keys are set.
+ *
+ * @param array $row
+ * @param array $keys
+ * @return array
+ */
+ static public function splitFromRow( array $row, array $keys ) {
+ $split = array();
+ foreach ( $keys as $key ) {
+ if ( !isset( $row[$key] ) ) {
+ return null;
+ }
+ $split[$key] = $row[$key];
+ }
+
+ return $split;
+ }
+}
diff --git a/Flow/includes/Data/ObjectMapper.php b/Flow/includes/Data/ObjectMapper.php
new file mode 100644
index 00000000..365feac3
--- /dev/null
+++ b/Flow/includes/Data/ObjectMapper.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Flow\Data;
+
+/**
+ * Interface for converting back and forth between a database row and
+ * a domain model.
+ */
+interface ObjectMapper {
+ /**
+ * Convert $object from the domain model to its db row
+ *
+ * @param object $object
+ * @return array
+ */
+ function toStorageRow( $object );
+
+ /**
+ * Convert a db row to its domain model. Object passing is intended for
+ * updating the object to match a changed storage representation.
+ *
+ * @param array $row Assoc array representing the domain model
+ * @param object|null $object The domain model to populate, creates when null
+ * @return object The domain model populated with $row
+ * @throws \Exception When object is the wrong class for the mapper
+ */
+ function fromStorageRow( array $row, $object = null );
+
+ /**
+ * Check internal cache for previously unserialized objects
+ *
+ * @param array $primaryKey
+ * @return object|null
+ */
+ function get( array $primaryKey );
+
+ /**
+ * Accepts a row representing domain model & returns that same row,
+ * normalized. It'll roundtrip the row from- & toStorageRow to cleanup data.
+ * We want to make sure that data type differences cause no false positives,
+ * like $row containing strings, & new row has integers with the same value.
+ *
+ * @param array $row Assoc array representing the domain model
+ * @return array Normalized row
+ */
+ function normalizeRow( array $row );
+
+ /**
+ * Clear any internally cached information
+ */
+ function clear();
+}
diff --git a/Flow/includes/Data/ObjectStorage.php b/Flow/includes/Data/ObjectStorage.php
new file mode 100644
index 00000000..fdf62668
--- /dev/null
+++ b/Flow/includes/Data/ObjectStorage.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Flow\Data;
+
+/**
+ * Interface representing backend data stores. Typically they
+ * will be implemented in SQL with the DbStorage base class.
+ */
+interface ObjectStorage {
+
+ /**
+ * Perform a single equality query.
+ *
+ * @param array $attributes Map of attributes the model must contain
+ * @param array $options Query options such as ORDER BY and LIMIT.
+ * @return array
+ */
+ function find( array $attributes, array $options = array() );
+
+ /**
+ * Perform the equivalent of array_map against self::find for multiple
+ * equality queries with the minimum of network round trips.
+ *
+ * @param array $queries list of queries to perform
+ * @param array $options Options to use for all queries
+ * @return array[] Array of results for every query
+ */
+ function findMulti( array $queries, array $options = array() );
+
+ /**
+ * @return array The list of columns that together uniquely identify a row
+ */
+ function getPrimaryKeyColumns();
+
+ /**
+ * Insert the specified row into the data store.
+ *
+ * @param array $rows An array of rows, each row is a map of columns => values.
+ * Currently, the old calling convention of a simple map of columns to values is
+ * also supported.
+ * @return array|false The resulting $row including any auto-assigned ids or false on failure
+ */
+ function insert( array $rows );
+
+ /**
+ * Perform all changes necessary to turn $old into $new in the data store.
+ *
+ * @param array $old Map of columns to values that was initially loaded.
+ * @param array $new Map of columns to values that the row should become.
+ * @return boolean true when the row is successfully updated
+ */
+ function update( array $old, array $new );
+
+ /**
+ * Remove the specified row from the data store.
+ *
+ * @param array $row Map of columns to values. Must contain the primary key columns.
+ * @return boolean true when the row is successfully removed
+ */
+ function remove( array $row );
+
+ /**
+ * Returns a boolean true/false to indicate if the result of a particular
+ * query is valid & can be cached.
+ * In some cases, the retrieved data should not be cached. E.g. revisions
+ * with external content: revision data may be loaded, but the content could
+ * not be fetched from external storage. That shouldn't persist in cache.
+ *
+ * @param array $row
+ * @return bool
+ */
+ function validate( array $row );
+}
diff --git a/Flow/includes/Data/Pager/HistoryPager.php b/Flow/includes/Data/Pager/HistoryPager.php
new file mode 100644
index 00000000..0cecded7
--- /dev/null
+++ b/Flow/includes/Data/Pager/HistoryPager.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Flow\Data\Pager;
+
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidDataException;
+use Flow\Formatter\BoardHistoryQuery;
+use Flow\Formatter\FormatterRow;
+use Flow\Formatter\TopicHistoryQuery;
+use Flow\Formatter\PostHistoryQuery;
+use Flow\Model\UUID;
+
+class HistoryPager extends \ReverseChronologicalPager {
+ /**
+ * @var BoardHistoryQuery|TopicHistoryQuery|PostHistoryQuery
+ */
+ protected $query;
+
+ /**
+ * @var UUID
+ */
+ protected $id;
+
+ /**
+ * @var UUID|null
+ */
+ public $mOffset;
+
+ /**
+ * @var FormatterRow[]
+ */
+ public $mResult;
+
+ /**
+ * @param BoardHistoryQuery|TopicHistoryQuery|PostHistoryQuery $query
+ * @param UUID $id
+ */
+ public function __construct( /* BoardHistoryQuery|TopicHistoryQuery|PostHistoryQuery */ $query, UUID $id ) {
+ $this->query = $query;
+ $this->id = $id;
+
+ $this->mDefaultLimit = $this->getUser()->getIntOption( 'rclimit' );
+ $this->mIsBackwards = $this->getRequest()->getVal( 'dir' ) == 'prev';
+ }
+
+ public function doQuery() {
+ $direction = $this->mIsBackwards ? 'rev' : 'fwd';
+
+ // over-fetch so we can figure out if there's anything after what we're showing
+ $this->mResult = $this->query->getResults( $this->id, $this->getLimit() + 1, $this->mOffset, $direction );
+ if ( !$this->mResult ) {
+ throw new InvalidDataException(
+ 'Unable to load history for ' . $this->id->getAlphadecimal(),
+ 'fail-load-history'
+ );
+ }
+ $this->mQueryDone = true;
+
+ // we over-fetched, now get rid of redundant value for our "real" data
+ $overfetched = null;
+ if ( count( $this->mResult ) > $this->getLimit() ) {
+ // when traversing history reverse, the overfetched entry will be at
+ // the beginning of the list; in normal mode it'll be last
+ if ( $this->mIsBackwards ) {
+ $overfetched = array_shift( $this->mResult );
+ } else {
+ $overfetched = array_pop( $this->mResult );
+ }
+ }
+
+ // set some properties that'll be used to generate navigation bar
+ $this->mLastShown = $this->mResult[count( $this->mResult ) - 1]->revision->getRevisionId()->getAlphadecimal();
+ $this->mFirstShown = $this->mResult[0]->revision->getRevisionId()->getAlphadecimal();
+
+ /*
+ * By overfetching, we've already figured out if there's additional
+ * entries at the next page (according to the current direction). Now
+ * go fetch 1 more in the other direction (the one we likely came from,
+ * when navigating)
+ */
+ $nextOffset = $this->mIsBackwards ? $this->mFirstShown : $this->mLastShown;
+ $nextOffset = UUID::create( $nextOffset );
+ $reverseDirection = $this->mIsBackwards ? 'fwd' : 'rev';
+ $this->mIsLast = !$overfetched;
+ $this->mIsFirst = !$this->mOffset || count( $this->query->getResults( $this->id, 1, $nextOffset, $reverseDirection ) ) === 0;
+
+ if ( $this->mIsBackwards ) {
+ // swap values if we're going backwards
+ list( $this->mIsFirst, $this->mIsLast ) = array( $this->mIsLast, $this->mIsFirst );
+
+ // id of the overfetched entry, used to build new links starting at
+ // this offset
+ if ( $overfetched ) {
+ $this->mPastTheEndIndex = $overfetched->revision->getRevisionId()->getAlphadecimal();
+ }
+ }
+ }
+
+ /**
+ * Override pointless parent method.
+ *
+ * @param bool $include
+ * @throws FlowException
+ */
+ public function setIncludeOffset( $include ) {
+ throw new FlowException( __METHOD__ . ' is not implemented.' );
+ }
+
+ // abstract functions required to extend ReverseChronologicalPager
+
+ public function formatRow( $row ) {
+ throw new FlowException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function getQueryInfo() {
+ return array();
+ }
+
+ public function getIndexField() {
+ return '';
+ }
+}
diff --git a/Flow/includes/Data/Pager/Pager.php b/Flow/includes/Data/Pager/Pager.php
new file mode 100644
index 00000000..994b4c04
--- /dev/null
+++ b/Flow/includes/Data/Pager/Pager.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Flow\Data\Pager;
+
+use Flow\Data\Index;
+use Flow\Data\ObjectManager;
+use Flow\Exception\InvalidInputException;
+
+/**
+ * Fetches paginated results from the OM provided in constructor
+ */
+class Pager {
+ private static $VALID_DIRECTIONS = array( 'fwd', 'rev' );
+ const DEFAULT_DIRECTION = 'fwd';
+ const DEFAULT_LIMIT = 1;
+ const MAX_LIMIT = 500;
+ const MAX_QUERIES = 4;
+
+ /**
+ * @var ObjectManager
+ */
+ protected $storage;
+
+ /**
+ * @var Index
+ */
+ protected $index;
+
+ /**
+ * @var array Results sorted by the values in this array
+ */
+ protected $sort;
+
+ /**
+ * @var array Map of column name to column value for equality query
+ */
+ protected $query;
+
+ /**
+ * @var array Options effecting the result such as `sort`, `order`, and `pager-limit`
+ */
+ protected $options;
+
+ /**
+ * @var string
+ */
+ protected $offsetKey;
+
+ public function __construct( ObjectManager $storage, array $query, array $options ) {
+ // not sure i like this
+ $this->storage = $storage;
+ $this->query = $query;
+ $this->options = $options + array(
+ 'pager-include-offset' => null,
+ 'pager-offset' => null,
+ 'pager-limit' => self::DEFAULT_LIMIT,
+ 'pager-dir' => self::DEFAULT_DIRECTION,
+ );
+
+ $this->options['pager-limit'] = intval( $this->options['pager-limit'] );
+ if ( ! ( $this->options['pager-limit'] > 0 && $this->options['pager-limit'] < self::MAX_LIMIT ) ) {
+ $this->options['pager-limit'] = self::DEFAULT_LIMIT;
+ }
+
+ if ( !in_array( $this->options['pager-dir'], self::$VALID_DIRECTIONS ) ) {
+ $this->options['pager-dir'] = self::DEFAULT_DIRECTION;
+ }
+
+ $indexOptions = array(
+ 'limit' => $this->options['pager-limit']
+ );
+ if ( isset( $this->options['sort'], $this->options['order'] ) ) {
+ $indexOptions += array(
+ 'sort' => array( $this->options['sort'] ),
+ 'order' => $this->options['order'],
+ );
+ }
+ $this->sort = $storage->getIndexFor(
+ array_keys( $query ),
+ $indexOptions
+ )->getSort();
+
+ $useId = false;
+ foreach ( $this->sort as $val ) {
+ if ( substr( $val, -3 ) === '_id' ) {
+ $useId = true;
+ }
+ break;
+ }
+ $this->offsetKey = $useId ? 'offset-id' : 'offset';
+ }
+
+ /**
+ * @param callable|null $filter Accepts an array of objects found in a single query
+ * as its only argument and returns an array of accepted objects.
+ * @return PagerPage
+ */
+ public function getPage( $filter = null ) {
+ $numNeeded = $this->options['pager-limit'] + 1;
+ $options = $this->options + array(
+ // We need one item of leeway to determine if there are more items
+ 'limit' => $numNeeded,
+ 'offset-dir' => $this->options['pager-dir'],
+ 'offset-id' => $this->options['pager-offset'],
+ 'include-offset' => $this->options['pager-include-offset'],
+ 'offset-elastic' => true,
+ );
+ $offset = $this->options['pager-offset'];
+ $results = array();
+ $queries = 0;
+
+ do {
+ if ( $queries === 2 ) {
+ // if we hit a third query ask for more items
+ $options['limit'] = min( self::MAX_LIMIT, $this->options['pager-limit'] * 5 );
+ }
+
+ // Retrieve results
+ $found = $this->storage->find( $this->query, array(
+ 'offset-id' => $offset,
+ ) + $options );
+
+ if ( !$found ) {
+ // nothing found
+ break;
+ }
+ $filtered = $filter ? call_user_func( $filter, $found ) : $found;
+ if ( $this->options['pager-dir'] === 'rev' ) {
+ // Paging A-Z with pager-offset F, pager-dir rev, pager-limit 2 gives
+ // DE on first query, BC on second, and A on third. The output
+ // needs to be ABCDE
+ $results = array_merge( $filtered, $results );
+ } else {
+ $results = array_merge( $results, $filtered );
+ }
+
+ if ( count( $found ) !== $options['limit'] ) {
+ // last page
+ break;
+ }
+
+ // setup offset for next query
+ if ( $this->options['pager-dir'] === 'rev' ) {
+ $last = reset( $found );
+ } else {
+ $last = end( $found );
+ }
+ $offset = $this->storage->serializeOffset( $last, $this->sort );
+
+ } while ( count( $results ) < $numNeeded && ++$queries < self::MAX_QUERIES );
+
+ if ( $queries >= self::MAX_QUERIES ) {
+ $count = count( $results );
+ $limit = $this->options['pager-limit'];
+ wfDebugLog( 'Flow', __METHOD__ . "Reached maximum of $queries queries with $count results of $limit requested with query of " . json_encode( $this->query ) . ' and options ' . json_encode( $options ) );
+ }
+
+ if ( $results ) {
+ return $this->processPage( $results );
+ } else {
+ return new PagerPage( array(), array(), $this );
+ }
+ }
+
+ /**
+ * @param array $results
+ * @return PagerPage
+ * @throws InvalidInputException
+ */
+ protected function processPage( $results ) {
+ $pagingLinks = array();
+
+ // Retrieve paging links
+ if ( $this->options['pager-dir'] === 'fwd' ) {
+ if ( count( $results ) > $this->options['pager-limit'] ) {
+ // We got extra, another page exists
+ $results = array_slice( $results, 0, $this->options['pager-limit'] );
+ $pagingLinks['fwd'] = $this->makePagingLink(
+ 'fwd',
+ end( $results ),
+ $this->options['pager-limit']
+ );
+ }
+
+ if ( $this->options['pager-offset'] !== null ) {
+ $pagingLinks['rev'] = $this->makePagingLink(
+ 'rev',
+ reset( $results ),
+ $this->options['pager-limit']
+ );
+ }
+ } elseif ( $this->options['pager-dir'] === 'rev' ) {
+ if ( count( $results ) > $this->options['pager-limit'] ) {
+ // We got extra, another page exists
+ $results = array_slice( $results, -$this->options['pager-limit'] );
+ $pagingLinks['rev'] = $this->makePagingLink(
+ 'rev',
+ reset( $results ),
+ $this->options['pager-limit']
+ );
+ }
+
+ if ( $this->options['pager-offset'] !== null ) {
+ $pagingLinks['fwd'] = $this->makePagingLink(
+ 'fwd',
+ end( $results ),
+ $this->options['pager-limit']
+ );
+ }
+ } else {
+ throw new InvalidInputException( "Unrecognised direction " . $this->options['pager-dir'], 'invalid-input' );
+ }
+
+ return new PagerPage( $results, $pagingLinks, $this );
+ }
+
+ /**
+ * @param string $direction
+ * @param object $object
+ * @param integer $pageLimit
+ * @return array
+ */
+ protected function makePagingLink( $direction, $object, $pageLimit ) {
+ $return = array(
+ 'offset-dir' => $direction,
+ 'limit' => $pageLimit,
+ $this->offsetKey => $this->storage->serializeOffset( $object, $this->sort ),
+ );
+ if ( isset( $this->options['sortby'] ) ) {
+ $return['sortby'] = $this->options['sortby'];
+ }
+ return $return;
+ }
+}
diff --git a/Flow/includes/Data/Pager/PagerPage.php b/Flow/includes/Data/Pager/PagerPage.php
new file mode 100644
index 00000000..13eb8209
--- /dev/null
+++ b/Flow/includes/Data/Pager/PagerPage.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Flow\Data\Pager;
+
+/**
+ * Represents a single page of data loaded via Flow\Data\Pager\Pager
+ */
+class PagerPage {
+ /**
+ * @var array
+ */
+ protected $results;
+
+ /**
+ * @var array
+ */
+ protected $pagingLinkOptions;
+
+ /**
+ * @var Pager
+ */
+ protected $pager;
+
+ /**
+ * @param array $results
+ * @param array $pagingLinkOptions
+ * @param Pager $pager
+ */
+ public function __construct( $results, $pagingLinkOptions, $pager ) {
+ $this->results = $results;
+ $this->pagingLinkOptions = $pagingLinkOptions;
+ $this->pager = $pager;
+ }
+
+ /**
+ * @return Pager
+ */
+ public function getPager() {
+ return $this->pager;
+ }
+
+ /**
+ * @return array
+ */
+ public function getResults() {
+ return $this->results;
+ }
+
+ /**
+ * @return array
+ */
+ public function getPagingLinksOptions() {
+ return $this->pagingLinkOptions;
+ }
+}
diff --git a/Flow/includes/Data/Storage/BasicDbStorage.php b/Flow/includes/Data/Storage/BasicDbStorage.php
new file mode 100644
index 00000000..d1bb3efd
--- /dev/null
+++ b/Flow/includes/Data/Storage/BasicDbStorage.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\Model\UUID;
+use Flow\DbFactory;
+use Flow\Data\ObjectManager;
+use Flow\Data\Utils\MultiDimArray;
+use Flow\Data\Utils\RawSql;
+use Flow\Exception\DataModelException;
+use Flow\Exception\DataPersistenceException;
+
+/**
+ * Standard backing store for data model with no special cases which is stored
+ * in a single table in mysql.
+ *
+ * Doesn't support updating primary key value yet
+ * Doesn't support auto-increment pk yet
+ */
+class BasicDbStorage extends DbStorage {
+ /**
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * @var string[]
+ */
+ protected $primaryKey;
+
+ /**
+ * @param DbFactory $dbFactory
+ * @param string $table
+ * @param string[] $primaryKey
+ * @throws DataModelException
+ */
+ public function __construct( DbFactory $dbFactory, $table, array $primaryKey ) {
+ if ( !$primaryKey ) {
+ throw new DataModelException( 'PK required', 'process-data' );
+ }
+ parent::__construct( $dbFactory );
+ $this->table = $table;
+ $this->primaryKey = $primaryKey;
+ }
+
+ /**
+ * Inserts a set of rows into the database
+ *
+ * @param array $rows The rows to insert. Also accepts a single row.
+ * @return array|false An array of the rows that now exist
+ * in the database. Integrity of keys is guaranteed.
+ * False if we failed.
+ */
+ public function insert( array $rows ) {
+ // Only allow the row to include key/value pairs.
+ // No raw SQL.
+ if ( is_array( reset( $rows ) ) ) {
+ $insertRows = $this->preprocessNestedSqlArray( $rows );
+ } else {
+ $insertRows = $this->preprocessSqlArray( $rows );
+ }
+
+ // insert returns boolean true/false
+ $res = $this->dbFactory->getDB( DB_MASTER )->insert(
+ $this->table,
+ $insertRows,
+ __METHOD__ . " ({$this->table})"
+ );
+ if ( $res ) {
+ return $rows;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Update a single row in the database.
+ *
+ * @param array $old The current state of the row.
+ * @param array $new The desired new state of the row.
+ * @return boolean Whether or not the operation was successful.
+ * @throws DataPersistenceException
+ */
+ public function update( array $old, array $new ) {
+ $pk = ObjectManager::splitFromRow( $old, $this->primaryKey );
+ if ( $pk === null ) {
+ $missing = array_diff( $this->primaryKey, array_keys( $old ) );
+ throw new DataPersistenceException( 'Row has null primary key: ' . implode( ', ', $missing ), 'process-data' );
+ }
+ $updates = $this->calcUpdates( $old, $new );
+ if ( !$updates ) {
+ return true; // nothing to change, success
+ }
+
+ // Only allow the row to include key/value pairs.
+ // No raw SQL.
+ $updates = $this->preprocessSqlArray( $updates );
+ $pk = $this->preprocessSqlArray( $pk );
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ // update returns boolean true/false as $res
+ $res = $dbw->update( $this->table, $updates, $pk, __METHOD__ . " ({$this->table})" );
+ // $dbw->update returns boolean true/false as $res
+ // we also want to check that $pk actually selected a row to update
+ return $res && $dbw->affectedRows();
+ }
+
+ /**
+ * @param array $row
+ * @return boolean success
+ * @throws DataPersistenceException
+ */
+ public function remove( array $row ) {
+ $pk = ObjectManager::splitFromRow( $row, $this->primaryKey );
+ if ( $pk === null ) {
+ $missing = array_diff( $this->primaryKey, array_keys( $row ) );
+ throw new DataPersistenceException( 'Row has null primary key: ' . implode( ', ', $missing ), 'process-data' );
+ }
+
+ $pk = $this->preprocessSqlArray( $pk );
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->delete( $this->table, $pk, __METHOD__ . " ({$this->table})" );
+ return $res && $dbw->affectedRows();
+ }
+
+ /*
+ * @return array|null Empty array means no result, null means query failure. Array with results is
+ * success.
+ */
+ public function find( array $attributes, array $options = array() ) {
+ $attributes = $this->preprocessSqlArray( $attributes );
+
+ if ( !$this->validateOptions( $options ) ) {
+ throw new \MWException( "Validation error in database options" );
+ }
+
+ $res = $this->dbFactory->getDB( DB_MASTER )->select(
+ $this->table,
+ '*',
+ $attributes,
+ __METHOD__ . " ({$this->table})",
+ $options
+ );
+ if ( ! $res ) {
+ return null;
+ }
+
+ $result = array();
+ foreach ( $res as $row ) {
+ $result[] = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ }
+ // wfDebugLog( 'Flow', __METHOD__ . ': ' . print_r( $result, true ) );
+ return $result;
+ }
+
+ protected function fallbackFindMulti( array $queries, array $options ) {
+ $result = array();
+ foreach ( $queries as $key => $query ) {
+ $result[$key] = $this->find( $query, $options );
+ }
+ return $result;
+ }
+
+ public function findMulti( array $queries, array $options = array() ) {
+ $keys = array_keys( reset( $queries ) );
+ $pks = $this->getPrimaryKeyColumns();
+ if ( count( $keys ) !== count( $pks ) || array_diff( $keys, $pks ) ) {
+ return $this->fallbackFindMulti( $queries, $options );
+ }
+ $conds = array();
+ $dbr = $this->dbFactory->getDB( DB_SLAVE );
+ foreach ( $queries as $query ) {
+ $conds[] = $dbr->makeList( $this->preprocessSqlArray( $query ), LIST_AND );
+ }
+ unset( $query );
+
+ $conds = $dbr->makeList( $conds, LIST_OR );
+
+ $result = array();
+ // options can be ignored for primary key search
+ $res = $this->find( array( new RawSql( $conds ) ) );
+ if ( !$res ) {
+ return $result;
+ }
+
+ // create temp array with pk value (usually uuid) as key and full db row
+ // as value
+ $temp = new MultiDimArray();
+ foreach ( $res as $val ) {
+ $val = UUID::convertUUIDs( $val, 'alphadecimal' );
+ $temp[ObjectManager::splitFromRow( $val, $this->primaryKey )] = $val;
+ }
+
+ // build return value by mapping the database rows to the matching array
+ // index in $queries
+ foreach ( $queries as $i => $val ) {
+ $val = UUID::convertUUIDs( $val, 'alphadecimal' );
+ $pk = ObjectManager::splitFromRow( $val, $this->primaryKey );
+ $result[$i][] = isset( $temp[$pk] ) ? $temp[$pk] : null;
+ }
+
+ return $result;
+ }
+
+ public function getPrimaryKeyColumns() {
+ return $this->primaryKey;
+ }
+}
diff --git a/Flow/includes/Data/Storage/BoardHistoryStorage.php b/Flow/includes/Data/Storage/BoardHistoryStorage.php
new file mode 100644
index 00000000..e372c4fa
--- /dev/null
+++ b/Flow/includes/Data/Storage/BoardHistoryStorage.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\Model\UUID;
+use Flow\Exception\DataModelException;
+
+/**
+ * SQL backing for BoardHistoryIndex fetches revisions related
+ * to a specific TopicList(board workflow)
+ */
+class BoardHistoryStorage extends DbStorage {
+
+ public function find( array $attributes, array $options = array() ) {
+ $multi = $this->findMulti( array( $attributes ), $options );
+ if ( $multi ) {
+ return reset( $multi );
+ }
+ return null;
+ }
+
+ public function findMulti( array $queries, array $options = array() ) {
+ if ( count( $queries ) > 1 ) {
+ throw new DataModelException( __METHOD__ . ' expects only one value in $queries', 'process-data' );
+ }
+
+ $merged = $this->findHeaderHistory( $queries, $options ) +
+ $this->findTopicListHistory( $queries, $options ) +
+ $this->findTopicSummaryHistory( $queries, $options );
+
+ // Having merged data from 3 sources, we now have to combine it
+ // (according to the current sort & limit)
+ $order = isset( $options['ORDER BY'][0] ) && preg_match( '/ASC$/', $options['ORDER BY'][0] ) ? 'ASC' : 'DESC';
+ if ( $order === 'DESC' ) {
+ krsort( $merged );
+ } else {
+ ksort( $merged );
+ }
+
+ if ( isset( $options['LIMIT'] ) ) {
+ $merged = array_splice( $merged, 0, $options['LIMIT'] );
+ }
+
+ // Merge data from external store & get rid of failures
+ $res = array( $merged );
+ $res = RevisionStorage::mergeExternalContent( $res );
+ foreach ( $res as $i => $result ) {
+ if ( $result ) {
+ $res[$i] = array_filter( $result, array( $this, 'validate' ) );
+ }
+ }
+
+ return $res;
+ }
+
+ protected function findHeaderHistory( array $queries, array $options = array() ) {
+ $queries = $this->preprocessSqlArray( reset( $queries ) );
+
+ $res = $this->dbFactory->getDB( DB_SLAVE )->select(
+ array( 'flow_revision' ),
+ array( '*' ),
+ array( 'rev_type' => 'header' ) + UUID::convertUUIDs( array( 'rev_type_id' => $queries['topic_list_id'] ) ),
+ __METHOD__,
+ $options
+ );
+
+ $retval = array();
+
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $row = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ $retval[$row['rev_id']] = $row;
+ }
+ }
+ return $retval;
+ }
+
+ protected function findTopicSummaryHistory( array $queries, array $options = array() ) {
+ $queries = $this->preprocessSqlArray( reset( $queries ) );
+
+ $res = $this->dbFactory->getDB( DB_SLAVE )->select(
+ array( 'flow_revision', 'flow_topic_list', 'flow_tree_node' ),
+ array( '*' ),
+ array(
+ 'rev_type' => 'post-summary',
+ 'topic_id = tree_ancestor_id',
+ 'rev_type_id = tree_descendant_id'
+ ) + UUID::convertUUIDs( array( 'topic_list_id' => $queries['topic_list_id'] ) ),
+ __METHOD__,
+ $options
+ );
+
+ $retval = array();
+
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $row = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ $retval[$row['rev_id']] = $row;
+ }
+ }
+ return $retval;
+ }
+
+ protected function findTopicListHistory( array $queries, array $options = array() ) {
+ $queries = $this->preprocessSqlArray( reset( $queries ) );
+
+ $res = $this->dbFactory->getDB( DB_SLAVE )->select(
+ array( 'flow_topic_list', 'flow_tree_node', 'flow_tree_revision', 'flow_revision' ),
+ array( '*' ),
+ array(
+ 'topic_id = tree_ancestor_id',
+ 'tree_descendant_id = tree_rev_descendant_id',
+ 'tree_rev_id = rev_id',
+ ) + $queries,
+ __METHOD__,
+ $options
+ );
+
+ $retval = array();
+
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $row = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ $retval[$row['rev_id']] = $row;
+ }
+ }
+ return $retval;
+ }
+
+ /**
+ * When retrieving revisions from DB, RevisionStorage::mergeExternalContent
+ * will be called to fetch the content. This could fail, resulting in the
+ * content being a 'false' value.
+ *
+ * {@inheritDoc}
+ */
+ public function validate( array $row ) {
+ return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
+ }
+
+ public function getPrimaryKeyColumns() {
+ return array( 'topic_list_id' );
+ }
+
+ public function insert( array $row ) {
+ throw new DataModelException( __CLASS__ . ' does not support insert action', 'process-data' );
+ }
+
+ public function update( array $old, array $new ) {
+ throw new DataModelException( __CLASS__ . ' does not support update action', 'process-data' );
+ }
+
+ public function remove( array $row ) {
+ throw new DataModelException( __CLASS__ . ' does not support remove action', 'process-data' );
+ }
+}
diff --git a/Flow/includes/Data/Storage/DbStorage.php b/Flow/includes/Data/Storage/DbStorage.php
new file mode 100644
index 00000000..7e5af3ad
--- /dev/null
+++ b/Flow/includes/Data/Storage/DbStorage.php
@@ -0,0 +1,229 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\Data\ObjectManager;
+use Flow\Data\ObjectStorage;
+use Flow\Data\Utils\RawSql;
+use Flow\Model\UUID;
+use Flow\DbFactory;
+use Flow\Exception\DataModelException;
+
+/**
+ * Base class for all ObjectStorage implementers
+ * which use a database as the backing store.
+ *
+ * Includes some utility methods for database management and
+ * SQL security.
+ */
+abstract class DbStorage implements ObjectStorage {
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * The revision columns allowed to be updated
+ *
+ * @var string[]|true Allow of selective columns to allow, or true to allow
+ * everything
+ */
+ protected $allowedUpdateColumns = true;
+
+ /**
+ * This is to prevent 'Update not allowed on xxx' error during moderation when
+ * * old cache is not purged and still holds obsolete deleted column
+ * * old cache is not purged and doesn't have the newly added column
+ *
+ * @var string[] Array of columns to ignore
+ */
+ protected $obsoleteUpdateColumns = array();
+
+ /**
+ * @param DbFactory $dbFactory
+ */
+ public function __construct( DbFactory $dbFactory ) {
+ $this->dbFactory = $dbFactory;
+ }
+
+ /**
+ * Runs preprocessSqlArray on each element of an array.
+ *
+ * @param array $outer The array to check
+ * @return array Preprocessed SQL array.
+ * @throws DataModelException
+ */
+ protected function preprocessNestedSqlArray( array $outer ) {
+ foreach ( $outer as $i => $data ) {
+ if ( ! is_array( $data) ) {
+ throw new DataModelException( "Unexpected non-array in nested SQL array" );
+ }
+ $outer[$i] = $this->preprocessSqlArray( $data );
+ }
+ return $outer;
+ }
+
+ /**
+ * At the moment, does three things:
+ * 1. Finds UUID objects and returns their database representation.
+ * 2. Checks for unarmoured raw SQL and errors out if it exists.
+ * 3. Finds armoured raw SQL and expands it out.
+ *
+ * @param array $data Query conditions for DatabaseBase::select
+ * @return array query conditions escaped for use
+ * @throws DataModelException
+ */
+ protected function preprocessSqlArray( array $data ) {
+ // Assuming that all databases have the same escaping settings.
+ $db = $this->dbFactory->getDB( DB_SLAVE );
+
+ $data = UUID::convertUUIDs( $data, 'binary' );
+
+ foreach( $data as $key => $value ) {
+ if ( $value instanceof RawSql ) {
+ $data[$key] = $value->getSql( $db );
+ } elseif ( is_numeric( $key ) ) {
+ throw new DataModelException( "Unescaped raw SQL found in " . __METHOD__, 'process-data' );
+ } elseif ( ! preg_match( '/^[A-Za-z0-9\._]+$/', $key ) ) {
+ throw new DataModelException( "Dangerous SQL field name '$key' found in " . __METHOD__, 'process-data' );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Internal security function which checks a row object
+ * (for inclusion as a condition or a row for insert/update)
+ * for any numeric keys (= raw SQL), or field names with
+ * potentially unsafe characters.
+ *
+ * @param array $row The row to check.
+ * @return boolean True if raw SQL is found
+ */
+ protected function hasUnescapedSQL( array $row ) {
+ foreach( $row as $key => $value ) {
+ if ( $value instanceof RawSql ) {
+ // Specifically allowed SQL
+ continue;
+ }
+
+ if ( is_numeric( $key ) ) {
+ return true;
+ }
+
+ if ( ! preg_match( '/^' . $this->getFieldRegexFragment() . '$/', $key ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns a regular expression fragment suitable for matching a valid
+ * SQL field name, and hopefully no injection attacks
+ * @return string Regular expression fragment
+ */
+ protected function getFieldRegexFragment() {
+ return '\s*[A-Za-z0-9\._]+\s*';
+ }
+
+ /**
+ * Internal security function to check an options array for
+ * SQL injection and other funkiness
+ * @todo Currently only supports LIMIT, OFFSET and ORDER BY
+ * @param array $options An options array passed to a query.
+ * @return boolean
+ */
+ protected function validateOptions( $options ) {
+ static $validUnaryOptions = array(
+ 'UNIQUE',
+ 'EXPLAIN',
+ );
+
+ $fieldRegex = $this->getFieldRegexFragment();
+
+ foreach( $options as $key => $value ) {
+ if ( is_numeric( $key ) && in_array( strtoupper( $value ), $validUnaryOptions ) ) {
+ continue;
+ } elseif ( is_numeric( $key ) ) {
+ wfDebug( __METHOD__.": Unrecognised unary operator $value\n" );
+ return false;
+ }
+
+ if ( $key === 'LIMIT' ) {
+ // LIMIT is one or two integers, separated by a comma.
+ if ( ! preg_match ( '/^\d+(,\d+)?$/', $value ) ) {
+ wfDebug( __METHOD__.": Invalid LIMIT $value\n" );
+ return false;
+ }
+ } elseif ( $key === 'ORDER BY' ) {
+ // ORDER BY is a list of field names with ASC / DESC afterwards
+ if ( is_string( $value ) ) {
+ $value = explode( ',', $value );
+ }
+ $orderByRegex = "/^\s*$fieldRegex\s*(ASC|DESC)?\s*$/i";
+
+ foreach( $value as $orderByField ) {
+ if ( ! preg_match( $orderByRegex, $orderByField ) ) {
+ wfDebug( __METHOD__.": invalid ORDER BY field $orderByField\n" );
+ return false;
+ }
+ }
+ } elseif ( $key === 'OFFSET' ) {
+ // OFFSET is just an integer
+ if ( ! is_numeric( $value ) ) {
+ wfDebug( __METHOD__.": non-numeric offset $value\n" );
+ return false;
+ }
+ } elseif ( $key === 'GROUP BY' ) {
+ if ( ! preg_match( "/^{$fieldRegex}(,{$fieldRegex})+$/", $value ) ) {
+ wfDebug( __METHOD__.": invalid GROUP BY field\n" );
+ }
+ } else {
+ wfDebug( __METHOD__.": Unknown option $key\n" );
+ return false;
+ }
+ }
+
+ // Everything passes
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function validate( array $row ) {
+ return true;
+ }
+
+ /**
+ * Calculates the DB updates to be performed to update data from $old to
+ * $new.
+ *
+ * @param array $old
+ * @param array $new
+ * @return array
+ * @throws DataModelException
+ */
+ public function calcUpdates( array $old, array $new ) {
+ $changeSet = ObjectManager::calcUpdates( $old, $new );
+
+ foreach ( $this->obsoleteUpdateColumns as $val ) {
+ // Need to use array_key_exists to check null value
+ if ( array_key_exists( $val, $changeSet ) ) {
+ unset( $changeSet[$val] );
+ }
+ }
+
+ if ( is_array( $this->allowedUpdateColumns ) ) {
+ $extra = array_diff( array_keys( $changeSet ), $this->allowedUpdateColumns );
+ if ( $extra ) {
+ throw new DataModelException( 'Update not allowed on: ' . implode( ', ', $extra ), 'process-data' );
+ }
+ }
+
+ return $changeSet;
+ }
+}
diff --git a/Flow/includes/Data/Storage/HeaderRevisionStorage.php b/Flow/includes/Data/Storage/HeaderRevisionStorage.php
new file mode 100644
index 00000000..f4e62fe2
--- /dev/null
+++ b/Flow/includes/Data/Storage/HeaderRevisionStorage.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+/**
+ * Generic storage implementation for Header revision instances
+ */
+class HeaderRevisionStorage extends RevisionStorage {
+ protected function getRevType() {
+ return 'header';
+ }
+}
diff --git a/Flow/includes/Data/Storage/PostRevisionStorage.php b/Flow/includes/Data/Storage/PostRevisionStorage.php
new file mode 100644
index 00000000..c49dfa5e
--- /dev/null
+++ b/Flow/includes/Data/Storage/PostRevisionStorage.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\DbFactory;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\Exception\DataModelException;
+
+/**
+ * SQL storage and query for PostRevision instances
+ */
+class PostRevisionStorage extends RevisionStorage {
+ /**
+ * @var TreeRepository
+ */
+ protected $treeRepo;
+
+ /**
+ * @param DbFactory $dbFactory
+ * @param array|false List of external store servers available for insert
+ * or false to disable. See $wgFlowExternalStore.
+ * @param TreeRepository $treeRepo
+ */
+ public function __construct( DbFactory $dbFactory, $externalStore, TreeRepository $treeRepo ) {
+ parent::__construct( $dbFactory, $externalStore );
+ $this->treeRepo = $treeRepo;
+ }
+
+ protected function joinTable() {
+ return 'flow_tree_revision';
+ }
+
+ protected function joinField() {
+ return 'tree_rev_id';
+ }
+
+ protected function getRevType() {
+ return 'post';
+ }
+
+ protected function insertRelated( array $rows ) {
+ if ( ! is_array( reset( $rows ) ) ) {
+ $rows = array( $rows );
+ }
+
+ $trees = array();
+ foreach( $rows as $key => $row ) {
+ $trees[$key] = $this->splitUpdate( $row, 'tree' );
+ }
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->insert(
+ $this->joinTable(),
+ $this->preprocessNestedSqlArray( $trees ),
+ __METHOD__
+ );
+
+ // If this is a brand new root revision it needs to be added to the tree
+ // If it has a rev_parent_id then its already a part of the tree
+ if ( $res ) {
+ foreach( $rows as $row ) {
+ if ( $row['rev_parent_id'] === null ) {
+ $res = $res && $this->treeRepo->insert(
+ UUID::create( $row['tree_rev_descendant_id'] ),
+ UUID::create( $row['tree_parent_id'] )
+ );
+ }
+ }
+ }
+
+ if ( !$res ) {
+ return array();
+ }
+
+ return $rows;
+ }
+
+ // Topic split will primarily be done through the TreeRepository directly, but
+ // we will need to accept updates to the denormalized tree_parent_id field for
+ // the new root post
+ protected function updateRelated( array $changes, array $old ) {
+ $treeChanges = $this->splitUpdate( $changes, 'tree' );
+
+ // no changes to be performed
+ if ( !$treeChanges ) {
+ return $changes;
+ }
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->update(
+ $this->joinTable(),
+ $this->preprocessSqlArray( $treeChanges ),
+ array( 'tree_rev_id' => $old['tree_rev_id'] ),
+ __METHOD__
+ );
+
+ if ( !$res ) {
+ return array();
+ }
+
+ return $changes;
+ }
+
+ // this doesn't delete the whole post, it just deletes the revision.
+ // The post will *always* exist in the tree structure, its just a tree
+ // and we aren't going to re-parent its children;
+ protected function removeRelated( array $row ) {
+ return $this->dbFactory->getDB( DB_MASTER )->delete(
+ $this->joinTable(),
+ $this->preprocessSqlArray( array( $this->joinField() => $row['rev_id'] ) )
+ );
+ }
+}
diff --git a/Flow/includes/Data/Storage/PostSummaryRevisionStorage.php b/Flow/includes/Data/Storage/PostSummaryRevisionStorage.php
new file mode 100644
index 00000000..f080c620
--- /dev/null
+++ b/Flow/includes/Data/Storage/PostSummaryRevisionStorage.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+/**
+ * Generic storage implementation for PostSummary instances
+ */
+class PostSummaryRevisionStorage extends RevisionStorage {
+ protected function getRevType() {
+ return 'post-summary';
+ }
+}
diff --git a/Flow/includes/Data/Storage/RevisionStorage.php b/Flow/includes/Data/Storage/RevisionStorage.php
new file mode 100644
index 00000000..97a5c13f
--- /dev/null
+++ b/Flow/includes/Data/Storage/RevisionStorage.php
@@ -0,0 +1,512 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use DatabaseBase;
+use ExternalStore;
+use Flow\Data\Utils\Merger;
+use Flow\Data\Utils\ResultDuplicator;
+use Flow\Data\ObjectManager;
+use Flow\DbFactory;
+use Flow\Exception\DataModelException;
+use Flow\Model\UUID;
+use MWException;
+
+/**
+ * Abstract storage implementation for models extending from AbstractRevision
+ */
+abstract class RevisionStorage extends DbStorage {
+ /**
+ * {@inheritDoc}
+ */
+ protected $allowedUpdateColumns = array(
+ 'rev_mod_state',
+ 'rev_mod_user_id',
+ 'rev_mod_user_ip',
+ 'rev_mod_user_wiki',
+ 'rev_mod_timestamp',
+ 'rev_mod_reason',
+
+ // This is temporary for a maint script, can be removed once
+ // FlowUpdateRevisionContentLength is no longer needed. These two
+ // fields should *not* be updated in the normal course of operation
+ 'rev_content_length',
+ 'rev_previous_content_length',
+ );
+
+ /**
+ * {@inheritDoc}
+ *
+ * @Todo - This may not be necessary anymore since we don't update historical
+ * revisions ( flow_revision ) during moderation
+ */
+ protected $obsoleteUpdateColumns = array (
+ 'tree_orig_user_text',
+ 'rev_user_text',
+ 'rev_edit_user_text',
+ 'rev_mod_user_text',
+ 'rev_type_id',
+ );
+
+ protected $externalStore;
+
+ /**
+ * Get the table to join for the revision storage, empty string for none
+ * @return string
+ */
+ protected function joinTable() {
+ return '';
+ }
+
+ /**
+ * Get the column to join with flow_revision.rev_id, empty string for none
+ * @return string
+ */
+ protected function joinField() {
+ return '';
+ }
+
+ /**
+ * Insert to joinTable() upon revision insert
+ * @param array $row
+ * @return array
+ */
+ protected function insertRelated( array $row ) {
+ return $row;
+ }
+
+ /**
+ * Update to joinTable() upon revision update
+ * @param array $changes
+ * @param array $old
+ * @return array
+ */
+ protected function updateRelated( array $changes, array $old ) {
+ return $changes;
+ }
+
+ /**
+ * Remove from joinTable upone revision delete
+ * @param array $row
+ * @return bool
+ */
+ protected function removeRelated( array $row ) {
+ return true;
+ }
+
+ /**
+ * The revision type
+ * @return string
+ */
+ abstract protected function getRevType();
+
+ /**
+ * @param DbFactory $dbFactory
+ * @param array|false List of external store servers available for insert
+ * or false to disable. See $wgFlowExternalStore.
+ */
+ public function __construct( DbFactory $dbFactory, $externalStore ) {
+ parent::__construct( $dbFactory );
+ $this->externalStore = $externalStore;
+ }
+
+ // Find one by specific attributes
+ // @todo: this method can probably be generalized in parent class?
+ public function find( array $attributes, array $options = array() ) {
+ $multi = $this->findMulti( array( $attributes ), $options );
+ if ( $multi ) {
+ return reset( $multi );
+ }
+ return null;
+ }
+
+ protected function findInternal( array $attributes, array $options = array() ) {
+ $dbr = $this->dbFactory->getDB( DB_MASTER );
+
+ if ( ! $this->validateOptions( $options ) ) {
+ throw new MWException( "Validation error in database options" );
+ }
+
+ // Add rev_type if rev_type_id exists in query condition
+ $attributes = $this->addRevTypeToQuery( $attributes );
+
+ $tables = array( 'rev' => 'flow_revision' );
+ $joins = array();
+ if ( $this->joinTable() ) {
+ $tables[] = $this->joinTable();
+ $joins = array( 'rev' => array( 'JOIN', $this->joinField() . ' = rev_id' ) );
+ }
+
+ $res = $dbr->select(
+ $tables, '*', $this->preprocessSqlArray( $attributes ), __METHOD__, $options, $joins
+ );
+ if ( !$res ) {
+ return null;
+ }
+ $retval = array();
+ foreach ( $res as $row ) {
+ $row = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ $retval[$row['rev_id']] = $row;
+ }
+ return $retval;
+ }
+
+ protected function addRevTypeToQuery( $query ) {
+ if ( isset( $query['rev_type_id'] ) ) {
+ $query['rev_type'] = $this->getRevType();
+ }
+ return $query;
+ }
+
+ public function findMulti( array $queries, array $options = array() ) {
+ if ( count( $queries ) < 3 ) {
+ $res = $this->fallbackFindMulti( $queries, $options );
+ } else {
+ $res = $this->findMultiInternal( $queries, $options );
+ }
+
+ // Merge data from external store & get rid of failures
+ $res = self::mergeExternalContent( $res );
+ foreach ( $res as $i => $result ) {
+ if ( $result ) {
+ $res[$i] = array_filter( $result, array( $this, 'validate' ) );
+ }
+ }
+
+ return $res;
+ }
+
+ protected function fallbackFindMulti( array $queries, array $options ) {
+ $result = array();
+ foreach ( $queries as $key => $attributes ) {
+ $result[$key] = $this->findInternal( $attributes, $options );
+ }
+ return $result;
+ }
+
+ protected function findMultiInternal( array $queries, array $options = array() ) {
+ $queriedKeys = array_keys( reset( $queries ) );
+ // The findMulti doesn't map well to SQL, basically we are asking to answer a bunch
+ // of queries. We can optimize those into a single query in a few select instances:
+ if ( isset( $options['LIMIT'] ) && $options['LIMIT'] == 1 ) {
+ // Find by primary key
+ if ( $options == array( 'LIMIT' => 1 ) &&
+ $queriedKeys === array( 'rev_id' )
+ ) {
+ return $this->findRevId( $queries );
+ }
+
+ // Find most recent revision of a number of posts
+ if ( !isset( $options['OFFSET'] ) &&
+ $queriedKeys == array( 'rev_type_id' ) &&
+ isset( $options['ORDER BY'] ) &&
+ $options['ORDER BY'] === array( 'rev_id DESC' )
+ ) {
+ return $this->findMostRecent( $queries );
+ }
+ }
+
+ // Fetch a list of revisions for each post
+ // @todo this is slow and inefficient. Mildly better solution would be if
+ // the index can ask directly for just the list of rev_id instead of whole rows,
+ // but would still have the need to run a bunch of queries serially.
+ if ( count( $options ) === 2 &&
+ isset( $options['LIMIT'], $options['ORDER BY'] ) &&
+ $options['ORDER BY'] === array( 'rev_id DESC' )
+ ) {
+ return $this->fallbackFindMulti( $queries, $options );
+ // unoptimizable query
+ } else {
+ wfDebugLog( 'Flow', __METHOD__
+ . ': Unoptimizable query for keys: '
+ . implode( ',', array_keys( $queriedKeys ) )
+ . ' with options '
+ . \FormatJson::encode( $options )
+ );
+ return $this->fallbackFindMulti( $queries, $options );
+ }
+ }
+
+ protected function findRevId( array $queries ) {
+ $duplicator = new ResultDuplicator( array( 'rev_id' ), 1 );
+ $pks = array();
+ foreach ( $queries as $idx => $query ) {
+ $query = UUID::convertUUIDs( (array) $query, 'alphadecimal' );
+ $duplicator->add( $query, $idx );
+ $id = $query['rev_id'];
+ $pks[$id] = UUID::create( $id )->getBinary();
+ }
+
+ return $this->findRevIdReal( $duplicator, $pks );
+ }
+
+ protected function findMostRecent( array $queries ) {
+ // SELECT MAX( rev_id ) AS rev_id
+ // FROM flow_tree_revision
+ // WHERE rev_type= 'post' AND rev_type_id IN (...)
+ // GROUP BY rev_type_id
+ $duplicator = new ResultDuplicator( array( 'rev_type_id' ), 1 );
+ foreach ( $queries as $idx => $query ) {
+ $query = UUID::convertUUIDs( (array) $query, 'alphadecimal' );
+ $duplicator->add( $query, $idx );
+ }
+
+ $dbr = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbr->select(
+ array( 'flow_revision' ),
+ array( 'rev_id' => "MAX( 'rev_id' )" ),
+ array( 'rev_type' => $this->getRevType() ) + $this->preprocessSqlArray( $this->buildCompositeInCondition( $dbr, $duplicator->getUniqueQueries() ) ),
+ __METHOD__,
+ array( 'GROUP BY' => 'rev_type_id' )
+ );
+ if ( !$res ) {
+ // TODO: dont fail, but dont end up caching bad result either
+ throw new DataModelException( 'query failure', 'process-data' );
+ }
+
+ $revisionIds = array();
+ foreach ( $res as $row ) {
+ $revisionIds[] = $row->rev_id;
+ }
+
+ // Due to the grouping and max, we cant reliably get a full
+ // columns info in the above query, forcing the join below
+ // rather than just querying flow_revision.
+ return $this->findRevIdReal( $duplicator, $revisionIds );
+ }
+
+ /**
+ * @param ResultDuplicator $duplicator
+ * @param array $revisionIds Binary strings representing revision uuid's
+ * @return array
+ * @throws DataModelException
+ */
+ protected function findRevIdReal( ResultDuplicator $duplicator, array $revisionIds ) {
+ if ( $revisionIds ) {
+ // SELECT * from flow_revision
+ // JOIN flow_tree_revision ON tree_rev_id = rev_id
+ // WHERE rev_id IN (...)
+ $dbr = $this->dbFactory->getDB( DB_MASTER );
+
+ $tables = array( 'flow_revision' );
+ $joins = array();
+ if ( $this->joinTable() ) {
+ $tables['rev'] = $this->joinTable();
+ $joins = array( 'rev' => array( 'JOIN', "rev_id = " . $this->joinField() ) );
+ }
+
+ $res = $dbr->select(
+ $tables,
+ '*',
+ array( 'rev_id' => $revisionIds ),
+ __METHOD__,
+ array(),
+ $joins
+ );
+ if ( !$res ) {
+ // TODO: dont fail, but dont end up caching bad result either
+ throw new DataModelException( 'query failure', 'process-data' );
+ }
+
+ foreach ( $res as $row ) {
+ $row = UUID::convertUUIDs( (array)$row, 'alphadecimal' );
+ $duplicator->merge( $row, array( $row ) );
+ }
+ }
+
+ return $duplicator->getResult();
+ }
+
+ /**
+ * Handle the injection of externalstore data into a revision
+ * row. All rows exiting this method will have rev_content_url
+ * set to either null or the external url. The rev_content
+ * field will be the final content (possibly compressed still)
+ *
+ * @param array $cacheResult 2d array of rows
+ * @return array 2d array of rows with content merged and rev_content_url populated
+ */
+ public static function mergeExternalContent( array $cacheResult ) {
+ foreach ( $cacheResult as &$source ) {
+ if ( $source === null ) {
+ // unanswered queries return null
+ continue;
+ }
+ foreach ( $source as &$row ) {
+ $flags = explode( ',', $row['rev_flags'] );
+ if ( in_array( 'external', $flags ) ) {
+ $row['rev_content_url'] = $row['rev_content'];
+ $row['rev_content'] = '';
+ } else {
+ $row['rev_content_url'] = null;
+ }
+ }
+ }
+
+ return Merger::mergeMulti(
+ $cacheResult,
+ /* fromKey = */ 'rev_content_url',
+ /* callable = */ array( 'ExternalStore', 'batchFetchFromURLs' ),
+ /* name = */ 'rev_content',
+ /* default = */ ''
+ );
+ }
+
+ protected function buildCompositeInCondition( DatabaseBase $dbr, array $queries ) {
+ $keys = array_keys( reset( $queries ) );
+ $conditions = array();
+ if ( count( $keys ) === 1 ) {
+ // standard in condition: tree_rev_descendant_id IN (1,2...)
+ $key = reset( $keys );
+ foreach ( $queries as $query ) {
+ $conditions[$key][] = reset( $query );
+ }
+ return $conditions;
+ } else {
+ // composite in condition: ( foo = 1 AND bar = 2 ) OR ( foo = 1 AND bar = 3 )...
+ // Could be more efficient if composed as a range scan, but seems more complex than
+ // its benefit.
+ foreach ( $queries as $query ) {
+ $conditions[] = $dbr->makeList( $query, LIST_AND );
+ }
+ return $dbr->makeList( $conditions, LIST_OR );
+ }
+ }
+
+ public function insert( array $rows ) {
+ if ( ! is_array( reset( $rows ) ) ) {
+ $rows = array( $rows );
+ }
+
+ // Holds the subset of the row to go into the revision table
+ $revisions = array();
+
+ foreach( $rows as $key => $row ) {
+ $row = $this->processExternalStore( $row );
+ $revisions[$key] = $this->splitUpdate( $row, 'rev' );
+ }
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->insert(
+ 'flow_revision',
+ $this->preprocessNestedSqlArray( $revisions ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ // throw exception?
+ return false;
+ }
+
+ return $this->insertRelated( $rows );
+ }
+
+ protected function processExternalStore( array $row ) {
+ // Check if we need to insert new content
+ if ( $this->externalStore && !isset( $row['rev_content_url'] ) ) {
+ $row = $this->insertExternalStore( $row );
+ }
+
+ // If a content url is available store that in the db
+ // instead of real content.
+ if ( isset( $row['rev_content_url'] ) ) {
+ $row['rev_content'] = $row['rev_content_url'];
+ }
+ unset( $row['rev_content_url'] );
+
+ return $row;
+ }
+
+ protected function insertExternalStore( array $row ) {
+ $url = ExternalStore::insertWithFallback( $this->externalStore, $row['rev_content'] );
+ if ( !$url ) {
+ throw new DataModelException( "Unable to store text to external storage", 'process-data' );
+ }
+ $row['rev_content_url'] = $url;
+ if ( $row['rev_flags'] ) {
+ $row['rev_flags'] .= ',external';
+ } else {
+ $row['rev_flags'] = 'external';
+ }
+
+ return $row;
+ }
+
+ // This is to *UPDATE* a revision. It should hardly ever be used.
+ // For the most part should insert a new revision. This will only be called
+ // for suppressing?
+ public function update( array $old, array $new ) {
+ $changeSet = $this->calcUpdates( $old, $new );
+
+ $rev = $this->splitUpdate( $changeSet, 'rev' );
+ $rev = $this->processExternalStore( $rev );
+
+ if ( $rev ) {
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->update(
+ 'flow_revision',
+ $this->preprocessSqlArray( $rev ),
+ $this->preprocessSqlArray( array( 'rev_id' => $old['rev_id'] ) ),
+ __METHOD__
+ );
+ if ( !( $res && $dbw->affectedRows() ) ) {
+ return false;
+ }
+ }
+ return (bool) $this->updateRelated( $changeSet, $old );
+ }
+
+
+ // Revisions can only be removed for LIMITED circumstances, in almost all cases
+ // the offending revision should be updated with appropriate suppression.
+ // Also note this doesnt delete the whole post, it just deletes the revision.
+ // The post will *always* exist in the tree structure, it will just show up as
+ // [deleted] or something
+ public function remove( array $row ) {
+ $res = $this->dbFactory->getDB( DB_MASTER )->delete(
+ 'flow_revision',
+ $this->preprocessSqlArray( array( 'rev_id' => $row['rev_id'] ) ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ return false;
+ }
+ return $this->removeRelated( $row );
+ }
+
+ /**
+ * Used to locate the index for a query by ObjectLocator::get()
+ */
+ public function getPrimaryKeyColumns() {
+ return array( 'rev_id' );
+ }
+
+ /**
+ * When retrieving revisions from DB, self::mergeExternalContent will be
+ * called to fetch the content. This could fail, resulting in the content
+ * being a 'false' value.
+ *
+ * {@inheritDoc}
+ */
+ public function validate( array $row ) {
+ return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
+ }
+
+ /**
+ * Gets all columns from $row that start with a given prefix and omits other
+ * columns.
+ *
+ * @param array $row Rows to split
+ * @param string[optional] $prefix
+ * @return array Remaining rows
+ */
+ protected function splitUpdate( array $row, $prefix = 'rev' ) {
+ $rev = array();
+ foreach ( $row as $key => $value ) {
+ $keyPrefix = strstr( $key, '_', true );
+ if ( $keyPrefix === $prefix ) {
+ $rev[$key] = $value;
+ }
+ }
+ return $rev;
+ }
+}
diff --git a/Flow/includes/Data/Storage/TopicHistoryStorage.php b/Flow/includes/Data/Storage/TopicHistoryStorage.php
new file mode 100644
index 00000000..0cc3052f
--- /dev/null
+++ b/Flow/includes/Data/Storage/TopicHistoryStorage.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\Data\ObjectStorage;
+use Flow\Exception\DataModelException;
+
+/**
+ * Query-only storage implementation merges PostRevision and
+ * PostSummary instances to provide a full list of revisions for
+ * a topics history.
+ */
+class TopicHistoryStorage implements ObjectStorage {
+
+ /**
+ * @var ObjectStorage
+ */
+ protected $postRevisionStorage;
+
+ /**
+ * @var ObjectStorage
+ */
+ protected $postSummaryStorage;
+
+ /**
+ * @param ObjectStorage $postRevisionStorage
+ * @param ObjectStorage $postSummaryStorage
+ */
+ public function __construct( ObjectStorage $postRevisionStorage, ObjectStorage $postSummaryStorage ) {
+ $this->postRevisionStorage = $postRevisionStorage;
+ $this->postSummaryStorage = $postSummaryStorage;
+ }
+
+ public function find( array $attributes, array $options = array() ) {
+ $multi = $this->findMulti( array( $attributes ), $options );
+ if ( $multi ) {
+ return reset( $multi );
+ }
+ return null;
+ }
+
+ public function findMulti( array $queries, array $options = array() ) {
+ $data = $this->postRevisionStorage->findMulti( $queries, $options );
+ $summary = $this->postSummaryStorage->findMulti( $queries, $options );
+ if ( $summary ) {
+ if ( $data ) {
+ foreach ( $summary as $key => $rows ) {
+ if ( isset( $data[$key] ) ) {
+ $data[$key] += $rows;
+ // Failing to sort is okay, we'd rather display unordered
+ // result than showing an error page with exception
+ krsort( $data[$key] );
+ } else {
+ $data[$key] = $rows;
+ }
+ }
+ } else {
+ $data = $summary;
+ }
+ }
+ return $data;
+ }
+
+ public function getPrimaryKeyColumns() {
+ return array( 'topic_root_id' );
+ }
+
+ public function insert( array $row ) {
+ throw new DataModelException( __CLASS__ . ' does not support insert action', 'process-data' );
+ }
+
+ public function update( array $old, array $new ) {
+ throw new DataModelException( __CLASS__ . ' does not support update action', 'process-data' );
+ }
+
+ public function remove( array $row ) {
+ throw new DataModelException( __CLASS__ . ' does not support remove action', 'process-data' );
+ }
+
+ public function validate( array $row ) {
+ return true;
+ }
+}
diff --git a/Flow/includes/Data/Storage/TopicListLastUpdatedStorage.php b/Flow/includes/Data/Storage/TopicListLastUpdatedStorage.php
new file mode 100644
index 00000000..51e4b03d
--- /dev/null
+++ b/Flow/includes/Data/Storage/TopicListLastUpdatedStorage.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+use Flow\Model\UUID;
+
+/**
+ * Storage class for topic list ordered by last updated
+ */
+class TopicListLastUpdatedStorage extends TopicListStorage {
+
+ /**
+ * Query topic list ordered by last updated field. The sort field is in a
+ * different table so we need to overwrite parent find() method slightly to
+ * achieve this goal
+ */
+ public function find( array $attributes, array $options = array() ) {
+ $attributes = $this->preprocessSqlArray( $attributes );
+
+ if ( !$this->validateOptions( $options ) ) {
+ throw new \MWException( "Validation error in database options" );
+ }
+
+ $res = $this->dbFactory->getDB( DB_MASTER )->select(
+ array( $this->table, 'flow_workflow' ),
+ 'topic_list_id, topic_id, workflow_last_update_timestamp',
+ array_merge( $attributes, array( 'topic_id = workflow_id' ) ),
+ __METHOD__ . " ({$this->table})",
+ $options
+ );
+ if ( ! $res ) {
+ // TODO: This should probably not silently fail on database errors.
+ return null;
+ }
+
+ $result = array();
+ foreach ( $res as $row ) {
+ $result[] = UUID::convertUUIDs( (array) $row, 'alphadecimal' );
+ }
+
+ return $result;
+ }
+
+}
diff --git a/Flow/includes/Data/Storage/TopicListStorage.php b/Flow/includes/Data/Storage/TopicListStorage.php
new file mode 100644
index 00000000..2e32dea5
--- /dev/null
+++ b/Flow/includes/Data/Storage/TopicListStorage.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Flow\Data\Storage;
+
+/**
+ * Storage class for topic list ordered by last updated
+ */
+class TopicListStorage extends BasicDbStorage {
+
+ /**
+ * We need workflow_last_update_timestamp for updating
+ * the ordering in cache
+ */
+ public function insert( array $rows ) {
+ $updateRows = array();
+ foreach ( $rows as $i => $row ) {
+ unset( $row['workflow_last_update_timestamp'] );
+ $updateRows[$i] = $row;
+ }
+ $res = parent::insert( $updateRows );
+ if ( $res ) {
+ return $rows;
+ } else {
+ return false;
+ }
+ }
+
+}
diff --git a/Flow/includes/Data/Utils/Merger.php b/Flow/includes/Data/Utils/Merger.php
new file mode 100644
index 00000000..ee4e6194
--- /dev/null
+++ b/Flow/includes/Data/Utils/Merger.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+/**
+ * This assists in performing client-side 1-to-1 joins. It collects the foreign key
+ * from a multi-dimensional array, queries a callable for the foreign key values and
+ * then returns the source data with related data merged in.
+ */
+class Merger {
+
+ /**
+ * @param array $source input two dimensional array
+ * @param string $fromKey Key in nested arrays of $source containing foreign key
+ * @param callable $callable Callable receiving array of foreign keys returning map
+ * from foreign key to its value
+ * @param string|null $name Name to merge loaded foreign data as. If null uses $fromKey.
+ * @param string $default Value to use when no matching foreign value can be located
+ * @return array $source array with all found foreign key values merged
+ */
+ static public function merge( array $source, $fromKey, $callable, $name = null, $default = '' ) {
+ if ( $name === null ) {
+ $name = $fromKey;
+ }
+ $ids = array();
+ foreach ( $source as $row ) {
+ $id = $row[$fromKey];
+ if ( $id !== null ) {
+ $ids[] = $id;
+ }
+ }
+ if ( !$ids ) {
+ return $source;
+ }
+ $res = call_user_func( $callable, $ids );
+ if ( $res === false ) {
+ return false;
+ }
+ foreach ( $source as $idx => $row ) {
+ $id = $row[$fromKey];
+ if ( $id === null ) {
+ continue;
+ }
+ $source[$idx][$name] = isset( $res[$id] ) ? $res[$id] : $default;
+ }
+ return $source;
+ }
+
+ /**
+ * Same as self::merge, but for 3-dimensional source arrays
+ *
+ * @param array $multiSource input three dimensonal array
+ * @param string $fromKey
+ * @param callable $callable Callable receiving array of foreign keys returning map
+ * from foreign key to its value
+ * @param string|null $name Name to merge loaded foreign data as. If null uses $fromKey.
+ * @param string $default Value to use when no matching foreign value can be located
+ * @return array $multiSource array with all found foreign key values merged
+ */
+ static public function mergeMulti( array $multiSource, $fromKey, $callable, $name = null, $default = '' ) {
+ if ( $name === null ) {
+ $name = $fromKey;
+ }
+ $ids = array();
+ foreach ( $multiSource as $source ) {
+ if ( $source === null ) {
+ continue;
+ }
+ foreach ( $source as $row ) {
+ $id = $row[$fromKey];
+ if ( $id !== null ) {
+ $ids[] = $id;
+ }
+ }
+ }
+ if ( !$ids ) {
+ return $multiSource;
+ }
+ $res = call_user_func( $callable, array_unique( $ids ) );
+ if ( $res === false ) {
+ return false;
+ }
+ foreach ( $multiSource as $i => $source ) {
+ if ( $source === null ) {
+ continue;
+ }
+ foreach ( $source as $j => $row ) {
+ $id = $row[$fromKey];
+ if ( $id === null ) {
+ continue;
+ }
+ $multiSource[$i][$j][$name] = isset( $res[$id] ) ? $res[$id] : $default;
+ }
+ }
+ return $multiSource;
+ }
+}
diff --git a/Flow/includes/Data/Utils/MultiDimArray.php b/Flow/includes/Data/Utils/MultiDimArray.php
new file mode 100644
index 00000000..c96ec93c
--- /dev/null
+++ b/Flow/includes/Data/Utils/MultiDimArray.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+use RecursiveArrayIterator;
+use RecursiveIteratorIterator;
+
+/**
+ * This object can be used to easily set keys in a multi-dimensional array.
+ *
+ * Usage:
+ *
+ * $arr = new Flow\Data\MultiDimArray;
+ * $arr[array(1,2,3)] = 4;
+ * $arr[array(2,3,4)] = 5;
+ * var_export( $arr->all() );
+ *
+ * array (
+ * 1 => array (
+ * 2 => array (
+ * 3 => 4,
+ * ),
+ * ),
+ * 2 => array (
+ * 3 => array (
+ * 4 => 5,
+ * ),
+ * ),
+ * )
+ */
+class MultiDimArray implements \ArrayAccess {
+ protected $data = array();
+
+ public function all() {
+ return $this->data;
+ }
+
+ // Probably not what you want. primary key value is lost, you only
+ // receive the final key in a composite key set.
+ public function getIterator() {
+ $it = new RecursiveArrayIterator( $this->data );
+ return new RecursiveIteratorIterator( $it );
+ }
+
+ public function offsetSet( $offset, $value ) {
+ $data =& $this->data;
+ foreach ( (array) $offset as $key ) {
+ if ( !isset( $data[$key] ) ) {
+ $data[$key] = array();
+ }
+ $data =& $data[$key];
+ }
+ $data = $value;
+ }
+
+ public function offsetGet( $offset ) {
+ $data =& $this->data;
+ foreach ( (array) $offset as $key ) {
+ if ( !isset( $data[$key] ) ) {
+ throw new \OutOfBoundsException( 'Does not exist' );
+ } elseif ( ! is_array( $data ) ) {
+ throw new \OutOfBoundsException( "Requested offset {$key} (full offset ".implode(':', $offset)."), but $data is not an array." );
+ }
+ $data =& $data[$key];
+ }
+ return $data;
+ }
+
+ public function offsetUnset( $offset ) {
+ $offset = (array) $offset;
+ // while loop is required to not leave behind empty arrays
+ $first = true;
+ while( $offset ) {
+ $end = array_pop( $offset );
+ $data =& $this->data;
+ foreach ( $offset as $key ) {
+ if ( !isset( $data[$key] ) ) {
+ return;
+ }
+ $data =& $data[$key];
+ }
+ if ( $first === true || ( is_array( $data[$end] ) && !count( $data[$end] ) ) ) {
+ unset( $data[$end] );
+ $first = false;
+ }
+ }
+ }
+
+ public function offsetExists( $offset ) {
+ $data =& $this->data;
+ foreach ( (array) $offset as $key ) {
+ if ( !isset( $data[$key] ) ) {
+ return false;
+ } elseif ( ! is_array( $data ) ) {
+ throw new \OutOfBoundsException( "Requested offset {$key} (full offset ".implode(':', $offset)."), but $data is not an array." );
+ }
+ $data =& $data[$key];
+ }
+ return true;
+ }
+}
diff --git a/Flow/includes/Data/Utils/RawSql.php b/Flow/includes/Data/Utils/RawSql.php
new file mode 100644
index 00000000..5b6a2af8
--- /dev/null
+++ b/Flow/includes/Data/Utils/RawSql.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+/**
+ * Value class wraps sql to be passed into queries. Values
+ * that are not wrapped in the RawSql class are escaped to
+ * plain strings.
+ */
+class RawSql {
+ protected $sql;
+
+ public function __construct( $sql ) {
+ $this->sql = $sql;
+ }
+
+ public function getSQL( $db ) {
+ if ( is_callable( $this->sql ) ) {
+ return call_user_func( $this->sql, $db );
+ }
+
+ return $this->sql;
+ }
+}
diff --git a/Flow/includes/Data/Utils/RecentChangeFactory.php b/Flow/includes/Data/Utils/RecentChangeFactory.php
new file mode 100644
index 00000000..e64a6f26
--- /dev/null
+++ b/Flow/includes/Data/Utils/RecentChangeFactory.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+/**
+ * Provides access to static methods of RecentChange so they
+ * can be swapped out during tests
+ */
+class RecentChangeFactory {
+ public function newFromRow( $obj ) {
+ $rc = \RecentChange::newFromRow( $obj );
+ $rc->setExtra( array( 'pageStatus' => 'update' ) );
+ return $rc;
+ }
+}
diff --git a/Flow/includes/Data/Utils/ResultDuplicator.php b/Flow/includes/Data/Utils/ResultDuplicator.php
new file mode 100644
index 00000000..6f3bacf1
--- /dev/null
+++ b/Flow/includes/Data/Utils/ResultDuplicator.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+use Flow\Data\ObjectManager;
+use Flow\Exception\InvalidInputException;
+
+// Better name?
+//
+// Add query arrays with a multi-dimensional position
+// Merge results with their query value
+// Get back result array with same positions as the original query
+//
+// Maintains merge ordering
+class ResultDuplicator {
+ /**
+ * Maps from the query array to its position in the query array
+ *
+ * @var array
+ */
+ protected $queryKeys;
+
+ /**
+ * @var int
+ */
+ protected $dimensions;
+
+ /**
+ * @var MultiDimArray
+ */
+ protected $desiredOrder;
+
+ /**
+ * @var MultiDimArray
+ */
+ protected $queryMap;
+
+ /**
+ * @var MultiDimArray
+ */
+ protected $result;
+
+ /**
+ * @var array
+ */
+ protected $queries = array();
+
+ /**
+ * @param array $queryKeys
+ * @param int $dimensions
+ */
+ public function __construct( array $queryKeys, $dimensions ) {
+ $this->queryKeys = $queryKeys;
+ $this->dimensions = $dimensions;
+ $this->desiredOrder = new MultiDimArray;
+ $this->queryMap = new MultiDimArray;
+ $this->result = new MultiDimArray;
+ }
+
+ // Add a query and its position. Positions must be unique.
+ public function add( $query, $position ) {
+ $dim = count( (array) $position );
+ if ( $dim !== $this->dimensions ) {
+ throw new InvalidInputException( "Expection position with {$this->dimensions} dimensions, received $dim", 'invalid-input' );
+ }
+ $query = ObjectManager::splitFromRow( $query, $this->queryKeys );
+ if ( $query === null ) {
+ // the queryKeys are either unset or null, and not indexable
+ // TODO: what should happen here?
+ return;
+ }
+ $this->desiredOrder[$position] = $query;
+ if ( !isset( $this->queryMap[$query] ) ) {
+ $this->queries[] = $query;
+ $this->queryMap[$query] = true;
+ }
+ }
+
+ // merge a query into the result set
+ public function merge( array $query, array $result ) {
+ $query = ObjectManager::splitFromRow( $query, $this->queryKeys );
+ if ( $query === null ) {
+ // the queryKeys are either unset or null, and not indexable
+ // TODO: what should happen here?
+ return;
+ }
+ $this->result[$query] = $result;
+ }
+
+ public function getUniqueQueries() {
+ return $this->queries;
+ }
+
+ public function getResult() {
+ return self::sortResult( $this->desiredOrder->all(), $this->result, $this->dimensions );
+ }
+
+ // merge() wasn't necessarily called in the same order as add(), this walks back through
+ // the results to put them in the desired order with the correct keys.
+ static public function sortResult( array $order, MultiDimArray $result, $dimensions ) {
+ $final = array();
+ foreach ( $order as $position => $query ) {
+ if ( $dimensions > 1 ) {
+ $final[$position] = self::sortResult( $query, $result, $dimensions - 1 );
+ } elseif ( isset( $result[$query] ) ) {
+ $final[$position] = $result[$query];
+ } else {
+ $final[$position] = null;
+ }
+ }
+ return $final;
+ }
+}
+
diff --git a/Flow/includes/Data/Utils/SortArrayByKeys.php b/Flow/includes/Data/Utils/SortArrayByKeys.php
new file mode 100644
index 00000000..7eec2e1f
--- /dev/null
+++ b/Flow/includes/Data/Utils/SortArrayByKeys.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+/**
+ * Performs the equivalent of an SQL ORDER BY c1 ASC, c2 ASC...
+ * Always sorts in ascending order. array_reverse to get all descending.
+ * For varied asc/desc needs implementation changes.
+ *
+ * usage: usort( $array, new SortArrayByKeys( array( 'c1', 'c2' ) ) );
+ */
+class SortArrayByKeys {
+ protected $keys;
+ protected $strict;
+
+ public function __construct( array $keys, $strict = false ) {
+ $this->keys = $keys;
+ $this->strict = $strict;
+ }
+
+ public function __invoke( $a, $b ) {
+ return self::compare( $a, $b, $this->keys, $this->strict );
+ }
+
+ static public function compare( $a, $b, $keys, $strict = false ) {
+ $key = array_shift( $keys );
+ if ( !isset( $a[$key] ) ) {
+ return isset( $b[$key] ) ? -1 : 0;
+ } elseif ( !isset( $b[$key] ) ) {
+ return 1;
+ } elseif ( $strict ? $a[$key] === $b[$key] : $a[$key] == $b[$key] ) {
+ return $keys ? self::compare( $a, $b, $keys, $strict ) : 0;
+ } else { // is there such a thing as strict gt/lt ?
+ return $a[$key] > $b[$key] ? 1 : -1;
+ }
+ }
+}
diff --git a/Flow/includes/Data/Utils/UserMerger.php b/Flow/includes/Data/Utils/UserMerger.php
new file mode 100644
index 00000000..b8f4c075
--- /dev/null
+++ b/Flow/includes/Data/Utils/UserMerger.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Flow\Data\Utils;
+
+use EchoBatchRowIterator;
+use Flow\Data\ManagerGroup;
+use Flow\DbFactory;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Iterator;
+
+class UserMerger {
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var array
+ */
+ protected $config;
+
+ /**
+ * @param DbFactory $dbFactory
+ * @param ManagerGroup $storage
+ */
+ public function __construct( DbFactory $dbFactory, ManagerGroup $storage ) {
+ $this->dbFactory = $dbFactory;
+ $this->storage = $storage;
+ $this->config = array(
+ 'flow_tree_revision' => array(
+ 'pk' => array( 'tree_rev_id' ),
+ 'userColumns' => array(
+ 'tree_orig_user_id' => 'getCreatorTuple',
+ ),
+ 'load' => array( $this, 'loadFromTreeRevision' ),
+ ),
+
+ 'flow_revision' => array(
+ 'pk' => array( 'rev_id' ),
+ 'userColumns' => array(
+ 'rev_user_id' => 'getUserTuple',
+ 'rev_mod_user_id' => 'getModeratedByTuple',
+ 'rev_edit_user_id' => 'getLastContentEditUserTuple',
+ ),
+ 'load' => array( $this, 'loadFromRevision' ),
+ 'loadColumns' => array( 'rev_type' ),
+ ),
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function getAccountFields() {
+ $fields = array();
+ $dbw = $this->dbFactory->getDb( DB_MASTER );
+ foreach ( $this->config as $table => $config ) {
+ $row = array(
+ 'db' => $dbw,
+ $table,
+ );
+ foreach ( array_keys( $config['userColumns'] ) as $column ) {
+ $row[] = $column;
+ }
+ $fields[] = $row;
+ }
+ return $fields;
+ }
+
+ /**
+ * Called after all databases have been updated. Needs to purge any
+ * cache that contained data about $oldUser
+ *
+ * @param integer $oldUserId
+ * @param integer $newUserId
+ */
+ public function finalizeMerge( $oldUserId, $newUserId ) {
+ $dbw = $this->dbFactory->getDb( DB_MASTER );
+ foreach ( $this->config as $table => $config ) {
+ foreach ( $config['userColumns'] as $column => $userTupleGetter ) {
+ $it = new EchoBatchRowIterator( $dbw, $table, $config['pk'], 500 );
+ // The database is migrated, so look for the new user id
+ $it->addConditions( array( $column => $newUserId ) );
+ if ( isset( $config['loadColumns'] ) ) {
+ $it->setFetchColumns( $config['loadColumns'] );
+ }
+ $this->purgeTable( $it, $oldUserId, $config['load'], $userTupleGetter );
+ }
+ }
+ }
+
+ /**
+ * @param Iterator $it
+ * @param integer $oldUserId
+ * @param callable $callback Receives a single row, returns domain object or null
+ * @param string $userTupleGetter Method to call on domain object that will return
+ * a UserTuple instance.
+ */
+ protected function purgeTable( Iterator $it, $oldUserId, $callback, $userTupleGetter ) {
+ foreach ( $it as $batch ) {
+ foreach ( $batch as $pkRow ) {
+ $obj = call_user_func( $callback, $pkRow );
+ if ( !$obj ) {
+ continue;
+ }
+ // This is funny looking because the loaded objects may have come from
+ // the db with new user ids, or the cache with old user ids.
+ // We need to tweak this object to look like the old user ids and then
+ // purge caches so they get the old user id cache keys.
+ $tuple = call_user_func( array( $obj, $userTupleGetter ) );
+ if ( !$tuple ) {
+ continue;
+ }
+ $tuple->id = $oldUserId;
+ $om = $this->storage->getStorage( get_class( $obj ) );
+ $om->clear();
+ $om->merge( $obj );
+ $om->cachePurge( $obj );
+ }
+ $this->storage->clear();
+ }
+ }
+
+ /**
+ * @param object $row Single row from database
+ * @return PostRevision|null
+ */
+ protected function loadFromTreeRevision( $row ) {
+ return $this->storage->get( 'PostRevision', $row->tree_rev_id );
+ }
+
+ /**
+ * @param object $row Single row from database
+ * @return AbstractRevision|null
+ */
+ protected function loadFromRevision( $row ) {
+ $revTypes = array(
+ 'header' => 'Flow\Model\Header',
+ 'post-summary' => 'Flow\Model\PostSummary',
+ 'post' => 'Flow\Model\PostRevision',
+ );
+ if ( !isset( $revTypes[$row->rev_type] ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Unknown revision type ' . $row->rev_type . ' did not merge ' . UUID::create( $row->rev_id )->getAlphadecimal() );
+ return null;
+ }
+
+ return $this->storage->get( $revTypes[$row->rev_type], $row->rev_id );
+ }
+}
diff --git a/Flow/includes/DbFactory.php b/Flow/includes/DbFactory.php
new file mode 100644
index 00000000..374255ab
--- /dev/null
+++ b/Flow/includes/DbFactory.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Flow;
+
+/**
+ * All classes within Flow that need to access the Flow db will go through
+ * this class. Having it separated into an object greatly simplifies testing
+ * anything that needs to talk to the database.
+ *
+ * The factory receives, in its constructor, the wiki name and cluster name
+ * that flow specific data is stored on. Multiple wiki's can and should be
+ * using the same wiki name and cluster to share flow specific data. These values
+ * are used. The $wiki parameter of getDB and getLB must be null to receive
+ * the flow database.
+ *
+ * To access core tables, use wfGetDB() etc. This is solely for Flow-specific
+ * data, which may live on a separate database.
+ */
+class DbFactory {
+ /**
+ * @var string|bool Wiki ID, or false for the current wiki
+ */
+ protected $wiki;
+
+ /**
+ * @var string|bool External storage cluster, or false for core
+ */
+ protected $cluster;
+
+ /**
+ * @var bool When true only DB_MASTER will be returned
+ */
+ protected $forceMaster = false;
+
+ /**
+ * @var string|boolean $wiki Wiki ID, or false for the current wiki
+ * @var string|boolean $cluster External storage cluster, or false for core
+ */
+ public function __construct( $wiki = false, $cluster = false ) {
+ $this->wiki = $wiki;
+ $this->cluster = $cluster;
+ }
+
+ public function forceMaster() {
+ $this->forceMaster = true;
+ }
+
+ /**
+ * @param integer $db index of the connection to get. DB_MASTER|DB_SLAVE.
+ * @param mixed $groups query groups. An array of group names that this query
+ * belongs to.
+ * @return \DatabaseBase
+ */
+ public function getDB( $db, $groups = array() ) {
+ return $this->getLB()->getConnection( $this->forceMaster ? DB_MASTER : $db, $groups, $this->wiki );
+ }
+
+ /**
+ * @return \LoadBalancer
+ */
+ public function getLB() {
+ if ( $this->cluster !== false ) {
+ return wfGetLBFactory()->getExternalLB( $this->cluster, $this->wiki );
+ } else {
+ return wfGetLB( $this->wiki );
+ }
+ }
+
+ /**
+ * Mockable version of wfGetDB.
+ *
+ * @param integer $db index of the connection to get. DB_MASTER|DB_SLAVE.
+ * @param array $groups query groups. An array of group names that this query
+ * belongs to.
+ * @param string|boolean $wiki The wiki ID, or false for the current wiki
+ * @return \DatabaseBase
+ */
+ public function getWikiDB( $db, $groups = array(), $wiki = false ) {
+ return wfGetDB( $this->forceMaster ? DB_MASTER : $db, $groups, $wiki );
+ }
+
+ /**
+ * Mockable version of wfGetLB.
+ *
+ * @param string|boolean $wiki wiki ID, or false for the current wiki
+ * @return \LoadBalancer
+ */
+ public function getWikiLB( $wiki = false ) {
+ return wfGetLB( $wiki );
+ }
+
+ /**
+ * Wait for the slaves of the Flow database
+ */
+ public function waitForSlaves() {
+ wfWaitForSlaves( false, $this->wiki, $this->cluster );
+ }
+}
diff --git a/Flow/includes/Exception/CatchableFatalErrorException.php b/Flow/includes/Exception/CatchableFatalErrorException.php
new file mode 100644
index 00000000..2594325d
--- /dev/null
+++ b/Flow/includes/Exception/CatchableFatalErrorException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Flow\Exception;
+
+use ErrorException;
+
+/**
+ * This class is not necessary, but having this so we could
+ * have a specific exception type to catch?
+ */
+class CatchableFatalErrorException extends ErrorException {
+
+}
diff --git a/Flow/includes/Exception/ExceptionHandling.php b/Flow/includes/Exception/ExceptionHandling.php
new file mode 100644
index 00000000..6461fbc1
--- /dev/null
+++ b/Flow/includes/Exception/ExceptionHandling.php
@@ -0,0 +1,355 @@
+<?php
+
+namespace Flow\Exception;
+
+use MWException;
+use OutputPage;
+use RequestContext;
+
+/**
+ * Flow base exception
+ */
+class FlowException extends MWException {
+
+ /**
+ * Flow exception error code
+ * @var string
+ */
+ protected $code;
+
+ /**
+ * The output object
+ * @var OutputPage
+ */
+ protected $output;
+
+ /**
+ * @param string - The message from exception, used for debugging error
+ * @param string - The error code used to display error message
+ */
+ public function __construct( $message, $code = 'default' ) {
+ global $wgOut;
+ parent::__construct( $message );
+ $this->code = $code;
+ // Set output object to the global $wgOut object by default
+ $this->output = $wgOut;
+ }
+
+ /**
+ * Set the output object
+ */
+ public function setOutput( OutputPage $output ) {
+ $this->output = $output;
+ }
+
+ /**
+ * Get the message key for the localized error message
+ */
+ public function getErrorCode() {
+ $list = $this->getErrorCodeList();
+ if ( !in_array( $this->code, $list ) ) {
+ $this->code = 'default';
+ }
+ return 'flow-error-' . $this->code;
+ }
+
+ /**
+ * Error code list for this exception
+ */
+ protected function getErrorCodeList() {
+ return array ( 'default' );
+ }
+
+ /**
+ * Override parent method: we can use wfMessage here
+ *
+ * @return bool
+ */
+ public function useMessageCache() {
+ return true;
+ }
+
+ /**
+ * Overrides MWException getHTML, adding a more human-friendly error message
+ *
+ * @return string
+ */
+ public function getHTML() {
+ /*
+ * We'll want both a proper humanized error msg & the stacktrace the
+ * parent exception handler generated.
+ * We'll create a stub OutputPage object here, to use its showErrorPage
+ * to add our own humanized error message. Then we'll append the stack-
+ * trace (parent::getHTML) and then just return the combined HTML.
+ */
+ $rc = new RequestContext();
+ $output = $rc->getOutput();
+ $output->showErrorPage( $this->getPageTitle(), $this->getErrorCode() );
+ $output->addHTML( parent::getHTML() );
+ return $output->getHTML();
+ }
+
+ /**
+ * Helper function for msg function in the convenience of a default callback
+ * @param string $key
+ * @return string
+ */
+ public function parsePageTitle( $key ) {
+ global $wgSitename;
+ return $this->msg( $key, "$1 - $wgSitename", $this->msg( 'internalerror', 'Internal error' ) );
+ }
+
+ /**
+ * Error page title
+ */
+ public function getPageTitle() {
+ return $this->parsePageTitle( 'errorpagetitle' );
+ }
+
+ /**
+ * Exception from API/commandline will be handled by MWException::report(),
+ * Overwrite the HTML display only
+ */
+ public function reportHTML() {
+ $this->output->setStatusCode( $this->getStatusCode() );
+
+ /*
+ * Parent exception handler uses global $wgOut
+ * We want to play nice and do inheritance and all, but that means we'll
+ * have to cheat here and assign out $this->output to $wgOut in order
+ * to have parent::reportHTML use the correct OutputPage object.
+ * After that, restore original $wgOut.
+ */
+ global $wgOut;
+ $wgOutBkp = $wgOut;
+ $wgOut = $this->output;
+ parent::reportHTML(); // this will do ->output() already
+ $wgOut = $wgOutBkp;
+ }
+
+ /**
+ * Default status code is 500, which is server error
+ */
+ public function getStatusCode() {
+ return 500;
+ }
+}
+
+/**
+ * Category: invalid input exception
+ */
+class InvalidInputException extends FlowException {
+ protected function getErrorCodeList() {
+ return array (
+ 'invalid-input',
+ 'missing-revision',
+ 'revision-comparison',
+ 'invalid-workflow'
+ );
+ }
+
+ /**
+ * Bad request
+ */
+ public function getStatusCode() {
+ return 400;
+ }
+
+ /**
+ * Do not log exception resulting from input error
+ */
+ function isLoggable() {
+ return false;
+ }
+}
+
+class InvalidReferenceException extends InvalidInputException {
+}
+
+/**
+ * Category: invalid action exception
+ */
+class InvalidActionException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'invalid-action' );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getPageTitle() {
+ return $this->parsePageTitle( 'nosuchaction' );
+ }
+
+ /**
+ * Bad request
+ */
+ public function getStatusCode() {
+ return 400;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getHTML() {
+ // we only want a nice error message here, no stack trace
+ $rc = new RequestContext();
+ $output = $rc->getOutput();
+ $output->showErrorPage( $this->getPageTitle(), $this->getErrorCode() );
+ return $output->getHTML();
+ }
+
+ /**
+ * Do not log exception resulting from input error
+ */
+ function isLoggable() {
+ return false;
+ }
+}
+
+/**
+ * Category: commit failure exception
+ */
+class FailCommitException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'fail-commit' );
+ }
+}
+
+/**
+ * Category: permission related exception
+ */
+class PermissionException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'insufficient-permission' );
+ }
+
+ /**
+ * Do not log exception resulting from user requesting
+ * disallowed content.
+ */
+ function isLoggable() {
+ return false;
+ }
+}
+
+/**
+ * Category: invalid data exception
+ */
+class InvalidDataException extends FlowException {
+ protected function getErrorCodeList() {
+ return array (
+ 'invalid-title',
+ 'fail-load-data',
+ 'fail-load-history',
+ 'missing-topic-title',
+ 'missing-metadata'
+ );
+ }
+}
+
+/**
+ * Category: data model processing exception
+ */
+class DataModelException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'process-data' );
+ }
+}
+
+/**
+ * Category: data persistency exception
+ */
+class DataPersistenceException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'process-data' );
+ }
+}
+
+/**
+ * Category: Parsoid
+ */
+class NoParsoidException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'process-wikitext' );
+ }
+}
+
+/**
+ * Category: wikitext/html conversion exception
+ */
+class WikitextException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'process-wikitext' );
+ }
+}
+
+/**
+ * Category: Data Index
+ */
+class NoIndexException extends FlowException {
+ protected function getErrorCodeList() {
+ return array ( 'no-index' );
+ }
+}
+
+/**
+ * Category: Cross Wiki
+ */
+class CrossWikiException extends FlowException {}
+
+/**
+ * Category: Template helper
+ */
+class WrongNumberArgumentsException extends FlowException {
+ /**
+ * @param array $args
+ * @param string $minExpected
+ * @param string|null $maxExpected
+ */
+ public function __construct( array $args, $minExpected, $maxExpected = null ) {
+ $count = count( $args );
+ if ( $maxExpected === null ) {
+ parent::__construct( "Expected $minExpected arguments but received $count" );
+ } else {
+ parent::__construct( "Expected between $minExpected and $maxExpected arguments but received $count" );
+ }
+ }
+}
+
+/**
+ * Specific exception thrown when a workflow is requested by id through
+ * WorkflowLoaderFactory and it does not exist.
+ */
+class UnknownWorkflowIdException extends InvalidInputException {
+ protected function getErrorCodeList() {
+ return array( 'invalid-input' );
+ }
+
+ public function getHTML() {
+ return wfMessage( 'flow-error-unknown-workflow-id' )->escaped();
+ }
+
+ public function getPageTitle() {
+ return wfMessage( 'flow-error-unknown-workflow-id-title' )->escaped();
+ }
+}
+
+/**
+ * Specific exception thrown when a page within NS_TOPIC is requested
+ * through WorkflowLoaderFactory and it is an invalid uuid
+ */
+class InvalidTopicUuidException extends InvalidInputException {
+ protected function getErrorCodeList() {
+ return array( 'invalid-input' );
+ }
+
+ public function getHTML() {
+ return wfMessage( 'flow-error-invalid-topic-uuid' )->escaped();
+ }
+
+ public function getPageTitle() {
+ return wfMessage( 'flow-error-invalid-topic-uuid-title' )->escaped();
+ }
+}
+
diff --git a/Flow/includes/FlowActions.php b/Flow/includes/FlowActions.php
new file mode 100644
index 00000000..6c4ee457
--- /dev/null
+++ b/Flow/includes/FlowActions.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Flow;
+
+use Flow\Data\Utils\MultiDimArray;
+
+class FlowActions {
+ /**
+ * @var MultiDimArray
+ */
+ protected $actions;
+
+ /**
+ * @param array $actions
+ */
+ public function __construct( array $actions ) {
+ $this->actions = new MultiDimArray();
+ $this->actions[] = $actions;
+ }
+
+ /**
+ * @return array
+ */
+ public function getActions() {
+ return array_keys( $this->actions->all() );
+ }
+
+ /**
+ * Function can be overloaded depending on how deep the desired value is.
+ *
+ * @param string $action
+ * @param string[optional] $type
+ * @return bool True when the requested parameter exists and is not null
+ */
+ public function hasValue( $action, $type = null /* [, $option = null [, ...]] */ ) {
+ $arguments = func_get_args();
+ try {
+ return isset( $this->actions[$arguments] );
+ } catch ( \OutOfBoundsException $e ) {
+ // Do nothing; the whole remainder of this method is fail-case.
+ }
+
+ /*
+ * If no value is found, check if the action is not actually referencing
+ * another action (for BC reasons), then try fetching the requested data
+ * from that action.
+ */
+ try {
+ $referencedAction = $this->actions[$action];
+ if ( is_string( $referencedAction ) && $referencedAction != $action ) {
+ // Replace action name in arguments.
+ $arguments[0] = $referencedAction;
+ return isset( $this->actions[$arguments] );
+ }
+ } catch ( \OutOfBoundsException $e ) {
+ // Do nothing; the whole remainder of this method is fail-case.
+ }
+
+ return false;
+ }
+
+ /**
+ * Function can be overloaded depending on how deep the desired value is.
+ *
+ * @param string $action
+ * @param string[optional] $type
+ * @return mixed|null Requested value or null if missing
+ */
+ public function getValue( $action, $type = null /* [, $option = null [, ...]] */ ) {
+ $arguments = func_get_args();
+
+ try {
+ return $this->actions[$arguments];
+ } catch ( \OutOfBoundsException $e ) {
+ // Do nothing; the whole remainder of this method is fail-case.
+ }
+
+ /*
+ * If no value is found, check if the action is not actually referencing
+ * another action (for BC reasons), then try fetching the requested data
+ * from that action.
+ */
+ try {
+ $referencedAction = $this->actions[$action];
+ if ( is_string( $referencedAction ) && $referencedAction != $action ) {
+ // Replace action name in arguments.
+ array_shift( $arguments );
+ array_unshift( $arguments, $referencedAction );
+
+ return call_user_func_array( array( $this, 'getValue' ), $arguments );
+ }
+ } catch ( \OutOfBoundsException $e ) {
+ // Do nothing; the whole remainder of this method is fail-case.
+ }
+
+ return null;
+ }
+}
diff --git a/Flow/includes/Formatter/AbstractFormatter.php b/Flow/includes/Formatter/AbstractFormatter.php
new file mode 100644
index 00000000..390c1a5a
--- /dev/null
+++ b/Flow/includes/Formatter/AbstractFormatter.php
@@ -0,0 +1,316 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\Anchor;
+use Flow\Model\PostRevision;
+use Flow\RevisionActionPermissions;
+use Html;
+use IContextSource;
+use Linker;
+use Message;
+
+/**
+ * This is a "utility" class that might come in useful to generate
+ * some output per Flow entry, e.g. for RecentChanges, Contributions, ...
+ * These share a lot of common characteristics (like displaying a date, links to
+ * the posts, some description of the action, ...)
+ *
+ * Just extend from this class to use these common util methods, and make sure
+ * to pass the correct parameters to these methods. Basically, you'll need to
+ * create a new method that'll accept the objects for your specific
+ * implementation (like ChangesList & RecentChange objects for RecentChanges, or
+ * ContribsPager and a DB row for Contributions). From those rows, you should be
+ * able to derive the objects needed to pass to these utility functions (mainly
+ * Workflow, AbstractRevision, Title, User and Language objects) and return the
+ * output.
+ *
+ * For implementation examples, check Flow\RecentChanges\Formatter or
+ * Flow\Contributions\Formatter.
+ */
+abstract class AbstractFormatter {
+ /**
+ * @var RevisionActionPermissions
+ */
+ protected $permissions;
+
+ /**
+ * @var RevisionFormatter
+ */
+ protected $serializer;
+
+ public function __construct( RevisionActionPermissions $permissions, RevisionFormatter $serializer ) {
+ $this->permissions = $permissions;
+ $this->serializer = $serializer;
+ }
+
+ abstract protected function getHistoryType();
+
+ /**
+ * @see RevisionFormatter::buildLinks
+ * @see RevisionFormatter::getDateFormats
+ *
+ * @param array $data Expects an array with keys 'dateFormats', 'isModerated'
+ * and 'links'. The former should be an array having the key $key being
+ * tossed in here; the latter an array of links in the [key => [href, msg]]
+ * format, where 'key' corresponds with a $linksKeys value. The central is
+ * a boolean.
+ * @param string $key Date format to use - any of the keys in the array
+ * returned by RevisionFormatter::getDateFormats
+ * @param string[] $linkKeys Link key(s) to use as link for the timestamp;
+ * the first available key will be used (but accepts an array of multiple
+ * keys for when different kinds of data are tossed in, which may not all
+ * have the same kind of links available)
+ * @return string HTML
+ */
+ protected function formatTimestamp( array $data, $key = 'timeAndDate', $linkKeys = array( 'header-revision', 'topic-revision', 'post-revision', 'summary-revision' ) ) {
+ // Format timestamp: add link
+ $formattedTime = $data['dateFormats'][$key];
+
+ // Find the first available link to attach to the timestamp
+ $anchor = null;
+ foreach ( $linkKeys as $linkKey ) {
+ if ( isset( $data['links'][$linkKey] ) ) {
+ $anchor = $data['links'][$linkKey];
+ break;
+ }
+ }
+
+ $class = array( 'mw-changeslist-date' );
+ if ( $data['isModerated'] ) {
+ $class[] = 'history-deleted';
+ }
+
+ if ( $anchor instanceof Anchor ) {
+ return Html::rawElement(
+ 'span',
+ array( 'class' => $class ),
+ $anchor->toHtml( $formattedTime )
+ );
+ } else {
+ return Html::element( 'span', array( 'class' => $class ), $formattedTime );
+ }
+ }
+
+ /**
+ * Generate HTML for "(foo | bar | baz)" based on the links provided by
+ * RevisionFormatter.
+ *
+ * @param array $links Contains any combination of Anchor|Message|string
+ * @param IContextSource $ctx
+ * @param string[] $request List of link names to be allowed in result output
+ * @return string Html valid for user output
+ */
+ protected function formatAnchorsAsPipeList( array $links, IContextSource $ctx, array $request = null ) {
+ if ( $request === null ) {
+ $request = array_keys( $links );
+ } elseif ( !$request ) {
+ // empty array was passed
+ return '';
+ }
+ $have = array_combine( $request, $request );
+
+ $formatted = array();
+ foreach ( $links as $key => $link ) {
+ if ( isset( $have[$key] ) ) {
+ if ( $link instanceof Anchor ) {
+ $formatted[] = $link->toHtml();
+ } elseif( $link instanceof Message ) {
+ $formatted[] = $link->escaped();
+ } else {
+ // plain text
+ $formatted[] = htmlspecialchars( $key );
+ }
+ }
+ }
+
+ if ( $formatted ) {
+ $content = $ctx->getLanguage()->pipeList( $formatted );
+ if ( $content ) {
+ return $ctx->msg( 'parentheses' )->rawParams( $content )->escaped();
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Gets the "diff" link; linking to the diff against the previous revision,
+ * in a format that can be wrapped in an array and passed to
+ * formatLinksAsPipeList.
+ *
+ * @param array[][] Associative array containing (url, message) tuples
+ * @param IContextSource $ctx
+ * @return Anchor|Message
+ */
+ protected function getDiffAnchor( array $input, IContextSource $ctx ) {
+ if ( !isset( $input['diff'] ) ) {
+ // plain text with no link
+ return $ctx->msg( 'diff' );
+ }
+
+ return $input['diff'];
+ }
+
+ /**
+ * Gets the "prev" link; linking to the diff against the previous revision,
+ * in a format that can be wrapped in an array and passed to
+ * formatLinksAsPipeList.
+ *
+ * @param array[][] Associative array containing (url, message) tuples
+ * @param IContextSource $ctx
+ * @return Anchor|Message
+ */
+ protected function getDiffPrevAnchor( array $input, IContextSource $ctx ) {
+ if ( !isset( $input['diff-prev'] ) ) {
+ // plain text with no link
+ return $ctx->msg( 'last' );
+ }
+
+ return $input['diff-prev'];
+ }
+
+ /**
+ * Gets the "cur" link; linking to the diff against the current revision,
+ * in a format that can be wrapped in an array and passed to
+ * formatLinksAsPipeList.
+ *
+ * @param array[][] Associative array containing (url, message) tuples
+ * @param IContextSource $ctx
+ * @return Anchor|Message
+ */
+ protected function getDiffCurAnchor( array $input, IContextSource $ctx ) {
+ if ( !isset( $input['diff-cur'] ) ) {
+ // plain text with no link
+ return $ctx->msg( 'cur' );
+ }
+
+ return $input['diff-cur'];
+ }
+
+ /**
+ * Gets the "hist" link; linking to the history of a certain element, in a
+ * format that can be wrapped in an array and passed to
+ * formatLinksAsPipeList.
+ *
+ * @param array[][] Associative array containing (url, message) tuples
+ * @param IContextSource $ctx
+ * @return Anchor|Message
+ */
+ protected function getHistAnchor( array $input, IContextSource $ctx ) {
+ if ( isset( $input['post-history'] ) ) {
+ $anchor = $input['post-history'];
+ } elseif ( isset( $input['topic-history'] ) ) {
+ $anchor = $input['topic-history'];
+ } elseif ( isset( $input['board-history'] ) ) {
+ $anchor = $input['board-history'];
+ } else {
+ $anchor = null;
+ }
+
+ if ( $anchor instanceof Anchor ) {
+ $anchor->setMessage( wfMessage( 'hist' ) );
+ return $anchor;
+ } else {
+ // plain text with no link
+ return $ctx->msg( 'hist' );
+ }
+ }
+
+ /**
+ * @return string Html valid for user output
+ */
+ protected function changeSeparator() {
+ return ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+
+ /**
+ * @param array $data
+ * @param IContextSource $ctx
+ * @return Message
+ */
+ protected function getDescription( array $data, IContextSource $ctx ) {
+ // Build description message, piggybacking on history i18n
+ $changeType = $data['changeType'];
+ $actions = $this->permissions->getActions();
+
+ $key = $actions->getValue( $changeType, 'history', 'i18n-message' );
+ // find specialized message for this particular formatter type
+ $msg = $ctx->msg( $key . '-' . $this->getHistoryType() );
+ if ( !$msg->exists() ) {
+ // fallback to default msg
+ $msg = $ctx->msg( $key );
+ }
+
+ return $msg->params( $this->getDescriptionParams( $data, $actions, $changeType ) );
+ }
+
+ /**
+ * @param array $data
+ * @param FlowActions $actions
+ * @param string $changeType
+ * @return array
+ */
+ protected function getDescriptionParams( array $data, FlowActions $actions, $changeType ) {
+ $source = $actions->getValue( $changeType, 'history', 'i18n-params' );
+ $params = array();
+ foreach ( $source as $param ) {
+ if ( isset( $data['properties'][$param] ) ) {
+ $params[] = $data['properties'][$param];
+ } else {
+ wfDebugLog( 'Flow', __METHOD__ . ": Missing expected parameter $param for change type $changeType" );
+ $params[] = '';
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Generate an HTML revision description.
+ *
+ * @param array $data
+ * @param IContextSource $ctx
+ * @return string Html valid for user output
+ */
+ protected function formatDescription( array $data, IContextSource $ctx ) {
+ $msg = $this->getDescription( $data, $ctx );
+ return '<span class="plainlinks">' . $msg->parse() . '</span>';
+ }
+
+ /**
+ * Returns HTML links to the page title and (if the action is topic-related)
+ * the topic.
+ *
+ * @param array $data
+ * @param FormatterRow $row
+ * @param IContextSource $ctx
+ * @return string HTML linking to topic & board
+ */
+ protected function getTitleLink( array $data, FormatterRow $row, IContextSource $ctx ) {
+ $ownerLink = Linker::link(
+ $row->workflow->getOwnerTitle(),
+ null,
+ array( 'class' => 'mw-title' )
+ );
+
+ if ( !isset( $data['links']['topic'] ) || !$row->revision instanceof PostRevision ) {
+ return $ownerLink;
+ }
+ /** @var Anchor $topic */
+ $topic = $data['links']['topic'];
+
+ // generated link has generic link text, should be actual topic title
+ $root = $row->revision->getRootPost();
+ if ( $root ) {
+ $topic->setMessage( Container::get( 'templating' )->getContent( $root, 'wikitext' ) );
+ }
+
+ return $ctx->msg( 'flow-rc-topic-of-board' )->rawParams(
+ $topic->toHtml(),
+ $ownerLink
+ )->escaped();
+ }
+}
diff --git a/Flow/includes/Formatter/AbstractQuery.php b/Flow/includes/Formatter/AbstractQuery.php
new file mode 100644
index 00000000..a0df73b4
--- /dev/null
+++ b/Flow/includes/Formatter/AbstractQuery.php
@@ -0,0 +1,405 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\ManagerGroup;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Repository\TreeRepository;
+use ResultWrapper;
+
+/**
+ * Base class that collects the data necessary to utilize AbstractFormatter
+ * based on a list of revisions. In some cases formatters will not utilize
+ * this query and will instead receive data from a table such as the core
+ * recentchanges.
+ */
+abstract class AbstractQuery {
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var TreeRepository
+ */
+ protected $treeRepository;
+
+ /**
+ * @var UUID[] Associative array of post ID to root post's UUID object.
+ */
+ protected $rootPostIdCache = array();
+
+ /**
+ * @var PostRevision[] Associative array of post ID to PostRevision object.
+ */
+ protected $postCache = array();
+
+ /**
+ * @var AbstractRevision[] Associative array of revision ID to AbstractRevision object
+ */
+ protected $revisionCache = array();
+
+ /**
+ * @var Workflow[] Associative array of workflow ID to Workflow object.
+ */
+ protected $workflowCache = array();
+
+ /**
+ * Array of collection ids mapping to their most recent revision ids.
+ *
+ * @var UUID[]
+ */
+ protected $currentRevisionsCache = array();
+
+ protected $identityMap = array();
+
+ /**
+ * @param ManagerGroup $storage
+ * @param TreeRepository $treeRepository
+ */
+ public function __construct( ManagerGroup $storage, TreeRepository $treeRepository ) {
+ $this->storage = $storage;
+ $this->treeRepository = $treeRepository;
+ }
+
+ /**
+ * Entry point for batch loading metadata for a variety of revisions
+ * into the internal cache.
+ *
+ * @param AbstractRevision[]|ResultWrapper $results
+ */
+ protected function loadMetadataBatch( $results ) {
+ // Batch load data related to a list of revisions
+ $postIds = array();
+ $workflowIds = array();
+ $revisions = array();
+ $previousRevisionIds = array();
+ $collectionIds = array();
+ foreach( $results as $result ) {
+ if ( $result instanceof PostRevision ) {
+ // If top-level, then just get the workflow.
+ // Otherwise we need to find the root post.
+ $id = $result->getPostId();
+ $alpha = $id->getAlphadecimal();
+ if ( $result->isTopicTitle() ) {
+ $workflowIds[] = $id;
+ } else {
+ $postIds[$alpha] = $id;
+ }
+ $this->postCache[$alpha] = $result;
+ } elseif ( $result instanceof Header ) {
+ $workflowIds[] = $result->getWorkflowId();
+ } elseif ( $result instanceof PostSummary ) {
+ // This would be the post id for the summary
+ $id = $result->getSummaryTargetId();
+ $postIds[$id->getAlphadecimal()] = $id;
+ }
+
+ $revisions[$result->getRevisionId()->getAlphadecimal()] = $result;
+ if ( $this->needsPreviousRevision( $result ) ) {
+ $previousRevisionIds[get_class( $result )][] = $result->getPrevRevisionId();
+ }
+
+ $collection = $result->getCollection();
+ $collectionIds[get_class( $result )][] = $collection->getId();
+ }
+
+ // map from post Id to the related root post id
+ $rootPostIds = array_filter( $this->treeRepository->findRoots( $postIds ) );
+ $rootPostRequests = array();
+ foreach( $rootPostIds as $postId ) {
+ $rootPostRequests[] = array( 'rev_type_id' => $postId );
+ }
+
+ // these tree identity maps are required for determining where a reply goes when
+ //
+ // replying to a specific post.
+ $identityMap = $this->treeRepository->fetchSubtreeIdentityMap(
+ array_unique( $rootPostIds, SORT_REGULAR )
+ );
+
+ $rootPostResult = $this->storage->findMulti(
+ 'PostRevision',
+ $rootPostRequests,
+ array(
+ 'SORT' => 'rev_id',
+ 'ORDER' => 'DESC',
+ 'LIMIT' => 1,
+ )
+ );
+
+ $rootPosts = array();
+ if ( count( $rootPostResult ) > 0 ) {
+ foreach ( $rootPostResult as $found ) {
+ $root = reset( $found );
+ $rootPosts[$root->getPostId()->getAlphadecimal()] = $root;
+ $revisions[$root->getRevisionId()->getAlphadecimal()] = $root;
+ }
+ }
+
+ // Workflow IDs are the same as root post IDs
+ // So any post IDs that *are* root posts + found root post IDs + header workflow IDs
+ // should cover the lot.
+ $workflows = $this->storage->getMulti( 'Workflow', array_merge( $rootPostIds, $workflowIds ) );
+ $workflows = $workflows ?: array();
+
+ // preload all requested previous revisions
+ foreach ( $previousRevisionIds as $revisionType => $ids ) {
+ // get rid of null-values (for original revisions, without previous revision)
+ $ids = array_filter( $ids );
+ /** @var AbstractRevision[] $found */
+ $found = $this->storage->getMulti( $revisionType, $ids );
+ foreach ( $found as $rev ) {
+ $revisions[$rev->getRevisionId()->getAlphadecimal()] = $rev;
+ }
+ }
+
+ // preload all current versions
+ foreach ( $collectionIds as $revisionType => $ids ) {
+ $queries = array();
+ foreach ( $ids as $uuid ) {
+ $queries[] = array( 'rev_type_id' => $uuid );
+ }
+
+ $found = $this->storage->findMulti( $revisionType,
+ $queries,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ /** @var AbstractRevision[] $result */
+ foreach ( $found as $result ) {
+ $rev = reset( $result );
+ $this->currentRevisionsCache[$rev->getCollectionId()->getAlphadecimal()] = $rev->getRevisionId();
+ $revisions[$rev->getRevisionId()->getAlphadecimal()] = $rev;
+ }
+ }
+
+ $this->revisionCache = array_merge( $this->revisionCache, $revisions );
+ $this->postCache = array_merge( $this->postCache, $rootPosts );
+ $this->rootPostIdCache = array_merge( $this->rootPostIdCache, $rootPostIds );
+ $this->workflowCache = array_merge( $this->workflowCache, $workflows );
+ $this->identityMap = array_merge( $this->identityMap, $identityMap );
+ }
+
+ /**
+ * Build a stdClass object that contains all related data models necessary
+ * for rendering a revision.
+ *
+ * @param AbstractRevision $revision
+ * @param string $indexField The field used for pagination
+ * @param FormatterRow|null Row to populate
+ * @return FormatterRow
+ * @throws FlowException
+ */
+ protected function buildResult( AbstractRevision $revision, $indexField, FormatterRow $row = null ) {
+ $uuid = $revision->getRevisionId();
+ $timestamp = $uuid->getTimestamp();
+
+ $workflow = $this->getWorkflow( $revision );
+ if ( !$workflow ) {
+ throw new FlowException( "could not locate workflow for revision " . $revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ $row = $row ?: new FormatterRow;
+ $row->revision = $revision;
+ if ( $this->needsPreviousRevision( $revision ) ) {
+ $row->previousRevision = $this->getPreviousRevision( $revision );
+ }
+ $row->currentRevision = $this->getCurrentRevision( $revision );
+ $row->workflow = $workflow;
+
+ // some core classes that process this row before our formatter
+ // require a specific field to handle pagination
+ if ( property_exists( $row, $indexField ) ) {
+ $row->$indexField = $timestamp;
+ }
+
+ if ( $revision instanceof PostRevision ) {
+ $row->rootPost = $this->getRootPost( $revision );
+ $revision->setRootPost( $row->rootPost );
+ $row->isLastReply = $this->isLastReply( $revision );
+ }
+
+ return $row;
+ }
+
+ protected function isLastReply( PostRevision $revision ) {
+ if ( $revision->isTopicTitle() ) {
+ return false;
+ }
+ $reply = $revision->getReplyToId()->getAlphadecimal();
+ if ( !isset( $this->identityMap[$reply] ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": Missing $reply in identity map" );
+ return false;
+ }
+ $parent = $this->identityMap[$revision->getReplyToId()->getAlphadecimal()];
+ $keys = array_keys( $parent['children'] );
+ return end( $keys ) === $revision->getPostId()->getAlphadecimal();
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @return Workflow
+ * @throws \MWException
+ */
+ protected function getWorkflow( AbstractRevision $revision ) {
+ if ( $revision instanceof PostRevision ) {
+ $rootPostId = $this->getRootPostId( $revision );
+ return $this->getWorkflowById( $rootPostId );
+ } elseif ( $revision instanceof Header ) {
+ return $this->getWorkflowById( $revision->getWorkflowId() );
+ } elseif ( $revision instanceof PostSummary ) {
+ return $this->getWorkflowById( $revision->getCollection()->getWorkflowId() );
+ } else {
+ throw new \MWException( 'Unsupported revision type ' . get_class( $revision ) );
+ }
+ }
+
+ /**
+ * Decides if the given abstract revision needs its prior revision for formatting
+ * @param AbstractRevision $revision
+ * @return boolean true when the previous revision to this should be loaded
+ */
+ protected function needsPreviousRevision( AbstractRevision $revision ) {
+ // crappy special case needs the previous object so it can show the title
+ // but only when outputting a full history api result(we don't know that here)
+ return $revision instanceof PostRevision
+ && $revision->getChangeType() === 'edit-title';
+ }
+
+ /**
+ * Retrieves the previous revision for a given AbstractRevision
+ * @param AbstractRevision $revision The revision to retrieve the previous revision for.
+ * @return AbstractRevision|null AbstractRevision of the previous revision or null if no previous revision.
+ */
+ protected function getPreviousRevision( AbstractRevision $revision ) {
+ $previousRevisionId = $revision->getPrevRevisionId();
+
+ // original post; no previous revision
+ if ( $previousRevisionId === null ) {
+ return null;
+ }
+
+ if ( !isset( $this->revisionCache[$previousRevisionId->getAlphadecimal()] ) ) {
+ $this->revisionCache[$previousRevisionId->getAlphadecimal()] =
+ $this->storage->get( 'PostRevision', $previousRevisionId );
+ }
+
+ return $this->revisionCache[$previousRevisionId->getAlphadecimal()];
+ }
+
+ /**
+ * Retrieves the current revision for a given AbstractRevision
+ * @param AbstractRevision $revision The revision to retrieve the current revision for.
+ * @return AbstractRevision|null AbstractRevision of the current revision.
+ */
+ protected function getCurrentRevision( AbstractRevision $revision ) {
+ $collectionId = $revision->getCollectionId();
+ if ( !isset( $this->currentRevisionsCache[$collectionId->getAlphadecimal()] ) ) {
+ $currentRevision = $revision->getCollection()->getLastRevision();
+
+ $this->currentRevisionsCache[$collectionId->getAlphadecimal()] = $currentRevision->getRevisionId();
+ $this->revisionCache[$currentRevision->getRevisionId()->getAlphadecimal()] = $currentRevision;
+ }
+
+ $currentRevisionId = $this->currentRevisionsCache[$collectionId->getAlphadecimal()];
+ return $this->revisionCache[$currentRevisionId->getAlphaDecimal()];
+ }
+
+ /**
+ * Retrieves the root post for a given PostRevision
+ * @param PostRevision $revision The revision to retrieve the root post for.
+ * @return PostRevision PostRevision of the root post.
+ * @throws \MWException
+ */
+ protected function getRootPost( PostRevision $revision ) {
+ if ( $revision->isTopicTitle() ) {
+ return $revision;
+ }
+ $rootPostId = $this->getRootPostId( $revision );
+
+ if ( !isset( $this->postCache[$rootPostId->getAlphadecimal()] ) ) {
+ throw new \MwException( 'Did not load root post ' . $rootPostId->getAlphadecimal() );
+ }
+
+ $rootPost = $this->postCache[$rootPostId->getAlphadecimal()];
+ if ( !$rootPost ) {
+ throw new \MWException( 'Did not locate root post ' . $rootPostId->getAlphadecimal() );
+ }
+ if ( !$rootPost->isTopicTitle() ) {
+ throw new \MWException( "Not a topic title: " . $rootPost->getRevisionId() );
+ }
+
+ return $rootPost;
+ }
+
+ /**
+ * Gets the root post ID for a given PostRevision
+ * @param PostRevision $revision The revision to get the root post ID for.
+ * @return UUID The UUID for the root post.
+ * @throws \MWException
+ */
+ protected function getRootPostId( PostRevision $revision ) {
+ $postId = $revision->getPostId();
+ if ( $revision->isTopicTitle() ) {
+ return $postId;
+ } elseif ( isset( $this->rootPostIdCache[$postId->getAlphadecimal()] ) ) {
+ return $this->rootPostIdCache[$postId->getAlphadecimal()];
+ } else {
+ throw new \MWException( "Unable to find root post ID for post " . $postId->getAlphadecimal() );
+ }
+ }
+
+ /**
+ * Gets a Workflow object given its ID
+ * @param UUID $workflowId The Workflow ID to retrieve.
+ * @return Workflow The Workflow.
+ */
+ protected function getWorkflowById( UUID $workflowId ) {
+ $alpha = $workflowId->getAlphadecimal();
+ if ( isset( $this->workflowCache[$alpha] ) ) {
+ return $this->workflowCache[$alpha];
+ } else {
+ return $this->workflowCache[$alpha] = $this->storage->get( 'Workflow', $workflowId );
+ }
+ }
+}
+
+/**
+ * Helper class represents a row of data from AbstractQuery
+ */
+class FormatterRow {
+ /** @var AbstractRevision */
+ public $revision;
+ /** @var AbstractRevision|null */
+ public $previousRevision;
+ /** @var AbstractRevision */
+ public $currentRevision;
+ /** @var Workflow */
+ public $workflow;
+ /** @var string */
+ public $indexFieldName;
+ /** @var string */
+ public $indexFieldValue;
+ /** @var PostRevision|null */
+ public $rootPost;
+ /** @var bool */
+ public $isLastReply = false;
+
+ // protect against typos
+ public function __get( $attribute ) {
+ throw new \MWException( "Accessing non-existent parameter: $attribute" );
+ }
+
+ // protect against typos
+ public function __set( $attribute, $value ) {
+ throw new \MWException( "Accessing non-existent parameter: $attribute" );
+ }
+}
+
diff --git a/Flow/includes/Formatter/BaseTopicListFormatter.php b/Flow/includes/Formatter/BaseTopicListFormatter.php
new file mode 100644
index 00000000..0247ffd0
--- /dev/null
+++ b/Flow/includes/Formatter/BaseTopicListFormatter.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\Pager\PagerPage;
+use Flow\Model\Anchor;
+use Flow\Model\Workflow;
+
+class BaseTopicListFormatter {
+ /**
+ * Builds the results for an empty topic.
+ *
+ * @param Workflow $workflow Workflow for topic list
+ * @return array Associative array with the the result
+ */
+ public function buildEmptyResult( Workflow $workflow ) {
+ return array(
+ 'type' => 'topiclist',
+ 'roots' => array(),
+ 'posts' => array(),
+ 'revisions' => array(),
+ 'links' => array( 'pagination' => array() ),
+ );
+ }
+
+ /**
+ * @param Workflow $workflow Topic list workflow
+ * @param array $links pagination link data
+ *
+ * @return array link structure
+ */
+ protected function buildPaginationLinks( Workflow $workflow, array $links ) {
+ $res = array();
+ $title = $workflow->getArticleTitle();
+ foreach ( $links as $key => $options ) {
+ // prefix all options with topiclist_
+ $realOptions = array();
+ foreach ( $options as $k => $v ) {
+ $realOptions["topiclist_$k"] = $v;
+ }
+ $res[$key] = new Anchor(
+ $key, // @todo i18n
+ $title,
+ $realOptions
+ );
+ }
+
+ return $res;
+ }
+}
diff --git a/Flow/includes/Formatter/BoardHistoryQuery.php b/Flow/includes/Formatter/BoardHistoryQuery.php
new file mode 100644
index 00000000..bddb4363
--- /dev/null
+++ b/Flow/includes/Formatter/BoardHistoryQuery.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+use MWExceptionHandler;
+
+class BoardHistoryQuery extends AbstractQuery {
+ /**
+ * @param UUID $workflowId
+ * @param int $limit
+ * @param UUID|null $offset
+ * @param string $direction 'rev' or 'fwd'
+ * @return FormatterRow[]
+ */
+ public function getResults( UUID $workflowId, $limit = 50, UUID $offset = null, $direction = 'fwd' ) {
+ // Load the history
+ $history = $this->storage->find(
+ 'BoardHistoryEntry',
+ array( 'topic_list_id' => $workflowId ),
+ array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => $limit,
+ 'offset-id' => $offset,
+ 'offset-dir' => $direction,
+ 'offset-include' => false,
+ 'offset-elastic' => false,
+ )
+ );
+
+ if ( !$history ) {
+ return array();
+ }
+
+ // fetch any necessary metadata
+ $this->loadMetadataBatch( $history );
+ // build rows with the extra metadata
+ $results = array();
+ foreach ( $history as $revision ) {
+ try {
+ $result = $this->buildResult( $revision, 'rev_id' );
+ } catch ( FlowException $e ) {
+ $result = false;
+ MWExceptionHandler::logException( $e );
+ }
+ if ( $result ) {
+ $results[] = $result;
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/Flow/includes/Formatter/CategoryViewerFormatter.php b/Flow/includes/Formatter/CategoryViewerFormatter.php
new file mode 100644
index 00000000..0c060229
--- /dev/null
+++ b/Flow/includes/Formatter/CategoryViewerFormatter.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\RevisionActionPermissions;
+use Flow\Model\UUID;
+use Linker;
+
+/**
+ * Formats single lines for inclusion on the page renders in
+ * NS_CATEGORY. Expects that all rows passed in are topic
+ * titles.
+ */
+class CategoryViewerFormatter {
+ /**
+ * @var RevisionActionPermissions
+ */
+ protected $permissions;
+
+ public function __construct( RevisionActionPermissions $permissions ) {
+ $this->permissions = $permissions;
+ }
+
+ public function format( FormatterRow $row ) {
+ if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
+ return '';
+ }
+
+ $topic = Linker::link(
+ $row->workflow->getArticleTitle(),
+ htmlspecialchars( $row->revision->getContent( 'plaintext' ) ),
+ array( 'class' => 'mw-title' )
+ );
+
+ $board = Linker::link( $row->workflow->getOwnerTitle() );
+
+ return wfMessage( 'flow-rc-topic-of-board' )->rawParams( $topic, $board )->escaped();
+ }
+}
diff --git a/Flow/includes/Formatter/CategoryViewerQuery.php b/Flow/includes/Formatter/CategoryViewerQuery.php
new file mode 100644
index 00000000..1bb21687
--- /dev/null
+++ b/Flow/includes/Formatter/CategoryViewerQuery.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\ManagerGroup;
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+
+/**
+ * This class is necessary so we can inject the name of
+ * a topic title into the category. Once we have pages
+ * in the topic namespace named after the topic themselves
+ * this can be simplified down to only pre-load the workflow
+ * and not the related posts.
+ */
+class CategoryViewerQuery {
+ /**
+ * @var PostRevision[]
+ */
+ protected $posts = array();
+
+ /**
+ * @var Workflow[]
+ */
+ protected $workflows = array();
+
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ public function __construct( ManagerGroup $storage ) {
+ $this->storage = $storage;
+ }
+
+ /**
+ * Accepts a result set as sent out to the CategoryViewer::doCategoryQuery
+ * hook.
+ *
+ * @param ResultWrapper|array $rows
+ */
+ public function loadMetadataBatch( $rows ) {
+ $neededPosts = array();
+ $neededWorkflows = array();
+ foreach ( $rows as $row ) {
+ if ( $row->page_namespace != NS_TOPIC ) {
+ continue;
+ }
+ $uuid = UUID::create( strtolower( $row->page_title ) );
+ if ( $uuid ) {
+ $alpha = $uuid->getAlphadecimal();
+ $neededPosts[$alpha] = array( 'rev_type_id' => $uuid );
+ $neededWorkflows[$alpha] = $uuid;
+ }
+ }
+
+ if ( !$neededPosts ) {
+ return;
+ }
+ $this->posts = $this->storage->findMulti(
+ 'PostRevision',
+ $neededPosts,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ $workflows = $this->storage->getMulti(
+ 'Workflow',
+ $neededWorkflows
+ );
+ // @todo fixme: these should have come back with the apropriate array
+ // key since we passed it in above, but didn't.
+ foreach ( $workflows as $workflow ) {
+ $this->workflows[$workflow->getId()->getAlphadecimal()] = $workflow;
+ }
+ }
+
+ public function getResult( UUID $uuid ) {
+ $alpha = $uuid->getAlphadecimal();
+
+ // Minimal set of data needed for the CategoryViewFormatter
+ $row = new FormatterRow;
+ if ( !isset( $this->posts[$alpha] ) ) {
+ throw new FlowException( "A required post has not been loaded: $alpha" );
+ }
+ $row->revision = reset( $this->posts[$alpha] );
+ if ( !isset( $this->workflows[$alpha] ) ) {
+ throw new FlowException( "A required workflow has not been loaded: $alpha" );
+ }
+ $row->workflow = $this->workflows[$alpha];
+
+ return $row;
+ }
+}
diff --git a/Flow/includes/Formatter/CheckUserFormatter.php b/Flow/includes/Formatter/CheckUserFormatter.php
new file mode 100644
index 00000000..c38197a5
--- /dev/null
+++ b/Flow/includes/Formatter/CheckUserFormatter.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Flow\Formatter;
+
+use IContextSource;
+
+class CheckUserFormatter extends AbstractFormatter {
+ protected function getHistoryType() {
+ return 'checkuser';
+ }
+
+ /**
+ * @return string
+ */
+ protected function changeSeparator() {
+ return ' . . ';
+ }
+
+ /**
+ * Create an array of links to be used when formatting the row in checkuser
+ *
+ * @param FormatterRow $row Row of data provided by the check user special page
+ * @param IContextSource $ctx
+ * @return array|null
+ */
+ public function format( FormatterRow $row, IContextSource $ctx ) {
+ // @todo: this currently only implements CU links
+ // we'll probably want to add a hook to CheckUser that lets us blank out
+ // the entire line for entries that !isAllowed( <this-revision>, 'checkuser' )
+ // @todo: we probably want to get the action description (generated revision comment)
+ // into checkuser sooner or later as well.
+
+ $links = $this->serializer->buildLinks( $row );
+ $properties = $this->serializer->buildProperties( $row->workflow->getId(), $row->revision, $ctx );
+ if ( $links === null ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': No links were generated for revision ' . $row->revision->getRevisionId()->getAlphadecimal() );
+ return null;
+ }
+
+ $data = array(
+ 'links' => array(
+ $this->getDiffAnchor( $links, $ctx ),
+ $this->getHistAnchor( $links, $ctx ),
+ ),
+ 'properties' => $properties
+ );
+
+ return array(
+ 'links' => $this->formatAnchorsAsPipeList( $data['links'], $ctx ),
+ 'title' => $this->changeSeparator() . $this->getTitleLink( $data, $row, $ctx ),
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/CheckUserQuery.php b/Flow/includes/Formatter/CheckUserQuery.php
new file mode 100644
index 00000000..e52c9c2c
--- /dev/null
+++ b/Flow/includes/Formatter/CheckUserQuery.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+use CheckUser;
+
+class CheckUserQuery extends AbstractQuery {
+ /**
+ * Revision data will be stored in cuc_comment & prefixed with this string
+ * so we can distinguish between different kinds of data in there, should we
+ * change that data format later.
+ *
+ * @var string
+ */
+ const VERSION_PREFIX = 'v1';
+
+ /**
+ * @param \stdClass[] $rows List of checkuser database rows
+ */
+ public function loadMetadataBatch( $rows ) {
+ $needed = array();
+
+ foreach ( $rows as $row ) {
+ if ( $row->cuc_type != RC_FLOW || !$row->cuc_comment ) {
+ continue;
+ }
+
+ $ids = self::extractIds( $row );
+ if ( !$ids ) {
+ continue;
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ list( $workflowId, $revisionId, $postId ) = $ids;
+
+ /*
+ * We'll load all revisions based on their revision id. There could
+ * be revisions from multiple models, so figure out what the id
+ * actually belongs to.
+ * This isn't really the most robust way to identify a revision
+ * type, but it'll work for now.
+ */
+ $revisionType = $postId ? 'PostRevision' : 'Header';
+ $needed[$revisionType][] = $revisionId;
+ }
+
+ $found = array();
+ foreach ( $needed as $type => $uids ) {
+ $found[] = $this->storage->getMulti( $type, $uids );
+ }
+
+ $count = count( $found );
+ if ( $count === 0 ) {
+ $results = array();
+ } elseif ( $count === 1 ) {
+ $results = reset( $found );
+ } else {
+ $results = call_user_func_array( 'array_merge', $found );
+ }
+
+ if ( $results ) {
+ parent::loadMetadataBatch( $results );
+ }
+ }
+
+ /**
+ * @param CheckUser $checkUser
+ * @param \StdClass $row
+ * @return CheckUserRow|null
+ * @throws FlowException
+ */
+ public function getResult( CheckUser $checkUser, $row ) {
+ if ( $row->cuc_type != RC_FLOW || !$row->cuc_comment ) {
+ return false;
+ }
+
+ $ids = self::extractIds( $row );
+ if ( !$ids ) {
+ return false;
+ }
+
+ // order of $ids is (workflowId, revisionId, postId)
+ $alpha = $ids[1]->getAlphadecimal();
+ if ( !isset( $this->revisionCache[$alpha] ) ) {
+ throw new FlowException( "Revision not found in revisionCache: $alpha" );
+ }
+ $revision = $this->revisionCache[$alpha];
+
+ $res = new CheckUserRow;
+ $this->buildResult( $revision, 'cuc_timestamp', $res );
+ $res->checkUser = $checkUser;
+
+ return $res;
+ }
+
+ /**
+ * Extracts the workflow, revision & post ID (if any) from the CU's
+ * comment-column (cuc_comment), where they're stored in comma-separated
+ * format.
+ *
+ * @param \StdClass $row
+ * @return UUID[]|false Array with workflow, revision & post id (when
+ * applicable), or false on error
+ */
+ protected function extractIds( $row ) {
+ $data = explode( ',', $row->cuc_comment );
+
+ // anything not prefixed v1 is a pre-versioned check user comment
+ // if it changes again the prefix can be updated.
+ if ( strpos( $row->cuc_comment, self::VERSION_PREFIX ) !== 0 ) {
+ return false;
+ }
+
+ // remove the version specifier
+ array_shift( $data );
+
+ $postId = null;
+ switch ( count( $data ) ) {
+ /** @noinspection PhpMissingBreakStatementInspection */
+ case 4:
+ $postId = UUID::create( $data[3] );
+ // fall-through to 3 parameter case
+ case 3:
+ $revisionId = UUID::create( $data[2] );
+ $workflowId = UUID::create( $data[1] );
+ break;
+ default:
+ wfDebugLog( 'Flow', __METHOD__ . ': Invalid number of parameters received from cuc_comment. Expected 2 or 3 but received ' . count( $data ) );
+ return false;
+ }
+
+ return array( $workflowId, $revisionId, $postId );
+ }
+}
+
+class CheckUserRow extends FormatterRow {
+ /**
+ * @var CheckUser
+ */
+ public $checkUser;
+}
diff --git a/Flow/includes/Formatter/Contributions.php b/Flow/includes/Formatter/Contributions.php
new file mode 100644
index 00000000..bd984667
--- /dev/null
+++ b/Flow/includes/Formatter/Contributions.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\Anchor;
+use Flow\Model\PostRevision;
+use Flow\Parsoid\Utils;
+use ChangesList;
+use IContextSource;
+use Html;
+
+class Contributions extends AbstractFormatter {
+ protected function getHistoryType() {
+ return 'contributions';
+ }
+
+ /**
+ * @param FormatterRow $row With properties workflow, revision, previous_revision
+ * @param IContextSource $ctx
+ * @return string|false HTML for contributions entry, or false on failure
+ */
+ public function format( FormatterRow $row, IContextSource $ctx ) {
+ try {
+ if ( !$this->permissions->isAllowed( $row->revision, 'contributions' ) ) {
+ return false;
+ }
+ if ( $row->revision instanceof PostRevision &&
+ !$this->permissions->isAllowed( $row->rootPost, 'contributions' ) ) {
+ return false;
+ }
+ return $this->formatHtml( $row, $ctx );
+ } catch ( FlowException $e ) {
+ \MWExceptionHandler::logException( $e );
+ return false;
+ }
+ }
+
+ /**
+ * @param FormatterRow $row
+ * @param IContextSource $ctx
+ * @return string
+ * @throws FlowException
+ */
+ protected function formatHtml( FormatterRow $row, IContextSource $ctx ) {
+ $this->serializer->setIncludeHistoryProperties( true );
+ $data = $this->serializer->formatApi( $row, $ctx );
+ if ( !$data ) {
+ throw new FlowException( 'Could not format data for row ' . $row->revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ $charDiff = ChangesList::showCharacterDifference(
+ $data['size']['old'],
+ $data['size']['new']
+ );
+
+ $separator = $this->changeSeparator();
+
+ $links = array();
+ $links[] = $this->getDiffAnchor( $data['links'], $ctx );
+ $links[] = $this->getHistAnchor( $data['links'], $ctx );
+
+ $description = $this->formatDescription( $data, $ctx );
+
+ // Put it all together
+ return
+ $this->formatTimestamp( $data ) . ' ' .
+ $this->formatAnchorsAsPipeList( $links, $ctx ) .
+ $separator .
+ $charDiff .
+ $separator .
+ $this->getTitleLink( $data, $row, $ctx ) .
+ ( Utils::htmlToPlaintext( $description ) ? $separator . $description : '' ) .
+ $this->getHideUnhide( $data, $row, $ctx );
+ }
+
+ /**
+ * @todo can be generic?
+ */
+ protected function getHideUnhide( array $data, FormatterRow $row, IContextSource $ctx ) {
+ if ( !$row->revision instanceof PostRevision ) {
+ return '';
+ }
+
+ $type = $row->revision->isTopicTitle() ? 'topic' : 'post';
+
+ if ( isset( $data['actions']['hide'] ) ) {
+ $key = 'hide';
+ // flow-post-action-hide-post, flow-post-action-hide-topic
+ $msg = "flow-$type-action-hide-$type";
+ } elseif ( isset( $data['actions']['unhide'] ) ) {
+ $key = 'unhide';
+ // flow-topic-action-restore-topic, flow-post-action-restore-post
+ $msg = "flow-$type-action-restore-$type";
+ } else {
+ return '';
+ }
+
+ /** @var Anchor $anchor */
+ $anchor = $data['actions'][$key];
+
+ return ' (' . Html::rawElement(
+ 'a',
+ array(
+ 'href' => $anchor->getFullURL(),
+ 'data-flow-interactive-handler' => 'moderationDialog',
+ 'data-flow-template' => "flow_moderate_$type.partial",
+ 'data-role' => $key,
+ 'class' => 'flow-history-moderation-action flow-click-interactive',
+ ),
+ $ctx->msg( $msg )->escaped()
+ ) . ')';
+ }
+}
diff --git a/Flow/includes/Formatter/ContributionsQuery.php b/Flow/includes/Formatter/ContributionsQuery.php
new file mode 100644
index 00000000..efe5092e
--- /dev/null
+++ b/Flow/includes/Formatter/ContributionsQuery.php
@@ -0,0 +1,336 @@
+<?php
+
+namespace Flow\Formatter;
+
+use BagOStuff;
+use ContribsPager;
+use DeletedContribsPager;
+use Flow\Container;
+use Flow\Data\Storage\RevisionStorage;
+use Flow\DbFactory;
+use Flow\Data\ManagerGroup;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\Exception\FlowException;
+use ResultWrapper;
+use User;
+
+class ContributionsQuery extends AbstractQuery {
+
+ /**
+ * @var BagOStuff
+ */
+ protected $cache;
+
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @param ManagerGroup $storage
+ * @param BagOStuff $cache
+ * @param TreeRepository $treeRepo
+ * @param DbFactory $dbFactory
+ */
+ public function __construct( ManagerGroup $storage, TreeRepository $treeRepo, BagOStuff $cache, DbFactory $dbFactory ) {
+ parent::__construct( $storage, $treeRepo );
+ $this->cache = $cache;
+ $this->dbFactory = $dbFactory;
+ }
+
+ /**
+ * @param ContribsPager|DeletedContribsPager $pager Object hooked into
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return FormatterRow[]
+ */
+ public function getResults( $pager, $offset, $limit, $descending ) {
+ // build DB query conditions
+ $conditions = $this->buildConditions( $pager, $offset, $descending );
+
+ $types = array(
+ // revision class => block type
+ 'PostRevision' => 'topic',
+ 'Header' => 'header',
+ 'PostSummary' => 'topicsummary'
+ );
+
+ $results = array();
+ foreach ( $types as $revisionClass => $blockType ) {
+ // query DB for requested revisions
+ $rows = $this->queryRevisions( $conditions, $limit, $revisionClass );
+ if ( !$rows ) {
+ continue;
+ }
+
+ // turn DB data into revision objects
+ $revisions = $this->loadRevisions( $rows, $revisionClass );
+
+ $this->loadMetadataBatch( $revisions );
+ foreach ( $revisions as $revision ) {
+ try {
+ $result = $pager instanceof ContribsPager ? new ContributionsRow : new DeletedContributionsRow;
+ $result = $this->buildResult( $revision, $pager->getIndexField(), $result );
+
+ // @todo: below code should also check status of board: if that's been deleted, it's posts should also be considered deleted
+ if (
+ $result instanceof ContributionsRow &&
+ ( $result->currentRevision->isDeleted() || $result->currentRevision->isSuppressed() )
+ ) {
+ // don't show deleted or suppressed entries in Special:Contributions
+ continue;
+ }
+ if ( $result instanceof DeletedContributionsRow && !$result->currentRevision->isDeleted() ) {
+ // only show deleted entries in Special:DeletedContributions
+ continue;
+ }
+
+ $results[] = $result;
+ } catch ( FlowException $e ) {
+ \MWExceptionHandler::logException( $e );
+ }
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param ContribsPager|DeletedContribsPager $pager Object hooked into
+ * @param string $offset Index offset, inclusive
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return array Query conditions
+ */
+ protected function buildConditions( $pager, $offset, $descending ) {
+ $conditions = array();
+
+ // Work out user condition
+ if ( property_exists( $pager, 'contribs' ) && $pager->contribs == 'newbie' ) {
+ list( $minUserId, $excludeUserIds ) = $this->getNewbieConditionInfo( $pager );
+
+ $conditions['rev_user_wiki'] = wfWikiId();
+ $conditions[] = 'rev_user_id > '. (int)$minUserId;
+ if ( $excludeUserIds ) {
+ // better safe than sorry - make sure everything's an int
+ $excludeUserIds = array_map( 'intval', $excludeUserIds );
+ $conditions[] = 'rev_user_id NOT IN ( ' . implode( ',', $excludeUserIds ) .' )';
+ $conditions['rev_user_ip'] = null;
+ }
+ } else {
+ $uid = User::idFromName( $pager->target );
+ if ( $uid ) {
+ $conditions['rev_user_id'] = $uid;
+ $conditions['rev_user_ip'] = null;
+ $conditions['rev_user_wiki'] = wfWikiId();
+ } else {
+ $conditions['rev_user_id'] = 0;
+ $conditions['rev_user_ip'] = $pager->target;
+ $conditions['rev_user_wiki'] = wfWikiId();
+ }
+ }
+
+ // Make offset parameter.
+ if ( $offset ) {
+ $dbr = $this->dbFactory->getDB( DB_SLAVE );
+ $offsetUUID = UUID::getComparisonUUID( $offset );
+ $direction = $descending ? '>' : '<';
+ $conditions[] = "rev_id $direction " . $dbr->addQuotes( $offsetUUID->getBinary() );
+ }
+
+ // Find only within requested wiki/namespace
+ $conditions['workflow_wiki'] = wfWikiId();
+ if ( $pager->namespace !== '' ) {
+ $conditions['workflow_namespace'] = $pager->namespace;
+ }
+
+ return $conditions;
+ }
+
+ /**
+ * @param array $conditions
+ * @param int $limit
+ * @param string $revisionClass Storage type (e.g. "PostRevision", "Header")
+ * @return ResultWrapper|false false on failure
+ * @throws \MWException
+ */
+ protected function queryRevisions( $conditions, $limit, $revisionClass ) {
+ $dbr = $this->dbFactory->getDB( DB_SLAVE );
+
+ switch ( $revisionClass ) {
+ case 'PostRevision':
+ return $dbr->select(
+ array(
+ 'flow_revision', // revisions to find
+ 'flow_tree_revision', // resolve to post id
+ 'flow_tree_node', // resolve to root post (topic title)
+ 'flow_workflow', // resolve to workflow, to test if in correct wiki/namespace
+ ),
+ array( '*' ),
+ $conditions,
+ __METHOD__,
+ array(
+ 'LIMIT' => $limit,
+ 'ORDER BY' => 'rev_id DESC',
+ ),
+ array(
+ 'flow_tree_revision' => array(
+ 'INNER JOIN',
+ array( 'tree_rev_id = rev_id' )
+ ),
+ 'flow_tree_node' => array(
+ 'INNER JOIN',
+ array(
+ 'tree_descendant_id = tree_rev_descendant_id',
+ // the one with max tree_depth will be root,
+ // which will have the matching workflow id
+ )
+ ),
+ 'flow_workflow' => array(
+ 'INNER JOIN',
+ array( 'workflow_id = tree_ancestor_id' )
+ ),
+ )
+ );
+ break;
+
+ case 'Header':
+ return $dbr->select(
+ array( 'flow_revision', 'flow_workflow' ),
+ array( '*' ),
+ $conditions,
+ __METHOD__,
+ array(
+ 'LIMIT' => $limit,
+ 'ORDER BY' => 'rev_id DESC',
+ ),
+ array(
+ 'flow_workflow' => array(
+ 'INNER JOIN',
+ array( 'workflow_id = rev_type_id' , 'rev_type' => 'header' )
+ ),
+ )
+ );
+ break;
+
+ case 'PostSummary':
+ return $dbr->select(
+ array( 'flow_revision', 'flow_workflow', 'flow_tree_node' ),
+ array( '*' ),
+ $conditions + array(
+ 'workflow_id = tree_ancestor_id',
+ 'tree_descendant_id = rev_type_id',
+ 'rev_type' => 'post-summary'
+ ),
+ __METHOD__,
+ array(
+ 'LIMIT' => $limit,
+ 'ORDER BY' => 'rev_id DESC',
+ )
+ );
+ break;
+
+ default:
+ throw new \MWException( 'Unsupported revision type ' . $revisionClass );
+ break;
+ }
+ }
+
+ /**
+ * Turns DB data into revision objects.
+ *
+ * @param ResultWrapper $rows
+ * @param string $revisionClass Class of revision object to build: PostRevision|Header
+ * @return array
+ */
+ protected function loadRevisions( ResultWrapper $rows, $revisionClass ) {
+ $revisions = array();
+ foreach ( $rows as $row ) {
+ $revisions[UUID::create( $row->rev_id )->getAlphadecimal()] = (array) $row;
+ }
+
+ // get content in external storage
+ $res = array( $revisions );
+ $res = RevisionStorage::mergeExternalContent( $res );
+ foreach ( $res as $i => $result ) {
+ if ( $result ) {
+ $res[$i] = array_filter( $result, array( $this, 'validate' ) );
+ }
+ }
+ $revisions = reset( $res );
+
+ // we have all required data to build revision
+ $mapper = $this->storage->getStorage( $revisionClass )->getMapper();
+ $revisions = array_map( array( $mapper, 'fromStorageRow' ), $revisions );
+
+ // @todo: we may already be able to build workflowCache (and rootPostIdCache) from this DB data
+
+ return $revisions;
+ }
+
+ /**
+ * @param ContribsPager|DeletedContribsPager $pager
+ * @return array [minUserId, excludeUserIds]
+ */
+ protected function getNewbieConditionInfo( $pager ) {
+ // unlike most of Flow, this one doesn't use wfForeignMemcKey; needs
+ // to be wiki-specific
+ $key = wfMemcKey( 'flow', '', 'maxUserId', Container::get( 'cache.version' ) );
+ $max = $this->cache->get( $key );
+ if ( $max === false ) {
+ // max user id not present in cache; fetch from db & save to cache for 1h
+ $max = (int) $pager->getDatabase()->selectField( 'user', 'MAX(user_id)', '', __METHOD__ );
+ $this->cache->set( $key, $max, 60 * 60 );
+ }
+ $minUserId = (int) ( $max - $max / 100 );
+
+ // exclude all users withing groups with bot permission
+ $excludeUserIds = array();
+ $groupsWithBotPermission = User::getGroupsWithPermission( 'bot' );
+ if ( count( $groupsWithBotPermission ) ) {
+ $rows = $pager->getDatabase()->select(
+ array( 'user', 'user_groups' ),
+ 'user_id',
+ array(
+ 'user_id > ' . $minUserId,
+ 'ug_group' => $groupsWithBotPermission
+ ),
+ __METHOD__,
+ array(),
+ array(
+ 'user_groups' => array(
+ 'INNER JOIN',
+ array( 'ug_user = user_id' )
+ )
+ )
+ );
+
+ $excludeUserIds = array();
+ foreach ( $rows as $row ) {
+ $excludeUserIds[] = $row->user_id;
+ }
+ }
+
+ return array( $minUserId, $excludeUserIds );
+ }
+
+ /**
+ * When retrieving revisions from DB, self::mergeExternalContent will be
+ * called to fetch the content. This could fail, resulting in the content
+ * being a 'false' value.
+ *
+ * {@inheritDoc}
+ */
+ public function validate( array $row ) {
+ return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
+ }
+}
+
+class ContributionsRow extends FormatterRow {
+ public $rev_timestamp;
+}
+
+class DeletedContributionsRow extends FormatterRow {
+ public $ar_timestamp;
+}
diff --git a/Flow/includes/Formatter/FeedItemFormatter.php b/Flow/includes/Formatter/FeedItemFormatter.php
new file mode 100644
index 00000000..f92fc93a
--- /dev/null
+++ b/Flow/includes/Formatter/FeedItemFormatter.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Exception;
+use FeedItem;
+use Flow\Exception\FlowException;
+use Flow\Model\PostRevision;
+use IContextSource;
+
+class FeedItemFormatter extends AbstractFormatter {
+ protected function getHistoryType() {
+ return 'feeditem';
+ }
+
+ /**
+ * @param FormatterRow $row With properties workflow, revision, previous_revision
+ * @param IContextSource $ctx
+ * @return FeedItem|false The requested format, or false on failure
+ */
+ public function format( FormatterRow $row, IContextSource $ctx ) {
+ try {
+ if ( !$this->permissions->isAllowed( $row->revision, 'history' ) ) {
+ return false;
+ }
+ if ( $row->revision instanceof PostRevision &&
+ !$this->permissions->isAllowed( $row->rootPost, 'history' ) ) {
+ return false;
+ }
+
+ return $this->createFeedItem( $row, $ctx );
+ } catch ( Exception $e ) {
+ \MWExceptionHandler::logException( $e );
+ return false;
+ }
+ }
+
+ protected function createFeedItem( FormatterRow $row, IContextSource $ctx ) {
+ $this->serializer->setIncludeHistoryProperties( true );
+ $data = $this->serializer->formatApi( $row, $ctx );
+ if ( !$data ) {
+ throw new FlowException( 'Could not format data for row ' . $row->revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ $preferredLinks = array(
+ 'header-revision',
+ 'post-revision', 'post',
+ 'topic-revision', 'topic',
+ 'board'
+ );
+ $url = '';
+ foreach ( $preferredLinks as $link ) {
+ if ( isset( $data['links'][$link] ) ) {
+ $url = $data['links'][$link]->getFullURL();
+ break;
+ }
+ }
+ // If we didn't choose anything just take the first.
+ // @todo perhaps just a convention that the first link
+ // is always the most specific, we use a similar pattern
+ // to above in TemplateHelper::historyTimestamp too.
+ if ( $url === '' && $data['links'] ) {
+ $link = reset( array_keys( $data['links'] ) );
+ $url = $data['links'][$link]->getFullURL();
+ }
+
+ return new FeedItem(
+ $row->workflow->getArticleTitle()->getPrefixedText(),
+ $this->formatDescription( $data, $ctx ),
+ $url,
+ $data['timestamp'],
+ $data['author']['name'],
+ $row->workflow->getOwnerTitle()->getFullURL()
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/IRCLineUrlFormatter.php b/Flow/includes/Formatter/IRCLineUrlFormatter.php
new file mode 100644
index 00000000..f8824c65
--- /dev/null
+++ b/Flow/includes/Formatter/IRCLineUrlFormatter.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Container;
+use Flow\Exception\FlowException;
+use Flow\RevisionActionPermissions;
+use IContextSource;
+use RCFeedFormatter;
+use RecentChange;
+use SplObjectStorage;
+
+/**
+ * Generates URL's to be inserted into the IRC
+ * recent changes feed.
+ */
+class IRCLineUrlFormatter extends AbstractFormatter implements RCFeedFormatter {
+ /**
+ * @var SplObjectStorage
+ */
+ protected $data;
+
+ public function __construct( RevisionActionPermissions $permissions, RevisionFormatter $serializer ) {
+ parent::__construct( $permissions, $serializer );
+ $this->data = new SplObjectStorage;
+ }
+
+ protected function getHistoryType() {
+ return 'irc';
+ }
+
+ public function associate( RecentChange $rc, array $metadata ) {
+ $this->data[$rc] = $metadata;
+ }
+
+ /**
+ * Allows us to set the rc_comment field
+ */
+ public function getLine( array $feed, RecentChange $rc, $actionComment ) {
+ $ctx = \RequestContext::getMain();
+ $rc->mAttribs['rc_comment'] = $this->formatDescription(
+ $this->serializeRcRevision( $rc, $ctx ),
+ $ctx
+ );
+
+ /** @var RCFeedFormatter $formatter */
+ $formatter = new $feed['original_formatter']();
+ return $formatter->getLine( $feed, $rc, $actionComment );
+ }
+
+ /**
+ * @fixme this looks slow, likely a better way
+ */
+ protected function serializeRcRevision( RecentChange $rc, IContextSource $ctx ) {
+ /** @var RecentChangesQuery $query */
+ $query = Container::get( 'query.recentchanges' );
+ $query->loadMetadataBatch( array( (object)$rc->mAttribs ) );
+ $rcRow = $query->getResult( null, $rc );
+
+ $this->serializer->setIncludeHistoryProperties( true );
+ $data = $this->serializer->formatApi( $rcRow, $ctx );
+ if ( !$data ) {
+ throw new FlowException( 'Could not format data for row ' . $rcRow->revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Generate a plaintext revision description suitable for IRC consumption
+ *
+ * @param array $data
+ * @param \IContextSource $ctx not used
+ * @return string
+ */
+ protected function formatDescription( array $data, \IContextSource $ctx ) {
+ $msg = $this->getDescription( $data, $ctx );
+ return $msg->inLanguage( 'en' )->text();
+ }
+
+ /**
+ * @param RecentChange $rc
+ * @return string|null
+ */
+ public function format( RecentChange $rc ) {
+ // commit metadata provided via self::associate
+ if ( !isset( $this->data[$rc] ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Nothing pre-loaded about rc ' . $rc->getAttribute( 'rc_id' ) );
+ return null;
+ }
+ $metadata = $this->data[$rc];
+
+ $row = new FormatterRow;
+ $row->revision = $metadata['revision'];
+ $row->currentRevision = $row->revision;
+ $row->workflow = $metadata['workflow'];
+ $links = $this->serializer->buildLinks( $row );
+
+ // Listed in order of preference
+ $accept = array(
+ 'diff',
+ 'post-history', 'topic-history', 'board-history',
+ 'post', 'topic',
+ 'workflow'
+ );
+
+ foreach ( $accept as $key ) {
+ if ( isset( $links[$key] ) ) {
+ return $links[$key]->getCanonicalURL();
+ }
+ }
+
+ wfDebugLog( 'Flow', __METHOD__
+ . ': No url generated for action ' . $change['action']
+ . ' on revision ' . $change['revision']
+ );
+ return null;
+ }
+}
diff --git a/Flow/includes/Formatter/PostHistoryQuery.php b/Flow/includes/Formatter/PostHistoryQuery.php
new file mode 100644
index 00000000..d7d967bc
--- /dev/null
+++ b/Flow/includes/Formatter/PostHistoryQuery.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+
+class PostHistoryQuery extends AbstractQuery {
+
+ /**
+ * @param UUID $postId
+ * @param int $limit
+ * @param UUID|null $offset
+ * @param string $direction 'rev' or 'fwd'
+ * @return FormatterRow[]
+ */
+ public function getResults( UUID $postId, $limit = 50, UUID $offset = null, $direction = 'fwd' ) {
+ $history = $this->storage->find(
+ 'PostRevision',
+ array( 'rev_type_id' => $postId ),
+ array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => $limit,
+ 'offset-id' => $offset,
+ 'offset-dir' => $direction,
+ 'offset-include' => false,
+ 'offset-elastic' => false,
+ )
+ );
+ if ( !$history ) {
+ return array();
+ }
+
+ $this->loadMetadataBatch( $history );
+ $results = array();
+ foreach ( $history as $revision ) {
+ try {
+ $results[] = $row = new FormatterRow;
+ $this->buildResult( $revision, null, $row );
+ } catch ( FlowException $e ) {
+ \MWExceptionHandler::logException( $e );
+ }
+ }
+
+ return $results;
+ }
+
+}
diff --git a/Flow/includes/Formatter/PostSummaryQuery.php b/Flow/includes/Formatter/PostSummaryQuery.php
new file mode 100644
index 00000000..c397a013
--- /dev/null
+++ b/Flow/includes/Formatter/PostSummaryQuery.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Model\UUID;
+
+class PostSummaryQuery extends AbstractQuery {
+ /**
+ * @param UUID $postId
+ * @return FormatterRow
+ */
+ public function getResult( UUID $postId ) {
+ $found = $this->storage->find(
+ 'PostSummary',
+ array( 'rev_type_id' => $postId ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( !$found ) {
+ return null;
+ }
+ $this->loadMetadataBatch( $found );
+
+ return $this->buildResult( reset( $found ), null );
+ }
+}
diff --git a/Flow/includes/Formatter/RecentChanges.php b/Flow/includes/Formatter/RecentChanges.php
new file mode 100644
index 00000000..b5817cf3
--- /dev/null
+++ b/Flow/includes/Formatter/RecentChanges.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\Anchor;
+use ChangesList;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Parsoid\Utils;
+use IContextSource;
+use Linker;
+
+class RecentChanges extends AbstractFormatter {
+ protected function getHistoryType() {
+ return 'recentchanges';
+ }
+
+ /**
+ * @param RecentChangesRow $row
+ * @param IContextSource $ctx
+ * @param bool $linkOnly
+ * @return string|false Output line, or false on failure
+ * @throws FlowException
+ */
+ public function format( RecentChangesRow $row, IContextSource $ctx, $linkOnly = false ) {
+ if ( !$this->permissions->isAllowed( $row->revision, 'recentchanges' ) ) {
+ return false;
+ }
+ if ( $row->revision instanceof PostRevision &&
+ !$this->permissions->isAllowed( $row->rootPost, 'recentchanges' ) ) {
+ return false;
+ }
+
+ $this->serializer->setIncludeHistoryProperties( true );
+ $this->serializer->setIncludeContent( false );
+
+ $data = $this->serializer->formatApi( $row, $ctx );
+ if ( !$data ) {
+ throw new FlowException( 'Could not format data for row ' . $row->revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ if ( $linkOnly ) {
+ return $this->getTitleLink( $data, $row, $ctx );
+ }
+
+ // The ' . . ' text between elements
+ $separator = $this->changeSeparator();
+
+ $links = array();
+ $links[] = $this->getDiffAnchor( $data['links'], $ctx );
+ $links[] = $this->getHistAnchor( $data['links'], $ctx );
+
+ $description = $this->formatDescription( $data, $ctx );
+
+ return $this->formatAnchorsAsPipeList( $links, $ctx ) .
+ $separator .
+ $this->getTitleLink( $data, $row, $ctx ) .
+ $ctx->msg( 'semicolon-separator' )->escaped() .
+ ' ' .
+ $this->formatTimestamp( $data, 'time' ) .
+ $separator .
+ ChangesList::showCharacterDifference(
+ $data['size']['old'],
+ $data['size']['new'],
+ $ctx
+ ) .
+ ( Utils::htmlToPlaintext( $description ) ? $separator . $description : '' ) .
+ $this->getEditSummary( $row, $ctx, $data );
+ }
+
+ /**
+ * @param RecentChangesRow $row
+ * @param IContextSource $ctx
+ * @param array $data
+ * @return string
+ */
+ public function getEditSummary( RecentChangesRow $row, IContextSource $ctx, array $data ) {
+ // Build description message, piggybacking on history i18n
+ $changeType = $data['changeType'];
+ $actions = $this->permissions->getActions();
+
+ $key = $actions->getValue( $changeType, 'history', 'i18n-message' );
+ // Find specialized message for summary
+ // i18n messages: flow-rev-message-new-post-recentchanges-summary,
+ // flow-rev-message-edit-post-recentchanges-summary
+ $msg = $ctx->msg( $key . '-' . $this->getHistoryType() . '-summary' );
+ if ( !$msg->exists() ) {
+ // No summary for this action
+ return '';
+ }
+
+ $msg = $msg->params( $this->getDescriptionParams( $data, $actions, $changeType ) );
+
+ // Below code is inspired by Linker::formatAutocomments
+ $prefix = $ctx->msg( 'autocomment-prefix' )->inContentLanguage()->escaped();
+ $link = Linker::link(
+ $title = $row->workflow->getOwnerTitle(),
+ $ctx->getLanguage()->getArrow('backwards'),
+ array(),
+ array(),
+ 'noclasses'
+ );
+ $summary = '<span class="autocomment">' . $msg->text() . '</span>';
+
+ // '(' + '' + '←' + summary + ')'
+ $text = Linker::commentBlock( $prefix . $link . $summary );
+
+ // Linker::commentBlock escaped everything, but what we built was safe
+ // and should not be escaped so let's go back to decoded entities
+ return htmlspecialchars_decode( $text );
+ }
+
+ /**
+ * This overrides the default title link to include highlights for the posts
+ * that have not yet been seen.
+ *
+ * @param array $data
+ * @param FormatterRow $row
+ * @param IContextSource $ctx
+ * @return string
+ */
+ protected function getTitleLink( array $data, FormatterRow $row, IContextSource $ctx ) {
+ if ( !$row instanceof RecentChangesRow ) {
+ // actually, this should be typehint, but can't because this needs
+ // to match the parent's more generic typehint
+ return parent::getTitleLink( $data, $row, $ctx );
+ }
+
+ if ( !isset( $data['links']['topic'] ) || !$data['links']['topic'] instanceof Anchor ) {
+ // no valid title anchor (probably header entry)
+ return parent::getTitleLink( $data, $row, $ctx );
+ }
+
+ $watched = $row->recentChange->getAttribute( 'wl_notificationtimestamp' );
+ if ( is_bool( $watched ) ) {
+ // RC & watchlist share most code; the latter is unaware of when
+ // something was watched though, so we'll ignore that here
+ return parent::getTitleLink( $data, $row, $ctx );
+ }
+
+ if ( $watched === null ) {
+ // there is no data for unread posts - they've all been seen
+ return parent::getTitleLink( $data, $row, $ctx );
+ }
+
+ // get comparison UUID corresponding to this last watched timestamp
+ $uuid = UUID::getComparisonUUID( $watched );
+
+ // add highlight details to anchor
+ /** @var Anchor $anchor */
+ $anchor = clone $data['links']['topic'];
+ $anchor->query['fromnotif'] = '1';
+ $anchor->fragment = '#flow-post-' . $uuid->getAlphadecimal();
+ $data['links']['topic'] = $anchor;
+
+ // now pass it on to parent with the new, updated, link ;)
+ return parent::getTitleLink( $data, $row, $ctx );
+ }
+
+ /**
+ * @param RecentChangesRow $row
+ * @param IContextSource $ctx
+ * @param array $block
+ * @param array $links
+ * @return array
+ * @throws FlowException
+ * @throws \Flow\Exception\InvalidInputException
+ */
+ public function getLogTextLinks( RecentChangesRow $row, IContextSource $ctx, array $block, array $links = array() ) {
+ $old = unserialize( $block[count( $block ) - 1]->mAttribs['rc_params'] );
+ $oldId = $old ? UUID::create( $old['flow-workflow-change']['revision'] ) : $row->revision->getRevisionId();
+
+ $data = $this->serializer->formatApi( $row, $ctx );
+ if ( !$data ) {
+ throw new FlowException( 'Could not format data for row ' . $row->revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ if ( isset( $data['links']['topic'] ) ) {
+ // add highlight details to anchor
+ /** @var Anchor $anchor */
+ $anchor = clone $data['links']['topic'];
+ $anchor->query['fromnotif'] = '1';
+ $anchor->fragment = '#flow-post-' . $oldId->getAlphadecimal();
+ } elseif ( isset( $data['links']['workflow'] ) ) {
+ $anchor = $data['links']['workflow'];
+ } else {
+ // this will be caught and logged by the RC hook, it will not fatal the page.
+ throw new FlowException( "No anchor available for revision $oldId" );
+ }
+
+ $changes = count( $block );
+ // link text: "n changes"
+ $text = $ctx->msg( 'nchanges' )->numParams( $changes )->escaped();
+
+ // override total changes link
+ $links['total-changes'] = $anchor->toHtml( $text );
+
+ return $links;
+ }
+}
diff --git a/Flow/includes/Formatter/RecentChangesQuery.php b/Flow/includes/Formatter/RecentChangesQuery.php
new file mode 100644
index 00000000..3ad7ca8d
--- /dev/null
+++ b/Flow/includes/Formatter/RecentChangesQuery.php
@@ -0,0 +1,218 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\ManagerGroup;
+use Flow\Data\Listener\RecentChangesListener;
+use Flow\Exception\FlowException;
+use Flow\FlowActions;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use RecentChange;
+
+class RecentChangesQuery extends AbstractQuery {
+
+ /**
+ * Check if the most recent action for an entity has been displayed already
+ *
+ * @var array
+ */
+ protected $displayStatus = array();
+
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ /**
+ * @var bool
+ */
+ protected $extendWatchlist = false;
+
+ public function __construct( ManagerGroup $storage, TreeRepository $treeRepo, FlowActions $actions ) {
+ parent::__construct( $storage, $treeRepo );
+ $this->actions = $actions;
+ }
+
+ /**
+ * @param bool $extend
+ */
+ public function setExtendWatchlist( $extend ) {
+ $this->extendWatchlist = (bool)$extend;
+ }
+
+ /**
+ * @param \stdClass[] $rows List of recentchange database rows
+ * @param bool $isWatchlist
+ */
+ public function loadMetadataBatch( $rows, $isWatchlist = false ) {
+ $needed = array();
+ foreach ( $rows as $row ) {
+ if ( !isset( $row->rc_source ) || $row->rc_source !== RecentChangesListener::SRC_FLOW ) {
+ continue;
+ }
+ if ( !isset( $row->rc_params ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Bad row without rc_params passed in $rows' );
+ continue;
+ }
+ $params = unserialize( $row->rc_params );
+ if ( !$params ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": rc_params does not contain serialized content: {$row->rc_params}" );
+ continue;
+ }
+ $changeData = $params['flow-workflow-change'];
+ /**
+ * Check to make sure revision_type exists, this is to make sure corrupted
+ * flow recent change data doesn't throw error on the page.
+ * See bug 59106 for more detail
+ */
+ if ( !isset( $changeData['revision_type'] ) ) {
+ continue;
+ }
+ if ( $isWatchlist && $this->isRecordHidden( $changeData ) ) {
+ continue;
+ }
+ $revisionType = $changeData['revision_type'];
+ $needed[$revisionType][] = UUID::create( $changeData['revision'] );
+ }
+
+ $found = array();
+ foreach ( $needed as $type => $uids ) {
+ $found[] = $this->storage->getMulti( $type, $uids );
+ }
+
+ $found = array_filter( $found );
+ $count = count( $found );
+ if ( $count === 0 ) {
+ $results = array();
+ } elseif ( $count === 1 ) {
+ $results = reset( $found );
+ } else {
+ $results = call_user_func_array( 'array_merge', $found );
+ }
+
+ if ( $results ) {
+ parent::loadMetadataBatch( $results );
+ }
+ }
+
+ /**
+ * @param null $cl No longer used
+ * @param RecentChange $rc
+ * @param bool $isWatchlist
+ * @return RecentChangesRow|null
+ * @throws FlowException
+ */
+ public function getResult( $cl, RecentChange $rc, $isWatchlist = false ) {
+ $rcParams = $rc->getAttribute( 'rc_params' );
+ $params = unserialize( $rcParams );
+ if ( !$params ) {
+ throw new FlowException( 'rc_params does not contain serialized content: ' . $rcParams );
+ }
+ $changeData = $params['flow-workflow-change'];
+
+ if ( !is_array( $changeData ) ) {
+ throw new FlowException( 'Flow data missing in recent changes.' );
+ }
+
+ /**
+ * Check to make sure revision_type exists, this is to make sure corrupted
+ * flow recent change data doesn't throw error on the page.
+ * See bug 59106 for more detail
+ */
+ if ( !isset( $changeData['revision_type'] ) ) {
+ throw new FlowException( 'Corrupted rc without changeData: ' . $rc->getAttribute( 'rc_id' ) );
+ }
+
+ // Only show most recent items for watchlist
+ if ( $isWatchlist && $this->isRecordHidden( $changeData ) ) {
+ return false;
+ }
+
+ $alpha = UUID::create( $changeData['revision'] )->getAlphadecimal();
+ if ( !isset( $this->revisionCache[$alpha] ) ) {
+ throw new FlowException( "Revision not found in revisionCache: $alpha" );
+ }
+ $revision = $this->revisionCache[$alpha];
+
+ $res = new RecentChangesRow;
+ $this->buildResult( $revision, 'timestamp', $res );
+ $res->recentChange = $rc;
+
+ return $res;
+ }
+
+ /**
+ * Determines if a flow record should be displayed in Special:Watchlist
+ *
+ * @param array $changeData
+ * @return bool
+ */
+ protected function isRecordHidden( array $changeData ) {
+ if ( $this->extendWatchlist ) {
+ return false;
+ }
+ // Check for legacy action names and convert it
+ $alias = $this->actions->getValue( $changeData['action'] );
+ if ( is_string( $alias ) ) {
+ $action = $alias;
+ } else {
+ $action = $changeData['action'];
+ }
+ // * Display the most recent new post, edit post, edit title for a topic
+ // * Display the most recent header edit
+ // * Display all new topic and moderation actions
+ switch ( $action ) {
+ case 'create-header':
+ case 'edit-header':
+ if (
+ isset( $this->displayStatus['header-' . $changeData['workflow']] ) &&
+ $this->displayStatus['header-' . $changeData['workflow']] !== $changeData['revision']
+ ) {
+ return true;
+ }
+ $this->displayStatus['header-' . $changeData['workflow']] = $changeData['revision'];
+ break;
+
+ case 'hide-post':
+ case 'hide-topic':
+ case 'delete-post':
+ case 'delete-topic':
+ case 'suppress-post':
+ case 'suppress-topic':
+ case 'restore-post':
+ case 'restore-topic':
+ case 'lock-topic':
+ // moderation actions are always shown when visible to the user
+ return false;
+
+ case 'new-topic':
+ case 'reply':
+ case 'edit-post':
+ case 'edit-title':
+ case 'create-topic-summary':
+ case 'edit-topic-summary':
+ if (
+ isset( $this->displayStatus['topic-' . $changeData['workflow']] ) &&
+ $this->displayStatus['topic-' . $changeData['workflow']] !== $changeData['revision']
+ ) {
+ return true;
+ }
+ $this->displayStatus['topic-' . $changeData['workflow']] = $changeData['revision'];
+ break;
+ }
+
+ return false;
+ }
+
+ protected function changeSeparator() {
+ return ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+}
+
+class RecentChangesRow extends FormatterRow {
+ /**
+ * @var RecentChange
+ */
+ public $recentChange;
+}
diff --git a/Flow/includes/Formatter/RevisionDiffViewFormatter.php b/Flow/includes/Formatter/RevisionDiffViewFormatter.php
new file mode 100644
index 00000000..f6ac2df2
--- /dev/null
+++ b/Flow/includes/Formatter/RevisionDiffViewFormatter.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Model\UUID;
+use Flow\UrlGenerator;
+use IContextSource;
+
+class RevisionDiffViewFormatter {
+
+ /**
+ * @var RevisionViewFormatter
+ */
+ protected $revisionViewFormatter;
+
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ public function __construct(
+ RevisionViewFormatter $revisionViewFormatter,
+ UrlGenerator $urlGenerator
+ ) {
+ $this->revisionViewFormatter = $revisionViewFormatter;
+ $this->urlGenerator = $urlGenerator;
+ }
+
+ /**
+ * Diff would format against two revisions
+ */
+ public function formatApi( FormatterRow $newRow, FormatterRow $oldRow, IContextSource $ctx ) {
+ $oldRes = $this->revisionViewFormatter->formatApi( $oldRow, $ctx );
+ $newRes = $this->revisionViewFormatter->formatApi( $newRow, $ctx );
+
+ $oldContent = $oldRow->revision->getContent( 'wikitext' );
+ $newContent = $newRow->revision->getContent( 'wikitext' );
+
+ $differenceEngine = new \DifferenceEngine();
+
+ $differenceEngine->setContent(
+ new \TextContent( $oldContent ),
+ new \TextContent( $newContent )
+ );
+
+ if ( $oldRow->revision->isFirstRevision() ) {
+ $prevLink = null;
+ } else {
+ $prevLink = $this->urlGenerator->diffLink(
+ $oldRow->revision,
+ $ctx->getTitle(),
+ UUID::create( $oldRes['workflowId'] )
+ )->getLocalURL();
+ }
+
+ // this is probably a network request which typically goes in the query
+ // half, but we don't have to worry about batching because we only show
+ // one diff at a time so just do it.
+ $nextRevision = $newRow->revision->getCollection()->getNextRevision( $newRow->revision );
+ if ( $nextRevision === null ) {
+ $nextLink = null;
+ } else {
+ $nextLink = $this->urlGenerator->diffLink(
+ $nextRevision,
+ $ctx->getTitle(),
+ UUID::create( $newRes['workflowId'] )
+ )->getLocalURL();
+ }
+
+ return array(
+ 'new' => $newRes,
+ 'old' => $oldRes,
+ 'diff_content' => $differenceEngine->getDiffBody(),
+ 'links' => array(
+ 'previous' => $prevLink,
+ 'next' => $nextLink,
+ ),
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/RevisionFormatter.php b/Flow/includes/Formatter/RevisionFormatter.php
new file mode 100644
index 00000000..7ab94fdd
--- /dev/null
+++ b/Flow/includes/Formatter/RevisionFormatter.php
@@ -0,0 +1,978 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Collection\PostCollection;
+use Flow\Repository\UserNameBatch;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Anchor;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\UUID;
+use Flow\Parsoid\Utils;
+use Flow\RevisionActionPermissions;
+use Flow\Templating;
+use Flow\UrlGenerator;
+use GenderCache;
+use IContextSource;
+use Message;
+
+/**
+ * This implements a serializer for converting revision objects
+ * into an array of localized and sanitized data ready for user
+ * consumption.
+ *
+ * The formatApi method is the primary method of interacting with
+ * this serializer. The results of formatApi can be passed on to
+ * html formatting or emitted directly as an api response.
+ *
+ * For performance considerations of special purpose formatters like
+ * CheckUser methods that build pieces of the api response are also
+ * public.
+ *
+ * @todo can't output as api yet, Message instances are returned
+ * for the various strings.
+ *
+ * @todo this needs a better name, RevisionSerializer? not sure yet
+ */
+class RevisionFormatter {
+
+ /**
+ * @var RevisionActionPermissions
+ */
+ protected $permissions;
+
+ /**
+ * @var Templating
+ */
+ protected $templating;
+
+ /**
+ * @var UrlGenerator;
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var bool
+ */
+ protected $includeProperties = false;
+
+ /**
+ * @var bool
+ */
+ protected $includeContent = true;
+
+ /**
+ * @var string[]
+ */
+ protected $allowedContentFormats = array( 'html', 'wikitext' );
+
+ /**
+ * @var string Default content format for revision output
+ */
+ protected $contentFormat = 'html';
+
+ /**
+ * @var array Map from alphadeicmal revision id to content format ovverride
+ */
+ protected $revisionContentFormat = array();
+
+ /**
+ * @var int
+ */
+ protected $maxThreadingDepth;
+
+ /**
+ * @var Message[]
+ */
+ protected $messages = array();
+
+ /**
+ * @var array
+ */
+ protected $userLinks = array();
+
+ /**
+ * @var UserNameBatch
+ */
+ protected $usernames;
+
+ /**
+ * @var GenderCache
+ */
+ protected $genderCache;
+
+ /**
+ * @param RevisionActionPermissions $permissions
+ * @param Templating $templating
+ * @param UserNameBatch $usernames
+ * @param int $maxThreadingDepth
+ */
+ public function __construct(
+ RevisionActionPermissions $permissions,
+ Templating $templating,
+ UserNameBatch $usernames,
+ $maxThreadingDepth
+ ) {
+ $this->permissions = $permissions;
+ $this->templating = $templating;
+ $this->urlGenerator = $this->templating->getUrlGenerator();
+ $this->usernames = $usernames;
+ $this->genderCache = GenderCache::singleton();
+ $this->maxThreadingDepth = $maxThreadingDepth;
+ }
+
+ /**
+ * The self::buildProperties method is fairly expensive and only used for rendering
+ * history entries. As such it is optimistically disabled unless requested
+ * here
+ *
+ * @param bool $shouldInclude
+ */
+ public function setIncludeHistoryProperties( $shouldInclude ) {
+ $this->includeProperties = (bool)$shouldInclude;
+ }
+
+ /**
+ * Outputing content can be somehwat expensive, as most of the content is loaded
+ * into DOMDocuemnts for processing of relidlinks and badimages. Set this to false
+ * if the content will not be used such as for recent changes.
+ */
+ public function setIncludeContent( $shouldInclude ) {
+ $this->includeContent = (bool)$shouldInclude;
+ }
+
+ public function setContentFormat( $format, UUID $revisionId = null ) {
+ if ( false === array_search( $format, $this->allowedContentFormats ) ) {
+ throw new FlowException( "Unknown content format: $format" );
+ }
+ if ( $revisionId === null ) {
+ // set default content format
+ $this->contentFormat = $format;
+ } else {
+ // set per-revision content format
+ $this->revisionContentFormat[$revisionId->getAlphadecimal()] = $format;
+ }
+ }
+
+ /**
+ * @param FormatterRow $row
+ * @param IContextSource $ctx
+ * @return array|false
+ */
+ public function formatApi( FormatterRow $row, IContextSource $ctx ) {
+ $language = $ctx->getLanguage();
+ $user = $ctx->getUser();
+ // @todo the only permissions currently checked in this class are prev-revision
+ // mostly permissions is used for the actions, figure out how permissions should
+ // fit into this class either used more or not at all.
+ if ( $user->getName() !== $this->permissions->getUser()->getName() ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Formatting for wrong user' );
+ return false;
+ }
+
+ $isContentAllowed = $this->includeContent && $this->permissions->isAllowed( $row->revision, 'view' );
+ $isHistoryAllowed = $this->permissions->isAllowed( $row->revision, 'history' );
+
+ if ( !$isHistoryAllowed ) {
+ return array();
+ }
+
+ $moderatedRevision = $this->templating->getModeratedRevision( $row->revision );
+ $ts = $row->revision->getRevisionId()->getTimestampObj();
+ $res = array(
+ // Change all '_BC_bools' to ApiResult::META_BC_BOOLS when core
+ // change is merged.
+ '_BC_bools' => array(
+ // https://gerrit.wikimedia.org/r/#/c/182858/
+ 'isOriginalContent',
+ 'isModerated',
+ ),
+ 'workflowId' => $row->workflow->getId()->getAlphadecimal(),
+ 'articleTitle' => $row->workflow->getArticleTitle()->getPrefixedText(),
+ 'revisionId' => $row->revision->getRevisionId()->getAlphadecimal(),
+ 'timestamp' => $ts->getTimestamp( TS_MW ),
+ 'changeType' => $row->revision->getChangeType(),
+ // @todo push all date formatting to the render side?
+ 'dateFormats' => $this->getDateFormats( $row->revision, $ctx ),
+ 'properties' => $this->buildProperties( $row->workflow->getId(), $row->revision, $ctx, $row ),
+ 'isOriginalContent' => $row->revision->isOriginalContent(),
+ 'isModerated' => $moderatedRevision->isModerated(),
+ // These are read urls
+ 'links' => $this->buildLinks( $row ),
+ // These are write urls
+ 'actions' => $this->buildActions( $row ),
+ 'size' => array(
+ 'old' => $row->revision->getPreviousContentLength(),
+ 'new' => $row->revision->getContentLength(),
+ ),
+ 'author' => $this->serializeUser(
+ $row->revision->getUserWiki(),
+ $row->revision->getUserId(),
+ $row->revision->getUserIp()
+ ),
+ 'lastEditUser' => $this->serializeUser(
+ $row->revision->getLastContentEditUserWiki(),
+ $row->revision->getLastContentEditUserId(),
+ $row->revision->getLastContentEditUserIp()
+ ),
+ 'lastEditId' => $row->revision->isOriginalContent() ? null : $row->revision->getLastContentEditId()->getAlphadecimal(),
+ 'previousRevisionId' => $row->revision->isFirstRevision()
+ ? null
+ : $row->revision->getPrevRevisionId()->getAlphadecimal(),
+ );
+
+ if ( $res['isModerated'] ) {
+ $res['moderator'] = $this->serializeUser(
+ $moderatedRevision->getModeratedByUserWiki(),
+ $moderatedRevision->getModeratedByUserId(),
+ $moderatedRevision->getModeratedByUserIp()
+ );
+ // @todo why moderate instead of moderated or something else?
+ $res['moderateState'] = $moderatedRevision->getModerationState();
+ $res['moderateReason'] = array(
+ 'content' => $moderatedRevision->getModeratedReason(),
+ 'format' => 'plaintext',
+ );
+ }
+
+ if ( $isContentAllowed ) {
+ // topic titles are always forced to plain text
+ $contentFormat = $this->decideContentFormat( $row->revision );
+
+ // @todo better name?
+ $res['content'] = array(
+ 'content' => $this->templating->getContent( $row->revision, $contentFormat ),
+ 'format' => $contentFormat
+ );
+ }
+
+ if ( $row instanceof TopicRow ) {
+ $res['_BC_bools'] = array_merge(
+ $res['_BC_bools'],
+ array(
+ 'isWatched',
+ 'watchable',
+ )
+ );
+ if (
+ $row->summary &&
+ $this->permissions->isAllowed( $row->summary, 'view' )
+ ) {
+ $res['summary']['content'] = $this->templating->getContent( $row->summary, $this->contentFormat );
+ $res['summary']['format'] = $this->contentFormat;
+ $res['summary']['revId'] = $row->summary->getRevisionId()->getAlphadecimal();
+ }
+
+ // Only non-anon users can watch/unwatch a flow topic
+ // isWatched - the topic is watched by current user
+ // watchable - the user could watch the topic, eg, anon-user can't watch a topic
+ if ( !$ctx->getUser()->isAnon() ) {
+ // default topic is not watched and topic is not always watched
+ $res['isWatched'] = (bool) $row->isWatched;
+ $res['watchable'] = true;
+ } else {
+ $res['watchable'] = false;
+ }
+ }
+
+ if ( $row->revision instanceof PostRevision ) {
+ $res['_BC_bools'] = array_merge(
+ $res['_BC_bools'],
+ array(
+ 'isMaxThreadingDepth',
+ )
+ );
+
+ $replyTo = $row->revision->getReplyToId();
+ $res['replyToId'] = $replyTo ? $replyTo->getAlphadecimal() : null;
+ $res['postId'] = $row->revision->getPostId()->getAlphadecimal();
+ $res['isMaxThreadingDepth'] = $row->revision->getDepth() >= $this->maxThreadingDepth;
+ $res['creator'] = $this->serializeUser(
+ $row->revision->getCreatorWiki(),
+ $row->revision->getCreatorId(),
+ $row->revision->getCreatorIp()
+ );
+
+ // Always output this along with topic titles so they
+ // have a safe parameter to use within l10n for content
+ // output.
+ if ( $row->revision->isTopicTitle() && !isset( $res['properties']['topic-of-post'] ) ) {
+ $res['properties']['topic-of-post'] = $this->processParam(
+ 'topic-of-post',
+ $row->revision,
+ $row->workflow->getId(),
+ $ctx,
+ $row
+ );
+ }
+ }
+
+ return $res;
+ }
+
+ /**
+ * @param array $userData Contains `name`, `wiki`, and `gender` keys
+ * @return array
+ */
+ public function serializeUserLinks( $userData ) {
+ $name = $userData['name'];
+ if ( isset( $this->userLinks[$name] ) ) {
+ return $this->userLinks[$name];
+ }
+
+ $talkPageTitle = null;
+ $userTitle = \Title::newFromText( $name, NS_USER );
+ if ( $userTitle ) {
+ $talkPageTitle = $userTitle->getTalkPage();
+ }
+
+ $blockTitle = \SpecialPage::getTitleFor( 'Block', $name );
+
+ $userContribsTitle = \SpecialPage::getTitleFor( 'Contributions', $name );
+ $userLinksBCBools = array(
+ '_BC_bools' => array(
+ 'exists',
+ ),
+ );
+ $links = array(
+ 'contribs' => array(
+ 'url' => $userContribsTitle->getLinkURL(),
+ 'title' => $userContribsTitle->getText(),
+ 'exists' => true,
+ ) + $userLinksBCBools,
+ 'userpage' => array(
+ 'url' => $userTitle->getLinkURL(),
+ 'title' => $userTitle->getText(),
+ 'exists' => $userTitle->isKnown(),
+ ) + $userLinksBCBools,
+ );
+
+ if ( $talkPageTitle ) {
+ $links['talk'] = array(
+ 'url' => $talkPageTitle->getLinkURL(),
+ 'title' => $talkPageTitle->getPrefixedText(),
+ 'exists' => $talkPageTitle->isKnown()
+ ) + $userLinksBCBools;
+ }
+ // is this right permissions? typically this would
+ // be sourced from Linker::userToolLinks, but that
+ // only undertands html strings.
+ if ( $this->permissions->getUser()->isAllowed( 'block' ) ) {
+ // only is the user has blocking rights
+ $links += array(
+ "block" => array(
+ 'url' => $blockTitle->getLinkURL(),
+ 'title' => wfMessage( 'blocklink' ),
+ 'exists' => true
+ ) + $userLinksBCBools,
+ );
+ }
+
+ return $this->userLinks[$name] = $links;
+ }
+
+ public function serializeUser( $userWiki, $userId, $userIp ) {
+ $res = array(
+ 'name' => $this->usernames->get( $userWiki, $userId, $userIp ),
+ 'wiki' => $userWiki,
+ 'gender' => 'unknown',
+ 'links' => array(),
+ 'id' => $userId
+ );
+ // Only works for the local wiki
+ if ( wfWikiId() === $userWiki ) {
+ $res['gender'] = $this->genderCache->getGenderOf( $res['name'], __METHOD__ );
+ }
+ if ( $res['name'] ) {
+ $res['links'] = $this->serializeUserLinks( $res );
+ }
+
+ return $res;
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @param IContextSource $ctx
+ * @return array Contains [timeAndDate, date, time]
+ */
+ public function getDateFormats( AbstractRevision $revision, IContextSource $ctx ) {
+ // also restricted to history
+ if ( $this->includeProperties === false ) {
+ return array();
+ }
+
+ $timestamp = $revision->getRevisionId()->getTimestampObj()->getTimestamp( TS_MW );
+ $user = $ctx->getUser();
+ $lang = $ctx->getLanguage();
+
+ return array(
+ 'timeAndDate' => $lang->userTimeAndDate( $timestamp, $user ),
+ 'date' => $lang->userDate( $timestamp, $user ),
+ 'time' => $lang->userTime( $timestamp, $user ),
+ );
+ }
+
+ /**
+ * @param FormatterRow $row
+ * @return array
+ * @throws FlowException
+ */
+ public function buildActions( FormatterRow $row ) {
+ $user = $this->permissions->getUser();
+ $workflow = $row->workflow;
+ $title = $workflow->getArticleTitle();
+
+ // If a user is blocked from performing actions on this page return
+ // an empty array of actions.
+ //
+ // We only check actual users and not anon's because the anonymous
+ // version can be cached and served to many different ip addresses
+ // which will not all be blocked.
+ if ( !$user->isAnon() &&
+ ( $user->isBlockedFrom( $title, true ) || !$title->quickUserCan( 'edit', $user ) )
+ ) {
+ return array();
+ }
+
+ $revision = $row->revision;
+ $action = $revision->getChangeType();
+ $workflowId = $workflow->getId();
+ $revId = $revision->getRevisionId();
+ $postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;
+ $actionTypes = $this->permissions->getActions()->getValue( $action, 'actions' );
+ if ( $actionTypes === null ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": No actions defined for action: $action" );
+ return array();
+ }
+
+ // actions primarily vary by revision type...
+ $links = array();
+ foreach ( $actionTypes as $type ) {
+ if ( !$this->permissions->isAllowed( $revision, $type ) ) {
+ continue;
+ }
+ switch( $type ) {
+ case 'thank':
+ if (
+ // thanks extension must be available
+ class_exists( 'ThanksHooks' ) &&
+ // anons can't give a thank
+ !$user->isAnon() &&
+ // can only thank for PostRevisions
+ // (other revision objects have no getCreator* methods)
+ $revision instanceof PostRevision &&
+ // only thank a logged in user
+ $revision->getCreatorId() > 0 &&
+ // can't thank self
+ $user->getId() !== $revision->getCreatorId()
+ ) {
+ $links['thank'] = $this->urlGenerator->thankAction( $postId );
+ }
+ break;
+
+ case 'reply':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ } elseif ( !$revision instanceof PostRevision ) {
+ throw new FlowException( "$type called without PostRevision object" );
+ }
+
+ /*
+ * If the post being replied to is the most recent post
+ * of its depth, the reply link should point to parent
+ */
+ $replyToId = $postId;
+ $replyToRevision = $revision;
+ if ( $row->isLastReply ) {
+ $replyToId = $replyToRevision->getReplyToId();
+ $replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
+ }
+
+ /*
+ * If the post being replied to is at or exceeds the max
+ * threading depth, the reply link should point to parent.
+ */
+ while ( $replyToRevision->getDepth() >= $this->maxThreadingDepth ) {
+ $replyToId = $replyToRevision->getReplyToId();
+ $replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
+ }
+
+ $links['reply'] = $this->urlGenerator->replyAction(
+ $title,
+ $workflowId,
+ $replyToId,
+ $revision->isTopicTitle()
+ );
+ break;
+
+ case 'edit-header':
+ $links['edit'] = $this->urlGenerator->editHeaderAction( $title, $workflowId, $revId );
+ break;
+
+ case 'edit-title':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $links['edit'] = $this->urlGenerator
+ ->editTitleAction( $title, $workflowId, $postId, $revId );
+ break;
+
+ case 'edit-post':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $links['edit'] = $this->urlGenerator
+ ->editPostAction( $title, $workflowId, $postId, $revId );
+ break;
+
+ case 'undo-edit-header':
+ case 'undo-edit-post':
+ case 'undo-edit-topic-summary':
+ if ( !$revision->isFirstRevision() ) {
+ $links['undo'] = $this->urlGenerator->undoAction( $revision, $title, $workflowId );
+ }
+ break;
+
+
+ case 'hide-post':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $links['hide'] = $this->urlGenerator->hidePostAction( $title, $workflowId, $postId );
+ break;
+
+ case 'delete-topic':
+ $links['delete'] = $this->urlGenerator->deleteTopicAction( $title, $workflowId );
+ break;
+
+ case 'delete-post':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $links['delete'] = $this->urlGenerator->deletePostAction( $title, $workflowId, $postId );
+ break;
+
+ case 'suppress-topic':
+ $links['suppress'] = $this->urlGenerator->suppressTopicAction( $title, $workflowId );
+ break;
+
+ case 'suppress-post':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $links['suppress'] = $this->urlGenerator->suppressPostAction( $title, $workflowId, $postId );
+ break;
+
+ case 'lock-topic':
+ // lock topic link is only available to topics
+ if ( !$revision instanceof PostRevision || !$revision->isTopicTitle() ) {
+ continue;
+ }
+
+ $links['lock'] = $this->urlGenerator->lockTopicAction( $title, $workflowId );
+ break;
+
+ case 'restore-topic':
+ $moderateAction = $flowAction = null;
+ switch ( $revision->getModerationState() ) {
+ case AbstractRevision::MODERATED_LOCKED:
+ $moderateAction = 'unlock';
+ $flowAction = 'lock-topic';
+ break;
+ case AbstractRevision::MODERATED_HIDDEN:
+ case AbstractRevision::MODERATED_DELETED:
+ case AbstractRevision::MODERATED_SUPPRESSED:
+ $moderateAction = 'un' . $revision->getModerationState();
+ $flowAction = 'moderate-topic';
+ break;
+ }
+ if ( isset( $moderateAction ) && $moderateAction ) {
+ $links[$moderateAction] = $this->urlGenerator->restoreTopicAction( $title, $workflowId, $moderateAction, $flowAction );
+ }
+ break;
+
+ case 'restore-post':
+ if ( !$postId ) {
+ throw new FlowException( "$type called without \$postId" );
+ }
+ $moderateAction = $flowAction = null;
+ switch( $revision->getModerationState() ) {
+ case AbstractRevision::MODERATED_HIDDEN:
+ case AbstractRevision::MODERATED_DELETED:
+ case AbstractRevision::MODERATED_SUPPRESSED:
+ $moderateAction = 'un' . $revision->getModerationState();
+ $flowAction = 'moderate-post';
+ break;
+ }
+ if ( $moderateAction ) {
+ $links[$moderateAction] = $this->urlGenerator->restorePostAction( $title, $workflowId, $postId, $moderateAction, $flowAction );
+ }
+ break;
+
+ case 'hide-topic':
+ $links['hide'] = $this->urlGenerator->hideTopicAction( $title, $workflowId );
+ break;
+
+ // Need to use 'edit-topic-summary' to match FlowActions
+ case 'edit-topic-summary':
+ // summarize link is only available to topic workflow
+ if( !in_array( $workflow->getType(), array( 'topic', 'topicsummary' ) ) ) {
+ continue;
+ }
+ $links['summarize'] = $this->urlGenerator->editTopicSummaryAction( $title, $workflowId );
+ break;
+
+
+ default:
+ wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
+ break;
+ }
+ }
+
+ return $links;
+ }
+
+ /**
+ * @param FormatterRow $row
+ * @return Anchor[]
+ * @throws FlowException
+ */
+ public function buildLinks( FormatterRow $row ) {
+ $workflow = $row->workflow;
+ $revision = $row->revision;
+ $title = $workflow->getArticleTitle();
+ $action = $revision->getChangeType();
+ $workflowId = $workflow->getId();
+ $revId = $revision->getRevisionId();
+ $postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;
+
+ $linkTypes = $this->permissions->getActions()->getValue( $action, 'links' );
+ if ( $linkTypes === null ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": No links defined for action: $action" );
+ return array();
+ }
+
+ $links = array();
+ foreach ( $linkTypes as $type ) {
+ switch( $type ) {
+ case 'watch-topic':
+ $links['watch-topic'] = $this->urlGenerator->watchTopicLink( $title, $workflowId );
+ break;
+
+ case 'unwatch-topic':
+ $links['unwatch-topic'] = $this->urlGenerator->unwatchTopicLink( $title, $workflowId );
+ break;
+
+ case 'topic':
+ $links['topic'] = $this->urlGenerator->topicLink( $title, $workflowId );
+ break;
+
+ case 'post':
+ if ( !$postId ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post link' );
+ break;
+ }
+ $links['post'] = $this->urlGenerator->postLink( $title, $workflowId, $postId );
+ break;
+
+ case 'header-revision':
+ $links['header-revision'] = $this->urlGenerator
+ ->headerRevisionLink( $title, $workflowId, $revId );
+ break;
+
+ case 'topic-revision':
+ if ( !$postId ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
+ break;
+ }
+
+ $links['topic-revision'] = $this->urlGenerator
+ ->topicRevisionLink( $title, $workflowId, $revId );
+ break;
+
+ case 'post-revision':
+ if ( !$postId ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
+ break;
+ }
+
+ $links['post-revision'] = $this->urlGenerator
+ ->postRevisionLink( $title, $workflowId, $postId, $revId );
+ break;
+
+ case 'summary-revision':
+ $links['summary-revision'] = $this->urlGenerator
+ ->summaryRevisionLink( $title, $workflowId, $revId );
+ break;
+
+ case 'post-history':
+ if ( !$postId ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post-history link' );
+ break;
+ }
+ $links['post-history'] = $this->urlGenerator->postHistoryLink( $title, $workflowId, $postId );
+ break;
+
+ case 'topic-history':
+ $links['topic-history'] = $this->urlGenerator->workflowHistoryLink( $title, $workflowId );
+ break;
+
+ case 'board-history':
+ $links['board-history'] = $this->urlGenerator->boardHistoryLink( $title );
+ break;
+
+ /** @noinspection PhpMissingBreakStatementInspection */
+ case 'diff-header':
+ $diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffHeaderLink' );
+ // don't break, diff links are rendered below
+ /** @noinspection PhpMissingBreakStatementInspection */
+ case 'diff-post':
+ $diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffPostLink' );
+ // don't break, diff links are rendered below
+ case 'diff-post-summary':
+ $diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffSummaryLink' );
+
+ /*
+ * To diff against previous revision, we don't really need that
+ * revision id; if no particular diff id is specified, it will
+ * assume a diff against previous revision. However, we do want
+ * to make sure that a previous revision actually exists to diff
+ * against. This could result in a network request (fetching the
+ * current revision), but it's likely being loaded anyways.
+ */
+ if ( $revision->getPrevRevisionId() !== null ) {
+ $links['diff'] = call_user_func( $diffCallback, $title, $workflowId, $revId );
+
+ /*
+ * Different formatters have different terminology for the link
+ * that diffs a certain revision to the previous revision.
+ *
+ * E.g.: Special:Contributions has "diff" ($links['diff']),
+ * ?action=history has "prev" ($links['prev']).
+ */
+ $links['diff-prev'] = clone $links['diff'];
+ $lastMsg = new Message( 'last' );
+ $links['diff-prev']->setTitleMessage( $lastMsg );
+ $links['diff-prev']->setMessage( $lastMsg );
+ }
+
+ /*
+ * To diff against the current revision, we need to know the id
+ * of this last revision. This could be an additional network
+ * request, though anything using formatter likely already needs
+ * to request the most current revision (e.g. to check
+ * permissions) so we should be able to get it from local cache.
+ */
+ $cur = $row->currentRevision;
+ if ( !$revId->equals( $cur->getRevisionId() ) ) {
+ $links['diff-cur'] = call_user_func( $diffCallback, $title, $workflowId, $cur->getRevisionId(), $revId );
+ $curMsg = new Message( 'cur' );
+ $links['diff-cur']->setTitleMessage( $curMsg );
+ $links['diff-cur']->setMessage( $curMsg );
+ }
+ break;
+
+ case 'workflow':
+ $links['workflow'] = $this->urlGenerator->workflowLink( $title, $workflowId );
+ break;
+
+ default:
+ wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
+ break;
+ }
+ }
+
+
+ return $links;
+ }
+
+ /**
+ * Build api properties defined in FlowActions for this change type
+ *
+ * This is a fairly expensive function(compared to the other methods in this class).
+ * As such its only output when specifically requested
+ *
+ * @param UUID $workflowId
+ * @param AbstractRevision $revision
+ * @param IContextSource $ctx
+ * @param FormatterRow|null $row
+ * @return array
+ */
+ public function buildProperties(
+ UUID $workflowId,
+ AbstractRevision $revision,
+ IContextSource $ctx,
+ FormatterRow $row = null
+ ) {
+ if ( $this->includeProperties === false ) {
+ return array();
+ }
+
+ $changeType = $revision->getChangeType();
+ $actions = $this->permissions->getActions();
+ $params = $actions->getValue( $changeType, 'history', 'i18n-params' );
+ if ( !$params ) {
+ // should we have a sigil for i18n with no parameters?
+ wfDebugLog( 'Flow', __METHOD__ . ": No i18n params for changeType $changeType on " . $revision->getRevisionId()->getAlphadecimal() );
+ return array();
+ }
+
+ $res = array( '_key' => $actions->getValue( $changeType, 'history', 'i18n-message' ) );
+ foreach ( $params as $param ) {
+ $res[$param] = $this->processParam( $param, $revision, $workflowId, $ctx, $row );
+ }
+
+ return $res;
+ }
+
+ /**
+ * Mimic Echo parameter formatting
+ *
+ * @param string $param The requested i18n parameter
+ * @param AbstractRevision|AbstractRevision[] $revision The revision or
+ * revisions to format or an array of revisions
+ * @param UUID $workflowId The UUID of the workflow $revision belongs tow
+ * @param IContextSource $ctx
+ * @param FormatterRow|null $row
+ * @return mixed A valid parameter for a core Message instance. These
+ * parameters will be used with Message::parse
+ * @throws FlowException
+ */
+ public function processParam(
+ $param,
+ $revision,
+ UUID $workflowId,
+ IContextSource $ctx,
+ FormatterRow $row = null
+ ) {
+ switch ( $param ) {
+ case 'creator-text':
+ if ( $revision instanceof PostRevision ) {
+ return $this->usernames->getFromTuple( $revision->getCreatorTuple() );
+ } else {
+ return '';
+ }
+
+ case 'user-text':
+ return $this->usernames->getFromTuple( $revision->getUserTuple() );
+
+ case 'user-links':
+ return Message::rawParam( $this->templating->getUserLinks( $revision ) );
+
+ case 'summary':
+ /*
+ * Fetch in HTML; unparsed wikitext in summary is pointless.
+ * Larger-scale wikis will likely also store content in html, so no
+ * Parsoid roundtrip is needed then (and if it *is*, it'll already
+ * be needed to render Flow discussions, so this is manageable)
+ */
+ $content = $this->templating->getContent( $revision, 'html' );
+ // strip html tags and decode to plaintext
+ $content = Utils::htmlToPlaintext( $content, 140, $ctx->getLanguage() );
+ return Message::plaintextParam( $content );
+
+ case 'wikitext':
+ $content = $this->templating->getContent( $revision, 'wikitext' );
+ // This must be escaped and marked raw to prevent special chars in
+ // content, like $1, from changing the i18n result
+ return Message::plaintextParam( $content );
+
+ // This is potentially two networked round trips, much too expensive for
+ // the rendering loop
+ case 'prev-wikitext':
+ if ( $revision->isFirstRevision() ) {
+ return '';
+ }
+ if ( $row === null ) {
+ $previousRevision = $revision->getCollection()->getPrevRevision( $revision );
+ } else {
+ $previousRevision = $row->previousRevision;
+ }
+ if ( !$previousRevision ) {
+ return '';
+ }
+ if ( !$this->permissions->isAllowed( $previousRevision, 'view' ) ) {
+ return '';
+ }
+
+ $content = $this->templating->getContent( $previousRevision, 'wikitext' );
+ return Message::plaintextParam( $content );
+
+ case 'workflow-url':
+ return $this->urlGenerator
+ ->workflowLink( null, $workflowId )
+ ->getFullUrl();
+
+ case 'post-url':
+ if ( !$revision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received' . get_class( $revision ) );
+ }
+ return $this->urlGenerator
+ ->postLink( null, $workflowId, $revision->getPostId() )
+ ->getFullUrl();
+
+ case 'moderated-reason':
+ // don-t parse wikitext in the moderation reason
+ return Message::plaintextParam( $revision->getModeratedReason() );
+
+ case 'topic-of-post':
+ if ( !$revision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
+ }
+ $root = $revision->getRootPost();
+ $content = $this->templating->getContent( $root, 'wikitext' );
+
+ return Message::plaintextParam( $content );
+
+ case 'post-of-summary':
+ if ( !$revision instanceof PostSummary ) {
+ throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) );
+ }
+ /** @var PostRevision $post */
+ $post = $revision->getCollection()->getPost()->getLastRevision();
+ if ( $post->isTopicTitle() ) {
+ return Message::plaintextParam( $this->templating->getContent( $post, 'wikitext' ) );
+ } else {
+ return Message::rawParam( $this->templating->getContent( $post, 'html' ) );
+ }
+
+ case 'bundle-count':
+ return Message::numParam( count( $revision ) );
+
+ default:
+ wfWarn( __METHOD__ . ': Unknown formatter parameter: ' . $param );
+ return '';
+ }
+ }
+
+ protected function msg( $key /*...*/ ) {
+ $params = func_get_args();
+ if ( count( $params ) !== 1 ) {
+ array_shift( $params );
+ return wfMessage( $key, $params );
+ }
+ if ( !isset( $this->messages[$key] ) ) {
+ $this->messages[$key] = new Message( $key );
+ }
+ return $this->messages[$key];
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @return string
+ */
+ protected function decideContentFormat( AbstractRevision $revision ) {
+ if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) {
+ return 'plaintext';
+ }
+ $alpha = $revision->getRevisionId()->getAlphadecimal();
+ if ( isset( $this->revisionContentFormat[$alpha] ) ) {
+ return $this->revisionContentFormat[$alpha];
+ }
+
+ return $this->contentFormat;
+ }
+
+}
diff --git a/Flow/includes/Formatter/RevisionUndoViewFormatter.php b/Flow/includes/Formatter/RevisionUndoViewFormatter.php
new file mode 100644
index 00000000..f9db4479
--- /dev/null
+++ b/Flow/includes/Formatter/RevisionUndoViewFormatter.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Formatter;
+
+use DifferenceEngine;
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use TextContent;
+
+class RevisionUndoViewFormatter {
+ protected $revisionViewFormatter;
+
+ public function __construct( RevisionViewFormatter $revisionViewFormatter ) {
+ $this->revisionViewFormatter = $revisionViewFormatter;
+ }
+
+ /**
+ * Undoes the change that occured between $start and $stop
+ */
+ public function formatApi(
+ FormatterRow $start,
+ FormatterRow $stop,
+ FormatterRow $current,
+ IContextSource $context
+ ) {
+ $undoContent = $this->getUndoContent(
+ $start->revision->getContent( 'wikitext' ),
+ $stop->revision->getContent( 'wikitext' ),
+ $current->revision->getContent( 'wikitext' )
+ );
+
+ $differenceEngine = new DifferenceEngine();
+ $differenceEngine->setContent(
+ new TextContent( $current->revision->getContent( 'wikitext' ) ),
+ new TextContent( $undoContent )
+ );
+
+ $this->revisionViewFormatter->setContentFormat( 'wikitext' );
+
+ // @todo if stop === current we could do a little less processing
+ return array(
+ 'start' => $this->revisionViewFormatter->formatApi( $start, $context ),
+ 'stop' => $this->revisionViewFormatter->formatApi( $stop, $context ),
+ 'current' => $this->revisionViewFormatter->formatApi( $current, $context ),
+ 'undo' => array(
+ 'possible' => $undoContent !== false,
+ 'content' => $undoContent,
+ 'diff_content' => $differenceEngine->getDiffBody(),
+ ),
+ 'articleTitle' => $start->workflow->getArticleTitle(),
+ // overrides the default modules list to only pull in ext.flow.undo
+ 'modules' => array( 'ext.flow.undo' ),
+ 'moduleStyles' => array( 'ext.flow.undo.styles' ),
+ );
+ }
+
+ protected function getUndoContent( $startContent, $stopContent, $currentContent ) {
+
+ if ( $currentContent === $stopContent ) {
+ return $startContent;
+ } else {
+ // 3-way merge
+ $ok = wfMerge( $stopContent, $startContent, $currentContent, $result );
+ if ( $ok ) {
+ return $result;
+ } else {
+ return false;
+ }
+ }
+ }
+}
diff --git a/Flow/includes/Formatter/RevisionViewFormatter.php b/Flow/includes/Formatter/RevisionViewFormatter.php
new file mode 100644
index 00000000..b17a23d4
--- /dev/null
+++ b/Flow/includes/Formatter/RevisionViewFormatter.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\UrlGenerator;
+use IContextSource;
+
+class RevisionViewFormatter {
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var RevisionFormatter
+ */
+ protected $serializer;
+
+ /**
+ * @param UrlGenerator $urlGenerator
+ * @param RevisionFormatter $serializer
+ */
+ public function __construct( UrlGenerator $urlGenerator, RevisionFormatter $serializer ) {
+ $this->urlGenerator = $urlGenerator;
+ $this->serializer = $serializer;
+ }
+
+ public function setContentFormat( $format, UUID $revisionId = null ) {
+ $this->serializer->setContentFormat( $format, $revisionId );
+ }
+
+ /**
+ * @param FormatterRow $row
+ * @param IContextSource $ctx
+ * @return array
+ */
+ public function formatApi( FormatterRow $row, IContextSource $ctx ) {
+ $res = $this->serializer->formatApi( $row, $ctx );
+ $res['rev_view_links'] = $this->buildLinks( $row );
+ $res['human_timestamp'] = $this->getHumanTimestamp( $res['timestamp'] );
+ if ( $row->revision instanceof PostRevision ) {
+ $res['properties']['topic-of-post'] = $this->serializer->processParam(
+ 'topic-of-post',
+ $row->revision,
+ $row->workflow->getId(),
+ $ctx
+ );
+ }
+ if ( $row->revision instanceof PostSummary ) {
+ $res['properties']['post-of-summary'] = $this->serializer->processParam(
+ 'post-of-summary',
+ $row->revision,
+ $row->workflow->getId(),
+ $ctx
+ );
+ }
+ return $res;
+ }
+
+ /**
+ * Generate the links for single and diff vie actions
+ * @param FormatterRow $row
+ * @return array
+ */
+ public function buildLinks( FormatterRow $row ) {
+ $workflowId = $row->workflow->getId();
+
+ $boardTitle = $row->workflow->getOwnerTitle();
+ $title = $row->workflow->getArticleTitle();
+ $links = array(
+ 'hist' => $this->urlGenerator->boardHistoryLink( $title ),
+ 'board' => $this->urlGenerator->boardLink( $boardTitle ),
+ );
+
+ if ( $row->revision instanceof PostRevision || $row->revision instanceof PostSummary ) {
+ $links['root'] = $this->urlGenerator->topicLink( $row->workflow->getArticleTitle(), $workflowId );
+ $links['root']->setMessage( $title->getPrefixedText() );
+ }
+
+ if ( $row->revision instanceof PostRevision ) {
+ $links['single-view'] = $this->urlGenerator->postRevisionLink(
+ $title,
+ $workflowId,
+ $row->revision->getPostId(),
+ $row->revision->getRevisionId()
+ );
+ $links['single-view']->setMessage( $title->getPrefixedText() );
+ } elseif ( $row->revision instanceof Header ) {
+ $links['single-view'] = $this->urlGenerator->headerRevisionLink(
+ $title,
+ $workflowId,
+ $row->revision->getRevisionId()
+ );
+ $links['single-view']->setMessage( $title->getPrefixedText() );
+ } elseif ( $row->revision instanceof PostSummary ) {
+ $links['single-view'] = $this->urlGenerator->summaryRevisionLink(
+ $title,
+ $workflowId,
+ $row->revision->getRevisionId()
+ );
+ $links['single-view']->setMessage( $title->getPrefixedText() );
+ } else {
+ wfDebugLog( 'Flow', __METHOD__ . ': Received unknown revision type ' . get_class( $row->revision ) );
+ }
+
+ if ( $row->revision->getPrevRevisionId() !== null ) {
+ $links['diff'] = $this->urlGenerator->diffLink(
+ $row->revision,
+ null,
+ $workflowId
+ );
+ $links['diff']->setMessage( wfMessage( 'diff' ) );
+ } else {
+ $links['diff'] = array(
+ 'url' => '',
+ 'title' => ''
+ );
+ }
+
+ return $links;
+ }
+
+ public function getHumanTimestamp( $timestamp ) {
+ $ts = new \MWTimestamp( $timestamp );
+ return $ts->getHumanTimestamp();
+ }
+
+}
diff --git a/Flow/includes/Formatter/RevisionViewQuery.php b/Flow/includes/Formatter/RevisionViewQuery.php
new file mode 100644
index 00000000..a6999e08
--- /dev/null
+++ b/Flow/includes/Formatter/RevisionViewQuery.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\ManagerGroup;
+use Flow\Exception\InvalidInputException;
+use Flow\Exception\PermissionException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\RevisionActionPermissions;
+
+abstract class RevisionViewQuery extends AbstractQuery {
+
+ /**
+ * @var RevisionActionPermissions
+ */
+ protected $permissions;
+
+ /**
+ * @param ManagerGroup $storage
+ * @param TreeRepository $treeRepository
+ * @param RevisionActionPermissions $permissions
+ */
+ public function __construct(
+ ManagerGroup $storage,
+ TreeRepository $treeRepository,
+ RevisionActionPermissions $permissions
+ ) {
+ parent::__construct( $storage, $treeRepository );
+ $this->permissions = $permissions;
+ }
+
+ /**
+ * Create a revision based on revisionId
+ * @param UUID|string
+ * @return AbstractRevision
+ */
+ abstract protected function createRevision( $revId );
+
+ /**
+ * Get the data for rendering single revision view
+ * @param string
+ * @return FormatterRow
+ * @throws InvalidInputException
+ */
+ public function getSingleViewResult( $revId ) {
+ if ( !$revId ) {
+ throw new InvalidInputException( 'Missing revision', 'missing-revision' );
+ }
+ $rev = $this->createRevision( $revId );
+ if ( !$rev ) {
+ throw new InvalidInputException( 'Could not find revision: ' . $revId, 'missing-revision' );
+ }
+ $this->loadMetadataBatch( array( $rev ) );
+ return $this->buildResult( $rev, null );
+ }
+
+ /**
+ * Get the data for rendering revisions diff view
+ * @param UUID $curId
+ * @param UUID|null $prevId
+ * @return FormatterRow[]
+ * @throws InvalidInputException
+ * @throws PermissionException
+ */
+ public function getDiffViewResult( UUID $curId, UUID $prevId = null ) {
+ $cur = $this->createRevision( $curId );
+ if ( !$cur ) {
+ throw new InvalidInputException( 'Could not find revision: ' . $curId, 'missing-revision' );
+ }
+ if ( !$prevId ) {
+ $prevId = $cur->getPrevRevisionId();
+ }
+ $prev = $this->createRevision( $prevId );
+ if ( !$prev ) {
+ throw new InvalidInputException( 'Could not find revision to compare against: ' . $curId->getAlphadecimal(), 'missing-revision' );
+ }
+ if ( !$this->isComparable( $cur, $prev ) ) {
+ throw new InvalidInputException( 'Attempt to compare revisions of different types', 'revision-comparison' );
+ }
+
+ // Re-position old and new revisions if necessary
+ if (
+ $cur->getRevisionId()->getTimestamp() >
+ $prev->getRevisionId()->getTimestamp()
+ ) {
+ $oldRev = $prev;
+ $newRev = $cur;
+ } else {
+ $oldRev = $cur;
+ $newRev = $prev;
+ }
+
+ /** @var RevisionActionPermissions $permission */
+ if (
+ !$this->permissions->isAllowed( $oldRev, 'view' ) ||
+ !$this->permissions->isAllowed( $newRev, 'view' )
+ ) {
+ throw new PermissionException( 'Insufficient permission to compare revisions', 'insufficient-permission' );
+ }
+
+ $this->loadMetadataBatch( array( $oldRev, $newRev ) );
+
+ return array(
+ $this->buildResult( $newRev, null ),
+ $this->buildResult( $oldRev, null ),
+ );
+ }
+
+ public function getUndoDiffResult( $startUndoId, $endUndoId ) {
+ $start = $this->createRevision( $startUndoId );
+ if ( !$start ) {
+ throw new InvalidInputException( 'Could not find revision: ' . $startUndoId, 'missing-revision' );
+ }
+ $end = $this->createRevision( $endUndoId );
+ if ( !$end ) {
+ throw new InvalidInputException( 'Could not find revision: ' . $endUndoId, 'missing-revision' );
+ }
+
+ // the two revision must have the same revision type id
+ if ( !$start->getCollectionId()->equals( $end->getCollectionId() ) ) {
+ throw new InvalidInputException( 'start and end are not from the same set' );
+ }
+
+ $current = $start->getCollection()->getLastRevision();
+
+ if (
+ !$this->permissions->isAllowed( $start, 'view' ) ||
+ !$this->permissions->isAllowed( $end, 'view' ) ||
+ !$this->permissions->isAllowed( $current, 'view' )
+ ) {
+ throw new PermissionException( 'Insufficient permission to undo revisions', 'insufficient-permission' );
+ }
+
+ $this->loadMetadataBatch( array( $start, $end, $current ) );
+
+ return array(
+ $this->buildResult( $start, null ),
+ $this->buildResult( $end, null ),
+ $this->buildResult( $current, null ),
+ );
+ }
+
+ public function isComparable( AbstractRevision $cur, AbstractRevision $prev ) {
+ if ( $cur->getRevisionType() == $prev->getRevisionType() ) {
+ return $cur->getCollectionId()->equals( $prev->getCollectionId() );
+ } else {
+ return false;
+ }
+ }
+}
+
+class HeaderViewQuery extends RevisionViewQuery {
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function createRevision( $revId ) {
+ if ( !$revId instanceof UUID ) {
+ $revId = UUID::create( $revId );
+ }
+ return $this->storage->get(
+ 'Header',
+ $revId
+ );
+ }
+}
+
+class PostViewQuery extends RevisionViewQuery {
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function createRevision( $revId ) {
+ if ( !$revId instanceof UUID ) {
+ $revId = UUID::create( $revId );
+ }
+ return $this->storage->get(
+ 'PostRevision',
+ $revId
+ );
+ }
+}
+
+class PostSummaryViewQuery extends RevisionViewQuery {
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function createRevision( $revId ) {
+ if ( !$revId instanceof UUID ) {
+ $revId = UUID::create( $revId );
+ }
+ return $this->storage->get(
+ 'PostSummary',
+ $revId
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/SinglePostQuery.php b/Flow/includes/Formatter/SinglePostQuery.php
new file mode 100644
index 00000000..ae860943
--- /dev/null
+++ b/Flow/includes/Formatter/SinglePostQuery.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\UUID;
+
+class SinglePostQuery extends AbstractQuery {
+ /**
+ * @param UUID $postId
+ * @return FormatterRow
+ * @throws FlowException
+ */
+ public function getResult( UUID $postId ) {
+ $found = $this->storage->find(
+ 'PostRevision',
+ array( 'rev_type_id' => $postId ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( !$found ) {
+ throw new FlowException( '@todo' );
+ }
+ $this->loadMetadataBatch( $found );
+
+ $formatterRow = null;
+ $post = reset( $found );
+ // Summary is only available to topic title now
+ if ( $post->isTopicTitle() ) {
+ $summary = $this->storage->find(
+ 'PostSummary',
+ array( 'rev_type_id' => $postId ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ if ( $summary ) {
+ $formatterRow = new TopicRow();
+ $formatterRow->summary = reset( $summary );
+ }
+ }
+
+ return $this->buildResult( $post, null, $formatterRow );
+ }
+}
diff --git a/Flow/includes/Formatter/TocTopicListFormatter.php b/Flow/includes/Formatter/TocTopicListFormatter.php
new file mode 100644
index 00000000..65aa0ce5
--- /dev/null
+++ b/Flow/includes/Formatter/TocTopicListFormatter.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\Pager\PagerPage;
+use Flow\Model\Workflow;
+use Flow\Templating;
+
+// The output of this is a strict subset of TopicListFormatter.
+// Anything accessible from the output of this should be accessible with the same path
+// from the output of TopicListFormatter. However, this output is much more minimal.
+class TocTopicListFormatter extends BaseTopicListFormatter {
+ /**
+ * @var Templating
+ */
+ protected $templating;
+
+ public function __construct( Templating $templating ) {
+ $this->templating = $templating;
+ }
+
+ /**
+ * Formats the response
+ *
+ * @param Workflow $listWorkflow Workflow corresponding to board/list of topics
+ * @param array $topicRootRevisionsByWorkflowId Associative array mapping topic ID (in alphadecimal form)
+ * to PostRevision for the topic root.
+ * @param array $workflowsByWorkflowId Associative array mapping topic ID (in alphadecimal form) to
+ * workflow
+ * @param PagerPage $page page from query, to support pagination
+ *
+ * @return array Array formatted for response
+ */
+ public function formatApi( Workflow $listWorkflow, $topicRootRevisionsByWorkflowId, $workflowsByWorkflowId, PagerPage $page ) {
+ $result = $this->buildEmptyResult( $listWorkflow );
+
+ foreach ( $topicRootRevisionsByWorkflowId as $topicId => $postRevision ) {
+ $result['roots'][] = $topicId;
+ $revisionId = $postRevision->getRevisionId()->getAlphadecimal();
+ $result['posts'][$topicId] = array( $revisionId );
+
+ $contentFormat = 'plaintext';
+
+ $workflow = $workflowsByWorkflowId[$topicId];
+
+ $result['revisions'][$revisionId] = array(
+ // Keep this as a minimal subset of
+ // RevisionFormatter->formatApi, and keep the same content
+ // format for topic titles as specified in that class for
+ // topic titles.
+
+ 'content' => array(
+ 'content' => $this->templating->getContent(
+ $postRevision,
+ $contentFormat
+ ),
+ 'format' => $contentFormat,
+ ),
+ 'last_updated' => $workflow->getLastModifiedObj()->getTimestamp() * 1000,
+ );
+ }
+
+ $pagingOption = $page->getPagingLinksOptions();
+ $result['links']['pagination'] = $this->buildPaginationLinks(
+ $listWorkflow,
+ $pagingOption
+ );
+
+ return $result;
+ }
+}
diff --git a/Flow/includes/Formatter/TopicFormatter.php b/Flow/includes/Formatter/TopicFormatter.php
new file mode 100644
index 00000000..bf6a9209
--- /dev/null
+++ b/Flow/includes/Formatter/TopicFormatter.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\UrlGenerator;
+use IContextSource;
+
+class TopicFormatter {
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var RevisionFormatter
+ */
+ protected $serializer;
+
+ public function __construct( UrlGenerator $urlGenerator, RevisionFormatter $serializer ) {
+ $this->urlGenerator = $urlGenerator;
+ $this->serializer = $serializer;
+ }
+
+ public function setContentFormat( $contentFormat, UUID $revisionId = null ) {
+ $this->serializer->setContentFormat( $contentFormat, $revisionId );
+ }
+
+ public function getEmptyResult( Workflow $workflow ) {
+ return array(
+ 'workflowId' => $workflow->getId()->getAlphadecimal(),
+ 'type' => 'topic',
+ 'roots' => array(),
+ 'posts' => array(),
+ 'revisions' => array(),
+ 'links' => array(),
+ 'actions' => $this->buildApiActions( $workflow ),
+ );
+ }
+
+ public function formatApi( Workflow $listWorkflow, array $found, IContextSource $ctx ) {
+ $roots = $revisions = $posts = $replies = array();
+ foreach( $found as $formatterRow ) {
+ $serialized = $this->serializer->formatApi( $formatterRow, $ctx );
+ if ( !$serialized ) {
+ continue;
+ }
+ $revisions[$serialized['revisionId']] = $serialized;
+ $posts[$serialized['postId']][] = $serialized['revisionId'];
+ if ( $serialized['replyToId'] ) {
+ $replies[$serialized['replyToId']][] = $serialized['postId'];
+ } else {
+ $roots[] = $serialized['postId'];
+ }
+ }
+
+ foreach ( $revisions as $i => $serialized ) {
+ $alpha = $serialized['postId'];
+ $revisions[$i]['replies'] = isset( $replies[$alpha] ) ? $replies[$alpha] : array();
+ }
+
+ $alpha = $listWorkflow->getId()->getAlphadecimal();
+ $workflows = array( $alpha => $listWorkflow );
+ // Metadata that requires everything to be serialied first
+ $metadata = $this->generateTopicMetadata( $posts, $revisions, $workflows, $alpha );
+ foreach ( $posts[$alpha] as $revId ) {
+ $revisions[$revId] += $metadata;
+ }
+
+ return array(
+ 'roots' => $roots,
+ 'posts' => $posts,
+ 'revisions' => $revisions,
+ ) + $this->getEmptyResult( $listWorkflow );
+ }
+
+ protected function buildApiActions( Workflow $workflow ) {
+ return array(
+ 'newtopic' => array(
+ 'url' => $this->urlGenerator
+ ->newTopicAction( $workflow->getArticleTitle(), $workflow->getId() )
+ ),
+ );
+ }
+
+ /**
+ * @param array $posts Map from alphadecimal postId to list of alphadecimal revisionId's
+ * for that postId contained within $revisions.
+ * @param array $revisions Map from alphadecimal revisionId to serialized representation
+ * of that revision.
+ * @param Workflow[] $workflows Map from alphadecimal workflowId to Workflow instance
+ * @param string $postAlphaId PostId of the topic title
+ * @return array
+ */
+ protected function generateTopicMetadata( array $posts, array $revisions, array $workflows, $postAlphaId ) {
+ $replies = -1;
+ $authors = array();
+ $stack = new \SplStack;
+ $stack->push( $revisions[$posts[$postAlphaId][0]] );
+ do {
+ $data = $stack->pop();
+ $replies++;
+ $authors[] = $data['creator']['name'];
+ foreach ( $data['replies'] as $postId ) {
+ $stack->push( $revisions[$posts[$postId][0]] );
+ }
+ } while( !$stack->isEmpty() );
+
+ $workflow = isset( $workflows[$postAlphaId] ) ? $workflows[$postAlphaId] : null;
+
+ return array(
+ 'reply_count' => $replies,
+ // ms timestamp
+ 'last_updated' => $workflow ? $workflow->getLastModifiedObj()->getTimestamp() * 1000 : null,
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/TopicHistoryQuery.php b/Flow/includes/Formatter/TopicHistoryQuery.php
new file mode 100644
index 00000000..b2cd5025
--- /dev/null
+++ b/Flow/includes/Formatter/TopicHistoryQuery.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+
+class TopicHistoryQuery extends AbstractQuery {
+ /**
+ * @param UUID $postId
+ * @param int $limit
+ * @param UUID|null $offset
+ * @param string $direction 'rev' or 'fwd'
+ * @return FormatterRow[]
+ */
+ public function getResults( UUID $postId, $limit = 50, UUID $offset = null, $direction = 'fwd' ) {
+ $history = $this->storage->find(
+ 'TopicHistoryEntry',
+ array( 'topic_root_id' => $postId ),
+ array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => $limit,
+ 'offset-id' => $offset,
+ 'offset-dir' => $direction,
+ 'offset-include' => false,
+ 'offset-elastic' => false,
+ )
+ );
+ if ( !$history ) {
+ return array();
+ }
+
+ $this->loadMetadataBatch( $history );
+ $results = $replies = array();
+ foreach ( $history as $revision ) {
+ try {
+ $results[] = $row = new TopicRow;
+ $this->buildResult( $revision, null, $row );
+ if ( $revision instanceof PostRevision ) {
+ $replyToId = $revision->getReplyToId();
+ if ( $replyToId ) {
+ // $revisionId into the key rather than value prevents
+ // duplicate insertion
+ $replies[$replyToId->getAlphadecimal()][$revision->getPostId()->getAlphadecimal()] = true;
+ }
+ }
+ } catch ( FlowException $e ) {
+ \MWExceptionHandler::logException( $e );
+ }
+ }
+
+ foreach ( $results as $result ) {
+ if ( $result->revision instanceof PostRevision ) {
+ $alpha = $result->revision->getPostId()->getAlphadecimal();
+ $result->replies = isset( $replies[$alpha] ) ? array_keys( $replies[$alpha] ) : array();
+ }
+ }
+
+ return $results;
+ }
+
+}
diff --git a/Flow/includes/Formatter/TopicListFormatter.php b/Flow/includes/Formatter/TopicListFormatter.php
new file mode 100644
index 00000000..8b28d122
--- /dev/null
+++ b/Flow/includes/Formatter/TopicListFormatter.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\Pager\PagerPage;
+use Flow\Model\Workflow;
+use Flow\UrlGenerator;
+use IContextSource;
+
+class TopicListFormatter extends BaseTopicListFormatter {
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var RevisionFormatter
+ */
+ protected $serializer;
+
+ public function __construct( UrlGenerator $urlGenerator, RevisionFormatter $serializer ) {
+ $this->urlGenerator = $urlGenerator;
+ $this->serializer = $serializer;
+ }
+
+ public function buildEmptyResult( Workflow $workflow ) {
+ $title = $workflow->getArticleTitle();
+ return array(
+ 'title' => $title->getPrefixedText(),
+ 'actions' => $this->buildApiActions( $workflow ),
+ ) + parent::buildEmptyResult( $workflow );
+ }
+
+ public function formatApi(
+ Workflow $listWorkflow,
+ array $workflows,
+ array $found,
+ PagerPage $page,
+ IContextSource $ctx
+ ) {
+ $res = $this->buildResult( $listWorkflow, $workflows, $found, $ctx ) +
+ $this->buildEmptyResult( $listWorkflow );
+ $pagingOption = $page->getPagingLinksOptions();
+ $res['links']['pagination'] = $this->buildPaginationLinks(
+ $listWorkflow,
+ $pagingOption
+ );
+ $title = $listWorkflow->getArticleTitle();
+ $saveSortBy = true;
+ $res['links']['board-sort']['updated'] = $this->urlGenerator->boardLink( $title, 'updated', $saveSortBy )->getLinkURL();
+ $res['links']['board-sort']['newest'] = $this->urlGenerator->boardLink( $title, 'newest', $saveSortBy )->getLinkURL();
+
+ // Link to designated new-topic page, for no-JS users
+ $res['links']['newtopic'] = $this->urlGenerator->newTopicAction( $title, $listWorkflow->getId() )->getLinkURL();
+
+ return $res;
+ }
+
+ /**
+ * @param Workflow $listWorkflow
+ * @param Workflow[] $workflows
+ * @param FormatterRow[] $found
+ * @param IContextSource $ctx
+ * @return array
+ */
+ protected function buildResult( Workflow $listWorkflow, array $workflows, array $found, IContextSource $ctx ) {
+ $revisions = $posts = $replies = array();
+ foreach( $found as $formatterRow ) {
+ $serialized = $this->serializer->formatApi( $formatterRow, $ctx );
+ if ( !$serialized ) {
+ continue;
+ }
+ $revisions[$serialized['revisionId']] = $serialized;
+ $posts[$serialized['postId']][] = $serialized['revisionId'];
+ $replies[$serialized['replyToId']][] = $serialized['postId'];
+ }
+
+ foreach ( $revisions as $i => $serialized ) {
+ $alpha = $serialized['postId'];
+ $revisions[$i]['replies'] = isset( $replies[$alpha] ) ? $replies[$alpha] : array();
+ }
+
+ $list = array();
+ if ( $workflows ) {
+ $orig = $workflows;
+ $workflows = array();
+ foreach ( $orig as $workflow ) {
+ $alpha = $workflow->getId()->getAlphadecimal();
+ if ( isset( $posts[$alpha] ) ) {
+ $list[] = $alpha;
+ $workflows[$alpha] = $workflow;
+ } else {
+ wfDebugLog( 'Flow', __METHOD__ . ": No matching root post for workflow $alpha" );
+ }
+ }
+
+ foreach ( $list as $alpha ) {
+ // Metadata that requires everything to be serialied first
+ $metadata = $this->generateTopicMetadata( $posts, $revisions, $workflows, $alpha, $ctx );
+ foreach ( $posts[$alpha] as $revId ) {
+ $revisions[$revId] += $metadata;
+ }
+ }
+ }
+
+ return array(
+ 'workflowId' => $listWorkflow->getId()->getAlphadecimal(),
+ // array_values must be used to ensure 0-indexed array
+ 'roots' => $list,
+ 'posts' => $posts,
+ 'revisions' => $revisions,
+ );
+ }
+
+ protected function buildApiActions( Workflow $workflow ) {
+ return array(
+ 'newtopic' => $this->urlGenerator->newTopicAction( $workflow->getArticleTitle() ),
+ );
+ }
+
+ protected function generateTopicMetadata( array $posts, array $revisions, array $workflows, $postAlphaId, IContextSource $ctx ) {
+ $language = $ctx->getLanguage();
+ $user = $ctx->getUser();
+
+ $replies = -1;
+ $authors = array();
+ $stack = new \SplStack;
+ $stack->push( $revisions[$posts[$postAlphaId][0]] );
+ do {
+ $data = $stack->pop();
+ $replies++;
+ $authors[] = $data['creator']['name'];
+ foreach ( $data['replies'] as $postId ) {
+ $stack->push( $revisions[$posts[$postId][0]] );
+ }
+ } while( !$stack->isEmpty() );
+
+ /** @var Workflow|null $workflow */
+ $workflow = isset( $workflows[$postAlphaId] ) ? $workflows[$postAlphaId] : null;
+ $ts = $workflow ? $workflow->getLastModifiedObj()->getTimestamp() : 0;
+ return array(
+ 'reply_count' => $replies,
+ 'last_updated_readable' => $language->userTimeAndDate( $ts, $user ),
+ // ms timestamp
+ 'last_updated' => $ts * 1000,
+ );
+ }
+}
diff --git a/Flow/includes/Formatter/TopicListQuery.php b/Flow/includes/Formatter/TopicListQuery.php
new file mode 100644
index 00000000..e68ff075
--- /dev/null
+++ b/Flow/includes/Formatter/TopicListQuery.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Data\ManagerGroup;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\TopicListEntry;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\RevisionActionPermissions;
+use Flow\WatchedTopicItems;
+use User;
+
+class TopicListQuery extends AbstractQuery {
+
+ protected $permissions;
+ protected $watchedTopicItems;
+
+ /**
+ * @param ManagerGroup $storage
+ * @param TreeRepository $treeRepository
+ * @param RevisionActionPermissions $permissions
+ * @param WatchedTopicItems $watchedTopicItems
+ */
+ public function __construct( ManagerGroup $storage, TreeRepository $treeRepository, RevisionActionPermissions $permissions, WatchedTopicItems $watchedTopicItems ) {
+ parent::__construct( $storage, $treeRepository );
+ $this->permissions = $permissions;
+ $this->watchedTopicItems = $watchedTopicItems;
+ }
+
+ /**
+ * @param UUID[]|TopicListEntry[] $topicIdsOrEntries
+ * @return FormatterRow[]
+ */
+ public function getResults( array $topicIdsOrEntries ) {
+ $topicIds = $this->getTopicIds( $topicIdsOrEntries );
+ $allPostIds = $this->collectPostIds( $topicIds );
+ $topicSummary = $this->collectSummary( $topicIds );
+ $posts = $this->collectRevisions( $allPostIds );
+ $watchStatus = $this->collectWatchStatus( $topicIds );
+
+ $missing = array_diff(
+ array_keys( $allPostIds ),
+ array_keys( $posts )
+ );
+ if ( $missing ) {
+ $needed = array();
+ foreach ( $missing as $alpha ) {
+ // convert alpha back into UUID object
+ $needed[] = $allPostIds[$alpha];
+ }
+ $posts += $this->createFakePosts( $needed );
+ }
+
+ $this->loadMetadataBatch( $posts );
+ $results = array();
+ $replies = array();
+ foreach ( $posts as $post ) {
+ try {
+ if ( !$this->permissions->isAllowed( $post, 'view' ) ) {
+ continue;
+ }
+ $row = new TopicRow;
+ $this->buildResult( $post, null, $row );
+ /** @var PostRevision $revision */
+ $revision = $row->revision;
+ $replyToId = $revision->getReplyToId();
+ $replyToId = $replyToId ? $replyToId->getAlphadecimal() : null;
+ $postId = $revision->getPostId()->getAlphadecimal();
+ $replies[$replyToId] = $postId;
+ if ( $post->isTopicTitle() ) {
+ // Attach the summary
+ if ( isset( $topicSummary[$postId] ) ) {
+ $row->summary = $topicSummary[$postId];
+ }
+ // Attach the watch status
+ if ( isset( $watchStatus[$postId] ) && $watchStatus[$postId] ) {
+ $row->isWatched = true;
+ }
+ }
+ $results[] = $row;
+ } catch ( FlowException $e ) {
+ \MWExceptionHandler::logException( $e );
+ }
+ }
+
+ foreach ( $results as $result ) {
+ $alpha = $result->revision->getPostId()->getAlphadecimal();
+ $result->replies = isset( $replies[$alpha] ) ? $replies[$alpha] : array();
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param TopicListEntry[]|UUID[] $topicsIdsOrEntries Topic IDs as UUID entries or
+ * TopicListEntry objects
+ * @return UUID[]
+ */
+ protected function getTopicIds( array $topicsIdsOrEntries ) {
+ $topicIds = array();
+ foreach ( $topicsIdsOrEntries as $entry ) {
+ if ( $entry instanceof UUID ) {
+ $topicIds[] = $entry;
+ } elseif ( $entry instanceof TopicListEntry ) {
+ $topicIds[] = $entry->getId();
+ }
+ }
+ return $topicIds;
+ }
+
+ /**
+ * @param UUID[] $topicIds
+ * @return UUID[] Indexed by alphadecimal representation
+ */
+ protected function collectPostIds( array $topicIds ) {
+ if ( !$topicIds ) {
+ return array();
+ }
+ // Get the full list of postId's necessary
+ $nodeList = $this->treeRepository->fetchSubtreeNodeList( $topicIds );
+
+ // Merge all the children from the various posts into one array
+ if ( !$nodeList ) {
+ // It should have returned at least $topicIds
+ wfDebugLog( 'Flow', __METHOD__ . ': No result received from TreeRepository::fetchSubtreeNodeList' );
+ $postIds = $topicIds;
+ } elseif ( count( $nodeList ) === 1 ) {
+ $postIds = reset( $nodeList );
+ } else {
+ $postIds = call_user_func_array( 'array_merge', $nodeList );
+ }
+
+ // re-index by alphadecimal id
+ return array_combine(
+ array_map( function( UUID $x ) { return $x->getAlphadecimal(); }, $postIds ),
+ $postIds
+ );
+ }
+
+ /**
+ * @param UUID[] $topicIds
+ * @return array
+ */
+ protected function collectWatchStatus( $topicIds ) {
+ $ids = array();
+ foreach ( $topicIds as $topicId ) {
+ $ids[] = $topicId->getAlphadecimal();
+ }
+ return $this->watchedTopicItems->getWatchStatus( $ids );
+ }
+
+ /**
+ * @param UUID[] $topicIds
+ * @return PostSummary[]
+ */
+ protected function collectSummary( $topicIds ) {
+ if ( !$topicIds ) {
+ return array();
+ }
+ $conds = array();
+ foreach ( $topicIds as $topicId ) {
+ $conds[] = array( 'rev_type_id' => $topicId );
+ }
+ $found = $this->storage->findMulti( 'PostSummary', $conds, array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => 1,
+ ) );
+ $result = array();
+ foreach ( $found as $row ) {
+ $summary = reset( $row );
+ $result[$summary->getSummaryTargetId()->getAlphadecimal()] = $summary;
+ }
+ return $result;
+ }
+
+ /**
+ * @param UUID[] $postIds
+ * @return PostRevision[] Indexed by alphadecimal post id
+ */
+ protected function collectRevisions( array $postIds ) {
+ $queries = array();
+ foreach ( $postIds as $postId ) {
+ $queries[] = array( 'rev_type_id' => $postId );
+ }
+ $found = $this->storage->findMulti( 'PostRevision', $queries, array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => 1,
+ ) );
+
+ // index results by post id for later filtering
+ $result = array();
+ foreach ( $found as $row ) {
+ $revision = reset( $row );
+ $result[$revision->getPostId()->getAlphadecimal()] = $revision;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Override parent, we only load the most recent version, so just
+ * return self.
+ */
+ protected function getCurrentRevision( AbstractRevision $revision ) {
+ return $revision;
+ }
+
+ /**
+ * @param UUID[] $missing
+ * @return PostRevision
+ */
+ protected function createFakePosts( array $missing ) {
+ $parents = $this->treeRepository->fetchParentMap( $missing );
+ $posts = array();
+ foreach ( $missing as $uuid ) {
+ $alpha = $uuid->getAlphadecimal();
+ if ( !isset( $parents[$alpha] ) ) {
+ wfDebugLog( 'Flow', __METHOD__ . ": Unable not locate parent for postid $alpha" );
+ continue;
+ }
+ $content = wfMessage( 'flow-stub-post-content' )->text();
+ $username = wfMessage( 'flow-system-usertext' )->text();
+ $user = User::newFromName( $username );
+
+ // create a stub post instead of failing completely
+ $post = PostRevision::newFromId( $uuid, $user, $content, 'wikitext' );
+ $post->setReplyToId( $parents[$alpha] );
+ $posts[$alpha] = $post;
+ }
+
+ return $posts;
+ }
+}
diff --git a/Flow/includes/Formatter/TopicRow.php b/Flow/includes/Formatter/TopicRow.php
new file mode 100644
index 00000000..122ee627
--- /dev/null
+++ b/Flow/includes/Formatter/TopicRow.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Flow\Formatter;
+
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+
+class TopicRow extends FormatterRow {
+ /**
+ * @var PostRevision[]
+ */
+ public $replies;
+
+ /**
+ * @var PostSummary
+ */
+ public $summary;
+
+ /**
+ * @var bool
+ */
+ public $isWatched;
+}
diff --git a/Flow/includes/Import/Converter.php b/Flow/includes/Import/Converter.php
new file mode 100644
index 00000000..b24bf2de
--- /dev/null
+++ b/Flow/includes/Import/Converter.php
@@ -0,0 +1,337 @@
+<?php
+
+namespace Flow\Import;
+
+use DatabaseBase;
+use Flow\Repository\TitleRepository;
+use MovePage;
+use MWExceptionHandler;
+use Psr\Log\LoggerInterface;
+use Revision;
+use Title;
+use User;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * Converts provided titles to Flow. This converter is idempotent when
+ * used with an appropriate ImportSourceStore, and may be run many times
+ * without worry for duplicate imports.
+ *
+ * Flow does not currently support viewing the history of its page prior
+ * to being flow enabled. Because of this prior to conversion the current
+ * wikitext page will be moved to an archive location.
+ *
+ * Implementing classes must choose a name for their archive page and
+ * be able to create an IImportSource when provided a Title. On successful
+ * import of a page a 'cleanup archive' edit is optionally performed.
+ *
+ * Any content changes to the imported content should be provided as part
+ * of the IImportSource.
+ */
+class Converter {
+ /**
+ * @var DatabaseBase Slave database of the current wiki. Required
+ * to lookup past page moves.
+ */
+ protected $dbr;
+
+ /**
+ * @var Importer Service capable of turning an IImportSource into
+ * flow revisions.
+ */
+ protected $importer;
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @var User The user for performing maintenance actions like moving
+ * pages or editing templates onto an archived page. This should be
+ * a system account and not a normal user.
+ */
+ protected $user;
+
+ /**
+ * @var IConversionStrategy Interface between this converter and an
+ * IImportSource implementation.
+ */
+ protected $strategy;
+
+ /**
+ * @param DatabaseBase $dbr Slave wiki database to read from
+ * @param Importer $importer
+ * @param LoggerInterface $logger
+ * @param User $user Administrative user for moves and edits related
+ * to the conversion process.
+ * @param IConversionStrategy $strategy
+ * @throws ImportException When $user does not have an Id
+ */
+ public function __construct(
+ DatabaseBase $dbr,
+ Importer $importer,
+ LoggerInterface $logger,
+ User $user,
+ IConversionStrategy $strategy
+ ) {
+ if ( !$user->getId() ) {
+ throw new ImportException( 'User must have id' );
+ }
+ $this->dbr = $dbr;
+ $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<Title> $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;
+ }
+
+ // 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 );
+ }
+
+ $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(
+ 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/Flow/includes/Import/Exception.php b/Flow/includes/Import/Exception.php
new file mode 100644
index 00000000..c531c60b
--- /dev/null
+++ b/Flow/includes/Import/Exception.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Flow\Import;
+
+/**
+ * Base class for errors in the Flow\Import module
+ */
+class ImportException extends \Flow\Exception\FlowException {
+}
+
+/**
+ * A failure occured trying to read or write to the
+ * permanant storage backing the ImportSourceStore.
+ */
+class ImportSourceStoreException extends ImportException {
+}
+
diff --git a/Flow/includes/Import/IConversionStrategy.php b/Flow/includes/Import/IConversionStrategy.php
new file mode 100644
index 00000000..c797da2d
--- /dev/null
+++ b/Flow/includes/Import/IConversionStrategy.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Flow\Import;
+
+use Flow\Import\ImportException;
+use Flow\Import\Postprocessor\Postprocessor;
+use Title;
+use WikitextContent;
+
+/**
+ * Interface between the Converter and an implementation of IImportSource.
+ */
+interface IConversionStrategy {
+ /**
+ * @return ImportSourceStore This should consistently return the
+ * same store between conversion runs from the same source to
+ * guarantee idempotent imports (without duplicate content).
+ */
+ function getSourceStore();
+
+ /**
+ * @param Title $from The original location of the page
+ * @param Title $to The archive location of the page
+ * @return string A reason for moving the page to an archive location.
+ */
+ function getMoveComment( Title $from, Title $to );
+
+ /**
+ * @param Title $from The original location of the page
+ * @param Title $to The archive location of the page
+ * @return string A reason for performing an edit to the
+ * archive location.
+ */
+ function getCleanupComment( Title $from, Title $to );
+
+ /**
+ * @param Title $title The current location of the page
+ * @param Title|null $movedFrom The location this was moved from
+ * in a prior run of the converter.
+ * @return bool True when the conversion is complete and nothing
+ * more can be done
+ */
+ function isConversionFinished( Title $title, Title $movedFrom = null );
+
+ /**
+ * Create an ImportSource implementation for the provided Title.
+ * This provides a consistent interface to the headers, topics,
+ * summaries and posts to be imported.
+ *
+ * @param Title $title The page to import from
+ * @return IImportSource
+ */
+ function createImportSource( Title $title );
+
+ /**
+ * Flow does not support viewing the history of the wikitext pages
+ * it takes over, so those need to be moved out the way. This method
+ * decides that destination.
+ *
+ * @param Title $source The title to be archived
+ * @return Title The title to archive $source to
+ * @throws ImportException When no title can be decided upon
+ */
+ function decideArchiveTitle( Title $source );
+
+ /**
+ * Creates the content for an edit to the archived page content. When
+ * null is returned no edit is performed. This edit is performed by
+ * an administrative user provided to the Converter.
+ *
+ * @param WikitextContent $content
+ * @param Title $title
+ * @return WikitextContent|null
+ */
+ function createArchiveCleanupRevisionContent( WikitextContent $content, Title $title );
+
+ /**
+ * Gets any postprocessors used for this type of conversion
+ * @return Postprocessor|null
+ */
+ function getPostprocessor();
+}
diff --git a/Flow/includes/Import/ImportSource.php b/Flow/includes/Import/ImportSource.php
new file mode 100644
index 00000000..c73bd62e
--- /dev/null
+++ b/Flow/includes/Import/ImportSource.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Flow\Import;
+
+use Iterator;
+
+interface IImportSource {
+ /**
+ * @return Iterator<IImportTopic>
+ */
+ function getTopics();
+
+ /**
+ * @return IImportHeader|null
+ */
+ function getHeader();
+}
+
+interface IImportObject {
+ /**
+ * Returns an opaque string that uniquely identifies this object.
+ * Should uniquely identify this particular object every time it is imported.
+ *
+ * @return string
+ */
+ function getObjectKey();
+}
+
+interface IRevisionableObject extends IImportObject {
+ /**
+ * @return Iterator<IObjectRevision>
+ */
+ function getRevisions();
+}
+
+interface IObjectRevision extends IImportObject {
+ /**
+ * @return string Wikitext
+ */
+ function getText();
+
+ /**
+ * @return string Timestamp compatible with wfTimestamp()
+ */
+ function getTimestamp();
+
+ /**
+ * @return string The name of the user who created this summary.
+ */
+ function getAuthor();
+}
+
+interface IImportPost extends IRevisionableObject {
+ /**
+ * @return Iterator<IImportPost>
+ */
+ function getReplies();
+}
+
+interface IImportTopic extends IImportPost {
+ /**
+ * @return IImportSummary|null The summary, if any, for a topic
+ */
+ function getTopicSummary();
+
+ /**
+ * @return string The subtype to use when logging topic imports
+ * to Special:Log. It will appear in the log as "import/$logType"
+ */
+ function getLogType();
+
+ /**
+ * @return string[string] A k/v map of strings containing additional
+ * parameters to be stored with the log about importing this topic.
+ */
+ function getLogParameters();
+}
+
+interface IImportHeader extends IRevisionableObject {
+}
+
+interface IImportSummary extends IRevisionableObject {
+}
+
diff --git a/Flow/includes/Import/ImportSourceStore.php b/Flow/includes/Import/ImportSourceStore.php
new file mode 100644
index 00000000..08ceeaf0
--- /dev/null
+++ b/Flow/includes/Import/ImportSourceStore.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Flow\Import;
+
+use Flow\Model\UUID;
+
+interface ImportSourceStore {
+ /**
+ * Stores the association between an object and where it was imported from.
+ *
+ * @param UUID $objectId ID for the object that was imported.
+ * @param string $importSourceKey String returned from IImportObject::getObjectKey()
+ */
+ function setAssociation( UUID $objectId, $importSourceKey );
+
+ /**
+ * @param string $importSourceKey String returned from IImportObject::getObjectKey()
+ * @return UUID|boolean UUID of the imported object if appropriate; otherwise, false.
+ */
+ function getImportedId( $importSourceKey );
+
+ /**
+ * Save any associations that have been added
+ * @throws ImportSourceStoreException When save fails
+ */
+ function save();
+
+ /**
+ * Forget any recorded associations since last save
+ */
+ function rollback();
+}
+
+class FileImportSourceStore implements ImportSourceStore {
+ /** @var string **/
+ protected $filename;
+ /** @var array */
+ protected $data;
+
+ public function __construct( $filename ) {
+ $this->filename = $filename;
+ $this->load();
+ }
+
+ protected function load() {
+ if ( file_exists( $this->filename ) ) {
+ $this->data = json_decode( file_get_contents( $this->filename ), true );
+ } else {
+ $this->data = array();
+ }
+ }
+
+ public function save() {
+ $bytesWritten = file_put_contents( $this->filename, json_encode( $this->data ) );
+ if ( $bytesWritten === false ) {
+ throw new ImportSourceStoreException( 'Could not write out source store to ' . $this->filename );
+ }
+ }
+
+ public function rollback() {
+ $this->load();
+ }
+
+ public function setAssociation( UUID $objectId, $importSourceKey ) {
+ $this->data[$importSourceKey] = $objectId->getAlphadecimal();
+ }
+
+ public function getImportedId( $importSourceKey ) {
+ return isset( $this->data[$importSourceKey] )
+ ? UUID::create( $this->data[$importSourceKey] )
+ : false;
+ }
+}
+
+class NullImportSourceStore implements ImportSourceStore {
+ public function setAssociation( UUID $objectId, $importSourceKey ) {
+ }
+
+ public function getImportedId( $importSourceKey ) {
+ return false;
+ }
+
+ public function save() {
+ }
+
+ public function rollback() {
+ }
+}
+
diff --git a/Flow/includes/Import/Importer.php b/Flow/includes/Import/Importer.php
new file mode 100644
index 00000000..7a062fc4
--- /dev/null
+++ b/Flow/includes/Import/Importer.php
@@ -0,0 +1,903 @@
+<?php
+
+namespace Flow\Import;
+
+use Article;
+use DeferredUpdates;
+use Flow\Data\BufferedCache;
+use Flow\Data\ManagerGroup;
+use Flow\DbFactory;
+use Flow\Import\Postprocessor\Postprocessor;
+use Flow\Import\Postprocessor\ProcessorGroup;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\TopicListEntry;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\OccupationController;
+use Flow\WorkflowLoaderFactory;
+use IP;
+use MWCryptRand;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use ReflectionProperty;
+use SplQueue;
+use Title;
+use UIDGenerator;
+use User;
+
+/**
+ * The import system uses a TalkpageImportOperation class.
+ * This class is essentially a factory class that makes the
+ * dependency injection less inconvenient for callers.
+ */
+class Importer {
+ /** @var ManagerGroup **/
+ protected $storage;
+ /** @var WorkflowLoaderFactory **/
+ protected $workflowLoaderFactory;
+ /** @var LoggerInterface|null */
+ protected $logger;
+ /** @var BufferedCache */
+ protected $cache;
+ /** @var DbFactory */
+ protected $dbFactory;
+ /** @var bool */
+ protected $allowUnknownUsernames;
+ /** @var ProcessorGroup **/
+ protected $postprocessors;
+ /** @var SplQueue Callbacks for DeferredUpdate that are queue'd up by the commit process */
+ protected $deferredQueue;
+ /** @var OccupationController */
+ protected $occupationController;
+
+ public function __construct(
+ ManagerGroup $storage,
+ WorkflowLoaderFactory $workflowLoaderFactory,
+ BufferedCache $cache,
+ DbFactory $dbFactory,
+ SplQueue $deferredQueue,
+ OccupationController $occupationController
+ ) {
+ $this->storage = $storage;
+ $this->workflowLoaderFactory = $workflowLoaderFactory;
+ $this->cache = $cache;
+ $this->dbFactory = $dbFactory;
+ $this->postprocessors = new ProcessorGroup;
+ $this->deferredQueue = $deferredQueue;
+ $this->occupationController = $occupationController;
+ }
+
+ public function addPostprocessor( Postprocessor $proc ) {
+ $this->postprocessors->add( $proc );
+ }
+
+ /**
+ * @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, $this->occupationController );
+ $pageImportState = new PageImportState(
+ $this->workflowLoaderFactory
+ ->createWorkflowLoader( $targetPage )
+ ->getWorkflow(),
+ $this->storage,
+ $sourceStore,
+ $this->logger ?: new NullLogger,
+ $this->cache,
+ $this->dbFactory,
+ $this->postprocessors,
+ $this->deferredQueue,
+ $this->allowUnknownUsernames
+ );
+ return $operation->import( $pageImportState );
+ }
+}
+
+/**
+ * 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;
+
+ /** @var OccupationController */
+ protected $occupationController;
+
+ /**
+ * @param IImportSource $source
+ */
+ public function __construct( IImportSource $source, OccupationController $occupationController ) {
+ $this->importSource = $source;
+ $this->occupationController = $occupationController;
+ }
+
+ /**
+ * @param PageImportState $state
+ * @return bool True if import completed successfully
+ * @throws ImportSourceStoreException
+ * @throws \Exception
+ */
+ public function import( PageImportState $state ) {
+ $destinationTitle = $state->boardWorkflow->getArticleTitle();
+ $state->logger->info( 'Importing to ' . $destinationTitle->getPrefixedText() );
+ if ( $state->boardWorkflow->isNew() ) {
+ $this->occupationController->allowCreation(
+ $destinationTitle,
+ $this->occupationController->getTalkpageManager()
+ );
+ $this->occupationController->ensureFlowRevision(
+ new Article( $destinationTitle ),
+ $state->boardWorkflow
+ );
+ $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/Flow/includes/Import/LiquidThreadsApi/CachedData.php b/Flow/includes/Import/LiquidThreadsApi/CachedData.php
new file mode 100644
index 00000000..1818a12b
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/CachedData.php
@@ -0,0 +1,178 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use ArrayIterator;
+use Iterator;
+
+/**
+ * Abstract class to store ID-indexed cached data.
+ */
+abstract class CachedData {
+ protected $data = array();
+
+ public function reset() {
+ $this->data = array();
+ }
+
+ /**
+ * Get the value for a given ID
+ *
+ * @param int $id The ID to get
+ * @return mixed The data returned by retrieve()
+ */
+ public function get( $id ) {
+ $result = $this->getMulti( array( $id ) );
+ return reset( $result );
+ }
+
+ public function getMaxId() {
+ if ( $this->data ) {
+ return max( array_keys( $this->data ) );
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Get the value for a number of IDs
+ *
+ * @param array $ids List of IDs to retrieve
+ * @return array Associative array, indexed by ID.
+ */
+ public function getMulti( array $ids ) {
+ $this->ensureLoaded( $ids );
+
+ $output = array();
+ foreach( $ids as $id ) {
+ $output[$id] = isset( $this->data[$id] ) ? $this->data[$id] : null;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Gets the number of items stored in this object.
+ *
+ * @return int
+ */
+ public function getSize() {
+ return count( $this->data );
+ }
+
+ /**
+ * Uncached retrieval of data from the backend.
+ *
+ * @param array $ids The IDs to retrieve data for
+ * @return array Associative array of data retrieved, indexed by ID.
+ */
+ abstract protected function retrieve( array $ids );
+
+ /**
+ * Adds data to the object
+ *
+ * @param array $data Associative array, indexed by ID.
+ */
+ protected function addData( array $data ) {
+ $this->data += $data;
+ }
+
+ /**
+ * Load missing IDs from a list
+ *
+ * @param array $ids The IDs to retrieve
+ */
+ protected function ensureLoaded( array $ids ) {
+ $missing = array_diff( $ids, array_keys( $this->data ) );
+
+ if ( count( $missing ) > 0 ) {
+ $data = $this->retrieve( $missing );
+ $this->addData( $data );
+ }
+ }
+}
+
+abstract class CachedApiData extends CachedData {
+ protected $backend;
+
+ function __construct( ApiBackend $backend ) {
+ $this->backend = $backend;
+ }
+}
+
+/**
+ * Cached LiquidThreads thread data.
+ */
+class CachedThreadData extends CachedApiData {
+ protected $topics = array();
+
+ protected function addData( array $data ) {
+ parent::addData( $data );
+
+ foreach( $data as $thread ) {
+ if ( self::isTopic( $thread ) ) {
+ $this->topics[$thread['id']] = true;
+ }
+ }
+ ksort( $this->topics );
+ }
+
+ /**
+ * Get the IDs of loaded threads that are top-level topics.
+ *
+ * @return array List of thread IDs in ascending order.
+ */
+ public function getTopics() {
+ return array_keys( $this->topics );
+ }
+
+ /**
+ * Create an iterator for the contained topic ids in ascending order
+ *
+ * @return Iterator<integer>
+ */
+ public function getTopicIdIterator() {
+ return new ArrayIterator( $this->getTopics() );
+ }
+
+ /**
+ * Retrieve data for threads from the given page starting with the provided
+ * offset.
+ *
+ * @param string $pageName
+ * @param integer $startId
+ * @return array Associative result array
+ */
+ public function getFromPage( $pageName, $startId = 0 ) {
+ $data = $this->backend->retrieveThreadData( array(
+ 'thpage' => $pageName,
+ 'thstartid' => $startId
+ ) );
+ $this->addData( $data );
+
+ return $data;
+ }
+
+ protected function retrieve( array $ids ) {
+ return $this->backend->retrieveThreadData( array(
+ 'thid' => implode( '|', $ids ),
+ ) );
+ }
+
+ /**
+ * @param array $thread
+ * @return bool
+ */
+ public static function isTopic( array $thread ) {
+ return $thread['parent'] === null;
+ }
+}
+
+/**
+ * Cached MediaWiki page data.
+ */
+class CachedPageData extends CachedApiData {
+ protected function retrieve( array $ids ) {
+ return $this->backend->retrievePageDataByID( $ids );
+ }
+}
diff --git a/Flow/includes/Import/LiquidThreadsApi/ConversionStrategy.php b/Flow/includes/Import/LiquidThreadsApi/ConversionStrategy.php
new file mode 100644
index 00000000..11462e6c
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/ConversionStrategy.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use DatabaseBase;
+use Flow\Import\Converter;
+use Flow\Import\IConversionStrategy;
+use Flow\Import\ImportSourceStore;
+use Flow\Import\Postprocessor\LqtRedirector;
+use Flow\UrlGenerator;
+use LqtDispatch;
+use MWTimestamp;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * Converts LiquidThreads pages on a wiki to Flow. This converter is idempotent
+ * when used with an appropriate ImportSourceStore, and may be run many times
+ * without worry for duplicate imports.
+ *
+ * Pages with the LQT magic word will be moved to a subpage of their original location
+ * named 'LQT Archive N' with N increasing starting at 1 looking for the first empty page.
+ * On successful import of an entire page the LQT magic word will be stripped from the
+ * archive version of the page.
+ */
+class ConversionStrategy implements IConversionStrategy {
+ /**
+ * @var DatabaseBase Slave database for the current wiki
+ */
+ protected $dbr;
+
+ /**
+ * @var ImportSourceStore
+ */
+ protected $sourceStore;
+
+ /**
+ * @var ApiBackend
+ */
+ public $api;
+
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var User
+ */
+ protected $talkpageUser;
+
+ public function __construct(
+ DatabaseBase $dbr,
+ ImportSourceStore $sourceStore,
+ ApiBackend $api,
+ UrlGenerator $urlGenerator,
+ User $talkpageUser
+ ) {
+ $this->dbr = $dbr;
+ $this->sourceStore = $sourceStore;
+ $this->api = $api;
+ $this->urlGenerator = $urlGenerator;
+ $this->talkpageUser = $talkpageUser;
+ }
+
+ public function getSourceStore() {
+ return $this->sourceStore;
+ }
+
+ public function getMoveComment( Title $from, Title $to ) {
+ return "Conversion of LQT to Flow from: {$from->getPrefixedText()}";
+ }
+
+ public function getCleanupComment( Title $from, Title $to ) {
+ return "LQT to Flow conversion";
+ }
+
+ public function isConversionFinished( Title $title, Title $movedFrom = null ) {
+ // After successful conversion we strip the LQT magic word
+ if ( LqtDispatch::isLqtPage( $title ) ) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ public function createImportSource( Title $title ) {
+ return new ImportSource( $this->api, $title->getPrefixedText(), $this->talkpageUser );
+ }
+
+ /**
+ * Flow does not support viewing the history of the wikitext pages it takes
+ * over, so those need to be moved out the way. This method decides that
+ * destination. The archived revisions include the headers displayed with
+ * lqt and potentially any pre-lqt wikitext talk page content.
+ *
+ * @param Title $source
+ * @return Title
+ */
+ public function decideArchiveTitle( Title $source ) {
+ return Converter::decideArchiveTitle( $source, array(
+ '%s/LQT Archive %d',
+ ) );
+ }
+
+ /**
+ * @param WikitextContent $content
+ * @param Title $title
+ * @return WikitextContent
+ */
+ public function createArchiveCleanupRevisionContent( WikitextContent $content, Title $title ) {
+ $arguments = implode( '|', array(
+ 'from=' . $title->getPrefixedText(),
+ 'date=' . MWTimestamp::getInstance()->timestamp->format( 'Y-m-d' ),
+ ) );
+
+ $newWikitext = preg_replace(
+ '/{{\s*#useliquidthreads:\s*1\s*}}/i',
+ '',
+ $content->getNativeData()
+ );
+ $template = wfMessage( 'flow-importer-lqt-converted-archive-template' )->inContentLanguage()->plain();
+ $newWikitext .= "\n\n{{{$template}|$arguments}}";
+
+ return new WikitextContent( $newWikitext );
+ }
+
+ public function getPostprocessor() {
+ $redirector = new LqtRedirector( $this->urlGenerator, $this->talkpageUser );
+ return $redirector;
+ }
+}
diff --git a/Flow/includes/Import/LiquidThreadsApi/Exception.php b/Flow/includes/Import/LiquidThreadsApi/Exception.php
new file mode 100644
index 00000000..f5bde977
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/Exception.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use Flow\Import\ImportException;
+
+/**
+ * Thrown when the liquidthreads api reports that a
+ * requested page or revision does not exist.
+ */
+class ApiNotFoundException extends ImportException {
+}
+
diff --git a/Flow/includes/Import/LiquidThreadsApi/Iterators.php b/Flow/includes/Import/LiquidThreadsApi/Iterators.php
new file mode 100644
index 00000000..3ed42961
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/Iterators.php
@@ -0,0 +1,251 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use ArrayIterator;
+use Flow\Import\IImportObject;
+use Iterator;
+
+class TopicIterator implements Iterator {
+ /**
+ * @var ImportSource
+ */
+ protected $importSource;
+
+ /**
+ * @var CachedThreadData Access point for api data
+ */
+ protected $threadData;
+
+ /**
+ * @var integer|false|null Lqt id of the current topic, false if no current topic, null if unknown.
+ */
+ protected $current = false;
+
+ /**
+ * @var ImportTopic The current topic.
+ */
+ protected $currentTopic = null;
+
+ /**
+ * @var string Name of the remote page the topics exist on
+ */
+ protected $pageName;
+
+ /**
+ * @var Iterator A list of topic ids. Iterator used to simplify maintaining
+ * an explicit position within the list.
+ */
+ protected $topicIdIterator;
+
+ /**
+ * @var integer The maximum id received by self::loadMore
+ */
+ protected $maxId;
+
+ /**
+ * @param ImportSource $source
+ * @param CachedThreadData $threadData
+ * @param string $pageName
+ */
+ public function __construct( ImportSource $source, CachedThreadData $threadData, $pageName ) {
+ $this->importSource = $source;
+ $this->threadData = $threadData;
+ $this->pageName = $pageName;
+ $this->topicIdIterator = new ArrayIterator( $threadData->getTopics() );
+ $this->rewind();
+ }
+
+ /**
+ * @return ImportTopic
+ */
+ public function current() {
+ if ( $this->current === false ) {
+ return null;
+ }
+ return $this->currentTopic;
+ }
+
+ /**
+ * @return integer
+ */
+ public function key() {
+ return $this->current;
+ }
+
+ public function next() {
+ if ( !$this->valid() ) {
+ return;
+ }
+
+ $lastOffset = $this->key();
+ do {
+ while( $this->topicIdIterator->valid() ) {
+ $topicId = $this->topicIdIterator->current();
+ $this->topicIdIterator->next();
+
+ // this topic id has been seen before.
+ if ( $topicId <= $lastOffset ) {
+ continue;
+ }
+
+ // hidden and deleted threads come back as null
+ $topic = $this->importSource->getTopic( $topicId );
+ if ( $topic === null ) {
+ continue;
+ }
+
+ $this->current = $topicId;
+ $this->currentTopic = $topic;
+ return;
+ }
+ } while( $this->loadMore() );
+
+ // nothing found, nothing more to load
+ $this->current = false;
+ }
+
+ public function rewind() {
+ $this->current = null;
+ $this->topicIdIterator->rewind();
+ $this->next();
+ }
+
+ /**
+ * @return bool
+ */
+ public function valid() {
+ return $this->current !== false;
+ }
+
+ /**
+ * @return bool True when more topics were loaded
+ */
+ protected function loadMore() {
+ try {
+ // + 1 to not return the existing max topic
+ $output = $this->threadData->getFromPage( $this->pageName, $this->maxId + 1 );
+ } catch ( ApiNotFoundException $e ) {
+ // No more results, end loop
+ return false;
+ }
+
+ $this->maxId = max( array_keys( $output ) );
+ $this->topicIdIterator = new ArrayIterator( $this->threadData->getTopics() );
+ $this->topicIdIterator->rewind();
+
+ // Keep looping until we get a not found error
+ return true;
+ }
+}
+
+class ReplyIterator implements Iterator {
+ /** @var ImportPost **/
+ protected $post;
+ /** @var array Array of thread IDs **/
+ protected $threadReplies;
+ /** @var int **/
+ protected $replyIndex;
+ /** @var ImportPost|null */
+ protected $current;
+
+ public function __construct( ImportPost $post ) {
+ $this->post = $post;
+ $this->replyIndex = 0;
+
+ $apiResponse = $post->getApiResponse();
+ $this->threadReplies = array_values( $apiResponse['replies'] );
+ }
+
+ /**
+ * @return ImportPost|null
+ */
+ public function current() {
+ return $this->current;
+ }
+
+ /**
+ * @return integer
+ */
+ public function key() {
+ return $this->replyIndex;
+ }
+
+ public function next() {
+ while( ++$this->replyIndex < count( $this->threadReplies ) ) {
+ try {
+ $replyId = $this->threadReplies[$this->replyIndex]['id'];
+ $this->current = $this->post->getSource()->getPost( $replyId );
+ return;
+ } catch ( ApiNotFoundException $e ) {
+ // while loop fall-through handles our error case
+ }
+ }
+
+ // Nothing found, set current to null
+ $this->current = null;
+ }
+
+ public function rewind() {
+ $this->replyIndex = -1;
+ $this->next();
+ }
+
+ public function valid() {
+ return $this->current !== null;
+ }
+}
+
+/**
+ * Iterates over the revisions of a foreign page to produce
+ * revisions of a Flow object.
+ */
+class RevisionIterator implements Iterator {
+ /** @var array **/
+ protected $pageData;
+
+ /** @var int **/
+ protected $pointer;
+
+ /** @var IImportObject **/
+ protected $parent;
+
+ public function __construct( array $pageData, IImportObject $parent, $factory ) {
+ $this->pageData = $pageData;
+ $this->pointer = 0;
+ $this->parent = $parent;
+ $this->factory = $factory;
+ }
+
+ protected function getRevisionCount() {
+ if ( isset( $this->pageData['revisions'] ) ) {
+ return count( $this->pageData['revisions'] );
+ } else {
+ return 0;
+ }
+ }
+
+ public function valid() {
+ return $this->pointer < $this->getRevisionCount();
+ }
+
+ public function next() {
+ ++$this->pointer;
+ }
+
+ public function key() {
+ return $this->pointer;
+ }
+
+ public function rewind() {
+ $this->pointer = 0;
+ }
+
+ public function current() {
+ return call_user_func(
+ $this->factory,
+ $this->pageData['revisions'][$this->pointer],
+ $this->parent
+ );
+ }
+}
diff --git a/Flow/includes/Import/LiquidThreadsApi/Objects.php b/Flow/includes/Import/LiquidThreadsApi/Objects.php
new file mode 100644
index 00000000..d0178b26
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/Objects.php
@@ -0,0 +1,464 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use ApiResult;
+use ArrayIterator;
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportObject;
+use Flow\Import\IImportPost;
+use Flow\Import\IImportSummary;
+use Flow\Import\IImportTopic;
+use Flow\Import\ImportException;
+use Flow\Import\IObjectRevision;
+use Flow\Import\IRevisionableObject;
+use Iterator;
+use MWTimestamp;
+use Title;
+use User;
+
+abstract class PageRevisionedObject implements IRevisionableObject {
+ /** @var int **/
+ protected $pageId;
+
+ /**
+ * @var ImportSource
+ */
+ protected $importSource;
+
+ /**
+ * @param ImportSource $source
+ * @param int $pageId ID of the remote page
+ */
+ function __construct( $source, $pageId ) {
+ $this->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];
+ $contentKey = isset( $revision[ApiResult::META_CONTENT] )
+ ? $revision[ApiResult::META_CONTENT]
+ : '*';
+
+ return $revision[$contentKey];
+ }
+
+ public function getTitle() {
+ $pageData = $this->importSource->getPageData( $this->apiResponse['rootid'] );
+
+ return Title::newFromText( $pageData['title'] );
+ }
+
+ /**
+ * @return Iterator<IImportPost>
+ */
+ 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(),
+ );
+ }
+}
+
+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() {
+ $contentKey = 'content';
+
+ $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 );
+ };
+ $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/Flow/includes/Import/LiquidThreadsApi/Source.php b/Flow/includes/Import/LiquidThreadsApi/Source.php
new file mode 100644
index 00000000..0e7c3449
--- /dev/null
+++ b/Flow/includes/Import/LiquidThreadsApi/Source.php
@@ -0,0 +1,445 @@
+<?php
+
+namespace Flow\Import\LiquidThreadsApi;
+
+use ApiBase;
+use ApiMain;
+use Exception;
+use FauxRequest;
+use Flow\Import\ImportException;
+use Flow\Import\IImportSource;
+use Flow\Import\ApiNullResponseException;
+use Http;
+use RequestContext;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use UsageException;
+use User;
+
+class ImportSource implements IImportSource {
+ // Thread types defined by LQT which are returned via api
+ const THREAD_TYPE_NORMAL = 0;
+ const THREAD_TYPE_MOVED = 1;
+ const THREAD_TYPE_DELETED = 2;
+ const THREAD_TYPE_HIDDEN = 4;
+
+ /**
+ * @var ApiBackend
+ */
+ protected $api;
+
+ /**
+ * @var string
+ */
+ protected $pageName;
+
+ /**
+ * @var CachedThreadData
+ */
+ protected $threadData;
+
+ /**
+ * @var CachedPageData
+ */
+ protected $pageData;
+
+ /**
+ * @var int
+ */
+ protected $cachedTopics = 0;
+
+ /**
+ * @var User Used for scripted actions and occurances (such as suppression)
+ * where the original user is not available.
+ */
+ protected $scriptUser;
+
+ /**
+ * @param ApiBackend $apiBackend
+ * @param string $pageName
+ */
+ public function __construct( ApiBackend $apiBackend, $pageName, User $scriptUser ) {
+ $this->api = $apiBackend;
+ $this->pageName = $pageName;
+ $this->scriptUser = $scriptUser;
+
+ $this->threadData = new CachedThreadData( $this->api );
+ $this->pageData = new CachedPageData( $this->api );
+ }
+
+ /**
+ * Returns a system user suitable for assigning programatic actions to.
+ *
+ * @return User
+ */
+ public function getScriptUser() {
+ return $this->scriptUser;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getHeader() {
+ return new ImportHeader( $this->api, $this, $this->pageName );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTopics() {
+ return new TopicIterator( $this, $this->threadData, $this->pageName );
+ }
+
+ /**
+ * @param integer $id
+ * @return ImportTopic|null
+ */
+ public function getTopic( $id ) {
+ // reset our internal cached data every 100 topics. Otherwise imports
+ // of any considerable size will take up large amounts of memory for
+ // no reason, running into swap on smaller machines.
+ $this->cachedTopics++;
+ if ( $this->cachedTopics > 100 ) {
+ $this->threadData->reset();
+ $this->pageData->reset();
+ $this->cachedTopics = 0;
+ }
+
+ $data = $this->threadData->get( $id );
+ switch ( $data['type'] ) {
+ // Standard thread
+ case self::THREAD_TYPE_NORMAL:
+ return new ImportTopic( $this, $data );
+
+ // The topic no longer exists at the queried location, but
+ // a stub was left behind pointing to it. This modified
+ // version of ImportTopic gracefully adjusts the #REDIRECT
+ // into a template to keep a similar output to lqt.
+ case self::THREAD_TYPE_MOVED:
+ return new MovedImportTopic( $this, $data );
+
+ // To get these back from the api we would have to send the `showdeleted`
+ // query param. As we are not requesting them, just ignore for now.
+ case self::THREAD_TYPE_DELETED:
+ return null;
+
+ // Was assigned but never used by LQT.
+ case self::THREAD_TYPE_HIDDEN:
+ return null;
+ }
+ }
+
+ /**
+ * @param integer $id
+ * @return ImportPost
+ */
+ public function getPost( $id ) {
+ return new ImportPost( $this, $this->threadData->get( $id ) );
+ }
+
+ /**
+ * @param integer $id
+ * @return array
+ */
+ public function getThreadData( $id ) {
+ if ( is_array( $id ) ) {
+ return $this->threadData->getMulti( $id );
+ } else {
+ return $this->threadData->get( $id );
+ }
+ }
+
+ /**
+ * @param integer[]|integer $pageIds
+ * @return array
+ */
+ public function getPageData( $pageIds ) {
+ if ( is_array( $pageIds ) ) {
+ return $this->pageData->getMulti( $pageIds );
+ } else {
+ return $this->pageData->get( $pageIds );
+ }
+ }
+
+ /**
+ * @param string $pageName
+ * @param integer $startId
+ * @return array
+ */
+ public function getFromPage( $pageName, $startId = 0 ) {
+ return $this->threadData->getFromPage( $pageName, $startId );
+ }
+
+ /**
+ * Gets a unique identifier for the wiki being imported
+ * @return string Usually either a string 'local' or an API URL
+ */
+ public function getApiKey() {
+ return $this->api->getKey();
+ }
+
+ /**
+ * Returns a key uniquely representing an object determined by arguments.
+ * Parameters: Zero or more strings that uniquely represent the object
+ * for this ImportSource
+ *
+ * @return string Unique key
+ */
+ public function getObjectKey( /* $args */ ) {
+ $components = array_merge(
+ array( 'lqt-api', $this->getApiKey() ),
+ func_get_args()
+ );
+
+ return implode( ':', $components );
+ }
+}
+
+abstract class ApiBackend implements LoggerAwareInterface {
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ public function __construct() {
+ $this->logger = new NullLogger;
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Retrieves LiquidThreads data from the API
+ *
+ * @param array $conditions The parameters to pass to select the threads. Usually used in two ways: with thstartid/thpage, or with ththreadid
+ * @return array Data as returned under query.threads by the API
+ * @throws ApiNotFoundException Thrown when the remote api reports that the provided conditions
+ * have no matching records.
+ * @throws ImportException When an error is received from the remote api. This is often either
+ * a bad request or lqt threw an exception trying to respond to a valid request.
+ */
+ public function retrieveThreadData( array $conditions ) {
+ $params = array(
+ 'action' => 'query',
+ 'list' => 'threads',
+ 'thprop' => 'id|subject|page|parent|ancestor|created|modified|author|summaryid|type|rootid|replies',
+ 'format' => 'json',
+ 'limit' => ApiBase::LIMIT_BIG1,
+ );
+ $data = $this->apiCall( $params + $conditions );
+
+ if ( ! isset( $data['query']['threads'] ) ) {
+ if ( $this->isNotFoundError( $data ) ) {
+ $message = "Did not find thread with conditions: " . json_encode( $conditions );
+ $this->logger->debug( __METHOD__ . ": $message" );
+ throw new ApiNotFoundException( $message );
+ } else {
+ $this->logger->error( __METHOD__ . ': Failed API call against ' . $this->getKey() . ' with conditions : ' . json_encode( $conditions ) );
+ throw new ImportException( "Null response from API module:" . json_encode( $data ) );
+ }
+ }
+
+ $firstThread = reset( $data['query']['threads'] );
+ if ( ! isset( $firstThread['replies'] ) ) {
+ throw new ImportException( "Foreign API does not support reply exporting:" . json_encode( $data ) );
+ }
+
+ return $data['query']['threads'];
+ }
+
+ /**
+ * Retrieves data about a set of pages from the API
+ *
+ * @param array $pageIds Page IDs to return data for.
+ * @return array The query.pages part of the API response.
+ * @throws \MWException
+ */
+ public function retrievePageDataById( array $pageIds ) {
+ if ( !$pageIds ) {
+ throw new \MWException( 'At least one page id must be provided' );
+ }
+
+ return $this->retrievePageData( array(
+ 'pageids' => implode( '|', $pageIds ),
+ ) );
+ }
+
+ /**
+ * Retrieves data about the latest revision of the titles
+ * from the API
+ *
+ * @param string[] $titles Titles to return data for
+ * @return array The query.pages prt of the API response.
+ * @throws \MWException
+ * @throws ImportException
+ */
+ public function retrieveTopRevisionByTitle( array $titles ) {
+ if ( !$titles ) {
+ throw new \MWException( 'At least one title must be provided' );
+ }
+
+ return $this->retrievePageData( array(
+ 'titles' => implode( '|', $titles ),
+ 'rvlimit' => 1,
+ 'rvdir' => 'older',
+ ), true );
+ }
+
+ /**
+ * Retrieves data about a set of pages from the API
+ *
+ * @param array $conditions Conditions to retrieve pages by; to be sent to the API.
+ * @param bool $expectContinue Pass true here when caller expects more revisions to exist than
+ * they are requesting information about.
+ * @return array The query.pages part of the API response.
+ * @throws ApiNotFoundException Thrown when the remote api reports that the provided conditions
+ * have no matching records.
+ * @throws ImportException When an error is received from the remote api. This is often either
+ * a bad request or lqt threw an exception trying to respond to a valid request.
+ * @throws ImportException When more revisions are available than can be returned in a single
+ * query and the calling code does not set $expectContinue to true.
+ */
+ public function retrievePageData( array $conditions, $expectContinue = false ) {
+ $conditions += array(
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'rvprop' => 'timestamp|user|content|ids',
+ 'format' => 'json',
+ 'rvlimit' => 5000,
+ 'rvdir' => 'newer',
+ 'continue' => '',
+ 'limit' => ApiBase::LIMIT_BIG1,
+ );
+ $data = $this->apiCall( $conditions );
+
+ if ( ! isset( $data['query'] ) ) {
+ $this->logger->error( __METHOD__ . ': Failed API call against ' . $this->getKey() . ' with conditions : ' . json_encode( $conditions ) );
+ if ( $this->isNotFoundError( $data ) ) {
+ $message = "Did not find pages: " . json_encode( $conditions );
+ $this->logger->debug( __METHOD__ . ": $message" );
+ throw new ApiNotFoundException( $message );
+ } else {
+ throw new ImportException( "Null response from API module: " . json_encode( $data ) );
+ }
+ } elseif ( !$expectContinue && isset( $data['continue'] ) ) {
+ throw new ImportException( "More revisions than can be retrieved for conditions, import would be incomplete: " . json_encode( $conditions ) );
+ }
+
+ return $data['query']['pages'];
+ }
+
+ /**
+ * Calls the remote API
+ *
+ * @param array $params The API request to send
+ * @param int $retry Retry the request on failure this many times
+ * @return array API return value, decoded from JSON into an array.
+ */
+ abstract function apiCall( array $params, $retry = 1 );
+
+ /**
+ * @return string A unique identifier for this backend.
+ */
+ abstract function getKey();
+
+ /**
+ * @param array $apiResponse
+ * @return bool
+ */
+ protected function isNotFoundError( $apiResponse ) {
+ // LQT has some bugs where not finding the requested item in the database throws
+ // returns this exception.
+ $expect = 'Exception Caught: DatabaseBase::makeList: empty input for field thread_parent';
+ return false !== strpos( $apiResponse['error']['info'], $expect );
+ }
+}
+
+class RemoteApiBackend extends ApiBackend {
+ /**
+ * @param string
+ */
+ protected $apiUrl;
+
+ /**
+ * @param string|null
+ */
+ protected $cacheDir;
+
+ /**
+ * @param string $apiUrl
+ * @param string|null $cacheDir
+ */
+ public function __construct( $apiUrl, $cacheDir = null ) {
+ parent::__construct();
+ $this->apiUrl = $apiUrl;
+ $this->cacheDir = $cacheDir;
+ }
+
+ public function getKey() {
+ return $this->apiUrl;
+ }
+
+ public function apiCall( array $params, $retry = 1 ) {
+ $params['format'] = 'json';
+ $url = wfAppendQuery( $this->apiUrl, $params );
+ $file = $this->cacheDir . '/' . md5( $url ) . '.cache';
+ $this->logger->debug( __METHOD__ . ": $url" );
+ if ( $this->cacheDir && file_exists( $file ) ) {
+ $result = file_get_contents( $file );
+ } else {
+ do {
+ $result = Http::get( $url );
+ } while ( $result === false && --$retry >= 0 );
+
+ if ( $this->cacheDir && file_put_contents( $file, $result ) === false ) {
+ $this->logger->warning( "Failed writing cached api result to $file" );
+ }
+ }
+
+ return json_decode( $result, true );
+ }
+}
+
+class LocalApiBackend extends ApiBackend {
+ /**
+ * @var User|null
+ */
+ protected $user;
+
+ public function __construct( User $user = null ) {
+ parent::__construct();
+ $this->user = $user;
+ }
+
+ public function getKey() {
+ return 'local';
+ }
+
+ public function apiCall( array $params, $retry = 1 ) {
+ try {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( $params ) );
+ if ( $this->user ) {
+ $context->setUser( $this->user );
+ }
+
+ $api = new ApiMain( $context );
+ $api->execute();
+ return $api->getResult()->getResultData( null, array( 'Strip' => 'all' ) );
+ } catch ( UsageException $exception ) {
+ // Mimic the behaviour when called remotely
+ return array( 'error' => $exception->getMessageArray() );
+ } catch ( Exception $exception ) {
+ // Mimic behaviour when called remotely
+ return array(
+ 'error' => array(
+ 'code' => 'internal_api_error_' . get_class( $exception ),
+ 'info' => 'Exception Caught: ' . $exception->getMessage(),
+ ),
+ );
+ }
+ }
+}
diff --git a/Flow/includes/Import/Plain/ImportHeader.php b/Flow/includes/Import/Plain/ImportHeader.php
new file mode 100644
index 00000000..33df3b67
--- /dev/null
+++ b/Flow/includes/Import/Plain/ImportHeader.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Flow\Import\Plain;
+
+use ArrayIterator;
+use Flow\Import\IImportHeader;
+use Flow\Import\IObjectRevision;
+
+class ImportHeader implements IImportHeader {
+ /** @var IObjectRevision[] */
+ protected $revisions;
+ /** @var string */
+ protected $objectKey;
+
+ /**
+ * @param IObjectRevision[] $revisions
+ * @param string $objectKey
+ */
+ public function __construct( array $revisions, $objectKey ) {
+ $this->revisions = $revisions;
+ $this->objectKey = $objectKey;
+ }
+
+ public function getRevisions() {
+ return new ArrayIterator( $this->revisions );
+ }
+
+ public function getObjectKey() {
+ return $this->objectKey;
+ }
+}
diff --git a/Flow/includes/Import/Plain/ObjectRevision.php b/Flow/includes/Import/Plain/ObjectRevision.php
new file mode 100644
index 00000000..0e94b2ae
--- /dev/null
+++ b/Flow/includes/Import/Plain/ObjectRevision.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Flow\Import\Plain;
+
+use Flow\Import\IObjectRevision;
+
+class ObjectRevision implements IObjectRevision {
+ /** @var string */
+ protected $text;
+ /** @var string */
+ protected $timestamp;
+ /** @var string */
+ protected $author;
+ /** @var string */
+ protected $objectKey;
+
+ /**
+ * @param string $text The content of the revision
+ * @param string $timestamp wfTimestamp() compatible creation timestamp
+ * @param string $author Name of the user that created the revision
+ * @param string $objectKey Unique key identifying this revision
+ */
+ public function __construct( $text, $timestamp, $author, $objectKey ) {
+ $this->text = $text;
+ $this->timestamp = $timestamp;
+ $this->author = $author;
+ $this->objectKey = $objectKey;
+ }
+
+ public function getText() {
+ return $this->text;
+ }
+
+ public function getTimestamp() {
+ return $this->timestamp;
+ }
+
+ public function getAuthor() {
+ return $this->author;
+ }
+
+ public function getObjectKey() {
+ return $this->objectKey;
+ }
+}
diff --git a/Flow/includes/Import/Postprocessor/LqtRedirector.php b/Flow/includes/Import/Postprocessor/LqtRedirector.php
new file mode 100644
index 00000000..e19155b3
--- /dev/null
+++ b/Flow/includes/Import/Postprocessor/LqtRedirector.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Flow\Import\Postprocessor;
+
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportPost;
+use Flow\Import\IImportTopic;
+use Flow\Import\LiquidThreadsApi\ImportPost;
+use Flow\Import\LiquidThreadsApi\ImportTopic;
+use Flow\Import\PageImportState;
+use Flow\Import\PostImportState;
+use Flow\Import\TopicImportState;
+use Flow\Model\UUID;
+use Flow\UrlGenerator;
+use Title;
+use User;
+use WatchedItem;
+use WikiPage;
+
+class LqtRedirector implements Postprocessor {
+ /** @var UrlGenerator **/
+ protected $urlGenerator;
+ /** @var array **/
+ protected $redirectsToDo;
+ /** @var User **/
+ protected $user;
+
+ public function __construct( UrlGenerator $urlGenerator, User $user ) {
+ $this->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/Flow/includes/Import/Postprocessor/PostprocessingException.php b/Flow/includes/Import/Postprocessor/PostprocessingException.php
new file mode 100644
index 00000000..0db0cc04
--- /dev/null
+++ b/Flow/includes/Import/Postprocessor/PostprocessingException.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Flow\Import\Postprocessor;
+
+use MWException;
+
+class PostprocessingException extends MWException {
+}
diff --git a/Flow/includes/Import/Postprocessor/Postprocessor.php b/Flow/includes/Import/Postprocessor/Postprocessor.php
new file mode 100644
index 00000000..de9be8eb
--- /dev/null
+++ b/Flow/includes/Import/Postprocessor/Postprocessor.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Flow\Import\Postprocessor;
+
+use Flow\Model\UUID;
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportPost;
+use Flow\Import\IImportTopic;
+use Flow\Import\PageImportState;
+use Flow\Import\TopicImportState;
+
+interface Postprocessor {
+ /**
+ * Called after the successfull commit of a header. This is
+ * currently called regardless of if any new content was imported.
+ *
+ * @param PageImportState $state
+ * @param IImportHeader $header
+ */
+ function afterHeaderImported( PageImportState $state, IImportHeader $header );
+
+ /**
+ * Called after the import of a single post. This has not yet been
+ * commited, and serves to inform the postprocessor about topic
+ * import progress. Only posts that have not been previously
+ * imported are reported here.
+ *
+ * @param TopicImportState $state
+ * @param IImportPost $post
+ * @param UUID $newPostId
+ */
+ function afterPostImported( TopicImportState $state, IImportPost $post, UUID $newPostId );
+
+ /**
+ * Called after the successful commit of a topic to the database.
+ * This may or may not have imported any actual posts, it is
+ * called on all topics run through the process regardless.
+ *
+ * @param TopicImportState $state
+ * @param IImportPost $post
+ */
+ function afterTopicImported( TopicImportState $state, IImportTopic $topic );
+
+ /**
+ * Callled when there has been an error in the import process.
+ * Any information the postprocessor has received since the last
+ * commit operation should be discarded as it will not be written
+ * to permenant storage.
+ */
+ function importAborted();
+}
diff --git a/Flow/includes/Import/Postprocessor/ProcessorGroup.php b/Flow/includes/Import/Postprocessor/ProcessorGroup.php
new file mode 100644
index 00000000..3cffc374
--- /dev/null
+++ b/Flow/includes/Import/Postprocessor/ProcessorGroup.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Flow\Import\Postprocessor;
+
+use Flow\Model\UUID;
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportPost;
+use Flow\Import\IImportTopic;
+use Flow\Import\TopicImportState;
+use Flow\Import\PageImportState;
+
+class ProcessorGroup implements Postprocessor {
+ /** @var array<Postprocessor> **/
+ 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/Flow/includes/Import/Postprocessor/SpecialLogTopic.php b/Flow/includes/Import/Postprocessor/SpecialLogTopic.php
new file mode 100644
index 00000000..36167fc8
--- /dev/null
+++ b/Flow/includes/Import/Postprocessor/SpecialLogTopic.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Flow\Import\Postprocessor;
+
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportPost;
+use Flow\Import\IImportTopic;
+use Flow\Import\PageImportState;
+use Flow\Import\TopicImportState;
+use Flow\Model\UUID;
+use ManualLogEntry;
+use User;
+
+/**
+ * Records topic imports to Special:Log.
+ */
+class SpecialLogTopic implements PostProcessor {
+ /**
+ * @var bool Indicates if new posts have been seen since the last commit operation
+ */
+ protected $newPosts = false;
+
+ /**
+ * @var User The user to attribute logs to
+ */
+ protected $user;
+
+ public function __construct( User $user ) {
+ $this->user = $user;
+ }
+
+ public function afterHeaderImported( PageImportState $state, IImportHeader $topic ) {
+ // nothing to do
+ }
+
+ public function afterPostImported( TopicImportState $state, IImportPost $post, UUID $newPostId ) {
+ $this->newPosts = true;
+ }
+
+ public function afterTopicImported( TopicImportState $state, IImportTopic $topic ) {
+ if ( !$this->newPosts ) {
+ return;
+ }
+ $logEntry = new ManualLogEntry( 'import', $topic->getLogType() );
+ $logEntry->setTarget( $state->topicWorkflow->getOwnerTitle() );
+ $logEntry->setPerformer( $this->user );
+ $logEntry->setParameters( array(
+ 'topic' => $state->topicWorkflow->getArticleTitle()->getPrefixedText(),
+ ) + $topic->getLogParameters() );
+ $logEntry->insert();
+
+ $this->newPosts = false;
+ }
+
+ public function importAborted() {
+ $this->newPosts = false;
+ }
+}
diff --git a/Flow/includes/Import/Wikitext/ConversionStrategy.php b/Flow/includes/Import/Wikitext/ConversionStrategy.php
new file mode 100644
index 00000000..546c9d80
--- /dev/null
+++ b/Flow/includes/Import/Wikitext/ConversionStrategy.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Flow\Import\Wikitext;
+
+use DateTime;
+use DateTimeZone;
+use Flow\Import\Converter;
+use Flow\Import\IConversionStrategy;
+use Flow\Import\ImportSourceStore;
+use Parser;
+use StubObject;
+use Title;
+use WikitextContent;
+
+/**
+ * Does not really convert. Archives wikitext pages out of the way and puts
+ * a new flow board in place. We take either the entire page, or the page up
+ * to the first section and put it into the header of the flow board. We
+ * additionally edit both the flow header and the archived page to include
+ * a localized template containing the reciprocal title and the conversion
+ * date in GMT.
+ *
+ * It is plausible something with the EchoDiscussionParser could be worked up
+ * to do an import of topics and posts. We know it wont work for everything,
+ * but we don't know if it works for 90%, 99%, or 99.99% of topics. We know
+ * for sure that it does not currently understand anything about editing an
+ * existing comment.
+ */
+class ConversionStrategy implements IConversionStrategy {
+ /**
+ * @var ImportSourceStore
+ */
+ protected $sourceStore;
+
+ /**
+ * @var Parser|StubObject
+ */
+ protected $parser;
+
+ /**
+ * @param Parser|StubObject $parser
+ * @param ImportSourceStore $sourceStore
+ */
+ public function __construct( $parser, ImportSourceStore $sourceStore ) {
+ $this->parser = $parser;
+ $this->sourceStore = $sourceStore;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getSourceStore() {
+ return $this->sourceStore;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMoveComment( Title $from, Title $to ) {
+ return wfMessage( 'flow-talk-conversion-move-reason', $from->getPrefixedText() )->plain();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCleanupComment( Title $from, Title $to ) {
+ return wfMessage( 'flow-talk-conversion-archive-edit-reason' )->plain();
+ }
+
+ /**
+ * @{inheritDoc}
+ */
+ public function isConversionFinished( Title $title, Title $movedFrom = null ) {
+ if ( $movedFrom ) {
+ // no good way to pick up where we left off
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function createImportSource( Title $title ) {
+ return new ImportSource( $title, $this->parser );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decideArchiveTitle( Title $source ) {
+ return Converter::decideArchiveTitle( $source, array(
+ '%s/Archive %d',
+ '%s/Archive%d',
+ '%s/archive %d',
+ '%s/archive%d',
+ ) );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getPostprocessor() {
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function createArchiveCleanupRevisionContent( WikitextContent $content, Title $title ) {
+ $now = new DateTime( "now", new DateTimeZone( "GMT" ) );
+ $arguments = implode( '|', array(
+ 'from=' . $title->getPrefixedText(),
+ 'date=' . $now->format( 'Y-m-d' ),
+ ) );
+
+ $template = wfMessage( 'flow-importer-wt-converted-archive-template' )->inContentLanguage()->plain();
+ $newWikitext = "{{{$template}|$arguments}}" . "\n\n" . $content->getNativeData();
+
+ return new WikitextContent( $newWikitext );
+ }
+}
diff --git a/Flow/includes/Import/Wikitext/ImportSource.php b/Flow/includes/Import/Wikitext/ImportSource.php
new file mode 100644
index 00000000..4b737ccb
--- /dev/null
+++ b/Flow/includes/Import/Wikitext/ImportSource.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Flow\Import\Wikitext;
+
+use ArrayIterator;
+use FlowHooks;
+use Flow\Import\ImportException;
+use Flow\Import\Plain\ImportHeader;
+use Flow\Import\Plain\ObjectRevision;
+use Flow\Import\IImportSource;
+use MWTimestamp;
+use Parser;
+use ParserOptions;
+use Revision;
+use StubObject;
+use Title;
+
+/**
+ * Imports the header of a wikitext talk page. Does not attempt to
+ * parse out and return individual topics. See the wikitext
+ * ConversionStrategy for more details.
+ */
+class ImportSource implements IImportSource {
+
+ /**
+ * @param Title $title
+ * @param Parser|StubObject $parser
+ * @throws ImportException When $title is an external title
+ */
+ public function __construct( Title $title, $parser ) {
+ if ( $title->isExternal() ) {
+ throw new ImportException( "Invalid non-local title: $title" );
+ }
+ $this->title = $title;
+ $this->parser = $parser;
+ }
+
+ /**
+ * Converts the existing wikitext talk page into a flow board header.
+ * If sections exist the header only receives the content up to the
+ * first section. Appends a template to the output indicating conversion
+ * occurred parameterized with the page the source lives at and the date
+ * of conversion in GMT.
+ *
+ * @return ImportHeader|null
+ */
+ public function getHeader() {
+ $revision = Revision::newFromTitle( $this->title );
+ if ( !$revision ) {
+ return null;
+ }
+
+
+ // If sections exist only take the content from the top of the page
+ // to the first section.
+ $content = $revision->getContent()->getNativeData();
+ $output = $this->parser->parse( $content, $this->title, new ParserOptions );
+ $sections = $output->getSections();
+ if ( $sections ) {
+ $content = substr( $content, 0, $sections[0]['byteoffset'] );
+ }
+
+ $template = wfMessage( 'flow-importer-wt-converted-template' )->inContentLanguage()->plain();
+ $arguments = implode( '|', array(
+ 'archive=' . $this->title->getPrefixedText(),
+ 'date=' . MWTimestamp::getInstance()->timestamp->format( 'Y-m-d' ),
+ ) );
+ $content .= "\n\n{{{$template}|$arguments}}";
+
+ return new ImportHeader(
+ array( new ObjectRevision(
+ $content,
+ wfTimestampNow(),
+ FlowHooks::getOccupationController()->getTalkpageManager()->getName(),
+ "wikitext-import:header-revision:{$revision->getId()}"
+ ) ),
+ "wikitext-import:header:{$this->title->getPrefixedText()}"
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTopics() {
+ return new ArrayIterator( array() );
+ }
+}
+
diff --git a/Flow/includes/LinksTableUpdater.php b/Flow/includes/LinksTableUpdater.php
new file mode 100644
index 00000000..9346dff4
--- /dev/null
+++ b/Flow/includes/LinksTableUpdater.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Flow;
+
+use DataUpdate;
+use Flow\Data\ManagerGroup;
+use Flow\Model\Reference;
+use Flow\Model\URLReference;
+use Flow\Model\WikiReference;
+use Flow\Model\Workflow;
+use LinkBatch;
+use LinkCache;
+use ParserOutput;
+use Title;
+use WikiPage;
+
+class LinksTableUpdater {
+
+ protected $storage;
+
+ /**
+ * Constructor
+ * @param ManagerGroup $storage A ManagerGroup
+ */
+ public function __construct( ManagerGroup $storage ) {
+ $this->storage = $storage;
+ }
+
+ public function doUpdate( Workflow $workflow ) {
+ $title = $workflow->getArticleTitle();
+ $page = WikiPage::factory( $title );
+ $content = $page->getContent();
+ if ( $content === null ) {
+ $updates = array();
+ } else {
+ $updates = $content->getSecondaryDataUpdates( $title );
+ }
+
+ DataUpdate::runUpdates( $updates );
+ }
+
+ /**
+ * @param Title $title
+ * @param ParserOutput $parserOutput
+ * @param Reference[] $references
+ */
+ public function mutateParserOutput( Title $title, ParserOutput $parserOutput, $references = null ) {
+ if ( $references === null ) {
+ $references = $this->getReferencesForTitle( $title );
+ }
+
+ $linkBatch = new LinkBatch();
+ /** @var Title[] $internalLinks */
+ $internalLinks = array();
+ /** @var Title[] $templates */
+ $templates = array();
+
+ foreach( $references as $reference ) {
+ if ( $reference->getType() === 'link' ) {
+ if ( $reference instanceof URLReference ) {
+ $parserOutput->mExternalLinks[$reference->getURL()] = true;
+ } elseif ( $reference instanceof WikiReference ) {
+ $internalLinks[$reference->getTitle()->getPrefixedDBkey()] = $reference->getTitle();
+ $linkBatch->addObj( $reference->getTitle() );
+ }
+ } elseif ( $reference->getType() === WikiReference::TYPE_CATEGORY ) {
+ if ( $reference instanceof WikiReference ) {
+ $title = $reference->getTitle();
+ $parserOutput->addCategory(
+ $title->getDBkey(),
+ // parsoid moves the sort key into the fragment
+ $title->getFragment()
+ );
+ }
+ } elseif ( $reference->getType() === 'file' ) {
+ if ( $reference instanceof WikiReference ) {
+ // Only local images supported
+ $parserOutput->mImages[$reference->getTitle()->getDBkey()] = true;
+ }
+ } elseif ( $reference->getType() === 'template' ) {
+ if ( $reference instanceof WikiReference ) {
+ $templates[$reference->getTitle()->getPrefixedDBkey()] = $reference->getTitle();
+ $linkBatch->addObj( $reference->getTitle() );
+ }
+ }
+ }
+
+ $linkBatch->execute();
+ $linkCache = LinkCache::singleton();
+
+ foreach( $internalLinks as $title ) {
+ $ns = $title->getNamespace();
+ $dbk = $title->getDBkey();
+ if ( !isset( $parserOutput->mLinks[$ns] ) ) {
+ $parserOutput->mLinks[$ns] = array();
+ }
+
+ $id = $linkCache->getGoodLinkID( $title->getPrefixedDBkey() );
+ $parserOutput->mLinks[$ns][$dbk] = $id;
+ }
+
+ foreach( $templates as $title ) {
+ $ns = $title->getNamespace();
+ $dbk = $title->getDBkey();
+ if ( !isset( $parserOutput->mTemplates[$ns] ) ) {
+ $parserOutput->mTemplates[$ns] = array();
+ }
+
+ $id = $linkCache->getGoodLinkID( $title->getPrefixedDBkey() );
+ $parserOutput->mTemplates[$ns][$dbk] = $id;
+ }
+ }
+
+ public function getReferencesForTitle( Title $title ) {
+ $wikiReferences = $this->storage->find(
+ 'WikiReference',
+ array(
+ 'ref_src_namespace' => $title->getNamespace(),
+ 'ref_src_title' => $title->getDBkey(),
+ )
+ );
+
+ $urlReferences = $this->storage->find(
+ 'URLReference',
+ array(
+ 'ref_src_namespace' => $title->getNamespace(),
+ 'ref_src_title' => $title->getDBkey(),
+ )
+ );
+
+ // let's make sure the merge doesn't fail when nothing was found
+ $wikiReferences = $wikiReferences ?: array();
+ $urlReferences = $urlReferences ?: array();
+
+ return array_merge( $wikiReferences, $urlReferences );
+ }
+}
diff --git a/Flow/includes/Log/ActionFormatter.php b/Flow/includes/Log/ActionFormatter.php
new file mode 100644
index 00000000..43468085
--- /dev/null
+++ b/Flow/includes/Log/ActionFormatter.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Flow\Log;
+
+use Flow\Collection\PostCollection;
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Model\UUID;
+use Flow\Parsoid\Utils;
+use Flow\Repository\TreeRepository;
+use Flow\UrlGenerator;
+use Message;
+
+class ActionFormatter extends \LogFormatter {
+ /**
+ * @var UUID[]
+ */
+ static $uuids = array();
+
+ /**
+ * @param \LogEntry $entry
+ */
+ public function __construct( \LogEntry $entry ) {
+ parent::__construct( $entry );
+
+ $params = $this->entry->getParameters();
+ // serialized topicId or postId can be stored
+ foreach ( $params as $key => $value ) {
+ if ( $value instanceof UUID ) {
+ static::$uuids[$value->getAlphadecimal()] = $value;
+ }
+ }
+ }
+
+ /**
+ * Formats an activity log entry.
+ *
+ * @return string The log entry
+ */
+ protected function getActionMessage() {
+ global $wgContLang;
+
+ /*
+ * At this point, all log entries will already have been created & we've
+ * gathered all uuids in constructor: we can now batch-load all of them.
+ * We won't directly be using that batch-loaded data (nothing will even
+ * be returned) but it'll ensure that everything we need will be
+ * retrieved from cache/storage efficiently & waiting in memory for when
+ * we request it again.
+ */
+ static $loaded = false;
+ if ( !$loaded ) {
+ /** @var ManagerGroup storage */
+ $storage = Container::get( 'storage' );
+ /** @var TreeRepository $treeRepository */
+ $treeRepository = Container::get( 'repository.tree' );
+
+ $query = new LogQuery( $storage, $treeRepository );
+ $query->loadMetadataBatch( static::$uuids );
+ $loaded = true;
+ }
+
+ $root = $this->getRoot();
+ if ( !$root ) {
+ // failed to load required data
+ return '';
+ }
+
+ $type = $this->entry->getType();
+ $action = $this->entry->getSubtype();
+ $title = $this->entry->getTarget();
+ $skin = $this->plaintext ? null : $this->context->getSkin();
+ $params = $this->entry->getParameters();
+
+ // @todo: we should probably check if user isAllowed( <this-revision>, 'log' )
+ // unlike RC, Contributions, ... this one does not batch-load all Flow
+ // revisions & does not use the same Formatter, i18n message text, etc
+
+ if ( isset( $params['postId'] ) ) {
+ /** @var UrlGenerator $urlGenerator */
+ $urlGenerator = Container::get( 'url_generator' );
+
+ // generate link that highlights the post
+ $anchor = $urlGenerator->postLink(
+ $title,
+ UUID::create( $params['topicId'] ),
+ UUID::create( $params['postId'] )
+ );
+ $title = $anchor->resolveTitle();
+ }
+
+ // Give grep a chance to find the usages:
+ // logentry-delete-flow-delete-post, logentry-delete-flow-restore-post,
+ // logentry-suppress-flow-restore-post, logentry-suppress-flow-suppress-post,
+ // logentry-delete-flow-delete-topic, logentry-delete-flow-restore-topic,
+ // logentry-suppress-flow-restore-topic, logentry-suppress-flow-suppress-topic,
+ $language = $skin === null ? $wgContLang : $skin->getLanguage();
+ $message = wfMessage( "logentry-$type-$action" )
+ ->params( array(
+ Message::rawParam( $this->getPerformerElement() ),
+ $this->entry->getPerformer()->getName(),
+ $title, // link to topic
+ $title->getFullUrl(), // link to topic, higlighted post
+ $root->getLastRevision()->getContent(), // topic title
+ $root->getWorkflow()->getOwnerTitle() // board title object
+ ) )
+ ->inLanguage( $language )
+ ->parse();
+
+ return \Html::rawElement(
+ 'span',
+ array( 'class' => 'plainlinks' ),
+ $message
+ );
+ }
+
+ /**
+ * The native LogFormatter::getActionText provides no clean way of handling
+ * the Flow action text in a plain text format (e.g. as used by CheckUser)
+ *
+ * @return string
+ */
+ public function getActionText() {
+ $text = $this->getActionMessage();
+ return $this->plaintext ? Utils::htmlToPlaintext( $text ) : $text;
+ }
+
+ /**
+ * @return PostCollection|bool
+ */
+ protected function getRoot() {
+ $params = $this->entry->getParameters();
+
+ try {
+ if ( !isset( $params['topicId'] ) ) {
+ // failed finding the expected data in storage
+ wfWarn( __METHOD__ . ': Failed to locate topicId in log_params for: ' . serialize( $params ) . ' (forgot to run FlowFixLog.php?)' );
+ return false;
+ }
+
+ $uuid = UUID::create( $params['topicId'] );
+ $collection = PostCollection::newFromId( $uuid );
+
+ // see if this post is valid
+ $collection->getLastRevision();
+ return $collection;
+ } catch ( \Exception $e ) {
+ // failed finding the expected data in storage
+ wfWarn( __METHOD__ . ': Failed to locate root for: ' . serialize( $params ) . ' (potentially storage issue)' );
+ return false;
+ }
+ }
+}
diff --git a/Flow/includes/Log/LqtImportFormatter.php b/Flow/includes/Log/LqtImportFormatter.php
new file mode 100644
index 00000000..3ef5b524
--- /dev/null
+++ b/Flow/includes/Log/LqtImportFormatter.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Flow\Log;
+
+use Message;
+use Title;
+
+class LqtImportFormatter extends \LogFormatter {
+
+ public function getPreloadTitles() {
+ $titles = array( $this->entry->getTarget() );
+ $params = $this->entry->getParameters() + array(
+ 'topic' => '',
+ );
+ $topic = Title::newFromText( $params['topic'] );
+ if ( $topic ) {
+ $titles[] = $topic;
+ }
+
+ return $titles;
+ }
+
+ /**
+ * Formats an activity log entry.
+ *
+ * @return string The log entry
+ */
+ protected function getActionMessage() {
+ $board = $this->entry->getTarget();
+ $params = $this->entry->getParameters() + array(
+ 'topic' => '',
+ 'lqt_subject' => '',
+ );
+ $topic = Title::newFromText( $params['topic'] );
+
+ $message = $this->msg( "logentry-import-lqt-to-flow-topic" )
+ ->params(
+ $topic ? $topic->getPrefixedText() : '',
+ Message::plaintextParam( $params['lqt_subject'] ),
+ $board->getPrefixedText()
+ );
+
+ return $this->plaintext ? $message->text() : $message->parse();
+ }
+}
diff --git a/Flow/includes/Log/ModerationLogger.php b/Flow/includes/Log/ModerationLogger.php
new file mode 100644
index 00000000..4b2932bf
--- /dev/null
+++ b/Flow/includes/Log/ModerationLogger.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Flow\Log;
+
+use Closure;
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use ManualLogEntry;
+use Title;
+
+class ModerationLogger {
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ /**
+ * @param FlowActions $actions
+ */
+ public function __construct( FlowActions $actions ) {
+ $this->actions = $actions;
+ }
+
+ /**
+ * Check if an action should be logged (= if a log_type is set)
+ *
+ * @param PostRevision $post
+ * @param string $action
+ * @return bool
+ */
+ public function canLog( PostRevision $post, $action ) {
+ return (bool) $this->getLogType( $post, $action );
+ }
+
+ /**
+ * Adds a moderation activity item to the log under the appropriate action
+ *
+ * @param PostRevision $post
+ * @param string $action The action we'll be logging
+ * @param string $reason Comment, reason for the moderation
+ * @param UUID $workflowId Workflow being worked on
+ * @return int The id of the newly inserted log entry
+ */
+ public function log( PostRevision $post, $action, $reason, UUID $workflowId ) {
+ if ( !$this->canLog( $post, $action ) ) {
+ return null;
+ }
+
+ $params = array(
+ 'topicId' => $workflowId->getAlphadecimal(),
+ );
+ if ( !$post->isTopicTitle() ) {
+ $params['postId'] = $post->getPostId()->getAlphadecimal();
+ }
+
+ $logType = $this->getLogType( $post, $action );
+
+ // reasonably likely this is already loaded in-process and just returns that object
+ /** @var Workflow $workflow */
+ $workflow = Container::get( 'storage.workflow' )->get( $workflowId );
+ if ( $workflow ) {
+ $title = $workflow->getArticleTitle();
+ } else {
+ $title = false;
+ }
+ $error = false;
+ if ( !$title ) {
+ // We dont want to fail logging due to this, so repoint it at Main_Page which
+ // will probably be noticed, also log it below once we know the logId
+ $title = Title::newMainPage();
+ $error = true;
+ }
+
+ // insert logging entry
+ $logEntry = new ManualLogEntry( $logType, "flow-$action" );
+ $logEntry->setTarget( $title );
+ $logEntry->setPerformer( $post->getUserTuple()->createUser() );
+ $logEntry->setParameters( $params );
+ $logEntry->setComment( $reason );
+ $logEntry->setTimestamp( $post->getModerationTimestamp() );
+
+ $logId = $logEntry->insert();
+
+ if ( $error ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Could not map workflowId to workflow object for ' . $workflowId->getAlphadecimal() . " log entry $logId defaulted to Main_Page");
+ }
+
+ return $logId;
+ }
+
+ /**
+ * @param PostRevision $post
+ * @param string $action
+ * @return string
+ */
+ public function getLogType( PostRevision $post, $action ) {
+ $logType = $this->actions->getValue( $action, 'log_type' );
+ if ( $logType instanceof Closure) {
+ $logType = $logType( $post, $this );
+ }
+
+ return $logType;
+ }
+}
diff --git a/Flow/includes/Log/Query.php b/Flow/includes/Log/Query.php
new file mode 100644
index 00000000..256c92b4
--- /dev/null
+++ b/Flow/includes/Log/Query.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Flow\Log;
+
+use Flow\Formatter\AbstractQuery;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+
+class LogQuery extends AbstractQuery {
+ /**
+ * @param UUID[] $uuids
+ */
+ public function loadMetadataBatch( $uuids ) {
+ $posts = $this->loadPostsBatch( $uuids );
+ parent::loadMetadataBatch( $posts );
+ }
+
+ /**
+ * @param UUID[] $uuids
+ * @return PostRevision[]
+ */
+ protected function loadPostsBatch( array $uuids ) {
+ $queries = array();
+ foreach ( $uuids as $uuid ) {
+ $queries[] = array( 'rev_type_id' => $uuid );
+ }
+
+ $found = $this->storage->findMulti(
+ 'PostRevision',
+ $queries,
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+
+ $revisions = array();
+ foreach ( $found as $result ) {
+ /** @var PostRevision $revision */
+ $revision = reset( $result );
+ $revisions[$revision->getPostId()->getAlphadecimal()] = $revision;
+ }
+
+ return $revisions;
+ }
+}
diff --git a/Flow/includes/Model/AbstractRevision.php b/Flow/includes/Model/AbstractRevision.php
new file mode 100644
index 00000000..5906c83f
--- /dev/null
+++ b/Flow/includes/Model/AbstractRevision.php
@@ -0,0 +1,716 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Collection\AbstractCollection;
+use Flow\Exception\DataModelException;
+use Flow\Exception\PermissionException;
+use Flow\Parsoid\Utils;
+use Title;
+use User;
+
+abstract class AbstractRevision {
+ const MODERATED_NONE = '';
+ const MODERATED_HIDDEN = 'hide';
+ const MODERATED_DELETED = 'delete';
+ const MODERATED_SUPPRESSED = 'suppress';
+ const MODERATED_LOCKED = 'lock';
+
+ /**
+ * List of available permission levels.
+ *
+ * @var string[]
+ **/
+ static public $perms = array(
+ self::MODERATED_NONE,
+ self::MODERATED_HIDDEN,
+ self::MODERATED_DELETED,
+ self::MODERATED_SUPPRESSED,
+ self::MODERATED_LOCKED,
+ );
+
+ /**
+ * @var UUID
+ */
+ protected $revId;
+
+ /**
+ * @var UserTuple
+ */
+ protected $user;
+
+ /**
+ * Array of flags strictly related to the content. Flags are reset when
+ * content changes.
+ *
+ * @var string[]
+ */
+ protected $flags = array();
+
+ /**
+ * Name of the action performed that generated this revision.
+ *
+ * @see FlowActions.php
+ * @var string
+ */
+ protected $changeType;
+
+ /**
+ * @var UUID|null The id of the revision prior to this one, or null if this is first revision
+ */
+ protected $prevRevision;
+
+ /**
+ * @var string Raw content of revision
+ */
+ protected $content;
+
+ /**
+ * @var string|null Only populated when external store is in use
+ */
+ protected $contentUrl;
+
+ /**
+ * @var string|null This is decompressed on-demand from $this->content in self::getContent()
+ */
+ protected $decompressedContent;
+
+ /**
+ * @var string[] Converted (wikitext|html) content, based off of $this->decompressedContent
+ */
+ protected $convertedContent = array();
+
+ /**
+ * html content has been allowed by the xss check. When we find the next xss
+ * in the parser this hook allows preventing any display of hostile html. True
+ * means the content is allowed. False means not allowed. Null means unchecked
+ *
+ * @var boolean
+ */
+ protected $xssCheck;
+
+ /**
+ * moderation states for the revision. This is technically denormalized data
+ * since it can be overwritten and does not provide a full history.
+ * The tricky part is updating moderation is a new revision for hide and
+ * delete, but adjusts an existing revision for full suppression.
+ *
+ * @var string
+ */
+ protected $moderationState = self::MODERATED_NONE;
+
+ /**
+ * @var string|null
+ */
+ protected $moderationTimestamp;
+
+ /**
+ * @var UserTuple|null
+ */
+ protected $moderatedBy;
+
+ /**
+ * @var string|null
+ */
+ protected $moderatedReason;
+
+ /**
+ * @var UUID|null The id of the last content edit revision
+ */
+ protected $lastEditId;
+
+ /**
+ * @var UserTuple|null
+ */
+ protected $lastEditUser;
+
+ /**
+ * @var integer Size of previous revision wikitext
+ */
+ protected $previousContentLength = 0;
+
+ /**
+ * @var integer Size of current revision wikitext
+ */
+ protected $contentLength = 0;
+
+ /**
+ * @param string[] $row
+ * @param AbstractRevision|null $obj
+ * @return AbstractRevision
+ * @throws DataModelException
+ */
+ static public function fromStorageRow( array $row, $obj = null ) {
+ if ( $obj === null ) {
+ /** @var AbstractRevision $obj */
+ $obj = new static;
+ } elseif ( !$obj instanceof static ) {
+ throw new DataModelException( 'wrong object type', 'process-data' );
+ }
+ $obj->revId = UUID::create( $row['rev_id'] );
+ $obj->user = UserTuple::newFromArray( $row, 'rev_user_' );
+ if ( $obj->user === null ) {
+ throw new DataModelException( 'Could not load UserTuple for rev_user_' );
+ }
+ $obj->prevRevision = UUID::create( $row['rev_parent_id'] );
+ $obj->changeType = $row['rev_change_type'];
+ $obj->flags = array_filter( explode( ',', $row['rev_flags'] ) );
+ $obj->content = $row['rev_content'];
+ // null if external store is not being used
+ $obj->contentUrl = isset( $row['rev_content_url'] ) ? $row['rev_content_url'] : null;
+ $obj->decompressedContent = null;
+
+ $obj->moderationState = $row['rev_mod_state'];
+ $obj->moderatedBy = UserTuple::newFromArray( $row, 'rev_mod_user_' );
+ $obj->moderationTimestamp = $row['rev_mod_timestamp'];
+ $obj->moderatedReason = isset( $row['rev_mod_reason'] ) ? $row['rev_mod_reason'] : null;
+
+ // BC: 'suppress' used to be called 'censor' & 'lock' was 'close'
+ $bc = array(
+ 'censor' => AbstractRevision::MODERATED_SUPPRESSED,
+ 'close' => AbstractRevision::MODERATED_LOCKED,
+ );
+ $obj->moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $obj->moderationState );
+
+ // isset required because there is a possible db migration, cached data will not have it
+ $obj->lastEditId = isset( $row['rev_last_edit_id'] ) ? UUID::create( $row['rev_last_edit_id'] ) : null;
+ $obj->lastEditUser = UserTuple::newFromArray( $row, 'rev_edit_user_' );
+
+ $obj->contentLength = isset( $row['rev_content_length'] ) ? $row['rev_content_length'] : 0;
+ $obj->previousContentLength = isset( $row['rev_previous_content_length'] ) ? $row['rev_previous_content_length'] : 0;
+
+ return $obj;
+ }
+
+ /**
+ * @param AbstractRevision $obj
+ * @return string[]
+ */
+ static public function toStorageRow( $obj ) {
+ return array(
+ 'rev_id' => $obj->revId->getAlphadecimal(),
+ 'rev_user_id' => $obj->user->id,
+ 'rev_user_ip' => $obj->user->ip,
+ 'rev_user_wiki' => $obj->user->wiki,
+ 'rev_parent_id' => $obj->prevRevision ? $obj->prevRevision->getAlphadecimal() : null,
+ 'rev_change_type' => $obj->changeType,
+ 'rev_type' => $obj->getRevisionType(),
+ 'rev_type_id' => $obj->getCollectionId()->getAlphadecimal(),
+
+ 'rev_content' => $obj->content,
+ 'rev_content_url' => $obj->contentUrl,
+ 'rev_flags' => implode( ',', $obj->flags ),
+
+ 'rev_mod_state' => $obj->moderationState,
+ 'rev_mod_user_id' => $obj->moderatedBy ? $obj->moderatedBy->id : null,
+ 'rev_mod_user_ip' => $obj->moderatedBy ? $obj->moderatedBy->ip : null,
+ 'rev_mod_user_wiki' => $obj->moderatedBy ? $obj->moderatedBy->wiki : null,
+ 'rev_mod_timestamp' => $obj->moderationTimestamp,
+ 'rev_mod_reason' => $obj->moderatedReason,
+
+ 'rev_last_edit_id' => $obj->lastEditId ? $obj->lastEditId->getAlphadecimal() : null,
+ 'rev_edit_user_id' => $obj->lastEditUser ? $obj->lastEditUser->id : null,
+ 'rev_edit_user_ip' => $obj->lastEditUser ? $obj->lastEditUser->ip : null,
+ 'rev_edit_user_wiki' => $obj->lastEditUser ? $obj->lastEditUser->wiki : null,
+
+ 'rev_content_length' => $obj->contentLength,
+ 'rev_previous_content_length' => $obj->previousContentLength,
+ );
+ }
+
+ /**
+ * NOTE: No guarantee is made here regarding if $this is the newest revision. Validation
+ * must happen externally. DB *will* throw an exception if this attempts to write to db
+ * and it is not the most recent revision.
+ *
+ * @param User $user
+ * @return AbstractRevision
+ * @throws PermissionException
+ */
+ public function newNullRevision( User $user ) {
+ if ( !$user->isAllowed( 'edit' ) ) {
+ throw new PermissionException( 'User does not have core edit permission', 'insufficient-permission' );
+ }
+ $obj = clone $this;
+ $obj->revId = UUID::create();
+ $obj->user = UserTuple::newFromUser( $user );
+ $obj->prevRevision = $this->revId;
+ $obj->changeType = '';
+ $obj->previousContentLength = $obj->contentLength;
+
+ return $obj;
+ }
+
+ /**
+ * Create the next revision with new content
+ *
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param string $changeType
+ * @param Title $title The article title of the related workflow
+ * @return AbstractRevision
+ */
+ public function newNextRevision( User $user, $content, $format, $changeType, Title $title ) {
+ $obj = $this->newNullRevision( $user );
+ $obj->setNextContent( $user, $content, $format, $title );
+ $obj->changeType = $changeType;
+ return $obj;
+ }
+
+ /**
+ * @param User $user
+ * @param string $state
+ * @param string $changeType
+ * @param string $reason
+ * @return AbstractRevision
+ */
+ public function moderate( User $user, $state, $changeType, $reason ) {
+ if ( ! $this->isValidModerationState( $state ) ) {
+ wfWarn( __METHOD__ . ': Provided moderation state does not exist : ' . $state );
+ return null;
+ }
+
+ $obj = $this->newNullRevision( $user );
+ $obj->changeType = $changeType;
+
+ // This is a bit hacky, but we store the restore reason
+ // in the "moderated reason" field. Hmmph.
+ $obj->moderatedReason = $reason;
+ $obj->moderationState = $state;
+
+ if ( $state === self::MODERATED_NONE ) {
+ $obj->moderatedBy = null;
+ $obj->moderationTimestamp = null;
+ } else {
+ $obj->moderatedBy = UserTuple::newFromUser( $user );
+ $obj->moderationTimestamp = $obj->revId->getTimestamp();
+ }
+
+ // all moderation levels past lock report a size of 0
+ if ( $obj->isModerated() && !$obj->isLocked() ) {
+ $obj->contentLength = 0;
+ } else {
+ // reset content length (we may be restoring, in which case $obj's
+ // current length will be 0)
+ $obj->contentLength = mb_strlen( $this->getContent( 'wikitext' ) );
+ }
+
+ return $obj;
+ }
+
+ /**
+ * @param string $state
+ * @return boolean
+ */
+ public function isValidModerationState( $state ) {
+ return in_array( $state, self::$perms );
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getRevisionId() {
+ return $this->revId;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function hasHiddenContent() {
+ return $this->moderationState === self::MODERATED_HIDDEN;
+ }
+
+ /**
+ * @return string
+ */
+ public function getContentRaw() {
+ if ( $this->decompressedContent === null ) {
+ $this->decompressedContent = \Revision::decompressRevisionText( $this->content, $this->flags );
+ }
+
+ return $this->decompressedContent;
+ }
+
+ /**
+ * DO NOT USE THIS METHOD to output the content; use
+ * Templating::getContent, which will do additional (permissions-based)
+ * checks to make sure it outputs something the user can see.
+ *
+ * @param string[optional] $format Format to output content in (html|wikitext)
+ * @return string
+ */
+ public function getContent( $format = 'html' ) {
+ if ( $this->xssCheck === false ) {
+ return '';
+ }
+ $raw = $this->getContentRaw();
+ $sourceFormat = $this->getContentFormat();
+ if ( $this->xssCheck === null && $sourceFormat === 'html' ) {
+ // returns true if no handler aborted the hook
+ $this->xssCheck = wfRunHooks( 'FlowCheckHtmlContentXss', array( $raw ) );
+ if ( !$this->xssCheck ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': XSS check prevented display of revision ' . $this->revId->getAlphadecimal() );
+ return '';
+ }
+ }
+
+ if ( !$this->isFormatted() ) {
+ return $raw;
+ }
+ if ( !isset( $this->convertedContent[$format] ) ) {
+ if ( $sourceFormat === $format ) {
+ $this->convertedContent[$format] = $raw;
+ } else {
+ $this->convertedContent[$format] = Utils::convert(
+ $sourceFormat,
+ $format,
+ $raw,
+ $this->getCollection()->getTitle()
+ );
+ }
+ }
+
+ return $this->convertedContent[$format];
+ }
+
+ /**
+ * @return UserTuple
+ */
+ public function getUserTuple() {
+ return $this->user;
+ }
+
+ /**
+ * @return integer
+ */
+ public function getUserId() {
+ return $this->user->id;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getUserIp() {
+ return $this->user->ip;
+ }
+
+ /**
+ * @return string
+ */
+ public function getUserWiki() {
+ return $this->user->wiki;
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() {
+ return $this->user->createUser();
+ }
+
+ /**
+ * Should only be used for setting the initial content. To set subsequent content
+ * use self::setNextContent
+ *
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param Title|null $title When null the related workflow will be lazy-loaded to locate the title
+ * @throws DataModelException
+ */
+ protected function setContent( $content, $format, Title $title = null ) {
+ if ( $this->moderationState !== self::MODERATED_NONE ) {
+ throw new DataModelException( 'TODO: Cannot change content of restricted revision', 'process-data' );
+ }
+
+ if ( $this->content !== null ) {
+ throw new DataModelException( 'Updating content must use setNextContent method', 'process-data' );
+ }
+
+ // never trust incoming html - roundtrip to wikitext first
+ if ( $format !== 'wikitext' ) {
+ $content = Utils::convert( $format, 'wikitext', $content, $title ?: $this->getCollection()->getTitle() );
+ $format = 'wikitext';
+ }
+
+ // Keep consistent with normal edit page, trim only trailing whitespaces
+ $content = rtrim( $content );
+ $this->convertedContent = array( $format => $content );
+
+ // convert content to desired storage format
+ $storageFormat = $this->getStorageFormat();
+ if ( $this->isFormatted() && $storageFormat !== $format ) {
+ $this->convertedContent[$storageFormat] = Utils::convert(
+ $format,
+ $storageFormat,
+ $content,
+ $title ?: $this->getCollection()->getTitle()
+ );
+ }
+
+ $this->content = $this->decompressedContent = $this->convertedContent[$storageFormat];
+ $this->contentUrl = null;
+
+ // should this only remove a subset of flags?
+ $this->flags = array_filter( explode( ',', \Revision::compressRevisionText( $this->content ) ) );
+ $this->flags[] = $storageFormat;
+
+ $this->contentLength = mb_strlen( $this->getContent( 'wikitext' ) );
+ }
+
+ /**
+ * Apply new content to a revision.
+ *
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param Title|null $title When null the related workflow will be lazy-loaded to locate the title
+ * @throws DataModelException
+ */
+ protected function setNextContent( User $user, $content, $format, Title $title = null ) {
+ if ( $this->moderationState !== self::MODERATED_NONE ) {
+ throw new DataModelException( 'Cannot change content of restricted revision', 'process-data' );
+ }
+ if ( $content !== $this->getContent() ) {
+ $this->content = null;
+ $this->setContent( $content, $format, $title );
+ $this->lastEditId = $this->getRevisionId();
+ $this->lastEditUser = UserTuple::newFromUser( $user );
+ }
+ }
+
+ /**
+ * Determines whether this revision contains formatted content
+ * (i.e. content with separate HTML and WikiText representations)
+ * or unformatted content (i.e. one plaintext representation)
+ * Note that this function may return different values for different
+ * instances of the same class.
+ *
+ * @return boolean True for formatted, False for plaintext
+ */
+ protected function isFormatted() {
+ return true;
+ }
+
+ /**
+ * @return string The content format of this revision
+ */
+ public function getContentFormat() {
+ return in_array( 'html', $this->flags ) ? 'html' : 'wikitext';
+ }
+
+ /**
+ * Determines the appropriate format to store content in.
+ * Usually, the default storage format, but if isFormatted() returns
+ * false, then it will return 'wikitext'.
+ * NOTE: The format of the current content is retrieved with getContentFormat
+ *
+ * @return string The name of the storage format.
+ */
+ protected function getStorageFormat() {
+ global $wgFlowContentFormat;
+ return $this->isFormatted() ? $wgFlowContentFormat : 'wikitext';
+ }
+
+ /**
+ * @return UUID|null
+ */
+ public function getPrevRevisionId() {
+ return $this->prevRevision;
+ }
+
+ /**
+ * @return string
+ */
+ public function getChangeType() {
+ return $this->changeType;
+ }
+
+ /**
+ * @return string
+ */
+ public function getModerationState() {
+ return $this->moderationState;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getModeratedReason() {
+ return $this->moderatedReason;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isModerated() {
+ return $this->moderationState !== self::MODERATED_NONE;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isHidden() {
+ return $this->moderationState === self::MODERATED_HIDDEN;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isDeleted() {
+ return $this->moderationState === self::MODERATED_DELETED;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isSuppressed() {
+ return $this->moderationState === self::MODERATED_SUPPRESSED;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isLocked() {
+ return $this->moderationState === self::MODERATED_LOCKED;
+ }
+
+ /**
+ * @return string|null Timestamp in TS_MW format
+ */
+ public function getModerationTimestamp() {
+ return $this->moderationTimestamp;
+ }
+
+ /**
+ * @param string|array $flags
+ * @return boolean True when at least one flag in $flags is set
+ */
+ public function isFlaggedAny( $flags ) {
+ foreach ( (array) $flags as $flag ) {
+ if ( false !== array_search( $flag, $this->flags ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @param string|array $flags
+ * @return boolean
+ */
+ public function isFlaggedAll( $flags ) {
+ foreach ( (array) $flags as $flag ) {
+ if ( false === array_search( $flag, $this->flags ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isFirstRevision() {
+ return $this->prevRevision === null;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isOriginalContent() {
+ return $this->lastEditId === null;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getLastContentEditId() {
+ return $this->lastEditId;
+ }
+
+ /**
+ * @return UserTuple
+ */
+ public function getLastContentEditUserTuple() {
+ return $this->lastEditUser;
+ }
+
+ /**
+ * @return integer
+ */
+ public function getLastContentEditUserId() {
+ return $this->lastEditUser ? $this->lastEditUser->id : null;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getLastContentEditUserIp() {
+ return $this->lastEditUser ? $this->lastEditUser->ip : null;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getLastContentEditUserWiki() {
+ return $this->lastEditUser ? $this->lastEditUser->wiki : null;
+ }
+
+ /**
+ * @return UserTuple
+ */
+ public function getModeratedByTuple() {
+ return $this->moderatedBy;
+ }
+
+ /**
+ * @return integer|null
+ */
+ public function getModeratedByUserId() {
+ return $this->moderatedBy ? $this->moderatedBy->id : null;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getModeratedByUserIp() {
+ return $this->moderatedBy ? $this->moderatedBy->ip : null;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getModeratedByUserWiki() {
+ return $this->moderatedBy ? $this->moderatedBy->wiki : null;
+ }
+
+ /**
+ * @return integer
+ */
+ public function getContentLength() {
+ return $this->contentLength;
+ }
+
+ /**
+ * @return integer
+ */
+ public function getPreviousContentLength() {
+ return $this->previousContentLength;
+ }
+
+ /**
+ * @return string
+ */
+ abstract public function getRevisionType();
+
+ /**
+ * @return UUID
+ */
+ abstract public function getCollectionId();
+
+ /**
+ * @return AbstractCollection
+ */
+ abstract public function getCollection();
+}
diff --git a/Flow/includes/Model/AbstractSummary.php b/Flow/includes/Model/AbstractSummary.php
new file mode 100644
index 00000000..ad12d647
--- /dev/null
+++ b/Flow/includes/Model/AbstractSummary.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Flow\Model;
+
+/**
+ * We share uuid for different entities in some cases. If both entities happen
+ * to have a summary, it's not easy to distinguish them with the same rev_type
+ */
+abstract class AbstractSummary extends AbstractRevision {
+
+ /**
+ * The id of the entity to be summarized
+ * @var UUID
+ */
+ protected $summaryTargetId;
+
+ static public function fromStorageRow( array $row, $obj = null ) {
+ /** @var $obj AbstractSummary */
+ $obj = parent::fromStorageRow( $row, $obj );
+ $obj->summaryTargetId = UUID::create( $row['rev_type_id'] );
+ return $obj;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getSummaryTargetId() {
+ return $this->summaryTargetId;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getCollectionId() {
+ return $this->getSummaryTargetId();
+ }
+
+}
diff --git a/Flow/includes/Model/Anchor.php b/Flow/includes/Model/Anchor.php
new file mode 100644
index 00000000..c4f697d8
--- /dev/null
+++ b/Flow/includes/Model/Anchor.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Flow\Model;
+
+use Html;
+use Message;
+use RawMessage;
+use Title;
+
+/**
+ * Represents a mutable anchor as a Message instance along with
+ * a title, query parameters, and a fragment.
+ */
+class Anchor {
+ /**
+ * Message used for the text of the anchor
+ *
+ * @var Message
+ */
+ protected $message;
+
+ /**
+ * Message used for the HTML title attribute of the anchor
+ *
+ * @var Message $titleMessage
+ */
+ protected $titleMessage;
+
+ /**
+ * Page title the anchor points to (not to be confused with title attribute)
+ *
+ * @var Title
+ */
+ public $title;
+
+ /**
+ * @var array
+ */
+ public $query = array();
+
+ /**
+ * @var string
+ */
+ public $fragment;
+
+ /**
+ * @param Message|string $message Text content of the anchor
+ * @param Title $title Page the anchor points to
+ * @param array $query Query parameters for the anchor
+ * @param string|null $fragment URL fragment of the anchor
+ * @param Message|string $htmlTitleMessage Title text of anchor
+ */
+ public function __construct( $message, Title $title, array $query = array(), $fragment = null, $htmlTitleMessage = null ) {
+ $this->title = $title;
+ $this->query = $query;
+ $this->fragment = $fragment;
+
+ $this->setTitleMessage( $htmlTitleMessage );
+ $this->setMessage( $message );
+ }
+
+ /**
+ * @return mixed
+ */
+ public function serializeForApiResult() {
+ return $this->toArray();
+ }
+
+ /**
+ * @return string
+ */
+ public function getLinkURL() {
+ return $this->resolveTitle()->getLinkURL( $this->query );
+ }
+
+ /**
+ * @return string
+ */
+ public function getLocalURL() {
+ return $this->resolveTitle()->getLocalURL( $this->query );
+ }
+
+ /**
+ * @return string
+ */
+ public function getFullURL() {
+ return $this->resolveTitle()->getFullURL( $this->query );
+ }
+
+ /**
+ * @return string
+ */
+ public function getCanonicalURL() {
+ return $this->resolveTitle()->getCanonicalURL( $this->query );
+ }
+
+ /**
+ * @param string|null $content Optional
+ * @return string HTML
+ */
+ public function toHtml( $content = null ) {
+ $text = $this->message->text();
+ $titleText = $this->getTitleMessage()->text();
+
+ // Should we instead use Linker?
+ return Html::element(
+ 'a',
+ array(
+ 'href' => $this->getLinkURL(),
+ 'title' => $titleText,
+ ),
+ $content === null ? $text : $content
+ );
+ }
+
+ public function toArray() {
+ return array(
+ 'url' => $this->getLinkURL(),
+ 'title' => $this->getTitleMessage()->text(), // Title text
+ 'text' => $this->message->text(), // Main text of link
+ );
+ }
+
+ /**
+ * @return Title
+ */
+ public function resolveTitle() {
+ $title = $this->title;
+ if ( $this->fragment !== null ) {
+ $title = clone $title;
+ $title->setFragment( $this->fragment );
+ }
+
+ return $title;
+ }
+
+ /**
+ * Canonicalizes and returns a message, or null if null was provided
+ *
+ * @param Message|string $message Message object, or text content, or null
+ * @return Message|null
+ */
+ protected function buildMessage( $rawMessage ) {
+ if ( $rawMessage instanceof Message || $rawMessage === null ) {
+ return $rawMessage;
+ } else {
+ // wrap non-messages into a message class
+ $message = new RawMessage( '$1' );
+ $message->plaintextParams( $rawMessage );
+ return $message;
+ }
+ }
+
+ /**
+ * Sets the text of the anchor. If title message is currently
+ * null, it will also set that.
+ *
+ * @param Message|string $message Text content of the anchor,
+ * as Message or text content.
+ */
+ public function setMessage( $message ) {
+ $this->message = $this->buildMessage( $message );
+ }
+
+ /**
+ * Sets the title attribute of the anchor
+ *
+ * @param Message|string $message Text for title attribute of anchor,
+ * as Message or text content.
+ */
+ public function setTitleMessage( $message ) {
+ $this->titleMessage = $this->buildMessage( $message );
+ }
+
+ /**
+ * Returns the effective title message. Takes into account defaulting
+ * to $this->message if there is none.
+ *
+ * @return Message Title message
+ */
+ protected function getTitleMessage() {
+ if ( $this->titleMessage !== null ) {
+ return $this->titleMessage;
+ } else {
+ return $this->message;
+ }
+ }
+}
diff --git a/Flow/includes/Model/Header.php b/Flow/includes/Model/Header.php
new file mode 100644
index 00000000..a0cd5b63
--- /dev/null
+++ b/Flow/includes/Model/Header.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Collection\HeaderCollection;
+use User;
+
+/**
+ * @Todo - Header is just a summary to the discussion workflow, it could be just
+ * migrated to Summary revision with rev_change_type: create-header-summary,
+ * edit-header-summary
+ */
+class Header extends AbstractRevision {
+
+ /**
+ * @var UUID
+ */
+ protected $workflowId;
+
+ /**
+ * @param Workflow $workflow
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param string[optional] $changeType
+ * @return Header
+ */
+ static public function create( Workflow $workflow, User $user, $content, $format, $changeType = 'create-header' ) {
+ $obj = new self;
+ $obj->revId = UUID::create();
+ $obj->workflowId = $workflow->getId();
+ $obj->user = UserTuple::newFromUser( $user );
+ $obj->prevRevision = null; // no prior revision
+ $obj->setContent( $content, $format, $workflow->getArticleTitle() );
+ $obj->changeType = $changeType;
+ return $obj;
+ }
+
+ /**
+ * @param string[] $row
+ * @param Header|null $obj
+ * @return Header
+ */
+ static public function fromStorageRow( array $row, $obj = null ) {
+ /** @var $obj Header */
+ $obj = parent::fromStorageRow( $row, $obj );
+ $obj->workflowId = UUID::create( $row['rev_type_id'] );
+ return $obj;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRevisionType() {
+ return 'header';
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getWorkflowId() {
+ return $this->workflowId;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getCollectionId() {
+ return $this->getWorkflowId();
+ }
+
+ /**
+ * @return HeaderCollection
+ */
+ public function getCollection() {
+ return HeaderCollection::newFromRevision( $this );
+ }
+
+ public function getObjectId() {
+ return $this->getWorkflowId();
+ }
+}
diff --git a/Flow/includes/Model/PostRevision.php b/Flow/includes/Model/PostRevision.php
new file mode 100644
index 00000000..64683683
--- /dev/null
+++ b/Flow/includes/Model/PostRevision.php
@@ -0,0 +1,389 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Collection\PostCollection;
+use Flow\Container;
+use Flow\Exception\DataModelException;
+use Flow\Repository\TreeRepository;
+use Title;
+use User;
+
+class PostRevision extends AbstractRevision {
+ const MAX_TOPIC_LENGTH = 260;
+ const MAX_POST_LENGTH = 25600;
+
+ /**
+ * @var UUID
+ */
+ protected $postId;
+
+ // The rest of the properties are denormalized data that
+ // must not change between revisions of same post
+
+ /**
+ * @var UserTuple
+ */
+ protected $origUser;
+
+ /**
+ * @var UUID|null
+ */
+ protected $replyToId;
+
+ /**
+ * @var PostRevision[]|null Optionally loaded list of children for this post.
+ */
+ protected $children;
+
+ /**
+ * @var int|null Optionally loaded distance of this post from the
+ * root of this post tree.
+ */
+ protected $depth;
+
+ /**
+ * @var PostRevision|null Optionally loaded root of this posts tree.
+ * This is always a topic title.
+ */
+ protected $rootPost;
+
+ /**
+ * Create a brand new root post for a brand new topic. Creating replies to
+ * an existing post(incl topic root) should use self::reply.
+ *
+ * @param Workflow $topic
+ * @param User $user
+ * @param string $content The title of the topic(they are Collection as well)
+ * @param string $format wikitext|html
+ * @return PostRevision
+ */
+ static public function create( Workflow $topic, User $user, $content, $format ) {
+ $obj = static::newFromId( $topic->getId(), $user, $content, $format, $topic->getArticleTitle() );
+
+ $obj->changeType = 'new-post';
+ // A newly created post has no children, a depth of 0, and
+ // is the root of its tree.
+ $obj->setChildren( array() );
+ $obj->setDepth( 0 );
+ $obj->rootPost = $obj;
+
+ return $obj;
+ }
+
+ /**
+ * DO NOT USE THIS METHOD!
+ *
+ * Seriously, you probably don't want to use this method, except from within
+ * this class.
+ *
+ * Although it may seem similar to Title::newFrom* or User::newFrom*, chances are slim to none
+ * that this will do what you'd expect.
+ *
+ * Unlike Title & User etc, a post is not something some object that can be
+ * used in isolation: a post should always be retrieved via it's parents,
+ * via a workflow, ...
+ *
+ * The only reasons we have this method are for creating root posts
+ * (called from PostRevision->create), and so when failing to load a
+ * post, we can create a stub object.
+ *
+ * @param UUID $uuid
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param Title|null $title
+ * @return PostRevision
+ */
+ static public function newFromId( UUID $uuid, User $user, $content, $format, Title $title = null ) {
+ $obj = new self;
+ $obj->revId = UUID::create();
+ $obj->postId = $uuid;
+
+ $obj->user = UserTuple::newFromUser( $user );
+ $obj->origUser = $obj->user;
+
+ $obj->setReplyToId( null ); // not a reply to anything
+ $obj->prevRevision = null; // no parent revision
+ $obj->setContent( $content, $format, $title );
+
+ return $obj;
+ }
+
+ /**
+ * @var string[] $row
+ * @var PostRevision|null $obj
+ * @return PostRevision
+ * @throws DataModelException
+ */
+ static public function fromStorageRow( array $row, $obj = null ) {
+ /** @var $obj PostRevision */
+ $obj = parent::fromStorageRow( $row, $obj );
+ $treeRevId = UUID::create( $row['tree_rev_id'] );
+ if ( ! $obj->revId->equals( $treeRevId ) ) {
+ throw new DataModelException(
+ 'tree revision doesn\'t match provided revision: '
+ . $treeRevId->getAlphadecimal() . ' != ' . $obj->revId->getAlphadecimal(),
+ 'process-data'
+ );
+ }
+ $obj->replyToId = UUID::create( $row['tree_parent_id'] );
+ $obj->postId = UUID::create( $row['rev_type_id'] );
+ $obj->origUser = UserTuple::newFromArray( $row, 'tree_orig_user_' );
+ if ( !$obj->origUser ) {
+ throw new DataModelException( 'Could not create UserTuple for tree_orig_user_' );
+ }
+ return $obj;
+ }
+
+ /**
+ * @param PostRevision $rev
+ * @return string[]
+ */
+ static public function toStorageRow( $rev ) {
+ return parent::toStorageRow( $rev ) + array(
+ 'tree_parent_id' => $rev->replyToId ? $rev->replyToId->getAlphadecimal() : null,
+ 'tree_rev_descendant_id' => $rev->postId->getAlphadecimal(),
+ 'tree_rev_id' => $rev->revId->getAlphadecimal(),
+ // rest of tree_ is denormalized data about first post revision
+ 'tree_orig_user_id' => $rev->origUser->id,
+ 'tree_orig_user_ip' => $rev->origUser->ip,
+ 'tree_orig_user_wiki' => $rev->origUser->wiki,
+ );
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param string[optional] $changeType
+ * @return PostRevision
+ */
+ public function reply( Workflow $workflow, User $user, $content, $format, $changeType = 'reply' ) {
+ $reply = new self;
+
+ // UUIDs should not be reused for different entities/entity types in the future.
+ // (It is also inconsistent with newFromId, which uses separate ones.)
+ // This may be changed here in the future.
+ $reply->revId = $reply->postId = UUID::create();
+
+ $reply->user = UserTuple::newFromUser( $user );
+ $reply->origUser = $reply->user;
+ $reply->replyToId = $this->postId;
+ $reply->setContent( $content, $format, $workflow->getArticleTitle() );
+ $reply->changeType = $changeType;
+ $reply->setChildren( array() );
+ $reply->setDepth( $this->getDepth() + 1 );
+ $reply->rootPost = $this->rootPost;
+
+ return $reply;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getPostId() {
+ return $this->postId;
+ }
+
+ /**
+ * @return UserTuple
+ */
+ public function getCreatorTuple() {
+ return $this->origUser;
+ }
+
+ /**
+ * Get the user ID of the user who created this post.
+ *
+ * @return integer The user ID
+ */
+ public function getCreatorId() {
+ return $this->origUser->id;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCreatorWiki() {
+ return $this->origUser->wiki;
+ }
+
+ /**
+ * Get the user ip of the user who created this post if it
+ * was created by an anonymous user
+ *
+ * @return string|null String if an creator is anon, or null if not.
+ */
+ public function getCreatorIp() {
+ return $this->origUser->ip;
+ }
+
+ /**
+ * @return boolean
+ */
+ public function isTopicTitle() {
+ return $this->replyToId === null;
+ }
+
+ /**
+ * @param UUID|null $id
+ */
+ public function setReplyToId( UUID $id = null ) {
+ $this->replyToId = $id;
+ }
+
+ /**
+ * @return UUID|null Id of the parent post, or null if this is the root
+ */
+ public function getReplyToId() {
+ return $this->replyToId;
+ }
+
+ /**
+ * @param PostRevision[] $children
+ */
+ public function setChildren( array $children ) {
+ $this->children = $children;
+ if ( $this->rootPost ) {
+ // Propagate root post into children.
+ $this->setRootPost( $this->rootPost );
+ }
+ }
+
+ /**
+ * @return PostRevision[]
+ * @throws DataModelException
+ */
+ public function getChildren() {
+ if ( $this->children === null ) {
+ throw new DataModelException( 'Children not loaded for post: ' . $this->postId->getAlphadecimal(), 'process-data' );
+ }
+ return $this->children;
+ }
+
+ /**
+ * @param integer $depth
+ */
+ public function setDepth( $depth ) {
+ $this->depth = (int)$depth;
+ }
+
+ /**
+ * @return integer
+ * @throws DataModelException
+ */
+ public function getDepth() {
+ if ( $this->depth === null ) {
+ /** @var TreeRepository $treeRepo */
+ $treeRepo = Container::get( 'repository.tree' );
+ $rootPath = $treeRepo->findRootPath( $this->getCollectionId() );
+ $this->setDepth( count( $rootPath ) - 1 );
+ }
+
+ return $this->depth;
+ }
+
+ /**
+ * @param PostRevision $root
+ * @deprecated Use PostCollection::getRoot instead
+ */
+ public function setRootPost( PostRevision $root ) {
+ $this->rootPost = $root;
+ if ( $this->children ) {
+ // Propagate root post into children.
+ foreach ( $this->children as $child ) {
+ $child->setRootPost( $root );
+ }
+ }
+ }
+
+ /**
+ * @return PostRevision
+ * @throws DataModelException
+ * @deprecated Use PostCollection::getRoot instead
+ */
+ public function getRootPost() {
+ if ( $this->isTopicTitle() ) {
+ return $this;
+ } elseif ( $this->rootPost === null ) {
+ $collection = $this->getCollection();
+ $root = $collection->getRoot();
+ return $root->getLastRevision();
+ }
+ return $this->rootPost;
+ }
+
+ /**
+ * Get the amount of posts in this topic.
+ *
+ * @return int
+ */
+ public function getChildCount() {
+ return count( $this->getChildren() );
+ }
+
+ /**
+ * Finds the provided postId within this posts descendants
+ *
+ * @param UUID $postId The id of the post to find.
+ * @return PostRevision|null
+ * @throws SomethingException
+ */
+ public function getDescendant( UUID $postId ) {
+ if ( $this->children === null ) {
+ throw new Exception;
+ }
+ foreach ( $this->children as $child ) {
+ if ( $child->getPostId()->equals( $postId ) ) {
+ return $child;
+ }
+ $found = $child->getDescendant( $postId );
+ if ( $found !== null ) {
+ return $found;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRevisionType() {
+ return 'post';
+ }
+
+ /**
+ * @return boolean Posts are unformatted if they are title posts, formatted otherwise.
+ */
+ public function isFormatted() {
+ return !$this->isTopicTitle();
+ }
+
+ /**
+ * @param User $user
+ * @return boolean
+ */
+ public function isCreator( User $user ) {
+ if ( $user->isAnon() ) {
+ return false;
+ }
+ return $user->getId() == $this->getCreatorId();
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getCollectionId() {
+ return $this->getPostId();
+ }
+
+ /**
+ * @return PostCollection
+ */
+ public function getCollection() {
+ return PostCollection::newFromRevision( $this );
+ }
+}
diff --git a/Flow/includes/Model/PostSummary.php b/Flow/includes/Model/PostSummary.php
new file mode 100644
index 00000000..d3282e95
--- /dev/null
+++ b/Flow/includes/Model/PostSummary.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Collection\PostSummaryCollection;
+use Title;
+use User;
+
+class PostSummary extends AbstractSummary {
+
+ /**
+ * @param Title $title
+ * @param PostRevision $post
+ * @param User $user
+ * @param string $content
+ * @param string $format wikitext|html
+ * @param string $changeType
+ * @return PostSummary
+ */
+ static public function create( Title $title, PostRevision $post, User $user, $content, $format, $changeType ) {
+ $obj = new self;
+ $obj->revId = UUID::create();
+ $obj->user = UserTuple::newFromUser( $user );
+ $obj->prevRevision = null;
+ $obj->changeType = $changeType;
+ $obj->summaryTargetId = $post->getPostId();
+ $obj->setContent( $content, $format, $title );
+ return $obj;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRevisionType() {
+ return 'post-summary';
+ }
+
+ /**
+ * @return PostSummaryCollection
+ */
+ public function getCollection() {
+ return PostSummaryCollection::newFromRevision( $this );
+ }
+
+}
diff --git a/Flow/includes/Model/Reference.php b/Flow/includes/Model/Reference.php
new file mode 100644
index 00000000..8dbe7ae7
--- /dev/null
+++ b/Flow/includes/Model/Reference.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\InvalidReferenceException;
+use Title;
+
+abstract class Reference {
+ const TYPE_LINK = 'link';
+
+ /**
+ * @var UUID
+ */
+ protected $workflowId;
+
+ /**
+ * @var Title
+ */
+ protected $srcTitle;
+
+ /**
+ * @var String
+ */
+ protected $objectType;
+
+ /**
+ * @var UUID
+ */
+ protected $objectId;
+
+ /**
+ * @var string
+ */
+ protected $type;
+
+ protected $validTypes = array( self::TYPE_LINK );
+
+ /**
+ * Standard constructor. Called from subclasses only
+ *
+ * @param UUID $srcWorkflow Source Workflow's ID
+ * @param Title $srcTitle Title of the Workflow from which this reference comes.
+ * @param String $objectType Output of getRevisionType for the AbstractRevision that this reference comes from.
+ * @param UUID $objectId Unique identifier for the revisioned object containing the reference.
+ * @param string $type The type of reference
+ * @throws InvalidReferenceException
+ */
+ protected function __construct( UUID $srcWorkflow, Title $srcTitle, $objectType, UUID $objectId, $type ) {
+ $this->workflowId = $srcWorkflow;
+ $this->objectType = $objectType;
+ $this->objectId = $objectId;
+ $this->type = $type;
+ $this->srcTitle = $srcTitle;
+
+ if ( !in_array( $type, $this->validTypes ) ) {
+ throw new InvalidReferenceException(
+ "Invalid type $type specified for reference " . get_class( $this )
+ );
+ }
+ }
+
+ /**
+ * Gives the UUID of the source Workflow
+ *
+ * @return UUID
+ */
+ public function getWorkflowId() {
+ return $this->workflowId;
+ }
+
+ /**
+ * Gives the Title from which this Reference comes.
+ *
+ * @return Title
+ */
+ public function getSrcTitle() {
+ return $this->srcTitle;
+ }
+
+ /**
+ * Gives the object type of the source object.
+ */
+ public function getObjectType() {
+ return $this->objectType;
+ }
+
+ /**
+ * Gives the UUID of the source object
+ *
+ * @return UUID
+ */
+ public function getObjectId() {
+ return $this->objectId;
+ }
+
+ /**
+ * Gives the type of Reference
+ *
+ * @return string
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * Returns the storage row for this Reference.
+ * For this abstract reference, only partial.
+ *
+ * @return array
+ */
+ public function getStorageRow() {
+ return array(
+ 'ref_src_workflow_id' => $this->workflowId->getAlphadecimal(),
+ 'ref_src_namespace' => $this->srcTitle->getNamespace(),
+ 'ref_src_title' => $this->srcTitle->getDBkey(),
+ 'ref_src_object_type' => $this->objectType,
+ 'ref_src_object_id' => $this->objectId->getAlphadecimal(),
+ 'ref_type' => $this->type,
+ );
+ }
+
+ /**
+ * @return string Unique string identifier for the target of this reference.
+ */
+ abstract public function getTargetIdentifier();
+
+ public function getIdentifier() {
+ return $this->getType() . ':' . $this->getTargetIdentifier();
+ }
+
+ public function getUniqueIdentifier() {
+ return $this->getSrcTitle() . '|' .
+ $this->getObjectType() . '|' .
+ $this->getObjectId()->getAlphadecimal() . '|' .
+ $this->getIdentifier();
+ }
+
+
+ /**
+ * We don't have a real PK (see comment in
+ * ReferenceClarifier::loadReferencesForPage) but I'll do a array_unique on
+ * multiple Reference objects, just to make sure we have no duplicates.
+ * But to be able to do an array_unique, the objects will be compared as
+ * strings.
+ *
+ * @return string
+ */
+ public function __toString() {
+ return $this->getUniqueIdentifier();
+ }
+}
diff --git a/Flow/includes/Model/TopicListEntry.php b/Flow/includes/Model/TopicListEntry.php
new file mode 100644
index 00000000..5bab54ca
--- /dev/null
+++ b/Flow/includes/Model/TopicListEntry.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\DataModelException;
+
+// TODO: We shouldn't need this class
+class TopicListEntry {
+
+ /**
+ * @var UUID
+ */
+ protected $topicListId;
+
+ /**
+ * @var UUID
+ */
+ protected $topicId;
+
+ /**
+ * @var string|null
+ */
+ protected $topicWorkflowLastUpdated;
+
+ /**
+ * @param Workflow $topicList
+ * @param Workflow $topic
+ * @return TopicListEntry
+ */
+ static public function create( Workflow $topicList, Workflow $topic ) {
+ // die( var_dump( array(
+ // 'topicList' => $topicList,
+ // 'topic' => $topic,
+ // )));
+ $obj = new self;
+ $obj->topicListId = $topicList->getId();
+ $obj->topicId = $topic->getId();
+ $obj->topicWorkflowLastUpdated = $topic->getLastModified();
+ return $obj;
+ }
+
+ /**
+ * @param array $row
+ * @param TopicListEntry|null $obj
+ * @return TopicListEntry
+ * @throws DataModelException
+ */
+ static public function fromStorageRow( array $row, $obj = null ) {
+ if ( $obj === null ) {
+ $obj = new self;
+ } elseif ( !$obj instanceof self ) {
+ throw new DataModelException( 'Wrong obj type: ' . get_class( $obj ), 'process-data' );
+ }
+ $obj->topicListId = UUID::create( $row['topic_list_id'] );
+ $obj->topicId = UUID::create( $row['topic_id'] );
+ if ( isset( $row['workflow_last_update_timestamp'] ) ) {
+ $obj->topicWorkflowLastUpdated = $row['workflow_last_update_timestamp'];
+ }
+ return $obj;
+ }
+
+ /**
+ * @param TopicListEntry $obj
+ * @return array
+ */
+ static public function toStorageRow( TopicListEntry $obj ) {
+ $row = array(
+ 'topic_list_id' => $obj->topicListId->getAlphadecimal(),
+ 'topic_id' => $obj->topicId->getAlphadecimal(),
+ );
+ if ( $obj->topicWorkflowLastUpdated ) {
+ $row['workflow_last_update_timestamp'] = $obj->topicWorkflowLastUpdated;
+ }
+ return $row;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getId() {
+ return $this->topicId;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getListId() {
+ return $this->topicListId;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getTopicWorkflowLastUpdated() {
+ return $this->topicWorkflowLastUpdated;
+ }
+}
+
diff --git a/Flow/includes/Model/URLReference.php b/Flow/includes/Model/URLReference.php
new file mode 100644
index 00000000..da36658f
--- /dev/null
+++ b/Flow/includes/Model/URLReference.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\InvalidReferenceException;
+use Title;
+
+class URLReference extends Reference {
+ protected $url;
+
+ /**
+ * @param UUID $srcWorkflow ID of the source Workflow
+ * @param Title $srcTitle Title of the page that the Workflow exists on
+ * @param String $objectType Output of getRevisionType for the AbstractRevision that this reference comes from.
+ * @param UUID $objectId Unique identifier for the revisioned object containing the reference.
+ * @param string $type Type of reference
+ * @param string $url URL of the reference's target.
+ * @throws InvalidReferenceException
+ */
+ public function __construct( UUID $srcWorkflow, Title $srcTitle, $objectType, UUID $objectId, $type, $url ) {
+ $this->url = $url;
+
+ if ( !is_array( wfParseUrl( $url ) ) ) {
+ throw new InvalidReferenceException(
+ "Invalid URL $url specified for reference " . get_class( $this )
+ );
+ }
+
+ parent::__construct( $srcWorkflow, $srcTitle, $objectType, $objectId, $type );
+ }
+
+ /**
+ * Gets the storage row for this URLReference
+ *
+ * @return array
+ */
+ public function getStorageRow() {
+ return parent::getStorageRow() + array(
+ 'ref_target' => $this->url,
+ );
+ }
+
+ /**
+ * Instantiates a URLReference object from a storage row.
+ *
+ * @param \StdClass $row
+ * @return URLReference
+ */
+ public static function fromStorageRow( $row ) {
+ $workflow = UUID::create( $row['ref_src_workflow_id'] );
+ $objectType = $row['ref_src_object_type'];
+ $objectId = UUID::create( $row['ref_src_object_id'] );
+ $url = $row['ref_target'];
+ $type = $row['ref_type'];
+ $srcTitle = Title::makeTitle( $row['ref_src_namespace'], $row['ref_src_title'] );
+
+ return new URLReference( $workflow, $srcTitle, $objectType, $objectId, $type, $url );
+ }
+
+ /**
+ * Gets the storage row from an object.
+ * Helper for BasicObjectMapper.
+ */
+ public static function toStorageRow( URLReference $object ) {
+ return $object->getStorageRow();
+ }
+
+ public function getUrl() {
+ return $this->url;
+ }
+
+ public function getTargetIdentifier() {
+ return 'url:' . $this->getUrl();
+ }
+}
diff --git a/Flow/includes/Model/UUID.php b/Flow/includes/Model/UUID.php
new file mode 100644
index 00000000..33539d2d
--- /dev/null
+++ b/Flow/includes/Model/UUID.php
@@ -0,0 +1,463 @@
+<?php
+
+namespace Flow\Model;
+
+use Blob;
+use Flow\Data\ObjectManager;
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidInputException;
+use Language;
+use MWTimestamp;
+use TimestampException;
+use User;
+
+/**
+ * Immutable class modeling timestamped UUID's from
+ * the core UIDGenerator.
+ *
+ * @todo probably should be UID since these dont match the UUID standard
+ */
+class UUID {
+ /**
+ * @var UUID[][][]
+ */
+ private static $instances;
+
+ /**
+ * binary UUID string
+ *
+ * @var string
+ */
+ protected $binaryValue;
+
+ /**
+ * base16 representation
+ *
+ * @var string
+ */
+ protected $hexValue;
+
+ /**
+ * base36 representation
+ *
+ * @var string
+ */
+ protected $alphadecimalValue;
+
+ /**
+ * Timestamp uuid was created
+ *
+ * @var MWTimestamp|null
+ */
+ protected $timestamp;
+
+ /**
+ * Acceptable input values for constructor.
+ * Values are the property names the input data will be saved to.
+ *
+ * @var string
+ */
+ const INPUT_BIN = 'binaryValue',
+ INPUT_HEX = 'hexValue',
+ INPUT_ALNUM = 'alphadecimalValue';
+
+ // UUID length in hex, always padded
+ const HEX_LEN = 22;
+ // UUID length in binary, always padded
+ const BIN_LEN = 11;
+ // UUID length in base36, with padding
+ const ALNUM_LEN = 19;
+ // unpadded base36 input string
+ const MIN_ALNUM_LEN = 16;
+
+ // 126 bit binary length
+ const OLD_BIN_LEN = 16;
+ // 128 bit hex length
+ const OLD_HEX_LEN = 32;
+
+ /**
+ * Constructs a UUID object based on either the binary, hex or alphanumeric
+ * representation.
+ *
+ * @param string $value UUID value
+ * @param string $format UUID format (static::INPUT_BIN, static::input_HEX
+ * or static::input_ALNUM)
+ * @throws InvalidInputException
+ */
+ protected function __construct( $value, $format ) {
+ if ( !in_array( $format, array( static::INPUT_BIN, static::INPUT_HEX, static::INPUT_ALNUM ) ) ) {
+ throw new InvalidInputException( 'Invalid UUID input format: ' . $format, 'invalid-input' );
+ }
+
+ // doublecheck validity of inputs, based on pre-determined lengths
+ $len = strlen( $value );
+ if ( $format === static::INPUT_BIN && $len !== self::BIN_LEN ) {
+ throw new InvalidInputException( 'Expected ' . self::BIN_LEN . ' char binary string, got: ' . $value, 'invalid-input' );
+ } elseif ( $format === static::INPUT_HEX && $len !== self::HEX_LEN ) {
+ throw new InvalidInputException( 'Expected ' . self::HEX_LEN . ' char hex string, got: ' . $value, 'invalid-input' );
+ } elseif ( $format === static::INPUT_ALNUM && ( $len < self::MIN_ALNUM_LEN || $len > self::ALNUM_LEN || !ctype_alnum( $value ) ) ) {
+ throw new InvalidInputException( 'Expected ' . self::MIN_ALNUM_LEN . ' to ' . self::ALNUM_LEN . ' char alphanumeric string, got: ' . $value, 'invalid-input' );
+ }
+
+ // If this is not a binary UUID, reject any string containing upper case characters.
+ if ( $format !== self::INPUT_BIN && $value !== strtolower( $value ) ) {
+ throw new InvalidInputException( 'Input UUID strings must be lowercase', 'invalid-input' );
+ }
+ self::$instances[$format][$value] = $this;
+ $this->{$format} = $value;
+ }
+
+ /**
+ * Alphanumeric value is all we need to construct a UUID object; saving
+ * anything more is just wasted storage/bandwidth.
+ *
+ * @return string[]
+ */
+ public function __sleep() {
+ // ensure alphadecimal is populated
+ $this->getAlphadecimal();
+ return array( 'alphadecimalValue' );
+ }
+
+ public function __wakeup() {
+ // some B/C code
+ // if we have outdated data, correct it and purge all other properties
+ if ( $this->binaryValue && strlen( $this->binaryValue ) !== self::BIN_LEN ) {
+ $this->binaryValue = substr( $this->binaryValue, 0, self::BIN_LEN );
+ $this->hexValue = null;
+ $this->alphadecimalValue = null;
+ }
+ if ( $this->alphadecimalValue ) {
+ // Bug 71377 was writing invalid uuid's into cache with an upper cased first letter. We
+ // added code in the constructor to prevent them from being created, but since this is
+ // coming from cache lets just fix them and move on with the request.
+ // We don't do a comparison first since we would have to lowercase the string to check
+ // anyways.
+ $this->alphadecimalValue = strtolower( $this->alphadecimalValue );
+ }
+ }
+
+ /**
+ * Returns a UUID objects based on given input. Will automatically try to
+ * determine the input format of the given $input or fail with an exception.
+ *
+ * @param mixed $input
+ * @return UUID|null
+ * @throws InvalidInputException
+ */
+ static public function create( $input = false ) {
+ // Most calls to UUID::create are binary strings, check string first
+ if ( is_string( $input ) || is_int( $input ) || $input === false ) {
+ if ( $input === false ) {
+ // new uuid in base 16 and pad to HEX_LEN with 0's
+ $hexValue = str_pad( \UIDGenerator::newTimestampedUID88( 16 ), self::HEX_LEN, '0', STR_PAD_LEFT );
+ return new static( $hexValue, static::INPUT_HEX );
+ } else {
+ $len = strlen( $input );
+ if ( $len === self::BIN_LEN ) {
+ $value = $input;
+ $type = static::INPUT_BIN;
+ } elseif ( $len >= self::MIN_ALNUM_LEN && $len <= self::ALNUM_LEN && ctype_alnum( $input ) ) {
+ $value = $input;
+ $type = static::INPUT_ALNUM;
+ } elseif ( $len === self::HEX_LEN && ctype_xdigit( $input ) ) {
+ $value = $input;
+ $type = static::INPUT_HEX;
+ } elseif ( $len === self::OLD_BIN_LEN ) {
+ $value = substr( $input, 0, self::BIN_LEN );
+ $type = static::INPUT_BIN;
+ } elseif ( $len === self::OLD_HEX_LEN && ctype_xdigit( $input ) ) {
+ $value = substr( $input, 0, self::HEX_LEN );
+ $type = static::INPUT_HEX;
+ } elseif ( is_numeric( $input ) ) {
+ // convert base 10 to base 16 and pad to HEX_LEN with 0's
+ $value = wfBaseConvert( $input, 10, 16, self::HEX_LEN );
+ $type = static::INPUT_HEX;
+ } else {
+ throw new InvalidInputException( 'Unknown input to UUID class', 'invalid-input' );
+ }
+
+ if ( isset( self::$instances[$type][$value] ) ) {
+ return self::$instances[$type][$value];
+ } else {
+ return new static( $value, $type );
+ }
+ }
+ } else if ( is_object( $input ) ) {
+ if ( $input instanceof UUID ) {
+ return $input;
+ } elseif ( $input instanceof Blob ) {
+ return self::create( $input->fetch() );
+ } else {
+ throw new InvalidInputException( 'Unknown input of type ' . get_class( $input ), 'invalid-input' );
+ }
+ } elseif ( $input === null ) {
+ return null;
+ } else {
+ throw new InvalidInputException( 'Unknown input type to UUID class: ' . gettype( $input ), 'invalid-input' );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ wfWarn( __METHOD__ . ': UUID __toString auto-converted to alphaDecimal; please do manually.' );
+
+ return $this->getAlphadecimal();
+ }
+
+ /**
+ * @return mixed
+ */
+ public function serializeForApiResult() {
+ return $this->getAlphadecimal();
+ }
+
+ /**
+ * @return Blob|string UUID encoded in binary format for database storage
+ * @throws FlowException
+ */
+ public function getBinary() {
+ if ( $this->binaryValue !== null ) {
+ return $this->encodeBlob( $this->binaryValue );
+ } elseif ( $this->hexValue !== null ) {
+ $this->binaryValue = static::hex2bin( $this->hexValue );
+ } elseif ( $this->alphadecimalValue !== null ) {
+ $this->hexValue = static::alnum2hex( $this->alphadecimalValue );
+ self::$instances[self::INPUT_HEX][$this->hexValue] = $this;
+ $this->binaryValue = static::hex2bin( $this->hexValue );
+ } else {
+ throw new FlowException( 'No binary, hex or alphadecimal value available' );
+ }
+ self::$instances[self::INPUT_BIN][$this->binaryValue] = $this;
+ // finally, encode the blob for database storage. This value
+ // may be a Blob object and unusable as an array key.
+ return $this->encodeBlob( $this->binaryValue );
+ }
+
+ /**
+ * Gets the UUID in hexadecimal format.
+ * Should not be used in Flow itself, but is useful in the PHP debug shell
+ * in conjunction with LOWER(HEX('...')) in MySQL.
+ *
+ * @return string
+ */
+ public function getHex() {
+ if ( $this->hexValue !== null ) {
+ return $this->hexValue;
+ } elseif ( $this->binaryValue !== null ) {
+ $this->hexValue = static::bin2hex( $this->binaryValue );
+ } elseif ( $this->alphadecimalValue !== null ) {
+ $this->hexValue = static::alnum2hex( $this->alphadecimalValue );
+ }
+ self::$instances[self::INPUT_HEX][$this->hexValue] = $this;
+ return $this->hexValue;
+ }
+
+ /**
+ * @return string base 36 representation
+ */
+ public function getAlphadecimal() {
+ if ( $this->alphadecimalValue !== null ) {
+ return $this->alphadecimalValue;
+ } elseif ( $this->hexValue !== null ) {
+ $this->alphadecimalValue = static::hex2alnum( $this->hexValue );
+ } elseif ( $this->binaryValue !== null ) {
+ $this->hexValue = static::bin2hex( $this->binaryValue );
+ self::$instances[self::INPUT_HEX][$this->hexValue] = $this;
+ $this->alphadecimalValue = static::hex2alnum( $this->hexValue );
+ }
+ self::$instances[self::INPUT_ALNUM][$this->alphadecimalValue] = $this;
+ return $this->alphadecimalValue;
+ }
+
+ /**
+ * @return MWTimestamp
+ * @throws TimestampException
+ */
+ public function getTimestampObj() {
+ if ( $this->timestamp === null ) {
+ try {
+ $this->timestamp = new MWTimestamp( self::hex2timestamp( $this->getHex() ) );
+ } catch ( TimestampException $e ) {
+ $alnum = $this->getAlphadecimal();
+ wfDebugLog( 'Flow', __METHOD__ . ": bogus time value: UUID=$alnum" );
+ throw $e;
+ }
+ }
+ return clone $this->timestamp;
+ }
+
+ /**
+ * Returns the timestamp in the desired format (defaults to TS_MW)
+ *
+ * @param int $format Desired format (TS_MW, TS_UNIX, etc.)
+ * @return string
+ */
+ public function getTimestamp( $format = TS_MW ) {
+ $ts = $this->getTimestampObj();
+ return $ts->getTimestamp( $format );
+ }
+
+ /**
+ * @param UUID|MWTimestamp|null $relativeTo
+ * @param User|null $user
+ * @param Language|null $lang
+ * @return string|false
+ * @throws InvalidInputException
+ */
+ public function getHumanTimestamp( $relativeTo = null, User $user = null, Language $lang = null ) {
+ if ( $relativeTo instanceof UUID ) {
+ $rel = $relativeTo->getTimestampObj();
+ } elseif ( $relativeTo instanceof MWTimestamp ) {
+ $rel = $relativeTo;
+ } else {
+ throw new InvalidInputException( 'Expected MWTimestamp or UUID, got ' . get_class( $relativeTo ), 'invalid-input' );
+ }
+ $ts = $this->getTimestampObj();
+ return $ts ? $ts->getHumanTimestamp( $rel, $user, $lang ) : false;
+ }
+
+ /**
+ * Takes an array of rows going to/from the database/cache. Converts uuid and
+ * things that look like uuids into the requested format.
+ *
+ * @param array $array
+ * @param string $format
+ * @return string[]|Blob[] Typically an array of strings. If required by the database when
+ * $format === 'binary' uuid values will be represented as Blob objects.
+ */
+ public static function convertUUIDs( $array, $format = 'binary' ) {
+ $array = ObjectManager::makeArray( $array );
+ foreach( $array as $key => $value ) {
+ if ( $value instanceof Blob ) {
+ // database encoded binary value
+ if ( $format === 'alphadecimal' ) {
+ $array[$key] = UUID::create( $value->fetch() )->getAlphadecimal();
+ }
+ } elseif ( $value instanceof UUID ) {
+ if ( $format === 'binary' ) {
+ $array[$key] = $value->getBinary();
+ } elseif ( $format === 'alphadecimal' ) {
+ $array[$key] = $value->getAlphadecimal();
+ }
+ } elseif ( is_string( $value ) && substr( $key, -3 ) === '_id' ) {
+ // things that look like uuids
+ $len = strlen( $value );
+ if ( $format === 'alphadecimal' && $len === self::BIN_LEN ) {
+ $array[$key] = UUID::create( $value )->getAlphadecimal();
+ } elseif ( $format === 'binary' && (
+ ( $len >= self::MIN_ALNUM_LEN && $len <= self::ALNUM_LEN )
+ ||
+ $len === self::HEX_LEN
+ ) ) {
+ // Note that if a value is a binary string, but needs to be encoded
+ // for the database, that is unhandled here. A patch is under
+ // consideration to allow binary data to always be wrapped in a Blob
+ // to clear up this inconsistency.
+ $array[$key] = UUID::create( $value )->getBinary();
+ }
+ }
+ }
+
+ return $array;
+ }
+
+ /**
+ * @param UUID|null $other
+ * @return boolean
+ */
+ public function equals( UUID $other = null ) {
+ return $other && $other->getAlphadecimal() === $this->getAlphadecimal();
+ }
+
+ /**
+ * Generates a fake UUID for a given timestamp that will have comparison
+ * results equivalent to a real UUID generated at that time
+ * @param mixed $ts Something accepted by wfTimestamp()
+ * @return UUID object.
+ */
+ public static function getComparisonUUID( $ts ) {
+ // It should be comparable with UUIDs in binary mode.
+ // Easiest way to do this is to take the 46 MSBs of the UNIX timestamp * 1000
+ // and pad the remaining characters with zeroes.
+ $millitime = wfTimestamp( TS_UNIX, $ts ) * 1000;
+ // base 10 -> base 2, taking 46 bits
+ $timestampBinary = wfBaseConvert( $millitime, 10, 2, 46 );
+ // pad out the 46 bits to binary len with 0's
+ $uuidBase2 = str_pad( $timestampBinary, self::BIN_LEN * 8, '0', STR_PAD_RIGHT );
+ // base 2 -> base 16
+ $uuidHex = wfBaseConvert( $uuidBase2, 2, 16, self::HEX_LEN );
+
+ return self::create( $uuidHex );
+ }
+
+ /**
+ * Converts binary UUID to HEX.
+ *
+ * @param string $binary Binary string (not a string of 1s & 0s)
+ * @return string
+ */
+ public static function bin2hex( $binary ) {
+ return str_pad( bin2hex( $binary ), self::HEX_LEN, '0', STR_PAD_LEFT );
+ }
+
+ /**
+ * Converts alphanumeric UUID to HEX.
+ *
+ * @param string $alnum
+ * @return string
+ */
+ public static function alnum2hex( $alnum ) {
+ return str_pad( wfBaseConvert( $alnum, 36, 16 ), self::HEX_LEN, '0', STR_PAD_LEFT );
+ }
+
+ /**
+ * Convert HEX UUID to binary string.
+ *
+ * @param string $hex
+ * @return string Binary string (not a string of 1s & 0s)
+ */
+ public static function hex2bin( $hex ) {
+ return pack( 'H*', $hex );
+ }
+
+ /**
+ * Converts HEX UUID to alphanumeric.
+ *
+ * @param string $hex
+ * @return string
+ */
+ public static function hex2alnum( $hex ) {
+ return wfBaseConvert( $hex, 16, 36 );
+ }
+
+ /**
+ * Converts a binary uuid into a MWTimestamp. This UUID must have
+ * been generated with \UIDGenerator::newTimestampedUID88.
+ *
+ * @param string $hex
+ * @return integer Number of seconds since epoch
+ */
+ public static function hex2timestamp( $hex ) {
+ $msTimestamp = hexdec( substr( $hex, 0, 12 ) ) >> 2;
+ return intval( $msTimestamp / 1000 );
+ }
+
+ /**
+ * encode a binary string for database storage
+ *
+ * @param string
+ * @return Blob|string
+ */
+ protected function encodeBlob( $binary ) {
+ static $dbr;
+ if ( $dbr === null ) {
+ // assume the any potential database we connect to is
+ // the same as this slave.
+ $dbr = wfGetDB( DB_SLAVE );
+ }
+ return $dbr->encodeBlob( $binary );
+ }
+}
diff --git a/Flow/includes/Model/UserTuple.php b/Flow/includes/Model/UserTuple.php
new file mode 100644
index 00000000..dcfc0a74
--- /dev/null
+++ b/Flow/includes/Model/UserTuple.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\CrossWikiException;
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidDataException;
+use User;
+
+/**
+ * Small value object holds the values necessary to uniquely identify
+ * a user across multiple wiki's.
+ */
+class UserTuple {
+ /**
+ * @param string The wiki the user belongs to
+ */
+ public $wiki;
+
+ /**
+ * @param integer The id of the user, or 0 for anonymous
+ */
+ public $id;
+
+ /**
+ * @param string|null The ip of the user, null if logged in.
+ */
+ public $ip;
+
+ /**
+ * @param string $wiki The wiki the user belongs to
+ * @param integer|string $id The id of the user, or 0 for anonymous
+ * @param string|null $ip The ip of the user, blank string for no ip.
+ * null special case pass-through to be removed.
+ * @throws InvalidDataException
+ */
+ public function __construct( $wiki, $id, $ip ) {
+ if ( !is_integer( $id ) ) {
+ if ( ctype_digit( $id ) ) {
+ $id = (int)$id;
+ } else {
+ throw new InvalidDataException( 'User id must be an integer' );
+ }
+ }
+ if ( $id < 0 ) {
+ throw new InvalidDataException( 'User id must be >= 0' );
+ }
+ if ( !$wiki ) {
+ throw new InvalidDataException( 'No wiki provided' );
+ }
+ if ( $id === 0 && strlen( $ip ) === 0 ) {
+ throw new InvalidDataException( 'User has no id and no ip' );
+ }
+ if ( $id !== 0 && strlen( $ip ) !== 0 ) {
+ throw new InvalidDataException( 'User has both id and ip' );
+ }
+ // @todo assert ip is ipv4 or ipv6, but do we really want
+ // that on every anon user we load from storage?
+
+ $this->wiki = $wiki;
+ $this->id = $id;
+ $this->ip = (string)$ip ?: null;
+ }
+
+ public static function newFromUser( User $user ) {
+ return new self(
+ wfWikiId(),
+ $user->getId(),
+ $user->isAnon() ? $user->getName() : null
+ );
+ }
+
+ public static function newFromArray( array $user, $prefix = '' ) {
+ $wiki = "{$prefix}wiki";
+ $id = "{$prefix}id";
+ $ip = "{$prefix}ip";
+
+ if (
+ isset( $user[$wiki] )
+ && array_key_exists( $id, $user ) && array_key_exists( $ip, $user )
+ // $user[$id] === 0 is special case when when IRC formatter mocks up objects
+ && ( $user[$id] || $user[$ip] || $user[$id] === 0 )
+ ) {
+ return new self( $user["{$prefix}wiki"], $user["{$prefix}id"], $user["{$prefix}ip"] );
+ } else {
+ return null;
+ }
+ }
+
+ public function toArray( $prefix = '' ) {
+ return array(
+ "{$prefix}wiki" => $this->wiki,
+ "{$prefix}id" => $this->id,
+ "{$prefix}ip" => $this->ip
+ );
+ }
+
+ public function createUser() {
+ if ( $this->wiki !== wfWikiId() ) {
+ throw new CrossWikiException( 'Can only retrieve same-wiki users' );
+ }
+ if ( $this->id ) {
+ return User::newFromId( $this->id );
+ } elseif ( !$this->ip ) {
+ throw new FlowException( 'Either $userId or $userIp must be set.' );
+ } else {
+ return User::newFromName( $this->ip, /* $validate = */ false );
+ }
+ }
+}
diff --git a/Flow/includes/Model/WikiReference.php b/Flow/includes/Model/WikiReference.php
new file mode 100644
index 00000000..43c4f086
--- /dev/null
+++ b/Flow/includes/Model/WikiReference.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\InvalidInputException;
+use Title;
+
+class WikiReference extends Reference {
+ const TYPE_FILE = 'file';
+ const TYPE_TEMPLATE = 'template';
+ const TYPE_CATEGORY = 'category';
+
+ protected $target;
+
+ /**
+ * @param UUID $srcWorkflow ID of the source Workflow
+ * @param Title $srcTitle Title of the reference's target.
+ * @param string $objectType Output of getRevisionType for the AbstractRevision that this reference comes from.
+ * @param UUID $objectId Unique identifier for the revisioned object containing the reference.
+ * @param string $type Type of reference
+ * @param Title $targetTitle Title of the reference's target.
+ */
+ public function __construct( UUID $srcWorkflow, Title $srcTitle, $objectType, UUID $objectId, $type, Title $targetTitle ) {
+ $this->target = $targetTitle;
+
+ $this->validTypes = array_merge( $this->validTypes,
+ array(
+ self::TYPE_FILE,
+ self::TYPE_TEMPLATE,
+ self::TYPE_CATEGORY,
+ )
+ );
+
+ parent::__construct( $srcWorkflow, $srcTitle, $objectType, $objectId, $type );
+ }
+
+ /**
+ * Gets the storage row for this WikiReference
+ *
+ * @return array
+ */
+ public function getStorageRow() {
+ return parent::getStorageRow() + array(
+ 'ref_target_namespace' => $this->target->getNamespace(),
+ 'ref_target_title' => $this->target->getDBkey(),
+ );
+ }
+
+ /**
+ * Instantiates a WikiReference object from a storage row.
+ *
+ * @param \StdClass $row
+ * @return WikiReference
+ */
+ public static function fromStorageRow( $row ) {
+ $workflow = UUID::create( $row['ref_src_workflow_id'] );
+ $objectType = $row['ref_src_object_type'];
+ $objectId = UUID::create( $row['ref_src_object_id'] );
+ $srcTitle = self::makeTitle( $row['ref_src_namespace'], $row['ref_src_title'] );
+ $targetTitle = self::makeTitle( $row['ref_target_namespace'], $row['ref_target_title'] );
+ $type = $row['ref_type'];
+
+ return new WikiReference( $workflow, $srcTitle, $objectType, $objectId, $type, $targetTitle );
+ }
+
+ /**
+ * Gets the storage row from an object.
+ * Helper for BasicObjectMapper.
+ */
+ public static function toStorageRow( WikiReference $object ) {
+ return $object->getStorageRow();
+ }
+
+ /**
+ * Many loaded references typically point to the same Title, cache those instead
+ * of generating a bunch of duplicate title classes.
+ */
+ public static function makeTitle( $namespace, $title ) {
+ try {
+ return Workflow::getFromTitleCache( wfWikiId(), $namespace, $title );
+ } catch ( InvalidInputException $e ) {
+ // duplicate Title::makeTitleSafe which returns null on failure,
+ // but only for InvalidInputException
+ return null;
+ }
+ }
+
+ public function getTitle() {
+ return $this->target;
+ }
+
+ public function getTargetIdentifier() {
+ return 'title:' . $this->getTitle()->getPrefixedDBKey();
+ }
+}
diff --git a/Flow/includes/Model/Workflow.php b/Flow/includes/Model/Workflow.php
new file mode 100644
index 00000000..bb09926c
--- /dev/null
+++ b/Flow/includes/Model/Workflow.php
@@ -0,0 +1,296 @@
+<?php
+
+namespace Flow\Model;
+
+use Flow\Exception\CrossWikiException;
+use Flow\Exception\DataModelException;
+use Flow\Exception\InvalidInputException;
+use MapCacheLRU;
+use MWTimestamp;
+use Title;
+use User;
+
+class Workflow {
+
+ /**
+ * @var MapCacheLRU
+ */
+ private static $titleCache;
+
+ /**
+ * @var string[]
+ */
+ static private $allowedTypes = array( 'discussion', 'topic' );
+
+ /**
+ * @var UUID
+ */
+ protected $id;
+
+ /**
+ * @var boolean false before writing to storage
+ */
+ protected $isNew;
+
+ /**
+ * @var string e.g. topic, discussion, etc.
+ */
+ protected $type;
+
+ /**
+ * @var string
+ */
+ protected $wiki;
+
+ /**
+ * @var integer
+ */
+ protected $pageId;
+
+ /**
+ * @var integer
+ */
+ protected $namespace;
+
+ /**
+ * @var string
+ */
+ protected $titleText;
+
+ /**
+ * @var string
+ */
+ protected $lastModified;
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var Title
+ */
+ protected $ownerTitle;
+
+ /**
+ * @param array $row
+ * @param Workflow|null $obj
+ * @return Workflow
+ * @throws DataModelException
+ */
+ static public function fromStorageRow( array $row, $obj = null ) {
+ if ( $obj === null ) {
+ $obj = new self;
+ } elseif ( !$obj instanceof self ) {
+ throw new DataModelException( 'Wrong obj type: ' . get_class( $obj ), 'process-data' );
+ }
+ $obj->id = UUID::create( $row['workflow_id'] );
+ $obj->isNew = false;
+ $obj->type = $row['workflow_type'];
+ $obj->wiki = $row['workflow_wiki'];
+ $obj->pageId = $row['workflow_page_id'];
+ $obj->namespace = (int) $row['workflow_namespace'];
+ $obj->titleText = $row['workflow_title_text'];
+ $obj->lastModified = $row['workflow_last_update_timestamp'];
+
+ return $obj;
+ }
+
+ /**
+ * @param Workflow $obj
+ * @return array
+ */
+ static public function toStorageRow( Workflow $obj ) {
+ return array(
+ 'workflow_id' => $obj->id->getAlphadecimal(),
+ 'workflow_type' => $obj->type,
+ 'workflow_wiki' => $obj->wiki,
+ 'workflow_page_id' => $obj->pageId,
+ 'workflow_namespace' => $obj->namespace,
+ 'workflow_title_text' => $obj->titleText,
+ 'workflow_lock_state' => 0, // unused
+ 'workflow_last_update_timestamp' => $obj->lastModified,
+ // not used, but set it to empty string so it doesn't fail in strict mode
+ 'workflow_name' => '',
+ );
+ }
+
+ /**
+ * @param string $type
+ * @param Title $title
+ * @return Workflow
+ * @throws DataModelException
+ */
+ static public function create( $type, Title $title ) {
+ // temporary limitation until we implement something more concrete
+ if ( !in_array( $type, self::$allowedTypes ) ) {
+ throw new DataModelException( 'Invalid workflow type provided: ' . $type, 'process-data' );
+ }
+ if ( $title->isLocal() ) {
+ $wiki = wfWikiId();
+ } else {
+ $wiki = $title->getTransWikiID();
+ }
+
+ $obj = new self;
+ $obj->id = UUID::create();
+ $obj->isNew = true; // has not been persisted
+ $obj->type = $type;
+ $obj->wiki = $wiki;
+ $obj->pageId = $title->getArticleID();
+ $obj->namespace = $title->getNamespace();
+ $obj->titleText = $title->getDBkey();
+ $obj->updateLastModified( $obj->id );
+
+ return $obj;
+ }
+
+ /**
+ * Return the title this workflow responds at
+ *
+ * @return Title
+ * @throws CrossWikiException
+ */
+ public function getArticleTitle() {
+ if ( $this->title ) {
+ return $this->title;
+ }
+ // evil hax
+ if ( $this->type === 'topic' ) {
+ $namespace = NS_TOPIC;
+ $titleText = $this->id->getAlphadecimal();
+ } else {
+ $namespace = $this->namespace;
+ $titleText = $this->titleText;
+ }
+ return $this->title = self::getFromTitleCache( $this->wiki, $namespace, $titleText );
+ }
+
+ /**
+ * Return the title this workflow was created at
+ *
+ * @return Title
+ * @throws CrossWikiException
+ */
+ public function getOwnerTitle() {
+ if ( $this->ownerTitle ) {
+ return $this->ownerTitle;
+ }
+ return $this->ownerTitle = self::getFromTitleCache( $this->wiki, $this->namespace, $this->titleText );
+ }
+
+ /**
+ * Can't use the title cache in Title class, it only operates on default namespace
+ *
+ * @param string $wiki
+ * @param int $namespace
+ * @param string $titleText
+ * @return Title
+ * @throws CrossWikiException
+ * @throws InvalidInputException
+ */
+ public static function getFromTitleCache( $wiki, $namespace, $titleText ) {
+ if ( $wiki !== wfWikiId() ) {
+ $thisWiki = wfWikiId();
+ throw new CrossWikiException( "Interwiki to '$wiki' from '$thisWiki' not implemented", 'default' );
+ }
+ if ( self::$titleCache === null ) {
+ self::$titleCache = new MapCacheLRU( 50 );
+ }
+
+ $key = implode( '|', array( $wiki, $namespace, $titleText ) );
+ $title = self::$titleCache->get( $key );
+ if ( $title === null ) {
+ $title = Title::makeTitleSafe( $namespace, $titleText );
+ if ( $title ) {
+ self::$titleCache->set( $key, $title );
+ } else {
+ throw new InvalidInputException( 'Fail to create title from ' . $titleText, 'invalid-input' );
+ }
+ }
+
+ return $title;
+ }
+
+ /**
+ * @return UUID
+ */
+ public function getId() { return $this->id; }
+
+ /**
+ * @return string
+ */
+ public function getType() { return $this->type; }
+
+ /**
+ * Returns true if the workflow is new as of this request (regardless of
+ * whether or not is it already saved yet - that's unknown).
+ *
+ * @return boolean
+ */
+ public function isNew() { return (bool) $this->isNew; }
+
+ /**
+ * @return string
+ */
+ public function getLastModified() { return $this->lastModified; }
+
+ /**
+ * @return \MWTimestamp
+ */
+ public function getLastModifiedObj() { return new MWTimestamp( $this->lastModified ); }
+
+ public function updateLastModified( UUID $latestRevisionId ) {
+ $this->lastModified = $latestRevisionId->getTimestamp();
+ }
+
+ /**
+ * @return string
+ */
+ public function getNamespaceName() {
+ global $wgContLang;
+
+ return $wgContLang->getNsText( $this->namespace );
+ }
+
+ /**
+ * @return string
+ */
+ public function getTitleFullText() {
+ $ns = $this->getNamespaceName();
+ if ( $ns ) {
+ return $ns . ':' . $this->titleText;
+ } else {
+ return $this->titleText;
+ }
+ }
+
+ /**
+ * these are exceptions currently to make debugging easier
+ * it should return false later on to allow wider use.
+ *
+ * @param Title $title
+ * @return boolean
+ * @throws InvalidInputException
+ * @throws InvalidInputException
+ */
+ public function matchesTitle( Title $title ) {
+ return $this->getArticleTitle()->equals( $title );
+ }
+
+ /**
+ * @param string $permission
+ * @param User $user
+ * @return bool
+ */
+ public function userCan( $permission, $user ) {
+ $title = $this->getArticleTitle();
+ $allowed = $title->userCan( $permission, $user );
+ if ( $allowed && $this->type === 'topic' ) {
+ $allowed = $this->getOwnerTitle()->userCan( $permission, $user );
+ }
+
+ return $allowed;
+ }
+}
+
diff --git a/Flow/includes/Notifications/Controller.php b/Flow/includes/Notifications/Controller.php
new file mode 100644
index 00000000..3edd9008
--- /dev/null
+++ b/Flow/includes/Notifications/Controller.php
@@ -0,0 +1,520 @@
+<?php
+
+namespace Flow;
+
+use Flow\Data\ManagerGroup;
+use Flow\Exception\FlowException;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Parsoid\Utils;
+use EchoEvent;
+use Language;
+use Title;
+use User;
+
+class NotificationController {
+ /**
+ * @var Language
+ */
+ protected $language;
+
+ /**
+ * @param Language $language
+ */
+ public function __construct( Language $language ) {
+ $this->language = $language;
+ }
+
+ /**
+ * Set up Echo notification for Flow extension
+ */
+ public static function setup() {
+ global $wgHooks,
+ $wgEchoNotifications, $wgEchoNotificationIcons, $wgEchoNotificationCategories;
+
+ $wgHooks['EchoGetDefaultNotifiedUsers'][] = 'Flow\NotificationController::getDefaultNotifiedUsers';
+ $wgHooks['EchoGetBundleRules'][] = 'Flow\NotificationController::onEchoGetBundleRules';
+
+ /**
+ * Load notification definitions from file.
+ * @var $notifications array[]
+ */
+ require( __DIR__ . "/Notifications.php" );
+ $wgEchoNotifications += $notifications;
+
+ $wgEchoNotificationIcons['flow-discussion'] = array(
+ 'path' => array(
+ 'ltr' => 'Flow/modules/notification/icon/Talk-ltr.png',
+ 'rtl' => 'Flow/modules/notification/icon/Talk-rtl.png'
+ )
+ );
+
+ $wgEchoNotificationCategories['flow-discussion'] = array(
+ 'priority' => 3,
+ 'tooltip' => 'echo-pref-tooltip-flow-discussion',
+ );
+ }
+
+ /**
+ * Causes notifications to be fired for a Flow event.
+ * @param String $eventName The event that occurred. Choice of:
+ * * flow-post-reply
+ * * flow-topic-renamed
+ * * flow-post-edited
+ * @param array $data Associative array of parameters.
+ * * user: The user who made the change. Always required.
+ * * revision: The PostRevision created by the action. Always required.
+ * * title: The Title on which this Topic sits. Always required.
+ * * topic-workflow: The Workflow object for the topic. Always required.
+ * * reply-to: The UUID of the post that is being replied to. Required for replies.
+ * * topic-title: The Title of the Topic that the post belongs to. Required except for topic renames.
+ * * old-subject: The old subject of a Topic. Required for topic renames.
+ * * new-subject: The new subject of a Topic. Required for topic renames.
+ * @return array Array of created EchoEvent objects.
+ * @throws FlowException When $data contains unexpected types/values
+ */
+ public function notifyPostChange( $eventName, $data = array() ) {
+ if ( !class_exists( 'EchoEvent' ) ) {
+ return array();
+ }
+
+ $extraData = array();
+
+ $revision = $data['revision'];
+ if ( !$revision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
+ }
+ $topicRevision = $data['topic-title'];
+ if ( !$topicRevision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) );
+ }
+ $topicWorkflow = $data['topic-workflow'];
+ if ( !$topicWorkflow instanceof Workflow ) {
+ throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
+ }
+
+ $title = $data['title'];
+ $user = $revision->getUser();
+
+ $extraData['revision-id'] = $revision->getRevisionId();
+ $extraData['post-id'] = $revision->getPostId();
+ $extraData['topic-workflow'] = $topicWorkflow->getId();
+ $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID();
+
+ $newPost = null;
+ switch( $eventName ) {
+ case 'flow-post-reply':
+ $replyTo = $data['reply-to'];
+ if ( !$replyTo instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $replyTo ) );
+ }
+ $replyToPostId = $replyTo->getPostId();
+ $extraData += array(
+ 'reply-to' => $replyToPostId,
+ 'content' => Utils::htmlToPlaintext( $revision->getContent(), 200, $this->language ),
+ 'topic-title' => $this->language->truncate( trim( $topicRevision->getContent( 'wikitext' ) ), 200 ),
+ );
+ $newPost = array(
+ 'title' => $title,
+ 'user' => $user,
+ 'post' => $revision,
+ 'reply-to' => $replyToPostId,
+ 'topic-title' => $topicRevision,
+ 'topic-workflow' => $topicWorkflow,
+ );
+
+ break;
+ case 'flow-topic-renamed':
+ $extraData += array(
+ 'old-subject' => $this->language->truncate( trim( $topicRevision->getContent( 'wikitext' ) ), 200 ),
+ 'new-subject' => $this->language->truncate( trim( $revision->getContent( 'wikitext' ) ), 200 ),
+ );
+ break;
+ case 'flow-post-edited':
+ $extraData += array(
+ 'content' => Utils::htmlToPlaintext( $revision->getContent(), 200, $this->language ),
+ 'topic-title' => $this->language->truncate( trim( $topicRevision->getContent( 'wikitext' ) ), 200 ),
+ );
+ break;
+ }
+
+ $events = array(
+ EchoEvent::create( array(
+ 'type' => $eventName,
+ 'agent' => $user,
+ 'title' => $title,
+ 'extra' => $extraData,
+ ) ),
+ );
+
+ if ( $newPost ) {
+ $events = array_merge( $events, $this->notifyNewPost( $newPost ) );
+ }
+
+ return $events;
+ }
+
+ /**
+ * Triggers notifications for a new topic.
+ * @param array $params Associative array of parameters, all required:
+ * * board-workflow: Workflow object for the Flow board.
+ * * topic-workflow: Workflow object for the new Topic.
+ * * topic-title: PostRevision object for the "topic post", containing the
+ * title.
+ * * first-post: PostRevision object for the first post, or null when no first post.
+ * * user: The User who created the topic.
+ * @return array Array of created EchoEvent objects.
+ * @throws FlowException When $params contains unexpected types/values
+ */
+ public function notifyNewTopic( $params ) {
+ if ( ! class_exists( 'EchoEvent' ) ) {
+ // Nothing to do here.
+ return array();
+ }
+
+ $topicWorkflow = $params['topic-workflow'];
+ if ( !$topicWorkflow instanceof Workflow ) {
+ throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
+ }
+ $topicTitle = $params['topic-title'];
+ if ( !$topicTitle instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicTitle ) );
+ }
+ $firstPost = $params['first-post'];
+ if ( $firstPost !== null && !$firstPost instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $firstPost ) );
+ }
+ $user = $topicTitle->getUser();
+ $boardWorkflow = $params['board-workflow'];
+ if ( !$boardWorkflow instanceof Workflow ) {
+ throw new FlowException( 'Expected Workflow but received ' . get_class( $boardWorkflow ) );
+ }
+
+ $events = array();
+ $events[] = EchoEvent::create( array(
+ 'type' => 'flow-new-topic',
+ 'agent' => $user,
+ 'title' => $boardWorkflow->getArticleTitle(),
+ 'extra' => array(
+ 'board-workflow' => $boardWorkflow->getId(),
+ 'topic-workflow' => $topicWorkflow->getId(),
+ 'post-id' => $firstPost ? $firstPost->getRevisionId() : null,
+ 'topic-title' => Utils::htmlToPlaintext( $topicTitle->getContent(), 200, $this->language ),
+ 'content' => $firstPost
+ ? Utils::htmlToPlaintext( $firstPost->getContent(), 200, $this->language )
+ : null,
+ // Force a read from master database since this could be a new page
+ 'target-page' => $topicWorkflow->getOwnerTitle()->getArticleID( Title::GAID_FOR_UPDATE ),
+ )
+ ) );
+
+ return $events;
+ }
+
+ /**
+ * Called when a new Post is added, whether it be a new topic or a reply.
+ * Do not call directly, use notifyPostChange for new replies.
+ * @param array $data Associative array of parameters, all required:
+ * * title: Title for the page on which the new Post sits.
+ * * user: User who created the new Post.
+ * * post: The Post that was created.
+ * * topic-title: The title for the Topic.
+ * @return array Array of created EchoEvent objects.
+ * @throws FlowException When $data contains unexpected types/values
+ */
+ protected function notifyNewPost( $data ) {
+ // Handle mentions.
+ $newRevision = $data['post'];
+ if ( $newRevision !== null && !$newRevision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $newRevision ) );
+ }
+ $topicRevision = $data['topic-title'];
+ if ( !$topicRevision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) );
+ }
+ $title = $data['title'];
+ if ( !$title instanceof \Title ) {
+ throw new FlowException( 'Expected Title but received ' . get_class( $title ) );
+ }
+ $user = $data['user'];
+ $topicWorkflow = $data['topic-workflow'];
+ if ( !$topicWorkflow instanceof Workflow ) {
+ throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) );
+ }
+ $events = array();
+
+ $mentionedUsers = $newRevision ? $this->getMentionedUsers( $newRevision, $title ) : array();
+
+ if ( !$topicRevision instanceof PostRevision ) {
+ throw new FlowException( 'Expected PostRevision but received: ' . get_class( $topicRevision ) );
+ }
+
+ if ( count( $mentionedUsers ) ) {
+ $events[] = EchoEvent::create( array(
+ 'type' => 'flow-mention',
+ 'title' => $title,
+ 'extra' => array(
+ 'content' => $newRevision
+ ? Utils::htmlToPlaintext( $newRevision->getContent(), 200, $this->language )
+ : null,
+ 'topic-title' => $this->language->truncate( trim( $topicRevision->getContent( 'wikitext' ) ), 200 ),
+ 'post-id' => $newRevision ? $newRevision->getPostId() : null,
+ 'mentioned-users' => $mentionedUsers,
+ 'topic-workflow' => $topicWorkflow->getId(),
+ 'target-page' => $topicWorkflow->getArticleTitle()->getArticleID(),
+ 'reply-to' => isset( $data['reply-to'] ) ? $data['reply-to'] : null
+ ),
+ 'agent' => $user,
+ ) );
+ }
+
+ return $events;
+ }
+
+ /**
+ * Analyses a PostRevision to determine which users are mentioned.
+ *
+ * @param PostRevision $post The Post to analyse.
+ * @param \Title $title
+ * @return User[] Array of User objects.
+ */
+ protected function getMentionedUsers( $post, $title ) {
+ // At the moment, it is not possible to get a list of mentioned users from HTML
+ // unless that HTML comes from Parsoid. But VisualEditor (what is currently used
+ // to convert wikitext to HTML) does not currently use Parsoid.
+ $wikitext = $post->getContent( 'wikitext' );
+ $mentions = $this->getMentionedUsersFromWikitext( $wikitext );
+ $notifyUsers = $this->filterMentionedUsers( $mentions, $post, $title );
+
+ return $notifyUsers;
+ }
+
+ /**
+ * Process an array of users linked to in a comment into a list of users
+ * who should actually be notified.
+ *
+ * Removes duplicates, anonymous users, self-mentions, and mentions of the
+ * owner of the talk page
+ * @param User[] $mentions Array of User objects
+ * @param PostRevision $post The Post that is being examined.
+ * @param \Title $title The Title of the page that the comment is made on.
+ * @return array Array of user IDs
+ */
+ protected function filterMentionedUsers( $mentions, PostRevision $post, $title ) {
+ $outputMentions = array();
+ global $wgFlowMaxMentionCount;
+
+ foreach( $mentions as $mentionedUser ) {
+ // Don't notify anonymous users
+ if ( $mentionedUser->isAnon() ) {
+ continue;
+ }
+
+ // Don't notify the user who made the post
+ if ( $mentionedUser->getId() == $post->getUserId() ) {
+ continue;
+ }
+
+ if ( count( $outputMentions ) > $wgFlowMaxMentionCount ) {
+ break;
+ }
+
+ $outputMentions[$mentionedUser->getId()] = $mentionedUser->getId();
+ }
+
+ return $outputMentions;
+ }
+
+ /**
+ * Examines a wikitext string and finds users that were mentioned
+ * @param string $wikitext
+ * @return array Array of User objects
+ */
+ protected function getMentionedUsersFromWikitext( $wikitext ) {
+ global $wgParser;
+
+ $title = Title::newMainPage(); // Bogus title used for parser
+
+ $options = new \ParserOptions;
+ $options->setTidy( true );
+ $options->setEditSection( false );
+
+ $output = $wgParser->parse( $wikitext, $title, $options );
+
+ $links = $output->getLinks();
+
+ if ( ! isset( $links[NS_USER] ) || ! is_array( $links[NS_USER] ) ) {
+ // Nothing
+ return array();
+ }
+
+ $users = array();
+ foreach ( $links[NS_USER] as $dbk => $page_id ) {
+ $user = User::newFromName( $dbk );
+ if ( !$user || $user->isAnon() ) {
+ continue;
+ }
+
+ $users[$user->getId()] = $user;
+ // If more than 20 users are being notified this is probably a spam/attack vector.
+ // Don't send any mention notifications
+ if ( count( $users ) > 20 ) {
+ return array();
+ }
+ }
+
+ return $users;
+ }
+
+ /**
+ * Handler for EchoGetBundleRule hook, which defines the bundle rules for each notification
+ *
+ * @param $event EchoEvent
+ * @param $bundleString string Determines how the notification should be bundled
+ * @return boolean True for success
+ */
+ public static function onEchoGetBundleRules( $event, &$bundleString ) {
+ switch ( $event->getType() ) {
+ case 'flow-new-topic':
+ $board = $event->getExtraParam( 'board-workflow' );
+ if ( $board instanceof UUID ) {
+ $bundleString = $event->getType() . '-' . $board->getAlphadecimal();
+ }
+ break;
+
+ case 'flow-post-reply':
+ case 'flow-post-edited':
+ $topic = $event->getExtraParam( 'topic-workflow' );
+ if ( $topic instanceof UUID ) {
+ $bundleString = $event->getType() . '-' . $topic->getAlphadecimal();
+ }
+ break;
+ }
+ return true;
+ }
+
+ /**
+ * Handler for EchoGetDefaultNotifiedUsers hook
+ * Returns a list of User objects in the second param
+ *
+ * @param $event EchoEvent being triggered
+ * @param &$users Array of User objects.
+ * @return bool
+ */
+ public static function getDefaultNotifiedUsers( EchoEvent $event, &$users ) {
+ $extra = $event->getExtra();
+ switch ( $event->getType() ) {
+ case 'flow-mention':
+ $mentionedUsers = $extra['mentioned-users'];
+
+ // Ignore mention if the user gets another notification
+ // already from the same flow event
+ $ids = array();
+ $topic = $extra['topic-workflow'];
+ if ( $topic instanceof UUID ) {
+ $ids[$topic->getAlphadecimal()] = $topic;
+ }
+ if ( isset( $extra['reply-to'] ) ) {
+ if ( $extra['reply-to'] instanceof UUID ) {
+ $ids[$extra['reply-to']->getAlphadecimal()] = $extra['reply-to'];
+ } else {
+ wfDebugLog( 'Flow', __METHOD__ . ': Expected UUID but received ' . get_class( $extra['reply-to'] ) );
+ }
+ }
+ $notifiedUsers = self::getCreatorsFromPostIDs( $ids );
+
+ foreach( $mentionedUsers as $uid ) {
+ if ( !isset( $notifiedUsers[$uid] ) ) {
+ $users[$uid] = User::newFromId( $uid );
+ }
+ }
+ break;
+ case 'flow-topic-renamed':
+ $users += self::getCreatorsFromPostIDs( array( $extra['topic-workflow'] ) );
+ break;
+ case 'flow-post-edited':
+ case 'flow-post-moderated':
+ if ( isset( $extra['reply-to'] ) ) {
+ $postId = $extra['reply-to'];
+ } else {
+ $postId = $extra['post-id'];
+ }
+ if ( !$postId instanceof UUID ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Non-UUID value provided' );
+ break;
+ }
+
+ $users += self::getCreatorsFromPostIDs( array( $postId ) );
+ break;
+ default:
+ // Do nothing
+ }
+ return true;
+ }
+
+ /**
+ * Retrieves the post creators from a set of posts.
+ * @param array $posts Array of UUIDs or hex representations
+ * @return array Associative array, of user ID => User object.
+ */
+ protected static function getCreatorsFromPostIDs( array $posts ) {
+ $users = array();
+ /** @var ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+
+ $user = new User;
+ $actionPermissions = new RevisionActionPermissions( Container::get( 'flow_actions' ), $user );
+
+ foreach ( $posts as $postId ) {
+ $post = $storage->find(
+ 'PostRevision',
+ array(
+ 'rev_type_id' => UUID::create( $postId )
+ ),
+ array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => 1
+ )
+ );
+
+ $post = reset( $post );
+
+ if ( $post && $actionPermissions->isAllowed( $post, 'view' ) ) {
+ $userid = $post->getCreatorId();
+ if ( $userid ) {
+ $users[$userid] = User::newFromId( $userid );
+ }
+ }
+ }
+
+ return $users;
+ }
+
+ /**
+ * Get the owner of the page if the workflow belongs to a talk page
+ *
+ * @param string|UUID topic workflow id
+ * @param array
+ * @return array Map from userid to User object
+ */
+ protected static function getTalkPageOwner( $topicId ) {
+ $talkUser = array();
+ // Owner of talk page should always get a reply notification
+ /** @var Workflow|null $workflow */
+ $workflow = Container::get( 'storage' )
+ ->getStorage( 'Workflow' )
+ ->get( UUID::create( $topicId ) );
+ if ( $workflow ) {
+ $title = $workflow->getOwnerTitle();
+ if ( $title->isTalkPage() ) {
+ $user = User::newFromName( $title->getDBkey() );
+ if ( $user && $user->getId() ) {
+ $talkUser[$user->getId()] = $user;
+ }
+ }
+ }
+ return $talkUser;
+ }
+}
diff --git a/Flow/includes/Notifications/Formatter.php b/Flow/includes/Notifications/Formatter.php
new file mode 100644
index 00000000..cc032290
--- /dev/null
+++ b/Flow/includes/Notifications/Formatter.php
@@ -0,0 +1,248 @@
+<?php
+
+namespace Flow;
+
+use Flow\Exception\FlowException;
+use Flow\Model\Anchor;
+use Flow\Model\UUID;
+use Flow\Parsoid\Utils;
+use Flow\Model\Workflow;
+use EchoBasicFormatter;
+use EchoEvent;
+use Message;
+use Title;
+use User;
+
+// could be renamed later if we have more formatters
+class NotificationFormatter extends EchoBasicFormatter {
+ protected $urlGenerator;
+
+ protected function processParam( $event, $param, $message, $user ) {
+ $extra = $event->getExtra();
+ if ( $param === 'subject' ) {
+ if ( isset( $extra['topic-title'] ) && $extra['topic-title'] ) {
+ $this->processParamEscaped( $message, trim( $extra['topic-title'] ) );
+ } else {
+ $message->params( '' );
+ }
+ } elseif ( $param === 'commentText' ) {
+ if ( isset( $extra['content'] ) && $extra['content'] ) {
+ // @todo assumes content is html, make explicit
+ $message->params( Utils::htmlToPlaintext( $extra['content'], 200 ) );
+ } else {
+ $message->params( '' );
+ }
+ } elseif ( $param === 'post-permalink' ) {
+ $anchor = $this->getPostLinkAnchor( $event, $user );
+ if ( $anchor ) {
+ $message->params( $anchor->getFullUrl() );
+ } else {
+ $message->params( '' );
+ }
+ } elseif ( $param === 'topic-permalink' ) {
+ // link to individual new-topic
+
+ if ( isset( $extra['topic-workflow'] ) ) {
+ $title = Workflow::getFromTitleCache(
+ wfWikiId(),
+ NS_TOPIC,
+ $extra['topic-workflow']->getAlphadecimal()
+ );
+ } else {
+ $title = $event->getTitle();
+ }
+
+ $anchor = $this->getUrlGenerator()->workflowLink( $title, $extra['topic-workflow'] );
+ $anchor->query['fromnotif'] = 1;
+ $message->params( $anchor->getFullUrl() );
+ } elseif ( $param === 'new-topics-permalink' ) {
+ // link to board sorted by newest topics
+ $anchor = $this->getUrlGenerator()->boardLink( $event->getTitle(), 'newest' );
+ $anchor->query['fromnotif'] = 1;
+ $message->params( $anchor->getFullUrl() );
+ } elseif ( $param == 'flow-title' ) {
+ $title = $event->getTitle();
+ if ( $title ) {
+ $formatted = $this->formatTitle( $title );
+ } else {
+ $formatted = $this->getMessage( 'echo-no-title' )->text();
+ }
+ $message->params( $formatted );
+ } elseif ( $param == 'old-subject' ) {
+ $this->processParamEscaped( $message, trim( $extra['old-subject'] ) );
+ } elseif ( $param == 'new-subject' ) {
+ $this->processParamEscaped( $message, trim( $extra['new-subject'] ) );
+ } else {
+ parent::processParam( $event, $param, $message, $user );
+ }
+ }
+
+ /**
+ * Helper method for generating a link to post notification
+ * @param EchoEvent $event
+ * @param User $user
+ * @return Anchor|boolean
+ * @throws FlowException
+ */
+ protected function getPostLinkAnchor( EchoEvent $event, User $user ) {
+ $urlGenerator = $this->getUrlGenerator();
+ $workflowId = $event->getExtraParam( 'topic-workflow' );
+ if ( !$workflowId instanceof UUID ) {
+ throw new FlowException( 'No topic-workflow available for event ' . $event->getId() );
+ }
+
+ // Get topic title
+ $title = Title::makeTitleSafe( NS_TOPIC, $workflowId->getAlphadecimal() );
+ $anchor = false;
+ if ( $workflowId && $title ) {
+ // Take user to the post if there is only one target post,
+ // otherwise, take user to the first unread post of topic
+ if ( $this->bundleData['raw-data-count'] <= 1 ) {
+ $postId = $event->getExtraParam( 'post-id' );
+ if ( !$postId instanceof UUID ) {
+ throw new FlowException( 'Expected UUID but received ' . get_class( $postId ) );
+ }
+ $anchor = $urlGenerator->postLink( $title, $workflowId, $postId );
+ } else {
+ $postId = $this->getFirstUnreadPostId( $event, $user );
+ if ( $postId ) {
+ $anchor = $urlGenerator->postLink( $title, $workflowId, $postId );
+ } else {
+ $anchor = $urlGenerator->topicLink( $title, $workflowId );
+ }
+ }
+ }
+
+ $anchor->query['fromnotif'] = 1;
+
+ return $anchor;
+ }
+
+ /**
+ * Helper function for getLink()
+ *
+ * @param \EchoEvent $event
+ * @param \User $user The user receiving the notification
+ * @param string $destination The destination type for the link
+ * @return array including target and query parameters
+ * @throws FlowException
+ */
+ protected function getLinkParams( $event, $user, $destination ) {
+ $anchor = null;
+
+ // Unfortunately this is not a Flow code path, so we have to reach
+ // into global state.
+ $urlGenerator = $this->getUrlGenerator();
+
+ // Set up link parameters based on the destination (or pass to parent)
+ switch ( $destination ) {
+ case 'flow-post':
+ $anchor = $this->getPostLinkAnchor( $event, $user );
+ break;
+
+ case 'flow-topic':
+ $workflowId = $event->getExtraParam( 'topic-workflow' );
+ if ( !$workflowId instanceof UUID ) {
+ break;
+ }
+ // Get topic title
+ $title = Title::makeTitleSafe( NS_TOPIC, $workflowId->getAlphadecimal() );
+ if ( $title ) {
+ $anchor = $urlGenerator->topicLink( $title, $workflowId );
+ }
+ break;
+
+ case 'flow-new-topics':
+ $title = $event->getTitle();
+ if ( $title ) {
+ $anchor = $urlGenerator->boardLink( $title, 'newest' );
+ }
+ break;
+
+ default:
+ return parent::getLinkParams( $event, $user, $destination );
+ }
+
+ if ( $anchor ) {
+ $anchor->query['fromnotif'] = 1;
+ return array( $anchor->resolveTitle(), $anchor->query );
+ } else {
+ return array( null, array() );
+ }
+ }
+
+ /**
+ * @return UrlGenerator
+ */
+ protected function getUrlGenerator() {
+ if ( ! $this->urlGenerator ) {
+ $this->urlGenerator = Container::get( 'url_generator' );
+ }
+
+ return $this->urlGenerator;
+ }
+
+ /**
+ * Get the very first unread post from a topic in an event
+ * @param \EchoEvent
+ * @param \User
+ * @return UUID|false
+ */
+ protected function getFirstUnreadPostId( $event, $user ) {
+ $data = $this->getBundleLastRawData( $event, $user );
+ if ( $data ) {
+ // Remove the check once the corresponding Echo patch is
+ // merged, $data should be always an instance of EchoEvent
+ if ( $data instanceof \EchoEvent ) {
+ $extra = $data->getExtra();
+ } elseif ( isset( $data->event_extra ) ) {
+ $extra = $data->event_extra;
+ }
+ if ( isset( $extra['post-id'] ) ) {
+ return $extra['post-id'];
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * @FIXME - Move bundle iterator logic into a centralized place in Echo and
+ * introduce bundle type param like 'agent', 'page', 'event' so child formatter
+ * only needs to specify what iterator to use
+ */
+class NewTopicFormatter extends NotificationFormatter {
+
+ /**
+ * New Topic user 'event' as the iterator
+ */
+ protected function generateBundleData( $event, $user, $type ) {
+ $data = $this->getRawBundleData( $event, $user, $type );
+
+ if ( !$data ) {
+ return;
+ }
+
+ // bundle event is excluding base event
+ $this->bundleData['event-count'] = count( $data ) + 1;
+ $this->bundleData['use-bundle'] = $this->bundleData['event-count'] > 1;
+ }
+
+ /**
+ * @param $event EchoEvent
+ * @param $param string
+ * @param $message Message
+ * @param $user User
+ */
+ protected function processParam( $event, $param, $message, $user ) {
+ switch ( $param ) {
+ case 'event-count':
+ $message->numParams( $this->bundleData['event-count'] );
+ break;
+ default:
+ parent::processParam( $event, $param, $message, $user );
+ break;
+ }
+ }
+}
diff --git a/Flow/includes/Notifications/Notifications.php b/Flow/includes/Notifications/Notifications.php
new file mode 100644
index 00000000..d815594c
--- /dev/null
+++ b/Flow/includes/Notifications/Notifications.php
@@ -0,0 +1,106 @@
+<?php
+
+$notificationTemplate = array(
+ 'category' => 'flow-discussion',
+ 'group' => 'other',
+ 'section' => 'message',
+ 'formatter-class' => 'Flow\NotificationFormatter',
+ 'icon' => 'flow-discussion',
+ 'use-jobqueue' => true,
+);
+
+$notifications = array(
+ 'flow-new-topic' => array(
+ 'formatter-class' => 'Flow\NewTopicFormatter',
+ 'user-locators' => array(
+ 'EchoUserLocator::locateUsersWatchingTitle',
+ 'EchoUserLocator::locateTalkPageOwner'
+ ),
+ 'primary-link' => array(
+ 'message' => 'flow-notification-link-text-view-topic',
+ 'destination' => 'flow-new-topics'
+ ),
+ 'title-message' => 'flow-notification-newtopic',
+ 'title-params' => array( 'agent', 'flow-title', 'title', 'subject', 'topic-permalink' ),
+ 'bundle' => array(
+ 'web' => true,
+ 'email' => true,
+ ),
+ 'bundle-type' => 'event',
+ 'bundle-message' => 'flow-notification-newtopic-bundle',
+ 'bundle-params' => array( 'event-count', 'title', 'new-topics-permalink' ),
+ 'email-subject-message' => 'flow-notification-newtopic-email-subject',
+ 'email-subject-params' => array( 'agent', 'title' ),
+ 'email-body-batch-message' => 'flow-notification-newtopic-email-batch-body',
+ 'email-body-batch-params' => array( 'agent', 'subject', 'title' ),
+ 'email-body-batch-bundle-message' => 'flow-notification-newtopic-bundle',
+ 'email-body-batch-bundle-params' => array( 'event-count', 'title' ),
+ ) + $notificationTemplate,
+ 'flow-post-reply' => array(
+ 'user-locators' => array(
+ 'Flow\\NotificationsUserLocator::locateUsersWatchingTopic',
+ ),
+ 'primary-link' => array(
+ 'message' => 'flow-notification-link-text-view-post',
+ 'destination' => 'flow-post'
+ ),
+ 'title-message' => 'flow-notification-reply',
+ 'title-params' => array( 'agent', 'subject', 'flow-title', 'title', 'post-permalink' ),
+ 'bundle' => array(
+ 'web' => true,
+ 'email' => true,
+ ),
+ 'bundle-message' => 'flow-notification-reply-bundle',
+ 'bundle-params' => array( 'agent', 'subject', 'title', 'post-permalink', 'agent-other-display', 'agent-other-count' ),
+ 'email-subject-message' => 'flow-notification-reply-email-subject',
+ 'email-subject-params' => array( 'agent', 'subject', 'title' ),
+ 'email-body-batch-message' => 'flow-notification-reply-email-batch-body',
+ 'email-body-batch-params' => array( 'agent', 'subject', 'title' ),
+ 'email-body-batch-bundle-message' => 'flow-notification-reply-email-batch-bundle-body',
+ 'email-body-batch-bundle-params' => array( 'agent', 'subject', 'title', 'agent-other-display', 'agent-other-count' ),
+ ) + $notificationTemplate,
+ 'flow-post-edited' => array(
+ 'primary-link' => array(
+ 'message' => 'flow-notification-link-text-view-post',
+ 'destination' => 'flow-post'
+ ),
+ 'title-message' => 'flow-notification-edit',
+ 'title-params' => array( 'agent', 'subject', 'flow-title', 'title', 'post-permalink', 'topic-permalink' ),
+ 'bundle' => array(
+ 'web' => true,
+ 'email' => true,
+ ),
+ 'bundle-message' => 'flow-notification-edit-bundle',
+ 'bundle-params' => array( 'agent', 'subject', 'title', 'post-permalink', 'agent-other-display', 'agent-other-count' ),
+ 'email-subject-message' => 'flow-notification-edit-email-subject',
+ 'email-subject-params' => array( 'agent' ),
+ 'email-body-batch-message' => 'flow-notification-edit-email-batch-body',
+ 'email-body-batch-params' => array( 'agent', 'subject', 'title' ),
+ 'email-body-batch-bundle-message' => 'flow-notification-edit-email-batch-bundle-body',
+ 'email-body-batch-bundle-params' => array( 'agent', 'subject', 'title', 'agent-other-display', 'agent-other-count' ),
+ ) + $notificationTemplate,
+ 'flow-topic-renamed' => array(
+ 'primary-link' => array(
+ 'message' => 'flow-notification-link-text-view-post',
+ 'destination' => 'flow-post'
+ ),
+ 'title-message' => 'flow-notification-rename',
+ 'title-params' => array( 'agent', 'topic-permalink', 'old-subject', 'new-subject', 'flow-title', 'title' ),
+ 'email-subject-message' => 'flow-notification-rename-email-subject',
+ 'email-subject-params' => array( 'agent' ),
+ 'email-body-batch-message' => 'flow-notification-rename-email-batch-body',
+ 'email-body-batch-params' => array( 'agent', 'old-subject', 'new-subject', 'title' ),
+ ) + $notificationTemplate,
+ 'flow-mention' => array(
+ 'primary-link' => array(
+ 'message' => 'notification-link-text-view-mention',
+ 'destination' => 'flow-post'
+ ),
+ 'title-message' => 'flow-notification-mention',
+ 'title-params' => array( 'agent', 'post-permalink', 'subject', 'title', 'user' ),
+ 'email-subject-message' => 'flow-notification-mention-email-subject',
+ 'email-subject-params' => array( 'agent', 'flow-title', 'user' ),
+ 'email-body-batch-message' => 'flow-notification-mention-email-batch-body',
+ 'email-body-batch-params' => array( 'agent', 'subject', 'title', 'user' ),
+ ) + $notificationTemplate,
+);
diff --git a/Flow/includes/Notifications/UserLocator.php b/Flow/includes/Notifications/UserLocator.php
new file mode 100644
index 00000000..67e64a76
--- /dev/null
+++ b/Flow/includes/Notifications/UserLocator.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow;
+
+use EchoEvent;
+use EchoUserLocator;
+use Flow\Model\UUID;
+use Title;
+use User;
+
+class NotificationsUserLocator extends EchoUserLocator {
+ /**
+ * Return all users watching the topic the event was for.
+ *
+ * The echo job queue must be enabled to prevent timeouts submitting to
+ * heavily watched pages when this is used.
+ *
+ * @param EchoEvent $event
+ * @return User[]
+ */
+ public static function locateUsersWatchingTopic( EchoEvent $event ) {
+ $workflowId = $event->getExtraParam( 'topic-workflow' );
+ if ( !$workflowId instanceof UUID ) {
+ // something wrong; don't notify anyone
+ return array();
+ }
+
+ // topic title is just the workflow id, but in NS_TOPIC
+ $title = Title::makeTitleSafe( NS_TOPIC, $workflowId->getAlphadecimal() );
+
+ /*
+ * Override title associated with this event. The existing code to
+ * locate users watching something uses the title associated with the
+ * event, which in this case is the board page.
+ * However, here, we're looking to get users who've watchlisted a
+ * specific NS_TOPIC page.
+ * I'm temporarily substituting the event's title so we can piggyback on
+ * locateUsersWatchingTitle instead of duplicating it.
+ */
+ $originalTitle = $event->getTitle();
+ $event->setTitle( $title );
+
+ $users = parent::locateUsersWatchingTitle( $event );
+
+ // reset original title
+ $event->setTitle( $originalTitle );
+
+ return $users;
+ }
+}
diff --git a/Flow/includes/Parsoid/ContentFixer.php b/Flow/includes/Parsoid/ContentFixer.php
new file mode 100644
index 00000000..93efa99d
--- /dev/null
+++ b/Flow/includes/Parsoid/ContentFixer.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use DOMDocument;
+use DOMXPath;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Title;
+
+class ContentFixer {
+ /**
+ * @var Fixer[] Array of Fixer objects
+ */
+ protected $contentFixers = array();
+
+ /**
+ * Accepts multiple content fixers.
+ *
+ * @param Fixer $contentFixer...
+ * @throws FlowException When provided arguments are not an instance of Fixer
+ */
+ public function __construct( Fixer $contentFixer /* [, Fixer $contentFixer2 [, ...]] */ ) {
+ $this->contentFixers = func_get_args();
+
+ // validate data
+ foreach ( $this->contentFixers as $contentFixer ) {
+ if ( !$contentFixer instanceof Fixer ) {
+ throw new FlowException( 'Invalid content fixer', 'default' );
+ }
+ }
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @return string
+ */
+ public function getContent( AbstractRevision $revision ) {
+ return $this->apply(
+ $revision->getContent( 'html' ),
+ $revision->getCollection()->getTitle()
+ );
+ }
+
+ /**
+ * Applies all contained content fixers to the provided HTML content.
+ * The resulting content is then suitible for display to the end user.
+ *
+ * @param string $content Html
+ * @param Title $title
+ * @return string Html
+ */
+ public function apply( $content, Title $title ) {
+ $dom = self::createDOM( $content );
+ $xpath = new DOMXPath( $dom );
+ foreach ( $this->contentFixers as $i => $contentFixer ) {
+ $found = $xpath->query( $contentFixer->getXPath() );
+ if ( !$found ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Invalid XPath from ' . get_class( $contentFixer ) . ' of: ' . $contentFixer->getXPath() );
+ unset( $this->contentFixers[$i] );
+ continue;
+ }
+
+ foreach ( $found as $node ) {
+ $contentFixer->apply( $node, $title );
+ }
+ }
+
+ return Utils::getInnerHtml( $dom->getElementsByTagName( 'body' )->item( 0 ) );
+ }
+
+ /**
+ * creates a DOM with extra considerations for BC with
+ * previous parsoid content, and for encoding issues.
+ *
+ * @param string $content HTML from parsoid
+ * @return DOMDocument
+ */
+ static public function createDOM( $content ) {
+ /*
+ * Workaround because DOMDocument can't guess charset.
+ * Content should be utf-8. Alternative "workarounds" would be to
+ * provide the charset in $response, as either:
+ * * <?xml encoding="utf-8" ?>
+ * * <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ * * mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' );
+ *
+ * The body tag is required otherwise <meta> tags at the top are
+ * magic'd into <head> rather than kept with the content.
+ */
+ if ( substr( $content, 0, 5 ) !== '<body' ) {
+ // BC: content currently comes from parsoid and is stored
+ // wrapped in <body> tags, but prior to I0d9659f we were
+ // storing only the contents and not the body tag itself.
+ $content = "<body>$content</body>";
+ }
+ return Utils::createDOM( '<?xml encoding="utf-8"?>' . $content );
+ }
+}
diff --git a/Flow/includes/Parsoid/Extractor.php b/Flow/includes/Parsoid/Extractor.php
new file mode 100644
index 00000000..6d4dacb9
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use DOMElement;
+use Flow\Model\Reference;
+
+/**
+ * Find and create References for DOM elements within parsoid HTML
+ */
+interface Extractor {
+ /**
+ * XPath selector that finds elements to be processed with self::perform
+ *
+ * @return string
+ */
+ function getXPath();
+
+ /**
+ * Generate one or no references for a DOMElement found with self::getXPath
+ *
+ * @param ReferenceFactory $factory
+ * @param DOMElement $element
+ * @return Reference|null
+ */
+ function perform( ReferenceFactory $factory, DOMElement $element );
+}
diff --git a/Flow/includes/Parsoid/Extractor/CategoryExtractor.php b/Flow/includes/Parsoid/Extractor/CategoryExtractor.php
new file mode 100644
index 00000000..8b0a0d30
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/CategoryExtractor.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\WikiReference;
+use Flow\Parsoid\ReferenceFactory;
+use Flow\Parsoid\Extractor;
+use Title;
+
+/**
+ * Runs against page content via Flow\Parsoid\ReferenceExtractor
+ * and collects all category references output by parsoid. The DOM
+ * spec states that categories are represented as such:
+ *
+ * <link rel="mw:PageProp/Category" href="...">
+ *
+ * Flow does not currently handle the other page properties. When it becomes
+ * necessary a more generic page property extractor should be implemented and
+ * this class should be removed.
+ */
+class CategoryExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//link[starts-with( @rel, "mw:PageProp/Category" )]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ // our provided xpath guarantees there is a rel attribute
+ // with our expected format so only perform a very minimal
+ // validation.
+ $rel = $element->getAttribute( 'rel' );
+ if ( $rel !== 'mw:PageProp/Category' ) {
+ return null;
+ }
+
+ $href = $element->getAttribute( 'href' );
+ if ( $href ) {
+ return $factory->createWikiReference(
+ WikiReference::TYPE_CATEGORY,
+ urldecode( $href )
+ );
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/Flow/includes/Parsoid/Extractor/ExtLinkExtractor.php b/Flow/includes/Parsoid/Extractor/ExtLinkExtractor.php
new file mode 100644
index 00000000..43b46cb1
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/ExtLinkExtractor.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\Reference;
+use Flow\Parsoid\Extractor;
+use Flow\Parsoid\ReferenceFactory;
+
+/**
+ * Finds and creates References for external links in parsoid HTML
+ */
+class ExtLinkExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//a[@rel="mw:ExtLink"]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ return $factory->createUrlReference(
+ Reference::TYPE_LINK,
+ urldecode( $element->getAttribute( 'href' ) )
+ );
+ }
+}
+
diff --git a/Flow/includes/Parsoid/Extractor/ImageExtractor.php b/Flow/includes/Parsoid/Extractor/ImageExtractor.php
new file mode 100644
index 00000000..5b444693
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/ImageExtractor.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\WikiReference;
+use Flow\Parsoid\Extractor;
+use Flow\Parsoid\ReferenceFactory;
+
+/**
+ * Finds and creates References for images in parsoid HTML
+ */
+class ImageExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//*[contains(concat(" ", @typeof, " "), " mw:Image " )]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ foreach ( $element->getElementsByTagName( 'img' ) as $item ) {
+ if ( !$item instanceof DOMElement ) {
+ continue;
+ }
+ $resource = $item->getAttribute( 'resource' );
+ if ( $resource !== '' ) {
+ return $factory->createWikiReference(
+ WikiReference::TYPE_FILE,
+ urldecode( $resource )
+ );
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Flow/includes/Parsoid/Extractor/PlaceholderExtractor.php b/Flow/includes/Parsoid/Extractor/PlaceholderExtractor.php
new file mode 100644
index 00000000..4022979b
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/PlaceholderExtractor.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\WikiReference;
+use Flow\Parsoid\Extractor;
+use Flow\Parsoid\ReferenceFactory;
+use FormatJson;
+use ParserOptions;
+use Title;
+
+/*
+ * Parsoid currently returns images that don't exist like:
+ * <meta typeof="mw:Placeholder" data-parsoid='{"src":"[[File:Image.png|25px]]","optList":[{"ck":"width","ak":"25px"}],"dsr":[0,23,null,null]}'>
+ *
+ * Links to those should also be registered, but since they're
+ * different nodes than what we expect above, we'll have to deal
+ * with them ourselves. This may change some day, as Parsoids
+ * codebase has a FIXME "Handle missing images properly!!"
+ */
+class PlaceholderExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//*[contains(concat(" ", @typeof, " "), " mw:Placeholder" )]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ $data = FormatJson::decode( $element->getAttribute( 'data-parsoid' ), true );
+ if ( !isset( $data['src'] ) ) {
+ return null;
+ }
+
+ /*
+ * Parsoid only gives us the raw source to play with. Run it
+ * through Parser to make sure we're dealing with an image
+ * and get the image name.
+ */
+ global $wgParser;
+ $output = $wgParser->parse( $data['src'], Title::newFromText( 'Main Page' ), new ParserOptions );
+
+ $file = $output->getImages();
+ if ( !$file ) {
+ return null;
+ }
+ // $file looks like array( 'Foo.jpg' => 1 )
+ $image = Title::newFromText( key( $file ), NS_FILE );
+
+ return $factory->createWikiReference( WikiReference::TYPE_FILE, $image->getPrefixedDBkey() );
+ }
+}
diff --git a/Flow/includes/Parsoid/Extractor/TransclusionExtractor.php b/Flow/includes/Parsoid/Extractor/TransclusionExtractor.php
new file mode 100644
index 00000000..771be952
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/TransclusionExtractor.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\WikiReference;
+use Flow\Parsoid\Extractor;
+use Flow\Parsoid\ReferenceFactory;
+use FormatJson;
+use Title;
+
+/**
+ * Finds and creates References for transclusions in parsoid HTML
+ */
+class TransclusionExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//*[@typeof="mw:Transclusion"]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ $orig = $element->getAttribute( 'data-mw' );
+ $data = FormatJson::decode( $orig );
+ if ( !isset( $data->parts ) || !is_array( $data->parts ) ) {
+ throw new \Exception( "Missing template target: $orig" );
+ }
+ $target = null;
+ foreach ( $data->parts as $part ) {
+ if ( isset( $part->template->target->wt ) ) {
+ $target = $part->template->target->wt;
+ break;
+ }
+ }
+ if ( $target === null ) {
+ throw new \Exception( "Missing template target: $orig" );
+ }
+ $templateTarget = Title::newFromText( $target, NS_TEMPLATE );
+
+ if ( !$templateTarget ) {
+ return null;
+ }
+
+ return $factory->createWikiReference(
+ WikiReference::TYPE_TEMPLATE,
+ $templateTarget->getPrefixedText()
+ );
+ }
+}
diff --git a/Flow/includes/Parsoid/Extractor/WikiLinkExtractor.php b/Flow/includes/Parsoid/Extractor/WikiLinkExtractor.php
new file mode 100644
index 00000000..f9f466d8
--- /dev/null
+++ b/Flow/includes/Parsoid/Extractor/WikiLinkExtractor.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Flow\Parsoid\Extractor;
+
+use DOMElement;
+use Flow\Model\Reference;
+use Flow\Parsoid\Extractor;
+use Flow\Parsoid\ReferenceFactory;
+
+/**
+ * Finds and creates References for internal wiki links in parsoid HTML
+ */
+class WikiLinkExtractor implements Extractor {
+ /**
+ * {@inheritDoc}
+ */
+ public function getXPath() {
+ return '//a[@rel="mw:WikiLink"][not(@typeof)]';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function perform( ReferenceFactory $factory, DOMElement $element ) {
+ $href = $element->getAttribute( 'href' );
+ if ( $href === '' ) {
+ return null;
+ }
+
+ return $factory->createWikiReference(
+ Reference::TYPE_LINK,
+ urldecode( $href )
+ );
+ }
+}
diff --git a/Flow/includes/Parsoid/Fixer.php b/Flow/includes/Parsoid/Fixer.php
new file mode 100644
index 00000000..93b894c1
--- /dev/null
+++ b/Flow/includes/Parsoid/Fixer.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use DOMNode;
+use Flow\Model\PostRevision;
+use Title;
+
+interface Fixer {
+ /**
+ * @param DOMNode $node
+ * @param Title $title
+ */
+ public function apply( DOMNode $node, Title $title );
+
+ /**
+ * @return string
+ */
+ public function getXPath();
+}
diff --git a/Flow/includes/Parsoid/Fixer/BadImageRemover.php b/Flow/includes/Parsoid/Fixer/BadImageRemover.php
new file mode 100644
index 00000000..4d02231e
--- /dev/null
+++ b/Flow/includes/Parsoid/Fixer/BadImageRemover.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Flow\Parsoid\Fixer;
+
+use DOMElement;
+use DOMNode;
+use Flow\Model\PostRevision;
+use Flow\Parsoid\Fixer;
+use Flow\Parsoid\Utils;
+use Title;
+
+/**
+ * Parsoid ignores bad_image_list. With good reason: bad images should only be
+ * removed when rendering the content, not when it's created. This
+ * class updates HTML content from Parsoid by deleting inappropriate images, as
+ * defined by wfIsBadImage().
+ *
+ * Usage:
+
+ * $badImageRemover = new BadImageRemover();
+ *
+ * // Before outputting content
+ * $content = $badImageRemover->apply( $foo->getContent(), $title );
+ */
+
+class BadImageRemover implements Fixer {
+ /**
+ * @var callable
+ */
+ protected $isFiltered;
+
+ /**
+ * @param callable $isFiltered (string, Title) returning bool. First
+ * argument is the image name to check. Second argument is the page on
+ * which the image occurs. Returns true when the image should be filtered.
+ */
+ public function __construct( $isFiltered = 'wfIsBadImage' ) {
+ $this->isFiltered = $isFiltered;
+ }
+
+ /**
+ * @return string
+ */
+ public function getXPath() {
+ return '//span[@typeof="mw:Image"]//img[@resource]';
+ }
+
+ /**
+ * Receives an html string. It find all images and run them through
+ * wfIsBadImage() to determine if the image can be shown.
+ *
+ * @param DOMNode $node
+ * @param Title $title
+ * @throws \MWException
+ */
+ public function apply( DOMNode $node, Title $title ) {
+ if ( !$node instanceof DOMElement ) {
+ return;
+ }
+
+ $resource = $node->getAttribute( 'resource' );
+ if ( $resource === '' ) {
+ return;
+ }
+
+ $image = Utils::createRelativeTitle( $resource, $title );
+ if ( !$image ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Could not construct title for node: ' . $node->ownerDocument->saveXML( $node ) );
+ return;
+ }
+
+ if ( !call_user_func( $this->isFiltered, $image->getDBkey(), $title ) ) {
+ return;
+ }
+
+ // Move up the DOM and remove the typeof="mw:Image" node
+ $nodeToRemove = $node->parentNode;
+ while( $nodeToRemove instanceof DOMElement && $nodeToRemove->getAttribute( 'typeof' ) !== 'mw:Image' ) {
+ $nodeToRemove = $nodeToRemove->parentNode;
+ }
+ if ( !$nodeToRemove ) {
+ throw new \MWException( 'Did not find parent mw:Image to remove' );
+ }
+ $nodeToRemove->parentNode->removeChild( $nodeToRemove );
+ }
+}
diff --git a/Flow/includes/Parsoid/Fixer/BaseHrefFixer.php b/Flow/includes/Parsoid/Fixer/BaseHrefFixer.php
new file mode 100644
index 00000000..86b8e27d
--- /dev/null
+++ b/Flow/includes/Parsoid/Fixer/BaseHrefFixer.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Flow\Parsoid\Fixer;
+
+use Flow\Parsoid\Fixer;
+
+/**
+ * Parsoid markup expects a <base href> of //domain/wiki/ .
+ * However, this would have to be added in the <head> and apply
+ * to the whole page, which could affect other content.
+ *
+ * For now, we just apply this transformation to our own user
+ * Parsoid content. It does not need to be done for WikiLink, since
+ * that is handled by WikiLinkFixer in another way.
+ */
+class BaseHrefFixer implements Fixer {
+ /**
+ * @var string $baseHref
+ */
+ protected $baseHref;
+
+ /**
+ * @param $articlePath Article path setting for wiki
+ */
+ public function __construct( $articlePath ) {
+ $replacedArticlePath = str_replace( '$1', '', $articlePath );
+ $this->baseHref = wfExpandUrl( $replacedArticlePath, PROTO_RELATIVE );
+ }
+
+ /**
+ * Returns XPath matching elements that need to be transformed
+ *
+ * @return string XPath of elements this acts on
+ */
+ public function getXPath() {
+ // WikiLinkFixer handles mw:WikiLink
+ return '//a[@href and not(@rel="mw:WikiLink")]';
+ }
+
+ /**
+ * Prefixes the href with base href.
+ *
+ * @param DOMNode $node Link
+ * @param Title $title
+ */
+ public function apply( \DOMNode $node, \Title $title ) {
+ if ( !$node instanceof \DOMElement ) {
+ return;
+ }
+
+ $href = $node->getAttribute( 'href' );
+ if ( strpos( $href, './' ) !== 0 ) {
+ // If we need to handle more complex cases, we should resolve it
+ // with a library like Net_URL2. This check will then be
+ // unnecessary.
+ return;
+ }
+
+ $href = $this->baseHref . $href;
+ $node->setAttribute( 'href', $href );
+ }
+}
diff --git a/Flow/includes/Parsoid/Fixer/WikiLinkFixer.php b/Flow/includes/Parsoid/Fixer/WikiLinkFixer.php
new file mode 100644
index 00000000..5fa31394
--- /dev/null
+++ b/Flow/includes/Parsoid/Fixer/WikiLinkFixer.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Flow\Parsoid\Fixer;
+
+use ArrayObject;
+use DOMElement;
+use DOMNode;
+use Flow\Model\PostRevision;
+use Flow\Parsoid\Fixer;
+use Flow\Parsoid\Utils;
+use LinkBatch;
+use Linker;
+use Title;
+
+/**
+ * Parsoid ignores red links. With good reason: redlinks should only be
+ * applied when rendering the content, not when it's created.
+ *
+ * This class updates HTML content from Parsoid with anchors generated by
+ * Linker::link. In addition to handling red links, this normalizes
+ * relative paths to start with a /, so the HTML renders correctly
+ * on any page.
+ */
+class WikiLinkFixer implements Fixer {
+ /**
+ * @var LinkBatch
+ */
+ protected $batch;
+
+ /**
+ * @param LinkBatch $batch
+ */
+ public function __construct( LinkBatch $batch ) {
+ $this->batch = $batch;
+ }
+
+ /**
+ * @return string
+ */
+ public function getXPath() {
+ return '//a[@rel="mw:WikiLink"]';
+ }
+
+ /**
+ * Parsoid ignores red links. With good reason: redlinks should only be
+ * applied when rendering the content, not when it's created.
+ *
+ * This method will parse a given content, fetch all of its links & let MW's
+ * Linker class build the link HTML (which will take redlinks into account.)
+ * It will then substitute original link HTML for the one Linker generated.
+ *
+ * This replaces both existing and non-existent anchors because the relative links
+ * output by parsoid are not usable when output within a subpage.
+ *
+ * @param DOMNode $node
+ * @param Title $title Title to resolve relative links against
+ * @throws \Flow\Exception\WikitextException
+ */
+ public function apply( DOMNode $node, Title $title ) {
+ if ( !$node instanceof DOMElement ) {
+ return;
+ }
+
+ $href = $node->getAttribute( 'href' );
+ if ( $href === '' ) {
+ return;
+ }
+
+ $title = Utils::createRelativeTitle( urldecode( $href ), $title );
+ if ( $title === null ) {
+ return;
+ }
+
+ // gather existing link attributes
+ $attributes = array();
+ foreach ( $node->attributes as $attribute ) {
+ $attributes[$attribute->name] = $attribute->value;
+ }
+ // let MW build link HTML based on Parsoid data
+ $html = Linker::link( $title, Utils::getInnerHtml( $node ), $attributes );
+ // create new DOM from this MW-built link
+ $replacementNode = Utils::createDOM( '<?xml encoding="utf-8"?>' . $html )
+ ->getElementsByTagName( 'a' )
+ ->item( 0 );
+ // import MW-built link node into content DOM
+ $replacementNode = $node->ownerDocument->importNode( $replacementNode, true );
+ // replace Parsoid link with MW-built link
+ $node->parentNode->replaceChild( $replacementNode, $node );
+ }
+}
diff --git a/Flow/includes/Parsoid/ReferenceExtractor.php b/Flow/includes/Parsoid/ReferenceExtractor.php
new file mode 100644
index 00000000..dc5af8cf
--- /dev/null
+++ b/Flow/includes/Parsoid/ReferenceExtractor.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use DOMXPath;
+use Flow\Exception\InvalidReferenceException;
+use Flow\Model\Reference;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use MWException;
+
+/**
+ * Extracts references to templates, files and pages (in the form of links)
+ * from Parsoid HTML.
+ */
+class ReferenceExtractor {
+ /**
+ * @var Extractor[][] Map from revision type (AbstractRevision::getRevisionType())
+ * to list of Extractor objects to use.
+ */
+ protected $extractors;
+
+ /**
+ * @param Extractor[][] $extractors Map from revision type (AbstractRevision::getRevisionType())
+ * to a list of extractors to use.
+ */
+ public function __construct( array $extractors ) {
+ $this->extractors = $extractors;
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @param string $objectType
+ * @param UUID $objectId
+ * @param string $text
+ * @return array
+ */
+ public function getReferences( Workflow $workflow, $objectType, UUID $objectId, $text ) {
+ if ( isset( $this->extractors[$objectType] ) ) {
+ return $this->extractReferences(
+ new ReferenceFactory( $workflow, $objectType, $objectId ),
+ $this->extractors[$objectType],
+ $text
+ );
+ } else {
+ throw new \Exception( "No extractors available for $objectType" );
+ return array();
+ }
+ }
+
+ /**
+ * @param ReferenceFactory $factory
+ * @param Extractor[] $extractors
+ * @param string $text
+ * @return Reference[]
+ * @throws MWException
+ * @throws \Flow\Exception\WikitextException
+ */
+ protected function extractReferences( ReferenceFactory $factory, array $extractors, $text ) {
+ $dom = Utils::createDOM( '<?xml encoding="utf-8" ?>' . $text );
+
+ $output = array();
+
+ $xpath = new DOMXPath( $dom );
+
+ foreach( $extractors as $extractor ) {
+ $elements = $xpath->query( $extractor->getXPath() );
+
+ if ( !$elements ) {
+ $class = get_class( $extractor );
+ throw new MWException( "Malformed xpath from $class: " . $extractor->getXPath() );
+ }
+
+ foreach( $elements as $element ) {
+ try {
+ $ref = $extractor->perform( $factory, $element );
+ } catch ( InvalidReferenceException $e ) {
+ wfDebugLog( 'Flow', 'Invalid reference detected, skipping element' );
+ $ref = null;
+ }
+ // no reference was generated
+ if ( $ref === null ) {
+ continue;
+ }
+ // reference points to a special page
+ if ( $ref->getSrcTitle()->isSpecialPage() ) {
+ continue;
+ }
+
+ $output[] = $ref;
+ }
+ }
+
+ return $output;
+ }
+}
diff --git a/Flow/includes/Parsoid/ReferenceFactory.php b/Flow/includes/Parsoid/ReferenceFactory.php
new file mode 100644
index 00000000..1443b79b
--- /dev/null
+++ b/Flow/includes/Parsoid/ReferenceFactory.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use Flow\Model\URLReference;
+use Flow\Model\UUID;
+use Flow\Model\WikiReference;
+use Flow\Model\Workflow;
+use Title;
+
+class ReferenceFactory {
+ /**
+ * @var UUID
+ */
+ protected $workflowId;
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var string
+ */
+ protected $objectType;
+
+ /**
+ * @var UUID
+ */
+ protected $objectId;
+
+ /**
+ * @param Workflow $workflow
+ * @param string $objectType
+ * @param UUID $objectId
+ */
+ public function __construct( Workflow $workflow, $objectType, UUID $objectId ) {
+ $this->workflowId = $workflow->getId();
+ $this->title = $workflow->getArticleTitle();
+ $this->objectType = $objectType;
+ $this->objectId = $objectId;
+ }
+
+ /**
+ * @param string $refType
+ * @param string $value
+ * @return URLReference
+ */
+ public function createUrlReference( $refType, $value ) {
+ return new URLReference(
+ $this->workflowId,
+ $this->title,
+ $this->objectType,
+ $this->objectId,
+ $refType,
+ $value
+ );
+ }
+
+ /**
+ * @param string $refType
+ * @param string $value
+ * @return WikiReference|null
+ */
+ public function createWikiReference( $refType, $value ) {
+ $title = Utils::createRelativeTitle( $value, $this->title );
+
+ if ( $title === null ) {
+ return null;
+ }
+
+ return new WikiReference(
+ $this->workflowId,
+ $this->title,
+ $this->objectType,
+ $this->objectId,
+ $refType,
+ $title
+ );
+ }
+}
diff --git a/Flow/includes/Parsoid/Utils.php b/Flow/includes/Parsoid/Utils.php
new file mode 100644
index 00000000..624c64c2
--- /dev/null
+++ b/Flow/includes/Parsoid/Utils.php
@@ -0,0 +1,313 @@
+<?php
+
+namespace Flow\Parsoid;
+
+use DOMDocument;
+use DOMNode;
+use FauxResponse;
+use Flow\Container;
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidDataException;
+use Flow\Exception\NoParsoidException;
+use Flow\Exception\WikitextException;
+use Language;
+use OutputPage;
+use RequestContext;
+use Title;
+use User;
+
+abstract class Utils {
+ /**
+ * Convert from/to wikitext/html.
+ *
+ * @param string $from Format of content to convert: html|wikitext
+ * @param string $to Format to convert to: html|wikitext
+ * @param string $content
+ * @param Title $title
+ * @return string
+ * @throws InvalidDataException When $title does not exist
+ */
+ public static function convert( $from, $to, $content, Title $title ) {
+ if ( $from === $to || $content === '' ) {
+ return $content;
+ }
+
+ try {
+ self::parsoidConfig();
+ } catch ( NoParsoidException $e ) {
+ // If we have no parsoid config, fallback to the parser.
+ return self::parser( $from, $to, $content, $title );
+ }
+
+ return self::parsoid( $from, $to, $content, $title );
+ }
+
+ /**
+ * Basic conversion of html to plaintext for use in recent changes, history,
+ * and other places where a roundtrip is undesired.
+ *
+ * @param string $html
+ * @param int|null $truncateLength Maximum length (including ellipses) or null for whole string.
+ * @param Language $lang Language to use for truncation. Defaults to $wgLang
+ * @return string plaintext
+ */
+ public static function htmlToPlaintext( $html, $truncateLength = null, Language $lang = null ) {
+ /** @var Language $wgLang */
+ global $wgLang;
+
+ $plain = trim( html_entity_decode( strip_tags( $html ) ) );
+
+ if ( $truncateLength === null ) {
+ return $plain;
+ } else {
+ $lang = $lang ?: $wgLang;
+ return $lang->truncate( $plain, $truncateLength );
+ }
+ }
+
+ /**
+ * Convert from/to wikitext/html via Parsoid.
+ *
+ * This will assume Parsoid is installed.
+ *
+ * @param string $from Format of content to convert: html|wikitext
+ * @param string $to Format to convert to: html|wikitext
+ * @param string $content
+ * @param Title $title
+ * @return string
+ * @throws NoParsoidException When parsoid configuration is not available
+ * @throws WikitextException When conversion is unsupported
+ */
+ protected static function parsoid( $from, $to, $content, Title $title ) {
+ list( $parsoidURL, $parsoidPrefix, $parsoidTimeout, $parsoidForwardCookies ) = self::parsoidConfig();
+
+ if ( $from == 'html' ) {
+ $from = 'html';
+ } elseif ( in_array( $from, array( 'wt', 'wikitext' ) ) ) {
+ $from = 'wt';
+ } else {
+ throw new WikitextException( 'Unknown source format: ' . $from, 'process-wikitext' );
+ }
+
+ $request = \MWHttpRequest::factory(
+ $parsoidURL . '/' . $parsoidPrefix . '/' . $title->getPrefixedDBkey(),
+ array(
+ 'method' => 'POST',
+ 'postData' => wfArrayToCgi( array(
+ $from => $content,
+ 'body' => true,
+ ) ),
+ 'timeout' => $parsoidTimeout,
+ 'connectTimeout' => 'default',
+ )
+ );
+ if ( $parsoidForwardCookies && !User::isEveryoneAllowed( 'read' ) ) {
+ if ( PHP_SAPI === 'cli' ) {
+ // From the command line we need to generate a cookie
+ $cookies = self::generateForwardedCookieForCli();
+ } else {
+ $cookies = RequestContext::getMain()->getRequest()->getHeader( 'Cookie' );
+ }
+ $request->setHeader( 'Cookie', $cookies );
+ }
+ $status = $request->execute();
+ if ( !$status->isOK() ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failed contacting parsoid: ' . $status->getMessage()->text() );
+ throw new NoParsoidException( 'Failed contacting Parsoid', 'process-wikitext' );
+ }
+
+ return $request->getContent();
+ }
+
+ /**
+ * Convert from/to wikitext/html using Parser.
+ *
+ * This only supports wikitext to HTML.
+ *
+ * @param string $from Format of content to convert: wikitext
+ * @param string $to Format to convert to: html
+ * @param string $content
+ * @param Title $title
+ * @return string
+ * @throws WikitextException When the conversion is unsupported
+ */
+ protected static function parser( $from, $to, $content, Title $title ) {
+ if ( $from !== 'wikitext' && $to !== 'html' ) {
+ throw new WikitextException( "Conversion from '$from' to '$to' was requested, but core's Parser only supports 'wikitext' to 'html' conversion", 'process-wikitext' );
+ }
+
+ global $wgParser;
+
+ $options = new \ParserOptions;
+ $options->setTidy( true );
+ $options->setEditSection( false );
+
+ $output = $wgParser->parse( $content, $title, $options );
+ return $output->getText();
+ }
+
+ /**
+ * Returns Flow's Parsoid config. $wgFlowParsoid* variables are used to
+ * specify how to connect to Parsoid.
+ *
+ * @return array Parsoid config, in array(URL, prefix, timeout, forwardCookies) format
+ * @throws NoParsoidException When parsoid is unconfigured
+ */
+ protected static function parsoidConfig() {
+ global
+ $wgFlowParsoidURL, $wgFlowParsoidPrefix, $wgFlowParsoidTimeout, $wgFlowParsoidForwardCookies;
+
+ if ( !$wgFlowParsoidURL ) {
+ throw new NoParsoidException( 'Flow Parsoid configuration is unavailable', 'process-wikitext' );
+ }
+
+ return array(
+ $wgFlowParsoidURL,
+ $wgFlowParsoidPrefix,
+ $wgFlowParsoidTimeout,
+ $wgFlowParsoidForwardCookies,
+ );
+ }
+
+ /**
+ * Turns given $content string into a DOMDocument object.
+ *
+ * Some libxml errors are forgivable, libxml errors that aren't
+ * ignored will throw a WikitextException.
+ *
+ * The default error codes allowed are:
+ * 76 - allow unexpected end tag. This is typically old wikitext using deprecated tags.
+ * 513 - allow multiple tags with same id
+ * 801 - allow unrecognized tags like figcaption
+ *
+ * @param string $content
+ * @param array[optional] $ignoreErrorCodes
+ * @return DOMDocument
+ * @throws WikitextException
+ * @see http://www.xmlsoft.org/html/libxml-xmlerror.html
+ */
+ public static function createDOM( $content, $ignoreErrorCodes = array( 76, 513, 801 ) ) {
+ $dom = new DOMDocument();
+
+ // Otherwise the parser may attempt to load the dtd from an external source.
+ // See: https://www.mediawiki.org/wiki/XML_External_Entity_Processing
+ $loadEntities = libxml_disable_entity_loader( true );
+
+ // don't output warnings
+ $useErrors = libxml_use_internal_errors( true );
+
+ $dom->loadHTML( $content );
+
+ libxml_disable_entity_loader( $loadEntities );
+
+ // check error codes; if not in the supplied list of ignorable errors,
+ // throw an exception
+ $errors = array_filter(
+ libxml_get_errors(),
+ function( $error ) use( $ignoreErrorCodes ) {
+ return !in_array( $error->code, $ignoreErrorCodes );
+ }
+ );
+
+ // restore libxml state before anything else
+ libxml_clear_errors();
+ libxml_use_internal_errors( $useErrors );
+
+ if ( $errors ) {
+ throw new WikitextException(
+ implode( "\n", array_map( function( $error ) { return $error->message; }, $errors ) )
+ . "\n\nFrom source content:\n" . $content,
+ 'process-wikitext'
+ );
+ }
+
+ return $dom;
+ }
+
+ /**
+ * Handler for FlowAddModules, avoids rest of Flow having to be aware if
+ * Parsoid is in use.
+ *
+ * @param OutputPage $out OutputPage object
+ * @return bool
+ */
+ public static function onFlowAddModules( OutputPage $out ) {
+
+ try {
+ self::parsoidConfig();
+ // XXX We only need the Parsoid CSS if some content being
+ // rendered has getContentFormat() === 'html'.
+ $out->addModuleStyles( 'mediawiki.skinning.content.parsoid' );
+ } catch ( NoParsoidException $e ) {
+ // The module is only necessary when we are using parsoid.
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves the html of the nodes children.
+ *
+ * @param DOMNode|null $node
+ * @return string html of the nodes children
+ */
+ public static function getInnerHtml( DOMNode $node = null ) {
+ $html = array();
+ if ( $node ) {
+ $dom = $node instanceof DOMDocument ? $node : $node->ownerDocument;
+ foreach ( $node->childNodes as $child ) {
+ $html[] = $dom->saveHTML( $child );
+ }
+ }
+ return implode( '', $html );
+ }
+
+ /**
+ * Subpage links from Parsoid don't contain any direct context, its applied via
+ * a <base href="..."> tag, so here we apply a similar rule resolving against
+ * $title
+ *
+ * @param string $text
+ * @param Title $title Title to resolve relative links against
+ * @return Title|null
+ */
+ public static function createRelativeTitle( $text, Title $title ) {
+ // currently parsoid always uses enough ../ or ./ to go
+ // back to the root, a bit of a kludge but just assume we
+ // can strip and will end up with a non-relative text.
+ $text = preg_replace( '|(\.\.?/)+|', '', $text );
+
+ if ( $text && ( $text[0] === '/' || $text[0] === '#' ) ) {
+ return Title::newFromText( $title->getDBkey() . $text, $title->getNamespace() );
+ }
+
+ return Title::newFromText( $text );
+ }
+
+ // @todo move into FauxRequest
+ public static function generateForwardedCookieForCli() {
+ global $wgCookiePrefix;
+
+ $user = Container::get( 'occupation_controller' )->getTalkpageManager();
+ // This takes a request object, but doesnt set the cookies against it.
+ // patch at https://gerrit.wikimedia.org/r/177403
+ $user->setCookies( null, null, /* rememberMe */ true );
+ $response = RequestContext::getMain()->getRequest()->response();
+ if ( !$response instanceof FauxResponse ) {
+ throw new FlowException( 'Expected a FauxResponse in CLI environment' );
+ }
+ // FauxResponse does not yet expose the full set of cookies
+ $reflProp = new \ReflectionProperty( $response, 'cookies' );
+ $reflProp->setAccessible( true );
+ $cookies = $reflProp->getValue( $response );
+
+ // now we need to convert the array into the cookie format of
+ // foo=bar; baz=bang
+ $output = array();
+ foreach ( $cookies as $key => $value ) {
+ $output[] = "$wgCookiePrefix$key=$value";
+ }
+
+ return implode( '; ', $output );
+ }
+}
diff --git a/Flow/includes/RecoverableErrorHandler.php b/Flow/includes/RecoverableErrorHandler.php
new file mode 100644
index 00000000..15b371ef
--- /dev/null
+++ b/Flow/includes/RecoverableErrorHandler.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Flow;
+
+use Flow\Exception\CatchableFatalErrorException;
+
+/**
+ * Catches E_RECOVERABLE_ERROR and converts into exceptions
+ * instead of fataling.
+ *
+ * Usage:
+ * set_error_handler( new RecoverableErrorHandler, E_RECOVERABLE_ERROR );
+ * try {
+ * ...
+ * } catch ( CatchableFatalErrorException $fatal ) {
+ *
+ * }
+ * restore_error_handler();
+ */
+class RecoverableErrorHandler {
+ public function __invoke( $errno, $errstr, $errfile, $errline ) {
+ if ( $errno !== E_RECOVERABLE_ERROR ) {
+ return false;
+ }
+
+ throw new CatchableFatalErrorException( $errstr, 0, $errno, $errfile, $errline );
+ }
+}
diff --git a/Flow/includes/ReferenceClarifier.php b/Flow/includes/ReferenceClarifier.php
new file mode 100644
index 00000000..7b5ef7b0
--- /dev/null
+++ b/Flow/includes/ReferenceClarifier.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Flow;
+
+use Flow\Data\ManagerGroup;
+use Flow\Model\Reference;
+use Flow\Model\WikiReference;
+use Flow\Exception\CrossWikiException;
+use Flow\Model\UUID;
+use Title;
+
+class ReferenceClarifier {
+ protected $storage, $urlGenerator;
+ protected $referenceCache;
+
+ function __construct( ManagerGroup $storage, UrlGenerator $urlGenerator ) {
+ $this->storage = $storage;
+ $this->urlGenerator = $urlGenerator;
+ $this->referenceCache = array();
+ }
+
+ public function getWhatLinksHereProps( $row, Title $from, Title $to ) {
+ $ids = array();
+ $props = array();
+ $references = $this->getWikiReferences( $from, $to );
+
+ // Collect referenced workflow ids and load them so we can generate
+ // links to their pages
+ foreach( $references as $reference ) {
+ $id = $reference->getWorkflowId();
+ // utilize array key to de-duplicate
+ $ids[$id->getAlphadecimal()] = $id;
+ }
+
+ // Don't need to do anything with them, they are automatically
+ // passed into url generator when loaded.
+ $this->storage->getMulti( 'Workflow', $ids );
+
+ // Messages that can be used here:
+ // * flow-whatlinkshere-header
+ // * flow-whatlinkshere-post
+ // Topic and Summary are plain text and do not have links.
+ foreach( $references as $reference ) {
+ if ( $reference->getType() === WikiReference::TYPE_CATEGORY ) {
+ // While it might make sense to have backlinks from categories to
+ // a page in what links here, thats not what mediawiki currently does.
+ continue;
+ }
+ try {
+ $url = $this->getObjectLink( $reference->getWorkflowId(), $reference->getObjectType(), $reference->getObjectId() );
+ $props[] = wfMessage( 'flow-whatlinkshere-' . $reference->getObjectType(), $url )->parse();
+ } catch ( CrossWikiException $e ) {
+ // Ignore expected cross-wiki exception.
+ // Gerrit 136280 would add a wiki field to the query in
+ // loadReferencesForPage(), we can remove catching the exception
+ // in here once it's merged
+ }
+ }
+
+ return $props;
+ }
+
+ /**
+ * @param Title $from
+ * @param Title $to
+ * @return WikiReference[]
+ */
+ public function getWikiReferences( Title $from, Title $to ) {
+ if ( ! isset( $this->referenceCache[$from->getPrefixedDBkey()] ) ) {
+ $this->loadReferencesForPage( $from );
+ }
+
+ $fromT = $from->getPrefixedDBkey();
+ $toT = 'title:' . $to->getPrefixedDBkey();
+
+ return isset( $this->referenceCache[$fromT][$toT] )
+ ? $this->referenceCache[$fromT][$toT]
+ : array();
+ }
+
+ /**
+ * @param UUID $workflow
+ * @param string $objectType
+ * @param UUID $objectId
+ * @return string Full URL
+ */
+ protected function getObjectLink( UUID $workflow, $objectType, UUID $objectId ) {
+ if ( $objectType === 'post' ) {
+ $anchor = $this->urlGenerator->postLink( null, $workflow, $objectId );
+ } elseif ( $objectType === 'header' ) {
+ $anchor = $this->urlGenerator->workflowLink( null, $workflow );
+ } else {
+ wfDebugLog( 'Flow', __METHOD__ . ": Unknown \$objectType: $objectType" );
+ $anchor = $this->urlGenerator->workflowLink( null, $workflow );
+ }
+
+ return $anchor->getFullURL();
+ }
+
+ protected function loadReferencesForPage( Title $from ) {
+ /** @var Reference[] $allReferences */
+ $allReferences = array();
+
+ foreach( array( 'WikiReference', 'URLReference' ) as $refType ) {
+ // find() returns null for error or empty result
+ $res = $this->storage->find(
+ $refType,
+ array(
+ 'ref_src_namespace' => $from->getNamespace(),
+ 'ref_src_title' => $from->getDBkey(),
+ )
+ );
+ if ( $res ) {
+ /*
+ * We're "cheating", we have no PK!
+ * We used to have a unique index (on all columns), but the size
+ * of the index was too small (urls can be pretty long...)
+ * We have no data integrity reasons to want to ensure unique
+ * entries, and the code actually does a good jon of only
+ * inserting uniques. Still, I'll do a sanity check and get rid
+ * of duplicates, should there be any...
+ */
+ $res = array_unique( $res );
+ $allReferences = array_merge( $allReferences, $res );
+ }
+ }
+
+ $cache = array();
+ foreach( $allReferences as $reference ) {
+ if ( !isset( $cache[$reference->getTargetIdentifier()] ) ) {
+ $cache[$reference->getTargetIdentifier()] = array();
+ }
+
+ $cache[$reference->getTargetIdentifier()][] = $reference;
+ }
+
+ $this->referenceCache[$from->getPrefixedDBkey()] = $cache;
+ }
+}
diff --git a/Flow/includes/Repository/MultiGetList.php b/Flow/includes/Repository/MultiGetList.php
new file mode 100644
index 00000000..7cfca54a
--- /dev/null
+++ b/Flow/includes/Repository/MultiGetList.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Flow\Repository;
+
+use Flow\Data\BufferedCache;
+use Flow\Model\UUID;
+use Flow\Container;
+use Flow\Exception\InvalidInputException;
+
+class MultiGetList {
+
+ /**
+ * @var BufferedCache
+ */
+ protected $cache;
+
+ /**
+ * @param BufferedCache $cache
+ */
+ public function __construct( BufferedCache $cache ) {
+ $this->cache = $cache;
+ }
+
+ /**
+ * @param string $key
+ * @param array $ids
+ * @param callable $loadCallback
+ * @return array
+ * @throws InvalidInputException
+ */
+ public function get( $key, array $ids, $loadCallback ) {
+ $key = implode( ':', (array) $key );
+ $cacheKeys = array();
+ foreach ( $ids as $id ) {
+ if ( $id instanceof UUID ) {
+ $cacheId = $id->getAlphadecimal();
+ } elseif ( !is_scalar( $id ) ) {
+ $type = is_object( $id ) ? get_class( $id ) : gettype( $id );
+ throw new InvalidInputException( 'Not scalar:' . $type, 'invalid-input' );
+ } else {
+ $cacheId = $id;
+ }
+ $cacheKeys[wfForeignMemcKey( 'flow', '', $key, $cacheId, Container::get( 'cache.version' ) )] = $id;
+ }
+ return $this->getByKey( $cacheKeys, $loadCallback );
+ }
+
+ /**
+ * @param array $cacheKeys
+ * @param callable $loadCallback
+ * @return array
+ */
+ public function getByKey( array $cacheKeys, $loadCallback ) {
+ if ( !$cacheKeys ) {
+ return array();
+ }
+ $result = array();
+ $multiRes = $this->cache->getMulti( array_keys( $cacheKeys ) );
+ if ( $multiRes === false ) {
+ // Falls through to query only backend
+ wfDebugLog( 'Flow', __METHOD__ . ': Failure querying memcache' );
+ } else {
+ // Memcached BagOStuff only returns found keys, but the redis bag
+ // returns false for not found keys.
+ $multiRes = array_filter(
+ $multiRes,
+ function( $val ) { return $val !== false; }
+ );
+ foreach ( $multiRes as $key => $value ) {
+ $idx = $cacheKeys[$key];
+ if ( $idx instanceof UUID ) {
+ $idx = $idx->getAlphadecimal();
+ }
+ $result[$idx] = $value;
+ unset( $cacheKeys[$key] );
+ }
+ }
+ if ( count( $cacheKeys ) === 0 ) {
+ return $result;
+ }
+ $res = call_user_func( $loadCallback, array_values( $cacheKeys ) );
+ if ( !$res ) {
+ // storage failure of some sort
+ return $result;
+ }
+ $invCacheKeys = array();
+ foreach ( $cacheKeys as $cacheKey => $id ) {
+ if ( $id instanceof UUID ) {
+ $id = $id->getAlphadecimal();
+ }
+ $invCacheKeys[$id] = $cacheKey;
+ }
+ foreach ( $res as $id => $row ) {
+ // If we failed contacting memcache a moment ago don't bother trying to
+ // push values either.
+ if ( $multiRes !== false ) {
+ $this->cache->set( $invCacheKeys[$id], $row );
+ }
+ $result[$id] = $row;
+ }
+
+ return $result;
+ }
+}
diff --git a/Flow/includes/Repository/RootPostLoader.php b/Flow/includes/Repository/RootPostLoader.php
new file mode 100644
index 00000000..9f688753
--- /dev/null
+++ b/Flow/includes/Repository/RootPostLoader.php
@@ -0,0 +1,237 @@
+<?php
+
+namespace Flow\Repository;
+
+use Flow\Data\ManagerGroup;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Exception\InvalidDataException;
+use FormatJson;
+
+/**
+ * I'm pretty sure this will generally work for any subtree, not just the topic
+ * root. The problem is once you allow any subtree you need to handle the
+ * depth and root post setters better, they make the assumption the root provided
+ * is actually a root.
+ */
+class RootPostLoader {
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var TreeRepository
+ */
+ protected $treeRepo;
+
+ /**
+ * @param ManagerGroup $storage
+ * @param TreeRepository $treeRepo
+ */
+ public function __construct( ManagerGroup $storage, TreeRepository $treeRepo ) {
+ $this->storage = $storage;
+ $this->treeRepo = $treeRepo;
+ }
+
+ /**
+ * Retrieves a single post and the related topic title.
+ *
+ * @param UUID|string $postId The uid of the post being requested
+ * @return PostRevision[]|null[] associative array with 'root' and 'post' keys. Array
+ * values may be null if not found.
+ * @throws InvalidDataException
+ */
+ public function getWithRoot( $postId ) {
+ $postId = UUID::create( $postId );
+ $rootId = $this->treeRepo->findRoot( $postId );
+ $found = $this->storage->findMulti(
+ 'PostRevision',
+ array(
+ array( 'rev_type_id' => $postId ),
+ array( 'rev_type_id' => $rootId ),
+ ),
+ array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
+ );
+ $res = array(
+ 'post' => null,
+ 'root' => null,
+ );
+ if ( !$found ) {
+ return $res;
+ }
+ foreach ( $found as $result ) {
+ // limit = 1 means single result
+ $post = reset( $result );
+ if ( $postId->equals( $post->getPostId() ) ) {
+ $res['post'] = $post;
+ } elseif( $rootId->equals( $post->getPostId() ) ) {
+ $res['root'] = $post;
+ } else {
+ throw new InvalidDataException( 'Unmatched: ' . $post->getPostId()->getAlphadecimal() );
+ }
+ }
+ // The above doesn't catch this condition
+ if ( $postId->equals( $rootId ) ) {
+ $res['root'] = $res['post'];
+ }
+ return $res;
+ }
+
+ /**
+ * @param UUID $topicId
+ * @return PostRevision
+ * @throws InvalidDataException
+ */
+ public function get( $topicId ) {
+ $result = $this->getMulti( array( $topicId ) );
+ return reset( $result );
+ }
+
+ /**
+ * @param UUID[] $topicIds
+ * @return PostRevision[]
+ * @throws InvalidDataException
+ */
+ public function getMulti( array $topicIds ) {
+ if ( !$topicIds ) {
+ return array();
+ }
+ // load posts for all located post ids
+ $allPostIds = $this->fetchRelatedPostIds( $topicIds );
+ $queries = array();
+ foreach ( $allPostIds as $postId ) {
+ $queries[] = array( 'rev_type_id' => $postId );
+ }
+ $found = $this->storage->findMulti( 'PostRevision', $queries, array(
+ 'sort' => 'rev_id',
+ 'order' => 'DESC',
+ 'limit' => 1,
+ ) );
+ /** @var PostRevision[] $posts */
+ $posts = $children = array();
+ foreach ( $found as $indexResult ) {
+ $post = reset( $indexResult ); // limit => 1 means only 1 result per query
+ if ( isset( $posts[$post->getPostId()->getAlphadecimal()] ) ) {
+ throw new InvalidDataException( 'Multiple results for id: ' . $post->getPostId()->getAlphadecimal(), 'fail-load-data' );
+ }
+ $posts[$post->getPostId()->getAlphadecimal()] = $post;
+ }
+ $prettyPostIds = array();
+ foreach ( $allPostIds as $id ) {
+ $prettyPostIds[] = $id->getAlphadecimal();
+ }
+ $missing = array_diff( $prettyPostIds, array_keys( $posts ) );
+ if ( $missing ) {
+ // convert string uuid's into UUID objects
+ /** @var UUID[] $missingUUID */
+ $missingUUID = array_map( array( 'Flow\Model\UUID', 'create' ), $missing );
+
+ // we'll need to know parents to add stub post correctly in post hierarchy
+ $parents = $this->treeRepo->fetchParentMap( $missingUUID );
+ $missingParents = array_diff( $missing, array_keys( $parents ) );
+ if ( $missingParents ) {
+ // if we can't fetch a post's original position in the tree
+ // hierarchy, we can't create a stub post to display, so bail
+ throw new InvalidDataException( 'Missing Posts & parents: ' . json_encode( $missingParents ), 'fail-load-data' );
+ }
+
+ foreach ( $missingUUID as $postId ) {
+ $content = wfMessage( 'flow-stub-post-content' )->text();
+ $username = wfMessage( 'flow-system-usertext' )->text();
+ $user = \User::newFromName( $username );
+
+ // create a stub post instead of failing completely
+ $post = PostRevision::newFromId( $postId, $user, $content, 'wikitext' );
+ $post->setReplyToId( $parents[$postId->getAlphadecimal()] );
+ $posts[$postId->getAlphadecimal()] = $post;
+
+ wfWarn( 'Missing Posts: ' . FormatJson::encode( $missing ) );
+ }
+ }
+ // another helper to catch bugs in dev
+ $extra = array_diff( array_keys( $posts ), $prettyPostIds );
+ if ( $extra ) {
+ throw new InvalidDataException( 'Found unrequested posts: ' . FormatJson::encode( $extra ), 'fail-load-data' );
+ }
+
+ // populate array of children
+ foreach ( $posts as $post ) {
+ if ( $post->getReplyToId() ) {
+ $children[$post->getReplyToId()->getAlphadecimal()][$post->getPostId()->getAlphadecimal()] = $post;
+ }
+ }
+ $extraParents = array_diff( array_keys( $children ), $prettyPostIds );
+ if ( $extraParents ) {
+ throw new InvalidDataException( 'Found posts with unrequested parents: ' . FormatJson::encode( $extraParents ), 'fail-load-data' );
+ }
+
+ foreach ( $posts as $postId => $post ) {
+ $postChildren = array();
+ $postDepth = 0;
+
+ // link parents to their children
+ if ( isset( $children[$postId] ) ) {
+ // sort children with oldest items first
+ ksort( $children[$postId] );
+ $postChildren = $children[$postId];
+ }
+
+ // determine threading depth of post
+ $replyToId = $post->getReplyToId();
+ while ( $replyToId && isset( $children[$replyToId->getAlphadecimal()] ) ) {
+ $postDepth++;
+ $replyToId = $posts[$replyToId->getAlphadecimal()]->getReplyToId();
+ }
+
+ $post->setChildren( $postChildren );
+ $post->setDepth( $postDepth );
+ }
+
+ // return only the requested posts, rest are available as children.
+ // Return in same order as requested
+ /** @var PostRevision[] $roots */
+ $roots = array();
+ foreach ( $topicIds as $id ) {
+ $roots[$id->getAlphadecimal()] = $posts[$id->getAlphadecimal()];
+ }
+ // Attach every post in the tree to its root. setRootPost
+ // recursively applies it to all children as well.
+ foreach ( $roots as $post ) {
+ $post->setRootPost( $post );
+ }
+ return $roots;
+ }
+
+ /**
+ * @param UUID[] $postIds
+ * @return UUID[] Map from alphadecimal id to UUID object
+ */
+ protected function fetchRelatedPostIds( array $postIds ) {
+ // list of all posts descendant from the provided $postIds
+ $nodeList = $this->treeRepo->fetchSubtreeNodeList( $postIds );
+ // merge all the children from the various posts into one array
+ if ( !$nodeList ) {
+ // It should have returned at least $postIds
+ // TODO: log errors?
+ $res = $postIds;
+ } elseif( count( $nodeList ) === 1 ) {
+ $res = reset( $nodeList );
+ } else {
+ $res = call_user_func_array( 'array_merge', $nodeList );
+ }
+
+ $retval = array();
+ foreach ( $res as $id ) {
+ $retval[$id->getAlphadecimal()] = $id;
+ }
+ return $retval;
+ }
+
+ /**
+ * @return TreeRepository
+ */
+ public function getTreeRepo() {
+ return $this->treeRepo;
+ }
+}
diff --git a/Flow/includes/Repository/TitleRepository.php b/Flow/includes/Repository/TitleRepository.php
new file mode 100644
index 00000000..fa6bc9db
--- /dev/null
+++ b/Flow/includes/Repository/TitleRepository.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Flow\Repository;
+
+use Title;
+
+/**
+ * Abstraction for calling stateful methods of the title class. Allows
+ * replacing them in unit tests.
+ */
+class TitleRepository {
+ public function exists( Title $title ) {
+ return $title->exists();
+ }
+}
diff --git a/Flow/includes/Repository/TreeRepository.php b/Flow/includes/Repository/TreeRepository.php
new file mode 100644
index 00000000..77b8e603
--- /dev/null
+++ b/Flow/includes/Repository/TreeRepository.php
@@ -0,0 +1,474 @@
+<?php
+
+namespace Flow\Repository;
+
+use Flow\Data\BufferedCache;
+use Flow\Data\ObjectManager;
+use Flow\DbFactory;
+use Flow\Model\UUID;
+use BagOStuff;
+use Flow\Container;
+use Flow\Exception\DataModelException;
+
+/*
+ *
+ * In SQL
+ *
+ * CREATE TABLE flow_tree_node (
+ * descendant DECIMAL(39) UNSIGNED NOT NULL,
+ * ancestor DECIMAL(39) UNSIGNED NULL,
+ * depth SMALLINT UNSIGNED NOT NULL,
+ * PRIMARY KEY ( ancestor, descendant ),
+ * UNIQUE KEY ( descendant, depth )
+ * );
+ *
+ * In Memcache
+ *
+ * flow:tree:subtree:<descendant>
+ * flow:tree:rootpath:<descendant>
+ * flow:tree:parent:<descendant> - should we just use rootpath?
+ *
+ * Not sure how to handle topic splits with caching yet, i can imagine
+ * a number of potential race conditions for writing root paths and sub trees
+ * during a topic split
+*/
+class TreeRepository {
+
+ /**
+ * @var string
+ */
+ protected $tableName = 'flow_tree_node';
+
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @var BufferedCache
+ */
+ protected $cache;
+
+ /**
+ * @param DbFactory $dbFactory Factory to source connection objects from
+ * @param BufferedCache $cache
+ */
+ public function __construct( DbFactory $dbFactory, BufferedCache $cache ) {
+ $this->dbFactory = $dbFactory;
+ $this->cache = $cache;
+ }
+
+ /**
+ * A helper function to generate cache keys for tree repository
+ * @param string $type
+ * @param \Flow\Model\UUID $uuid
+ * @return string
+ */
+ protected function cacheKey( $type, UUID $uuid ) {
+ return wfForeignMemcKey( 'flow', '', 'tree', $type, $uuid->getAlphadecimal(), Container::get( 'cache.version' ) );
+ }
+
+ /**
+ * Insert a new tree node. If ancestor === null then this node is a root.
+ *
+ * To also write this to cache we would have to read our own write, which
+ * isn't guaranteed during a node split. Master reads can potentially be
+ * a different server than master writes.
+ *
+ * The way to do it without that is to CAS update memcache, assuming it currently
+ * has what we need
+ */
+ public function insert( UUID $descendant, UUID $ancestor = null ) {
+ $subtreeKey = $this->cacheKey( 'subtree', $descendant );
+ $parentKey = $this->cacheKey( 'parent', $descendant );
+ $pathKey = $this->cacheKey( 'rootpath', $descendant );
+ $this->cache->set( $subtreeKey, array( $descendant ) );
+ if ( $ancestor === null ) {
+ $this->cache->set( $parentKey, null );
+ $this->cache->set( $pathKey, array( $descendant ) );
+ $path = array( $descendant );
+ } else {
+ $this->cache->set( $parentKey, $ancestor );
+ $path = $this->findRootPath( $ancestor );
+ $path[] = $descendant;
+ $this->cache->set( $pathKey, $path );
+ }
+
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+ $res = $dbw->insert(
+ $this->tableName,
+ array(
+ 'tree_descendant_id' => $descendant->getBinary(),
+ 'tree_ancestor_id' => $descendant->getBinary(),
+ 'tree_depth' => 0,
+ ),
+ __METHOD__
+ );
+
+ if ( $res && $ancestor !== null ) {
+ try {
+ $res = $dbw->insertSelect(
+ $this->tableName,
+ $this->tableName,
+ array(
+ 'tree_descendant_id' => $dbw->addQuotes( $descendant->getBinary() ),
+ 'tree_ancestor_id' => 'tree_ancestor_id',
+ 'tree_depth' => 'tree_depth + 1',
+ ),
+ array(
+ 'tree_descendant_id' => $ancestor->getBinary(),
+ ),
+ __METHOD__
+ );
+ } catch( \DBQueryError $e ) {
+ $res = false;
+ }
+ /*
+ * insertSelect won't work on temporary tables (as used for MW
+ * unit tests), because it refers to the same table twice, in
+ * one query.
+ * In this case, we'll do a separate select & insert. This used
+ * to always be detected via the DBQueryError, but it can also
+ * return false from insertSelect.
+ *
+ * @see https://dev.mysql.com/doc/refman/5.0/en/temporary-table-problems.html
+ * @see http://dba.stackexchange.com/questions/45270/mysql-error-1137-hy000-at-line-9-cant-reopen-table-temp-table
+ */
+ if ( !$res && $dbw->lastErrno() === 1137 ) {
+ $rows = $dbw->select(
+ $this->tableName,
+ array( 'tree_depth', 'tree_ancestor_id' ),
+ array( 'tree_descendant_id' => $ancestor->getBinary() ),
+ __METHOD__
+ );
+
+ $res = true;
+ foreach ( $rows as $row ) {
+ $res &= $dbw->insert(
+ $this->tableName,
+ array(
+ 'tree_descendant_id' => $descendant->getBinary(),
+ 'tree_ancestor_id' => $row->tree_ancestor_id,
+ 'tree_depth' => $row->tree_depth + 1,
+ ),
+ __METHOD__
+ );
+ }
+ }
+ }
+
+ if ( !$res ) {
+ $this->cache->delete( $parentKey );
+ $this->cache->delete( $pathKey );
+ throw new DataModelException( 'Failed inserting new tree node', 'process-data' );
+ }
+ $this->appendToSubtreeCache( $descendant, $path );
+ return true;
+ }
+
+ protected function appendToSubtreeCache( UUID $descendant, array $rootPath ) {
+ $callback = function( BagOStuff $cache, $key, $value ) use( $descendant ) {
+ if ( $value === false ) {
+ return false;
+ }
+ $value[$descendant->getAlphadecimal()] = $descendant;
+ return $value;
+ };
+
+ // This could be pretty slow if there is contention
+ foreach ( $rootPath as $subtreeRoot ) {
+ $cacheKey = $this->cacheKey( 'subtree', $subtreeRoot );
+ $success = $this->cache->merge( $cacheKey, $callback );
+
+ // $success is always true if bufferCache starts with begin()
+ // if we failed to CAS new data, kill the cached value so it'll be
+ // re-fetched from DB
+ if ( !$success ) {
+ $this->cache->delete( $cacheKey );
+ }
+ }
+ }
+
+ public function findParent( UUID $descendant ) {
+ $map = $this->fetchParentMap( array( $descendant ) );
+ return isset( $map[$descendant->getAlphadecimal()] ) ? $map[$descendant->getAlphadecimal()] : null;
+ }
+
+ /**
+ * Given a list of nodes, find the path from each node to the root of its tree.
+ * the root must be the first element of the array, $node must be the last element.
+ * @param UUID[] $descendants Array of UUID objects to find the root paths for.
+ * @return UUID[][] Associative array, key is the post ID in hex, value is the path as an array.
+ */
+ public function findRootPaths( array $descendants ) {
+ // alphadecimal => cachekey
+ $cacheKeys = array();
+ // alphadecimal => cache result ( distance => parent uuid obj )
+ $cacheValues = array();
+ // list of binary values for db query
+ $missingValues = array();
+ // alphadecimal => distance => parent uuid obj
+ $paths = array();
+
+ foreach( $descendants as $descendant ) {
+ $cacheKeys[$descendant->getAlphadecimal()] = $this->cacheKey( 'rootpath', $descendant );
+ }
+
+ $cacheResult = $this->cache->getMulti( array_values( $cacheKeys ) );
+ foreach( $descendants as $descendant ) {
+ $alpha = $descendant->getAlphadecimal();
+ if ( isset( $cacheResult[$cacheKeys[$alpha]] ) ) {
+ $cacheValues[$alpha] = $cacheResult[$cacheKeys[$alpha]];
+ } else {
+ $missingValues[] = $descendant->getBinary();
+ $paths[$alpha] = array();
+ }
+ }
+
+ if ( ! count( $missingValues ) ) {
+ return $cacheValues;
+ }
+
+ $dbr = $this->dbFactory->getDB( DB_SLAVE );
+ $res = $dbr->select(
+ $this->tableName,
+ array( 'tree_descendant_id', 'tree_ancestor_id', 'tree_depth' ),
+ array(
+ 'tree_descendant_id' => $missingValues,
+ ),
+ __METHOD__
+ );
+
+ if ( !$res || $res->numRows() === 0 ) {
+ return $cacheValues;
+ }
+
+ foreach ( $res as $row ) {
+ $alpha = UUID::create( $row->tree_descendant_id )->getAlphadecimal();
+ $paths[$alpha][$row->tree_depth] = UUID::create( $row->tree_ancestor_id );
+ }
+
+ foreach( $paths as $descendantId => &$path ) {
+ if ( !$path ) {
+ $path = null;
+ continue;
+ }
+
+ // sort by reverse distance, so furthest away
+ // parent (root) is at position 0.
+ ksort( $path );
+ $path = array_reverse( $path );
+
+ $this->cache->set( $cacheKeys[$descendantId], $path );
+ }
+
+ return $paths + $cacheValues;
+ }
+
+ /**
+ * Finds the root path for a single post ID.
+ * @param UUID $descendant Post ID
+ * @return UUID[]|null Path to the root of that node.
+ */
+ public function findRootPath( UUID $descendant ) {
+ $paths = $this->findRootPaths( array( $descendant ) );
+
+ return isset( $paths[$descendant->getAlphadecimal()] ) ? $paths[$descendant->getAlphadecimal()] : null;
+ }
+
+ /**
+ * Finds the root posts of a list of posts.
+ * @param UUID[] $descendants Array of PostRevision objects to find roots for.
+ * @return UUID[] Associative array of post ID (as hex) to UUID object representing its root.
+ */
+ public function findRoots( array $descendants ) {
+ $paths = $this->findRootPaths( $descendants );
+ $roots = array();
+
+ foreach( $descendants as $descendant ) {
+ $alpha = $descendant->getAlphadecimal();
+ if ( isset( $paths[$alpha] ) ) {
+ $roots[$alpha] = $paths[$alpha][0];
+ }
+ }
+
+ return $roots;
+ }
+
+ /**
+ * Given a specific child node find the associated root node
+ *
+ * @param UUID $descendant
+ * @return UUID
+ * @throws DataModelException
+ */
+ public function findRoot( UUID $descendant ) {
+ // To simplify caching we will work through the root path instead
+ // of caching our own value
+ $path = $this->findRootPath( $descendant );
+ if ( !$path ) {
+ throw new DataModelException( $descendant->getAlphadecimal().' has no root post. Probably is a root post.', 'process-data' );
+ }
+
+ $root = array_shift( $path );
+
+ return $root;
+ }
+
+ /**
+ * Fetch a node and all its descendants. Children are returned in the
+ * same order they were inserted.
+ *
+ * @param UUID|UUID[] $roots
+ * @return array Multi-dimensional tree. The top level is a map from the uuid of a node
+ * to attributes about that node. The top level contains not just the parents, but all nodes
+ * within this tree. Within each node there is a 'children' key that contains a map from
+ * the child uuid's to references back to the top level of this identity map. As such this
+ * result can be read either as a list or a tree.
+ * @throws DataModelException When invalid data is received from self::fetchSubtreeNodeList
+ */
+ public function fetchSubtreeIdentityMap( $roots ) {
+ $roots = ObjectManager::makeArray( $roots );
+ if ( !$roots ) {
+ return array();
+ }
+ $nodes = $this->fetchSubtreeNodeList( $roots );
+ if ( !$nodes ) {
+ throw new DataModelException( 'subtree node list should have at least returned root: ' . $root, 'process-data' );
+ } elseif ( count( $nodes ) === 1 ) {
+ $parentMap = $this->fetchParentMap( reset( $nodes ) );
+ } else {
+ $parentMap = $this->fetchParentMap( call_user_func_array( 'array_merge', $nodes ) );
+ }
+ $identityMap = array();
+ foreach ( $parentMap as $child => $parent ) {
+ if ( !array_key_exists( $child, $identityMap ) ) {
+ $identityMap[$child] = array( 'children' => array() );
+ }
+ // Root nodes have no parent
+ if ( $parent !== null ) {
+ $identityMap[$parent->getAlphadecimal()]['children'][$child] =& $identityMap[$child];
+ }
+ }
+ foreach ( array_keys( $identityMap ) as $parent ) {
+ ksort( $identityMap[$parent]['children'] );
+ }
+
+ return $identityMap;
+ }
+
+ public function fetchSubtree( UUID $root, $maxDepth = null ) {
+ $identityMap = $this->fetchSubtreeIdentityMap( $root, $maxDepth );
+ if ( !isset( $identityMap[$root->getAlphadecimal()] ) ) {
+ throw new DataModelException( 'No root exists in the identityMap', 'process-data' );
+ }
+
+ return $identityMap[$root];
+ }
+
+ public function fetchFullTree( UUID $nodeId ) {
+ return $this->fetchSubtree( $this->findRoot( $nodeId ) );
+ }
+
+ /**
+ * Return the id's of all nodes which are a descendant of provided roots
+ *
+ * @param UUID[] $roots
+ * @return array map from root id to its descendant list
+ */
+ public function fetchSubtreeNodeList( array $roots ) {
+ $list = new MultiGetList( $this->cache );
+ $res = $list->get(
+ array( 'tree', 'subtree' ),
+ $roots,
+ array( $this, 'fetchSubtreeNodeListFromDb' )
+ );
+ if ( $res === false ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failure fetching node list from cache' );
+ return false;
+ }
+ // $idx is a binary UUID
+ $retval = array();
+ foreach ( $res as $idx => $val ) {
+ $retval[UUID::create( $idx )->getAlphadecimal()] = $val;
+ }
+ return $retval;
+ }
+
+ public function fetchSubtreeNodeListFromDb( array $roots ) {
+ $res = $this->dbFactory->getDB( DB_SLAVE )->select(
+ $this->tableName,
+ array( 'tree_ancestor_id', 'tree_descendant_id' ),
+ array(
+ 'tree_ancestor_id' => UUID::convertUUIDs( $roots ),
+ ),
+ __METHOD__
+ );
+ if ( $res === false ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failure fetching node list from database' );
+ return false;
+ }
+ if ( !$res ) {
+ return array();
+ }
+ $nodes = array();
+ foreach ( $res as $node ) {
+ $ancestor = UUID::create( $node->tree_ancestor_id );
+ $descendant = UUID::create( $node->tree_descendant_id );
+ $nodes[$ancestor->getAlphadecimal()][$descendant->getAlphadecimal()] = $descendant;
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * Fetch the id of the immediate parent node of all ids in $nodes. Non-existent
+ * nodes are not represented in the result set.
+ */
+ public function fetchParentMap( array $nodes ) {
+ $list = new MultiGetList( $this->cache );
+ return $list->get(
+ array( 'tree', 'parent' ),
+ $nodes,
+ array( $this, 'fetchParentMapFromDb' )
+ );
+ }
+
+ /**
+ * @param UUID[] $nodes
+ * @return UUID[]
+ * @throws \Flow\Exception\DataModelException
+ */
+ public function fetchParentMapFromDb( array $nodes ) {
+ // Find out who the parent is for those nodes
+ $dbr = $this->dbFactory->getDB( DB_SLAVE );
+ $res = $dbr->select(
+ $this->tableName,
+ array( 'tree_ancestor_id', 'tree_descendant_id' ),
+ array(
+ 'tree_descendant_id' => UUID::convertUUIDs( $nodes ),
+ 'tree_depth' => 1,
+ ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ return array();
+ }
+ $result = array();
+ foreach ( $res as $node ) {
+ if ( isset( $result[$node->tree_descendant_id] ) ) {
+ throw new DataModelException( 'Already have a parent for ' . $node->tree_descendant_id, 'process-data' );
+ }
+ $descendant = UUID::create( $node->tree_descendant_id );
+ $result[$descendant->getAlphadecimal()] = UUID::create( $node->tree_ancestor_id );
+ }
+ foreach ( $nodes as $node ) {
+ if ( !isset( $result[$node->getAlphadecimal()] ) ) {
+ // $node is a root, it has no parent
+ $result[$node->getAlphadecimal()] = null;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/Flow/includes/Repository/UserName/OneStepUserNameQuery.php b/Flow/includes/Repository/UserName/OneStepUserNameQuery.php
new file mode 100644
index 00000000..4adf018b
--- /dev/null
+++ b/Flow/includes/Repository/UserName/OneStepUserNameQuery.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Flow\Repository\UserName;
+
+use Flow\DbFactory;
+
+/**
+ * Provide usernames filtered by per-wiki ipblocks. Batches together
+ * database requests for multiple usernames when possible.
+ */
+class OneStepUserNameQuery implements UserNameQuery {
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @param DbFactory $dbFactory
+ */
+ public function __construct( DbFactory $dbFactory ) {
+ $this->dbFactory = $dbFactory;
+ }
+
+ /**
+ * Look up usernames while respecting ipblocks with one query.
+ * Unused, check to see if this is reasonable to use.
+ *
+ * @param string $wiki
+ * @param array $userIds
+ * @return \ResultWrapper|null
+ */
+ public function execute( $wiki, array $userIds ) {
+ $dbr = $this->dbFactory->getWikiDb( DB_SLAVE, array(), $wiki );
+ return $dbr->select(
+ /* table */ array( 'user', 'ipblocks' ),
+ /* select */ array( 'user_id', 'user_name' ),
+ /* conds */ array(
+ 'user_id' => $userIds,
+ // only accept records that did not match ipblocks
+ 'ipb_deleted is null'
+ ),
+ __METHOD__,
+ /* options */ array(),
+ /* join_conds */ array(
+ 'ipblocks' => array( 'LEFT OUTER', array(
+ 'ipb_user' => 'user_id',
+ // match only deleted users
+ 'ipb_deleted' => 1,
+ ) )
+ )
+ );
+ }
+}
diff --git a/Flow/includes/Repository/UserName/TwoStepUserNameQuery.php b/Flow/includes/Repository/UserName/TwoStepUserNameQuery.php
new file mode 100644
index 00000000..c23ffb4b
--- /dev/null
+++ b/Flow/includes/Repository/UserName/TwoStepUserNameQuery.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Provide usernames filtered by per-wiki ipblocks. Batches together
+ * database requests for multiple usernames when possible.
+ */
+namespace Flow\Repository\UserName;
+
+use Flow\DbFactory;
+use Flow\Exception\FlowException;
+
+/**
+ * Helper query for UserNameBatch fetches requested userIds
+ * from the wiki with two independent queries. There is
+ * a different implementation that does this in one query
+ * with a join.
+ *
+ * @todo Is TwoStep usefull? shouldn't we always use the join?
+ */
+class TwoStepUserNameQuery implements UserNameQuery {
+ /**
+ * @var DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @param DbFactory $dbFactory
+ */
+ public function __construct( DbFactory $dbFactory ) {
+ $this->dbFactory = $dbFactory;
+ }
+
+ /**
+ * Look up usernames while respecting ipblocks with two queries
+ *
+ * @param string $wiki
+ * @param array $userIds
+ * @return \ResultWrapper|bool
+ * @throws FlowException
+ */
+ public function execute( $wiki, array $userIds ) {
+ if ( !$wiki ) {
+ throw new FlowException( 'No wiki provided with user ids' );
+ }
+
+ $dbr = $this->dbFactory->getWikiDB( DB_SLAVE, array(), $wiki );
+ $res = $dbr->select(
+ 'ipblocks',
+ 'ipb_user',
+ array(
+ 'ipb_user' => $userIds,
+ 'ipb_deleted' => 1,
+ ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ return $res;
+ }
+ $blocked = array();
+ foreach ( $res as $row ) {
+ $blocked[] = $row->ipb_user;
+ }
+ // return ids in $userIds that are not in $blocked
+ $allowed = array_diff( $userIds, $blocked );
+ if ( !$allowed ) {
+ return false;
+ }
+ return $dbr->select(
+ 'user',
+ array( 'user_id', 'user_name' ),
+ array( 'user_id' => $allowed ),
+ __METHOD__
+ );
+ }
+}
diff --git a/Flow/includes/Repository/UserName/UserNameQuery.php b/Flow/includes/Repository/UserName/UserNameQuery.php
new file mode 100644
index 00000000..ac9cd6d0
--- /dev/null
+++ b/Flow/includes/Repository/UserName/UserNameQuery.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * Provide usernames filtered by per-wiki ipblocks. Batches together
+ * database requests for multiple usernames when possible.
+ */
+namespace Flow\Repository\UserName;
+
+/**
+ * Classes implementing the interface can lookup
+ * user names based on wiki + id
+ */
+Interface UserNameQuery {
+ /**
+ * @param string $wiki wiki id
+ * @param array $userIds List of user ids to lookup
+ * @return \ResultWrapper|bool Containing objects with user_id and
+ * user_name properies.
+ */
+ function execute( $wiki, array $userIds );
+}
diff --git a/Flow/includes/Repository/UserNameBatch.php b/Flow/includes/Repository/UserNameBatch.php
new file mode 100644
index 00000000..72a04489
--- /dev/null
+++ b/Flow/includes/Repository/UserNameBatch.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Provide usernames filtered by per-wiki ipblocks. Batches together
+ * database requests for multiple usernames when possible.
+ */
+namespace Flow\Repository;
+
+use Flow\Model\UserTuple;
+use User;
+
+/**
+ * Batch together queries for a bunch of wiki+userid -> username
+ */
+class UserNameBatch {
+ /**
+ * @var UserName\UserNameQuery
+ */
+ protected $query;
+
+ /**
+ * @var array[] map from wikiid to list of userid's to request
+ */
+ protected $queued = array();
+
+ /**
+ * @var array[] 2-d map from wiki id and user id to display username or false
+ */
+ protected $usernames = array();
+
+ /**
+ * @param UserName\UserNameQuery $query
+ * @param array $queued map from wikiid to list of userid's to request
+ */
+ public function __construct( UserName\UserNameQuery $query, array $queued = array() ) {
+ $this->query = $query;
+ foreach ( $queued as $wiki => $userIds ) {
+ $this->queued[$wiki] = array_map( 'intval', $userIds );
+ }
+ }
+
+ /**
+ * @param string $wiki
+ * @param integer $userId
+ * @param string $userName Non null to set known usernames like $wgUser
+ */
+ public function add( $wiki, $userId, $userName = null ) {
+ $userId = (int)$userId;
+ if ( $userName !== null ) {
+ $this->usernames[$wiki][$userId] = $userName;
+ } elseif ( !isset( $this->usernames[$wiki][$userId] ) ) {
+ $this->queued[$wiki][] = $userId;
+ }
+ }
+
+ /**
+ * @param UserTuple $tuple
+ */
+ public function addFromTuple( UserTuple $tuple ) {
+ $this->add( $tuple->wiki, $tuple->id, $tuple->ip );
+ }
+
+ /**
+ * Get the displayable username
+ *
+ * @param string $wiki
+ * @param integer $userId
+ * @param string|boolean $userIp
+ * @return string|boolean false if username is not found or display is suppressed
+ * @todo Return something better for not found / suppressed, but what? Making
+ * return type string|Message would suck.
+ */
+ public function get( $wiki, $userId, $userIp = false ) {
+ $userId = (int)$userId;
+ if ( $userId === 0 ) {
+ return $userIp;
+ }
+ if ( !isset( $this->usernames[$wiki][$userId] ) ) {
+ $this->queued[$wiki][] = $userId;
+ $this->resolve( $wiki );
+ }
+ return $this->usernames[$wiki][$userId];
+ }
+
+ /**
+ * @param UserTuple $tuple
+ * @return string|boolean false if username is not found or display is suppressed
+ */
+ public function getFromTuple( UserTuple $tuple ) {
+ return $this->get( $tuple->wiki, $tuple->id, $tuple->ip );
+ }
+
+ /**
+ * Resolve all queued user ids to usernames for the given wiki
+ *
+ * @param string $wiki
+ */
+ public function resolve( $wiki ) {
+ if ( empty( $this->queued[$wiki] ) ) {
+ return;
+ }
+ $queued = array_unique( $this->queued[$wiki] );
+ if ( isset( $this->usernames[$wiki] ) ) {
+ $queued = array_diff( $queued, array_keys( $this->usernames[$wiki] ) );
+ }
+ $res = $this->query->execute( $wiki, $queued );
+ unset( $this->queued[$wiki] );
+ if ( $res ) {
+ $usernames = array();
+ foreach ( $res as $row ) {
+ $id = (int)$row->user_id;
+ $this->usernames[$wiki][$id] = $usernames[$id] = $row->user_name;
+ }
+ $this->resolveUserPages( $wiki, $usernames );
+ $missing = array_diff( $queued, array_keys( $usernames ) );
+ } else {
+ $missing = $queued;
+ }
+ foreach ( $missing as $id ) {
+ $this->usernames[$wiki][$id] = false;
+ }
+ }
+
+ /**
+ * Update in-process title existence cache with NS_USER and
+ * NS_USER_TALK pages related to the provided usernames.
+ *
+ * @param string $wiki Wiki the users belong to
+ * @param array $usernames List of user names
+ */
+ protected function resolveUserPages( $wiki, array $usernames ) {
+ // LinkBatch currently only supports the current wiki
+ if ( $wiki !== wfWikiId() || !$usernames ) {
+ return;
+ }
+
+ $lb = new \LinkBatch();
+ foreach ( $usernames as $name ) {
+ $user = User::newFromName( $name );
+ if ( $user ) {
+ $lb->addObj( $user->getUserPage() );
+ $lb->addObj( $user->getTalkPage() );
+ }
+ }
+ $lb->setCaller( __METHOD__ );
+ $lb->execute();
+ }
+}
diff --git a/Flow/includes/RevisionActionPermissions.php b/Flow/includes/RevisionActionPermissions.php
new file mode 100644
index 00000000..a52784af
--- /dev/null
+++ b/Flow/includes/RevisionActionPermissions.php
@@ -0,0 +1,215 @@
+<?php
+
+namespace Flow;
+
+use Flow\Collection\CollectionCache;
+use Flow\Exception\InvalidDataException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Closure;
+use User;
+
+/**
+ * Role based security for revisions based on moderation state
+ */
+class RevisionActionPermissions {
+ /**
+ * @var FlowActions
+ */
+ protected $actions = array();
+
+ /**
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * @param FlowActions $actions
+ * @param User $user
+ */
+ public function __construct( FlowActions $actions, User $user ) {
+ $this->user = $user;
+ $this->actions = $actions;
+ }
+
+ /**
+ * Get the name of all the actions the user is allowed to perform.
+ *
+ * @param AbstractRevision|null $revision The revision to check permissions against
+ * @return array Array of action names that are allowed
+ */
+ public function getAllowedActions( AbstractRevision $revision = null ) {
+ $allowed = array();
+ foreach( array_keys( $this->actions->getActions() ) as $action ) {
+ if ( $this->isAllowedAny( $revision, $action ) ) {
+ $allowed[] = $action;
+ }
+ }
+ return $allowed;
+ }
+
+ /**
+ * Check if a user is allowed to perform a certain action.
+ *
+ * @param AbstractRevision|null $revision
+ * @param string $action
+ * @return bool
+ */
+ public function isAllowed( AbstractRevision $revision = null, $action ) {
+ $allowed = $this->isRevisionAllowed( $revision, $action );
+
+ if ( $allowed && $revision instanceof PostRevision ) {
+ $allowed = $this->isRootAllowed( $revision, $action );
+ }
+
+ // if there was no revision object, it's pointless to find last revision
+ // if we already fail, no need in checking most recent revision status
+ if ( $allowed && $revision !== null ) {
+ try {
+ // Also check if the user would be allowed to perform this against
+ // against the most recent revision - the last revision is the
+ // current state of an object, so checking against a revision at one
+ // point in time alone isn't enough.
+ /** @var CollectionCache $cache */
+ $cache = Container::get( 'collection.cache' );
+ $last = $cache->getLastRevisionFor( $revision );
+ $isLastRevision = $last->getRevisionId()->equals( $revision->getRevisionId() );
+ $allowed = $isLastRevision || $this->isRevisionAllowed( $last, $action );
+ } catch ( InvalidDataException $e ) {
+ // If data is not in storage, just return that revision's status
+ }
+ }
+ return $allowed;
+ }
+
+ /**
+ * Check if a user is allowed to perform certain actions.
+ *
+ * @param AbstractRevision|null $revision
+ * @param string $action Multiple parameters to check if either of the provided actions are allowed
+ * @return bool
+ */
+ public function isAllowedAny( AbstractRevision $revision = null, $action /* [, $action2 [, ... ]] */ ) {
+ $actions = func_get_args();
+ // Pull $revision out of the actions list
+ array_shift( $actions );
+ $allowed = false;
+
+ foreach ( $actions as $action ) {
+ $allowed |= $this->isAllowed( $revision, $action );
+
+ // as soon as we've found one that is allowed, break
+ if ( $allowed ) {
+ break;
+ }
+ }
+
+ return $allowed;
+ }
+
+ /**
+ * Check if a user is allowed to perform a certain action, against the latest
+ * root(topic) post related to the provided revision. This is required for
+ * things like preventing replys to locked topics.
+ *
+ * @param PostRevision $revision
+ * @param string $action
+ * @return bool
+ */
+ protected function isRootAllowed( PostRevision $revision, $action ) {
+ // If the revision is a root then this does not apply.
+ if ( $revision->isTopicTitle() ) {
+ return true;
+ }
+ // If the `root-permissions` key is not set then it is allowed
+ if ( !$this->actions->hasValue( $action, 'root-permissions' ) ) {
+ return true;
+ }
+
+ $root = $revision->getRootPost();
+ $permission = $this->getPermission( $root, $action, 'root-permissions' );
+
+ // If `root-permissions` is defined but not for the current state
+ // then action is denied
+ if ( $permission === null ) {
+ return false;
+ }
+
+ return call_user_func_array(
+ array( $this->user, 'isAllowedAny' ),
+ (array) $permission
+ );
+ }
+
+ /**
+ * Check if a user is allowed to perform a certain action, only against 1
+ * specific revision (whereas the default isAllowed() will check if the
+ * given $action is allowed for both given and the most current revision)
+ *
+ * @param AbstractRevision|null $revision
+ * @param string $action
+ * @return bool
+ */
+ protected function isRevisionAllowed( AbstractRevision $revision = null, $action ) {
+ // Users must have the core 'edit' permission to perform any write action in flow
+ $performsWrites = $this->actions->getValue( $action, 'performs-writes' );
+ if ( $performsWrites && !$this->user->isAllowed( 'edit' ) ) {
+ return false;
+ }
+
+ $permission = $this->getPermission( $revision, $action );
+
+ // If no permission is defined for this state, then the action is not allowed
+ // check if permission is set for this action
+ if ( $permission === null ) {
+ return false;
+ }
+
+ // Check if user is allowed to perform action against this revision
+ return call_user_func_array(
+ array( $this->user, 'isAllowedAny' ),
+ (array) $permission
+ );
+ }
+
+ /**
+ * Returns the permission specified in FlowActions for the given action
+ * against the given revision's moderation state.
+ *
+ * @param AbstractRevision|null $revision
+ * @param string $action
+ * @param string $type
+ * @return Closure|string
+ */
+ public function getPermission( AbstractRevision $revision = null, $action, $type = 'permissions' ) {
+ // $revision may be null if the revision has yet to be created
+ $moderationState = AbstractRevision::MODERATED_NONE;
+ if ( $revision !== null ) {
+ $moderationState = $revision->getModerationState();
+ }
+ $permission = $this->actions->getValue( $action, $type, $moderationState );
+
+ // Some permissions may be more complex to be defined as simple array
+ // values, in which case they're a Closure (which will accept
+ // AbstractRevision & FlowActionPermissions as arguments)
+ if ( $permission instanceof Closure ) {
+ $permission = $permission( $revision, $this );
+ }
+
+ return $permission;
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * @return FlowActions
+ */
+ public function getActions() {
+ return $this->actions;
+ }
+}
diff --git a/Flow/includes/SpamFilter/AbuseFilter.php b/Flow/includes/SpamFilter/AbuseFilter.php
new file mode 100644
index 00000000..726a4aae
--- /dev/null
+++ b/Flow/includes/SpamFilter/AbuseFilter.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Status;
+use Title;
+use User;
+
+class AbuseFilter implements SpamFilter {
+ /**
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * @var string
+ */
+ protected $group;
+
+ /**
+ * @param User $user The user submitting content
+ * @param string $group The abuse filter group to use
+ */
+ public function __construct( User $user, $group ) {
+ $this->user = $user;
+ $this->group = $group;
+ }
+
+ /**
+ * Set up AbuseFilter for Flow extension
+ *
+ * @param array $emergencyDisable optional AbuseFilter emergency disable values
+ */
+ public function setup( array $emergencyDisable = array() ) {
+ global
+ $wgAbuseFilterValidGroups,
+ $wgAbuseFilterEmergencyDisableThreshold,
+ $wgAbuseFilterEmergencyDisableCount,
+ $wgAbuseFilterEmergencyDisableAge;
+
+ if ( !$this->enabled() ) {
+ return;
+ }
+
+ // if no Flow-specific emergency disable threshold given, use defaults
+ $emergencyDisable += array(
+ 'threshold' => $wgAbuseFilterEmergencyDisableThreshold['default'],
+ 'count' => $wgAbuseFilterEmergencyDisableCount['default'],
+ 'age' => $wgAbuseFilterEmergencyDisableAge['default'],
+ );
+
+ // register Flow's AbuseFilter filter group
+ if ( !in_array( $this->group, $wgAbuseFilterValidGroups ) ) {
+ $wgAbuseFilterValidGroups[] = $this->group;
+
+ // AbuseFilter emergency disable values for Flow
+ $wgAbuseFilterEmergencyDisableThreshold[$this->group] = $emergencyDisable['threshold'];
+ $wgAbuseFilterEmergencyDisableCount[$this->group] = $emergencyDisable['count'];
+ $wgAbuseFilterEmergencyDisableAge[$this->group] = $emergencyDisable['age'];
+ }
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ $vars = \AbuseFilter::getEditVars( $title );
+ $vars->addHolders( \AbuseFilter::generateUserVars( $this->user ), \AbuseFilter::generateTitleVars( $title , 'ARTICLE' ) );
+ $vars->setVar( 'ACTION', $newRevision->getChangeType() );
+
+ /*
+ * This should not roundtrip to Parsoid; AbuseFilter checks will be
+ * performed upon submitting new content, and content is always
+ * submitted in wikitext. It will only be transformed once it's being
+ * saved to DB.
+ */
+ $vars->setLazyLoadVar( 'new_wikitext', 'FlowRevisionContent', array( 'revision' => $newRevision ) );
+ $vars->setLazyLoadVar( 'new_size', 'length', array( 'length-var' => 'new_wikitext' ) );
+
+ /*
+ * This may roundtrip to Parsoid if content is stored in HTML.
+ * Since the variable is lazy-loaded, it will not roundtrip unless the
+ * variable is actually used.
+ */
+ $vars->setLazyLoadVar( 'old_wikitext', 'FlowRevisionContent', array( 'revision' => $oldRevision ) );
+ $vars->setLazyLoadVar( 'old_size', 'length', array( 'length-var' => 'old_wikitext' ) );
+
+ return \AbuseFilter::filterAction( $vars, $title, $this->group );
+ }
+
+ /**
+ * Checks if AbuseFilter is installed.
+ *
+ * @return bool
+ */
+ public function enabled() {
+ return class_exists( 'AbuseFilter' ) && (bool) $this->group;
+ }
+
+ /**
+ * Additional lazy-load methods for dealing with AbstractRevision objects,
+ * to delay processing data until/if variables are actually used.
+ *
+ * @return array
+ */
+ public function lazyLoadMethods() {
+ return array(
+ /**
+ * @param string $method: Method to generate the variable
+ * @param \AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param mixed &$result Result of the computation
+ */
+ 'FlowRevisionContent' => function ( \AbuseFilterVariableHolder $vars, $parameters ) {
+ if ( !isset( $parameters['revision'] ) ) {
+ return '';
+ }
+ $revision = $parameters['revision'];
+ if ( $revision instanceof AbstractRevision ) {
+ return $revision->getContent( 'wikitext' );
+ } else {
+ return '';
+ }
+ }
+ );
+ }
+}
diff --git a/Flow/includes/SpamFilter/ConfirmEdit.php b/Flow/includes/SpamFilter/ConfirmEdit.php
new file mode 100644
index 00000000..89baa3e3
--- /dev/null
+++ b/Flow/includes/SpamFilter/ConfirmEdit.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use ConfirmEditHooks;
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use SimpleCaptcha;
+use Status;
+use Title;
+use WikiPage;
+
+class ConfirmEdit implements SpamFilter {
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ global $wgOut;
+ $newContent = $newRevision->getContent( 'wikitext' );
+ $oldContent = ( $oldRevision !== null ) ? $oldRevision->getContent( 'wikitext' ) : '';
+
+ /** @var SimpleCaptcha $captcha */
+ $captcha = ConfirmEditHooks::getInstance();
+ $wikiPage = new WikiPage( $title );
+
+ // first check if the submitted content is offensive (as flagged by
+ // ConfirmEdit), next check for a (valid) captcha to have been entered
+ if ( $captcha->shouldCheck( $wikiPage, $newContent, false, false, $oldContent ) && !$captcha->passCaptcha() ) {
+ // getting here means we submitted bad content without good captcha
+ // result (or any captcha result at all) - let's get the captcha
+ // HTML to display as error message!
+ $html = $captcha->getForm();
+
+ // some captcha implementations need CSS and/or JS, which is added
+ // via their getForm() methods (which we just called) -
+ // let's extract those and respond them along with the form HTML
+ $html = $wgOut->buildCssLinks() .
+ $wgOut->getScriptsForBottomQueue( true ) .
+ $html;
+
+ $msg = wfMessage( 'flow-spam-confirmedit-form' )->rawParams( $html );
+ return Status::newFatal( $msg );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Checks if ConfirmEdit is installed.
+ *
+ * @return bool
+ */
+ public function enabled() {
+ return class_exists( 'ConfirmEditHooks' );
+ }
+}
diff --git a/Flow/includes/SpamFilter/ContentLengthFilter.php b/Flow/includes/SpamFilter/ContentLengthFilter.php
new file mode 100644
index 00000000..b8cb2702
--- /dev/null
+++ b/Flow/includes/SpamFilter/ContentLengthFilter.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Status;
+use Title;
+
+class ContentLengthFilter implements SpamFilter {
+
+ /**
+ * @var integer The maximum number of characters of wikitext to allow through filter
+ */
+ protected $maxLength;
+
+ public function __construct( $maxLength = 25600 ) {
+ $this->maxLength = $maxLength;
+ }
+
+ public function enabled() {
+ return true;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ return $newRevision->getContentLength() > $this->maxLength
+ ? Status::newFatal( 'flow-error-content-too-long', $this->maxLength )
+ : Status::newGood();
+ }
+}
diff --git a/Flow/includes/SpamFilter/Controller.php b/Flow/includes/SpamFilter/Controller.php
new file mode 100644
index 00000000..a6dde37f
--- /dev/null
+++ b/Flow/includes/SpamFilter/Controller.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Title;
+use Status;
+
+class Controller {
+ /**
+ * @var SpamFilter[] Array of SpamFilter objects
+ */
+ protected $spamfilters = array();
+
+ /**
+ * Accepts multiple spamfilters.
+ *
+ * @param SpamFilter $spamfilter...
+ * @throws FlowException When provided arguments are not an instance of SpamFilter
+ */
+ public function __construct( SpamFilter $spamfilter /* [, SpamFilter $spamfilter2 [, ...]] */ ) {
+ $this->spamfilters = array_filter( func_get_args() );
+
+ // validate data
+ foreach ( $this->spamfilters as $spamfilter ) {
+ if ( !$spamfilter instanceof SpamFilter ) {
+ throw new FlowException( 'Invalid spamfilter', 'default' );
+ }
+ }
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ foreach ( $this->spamfilters as $spamfilter ) {
+ if ( !$spamfilter->enabled() ) {
+ continue;
+ }
+
+ $status = $spamfilter->validate( $context, $newRevision, $oldRevision, $title );
+
+ // no need to go through other filters when invalid data is discovered
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ }
+
+ return Status::newGood();
+ }
+}
diff --git a/Flow/includes/SpamFilter/SpamBlacklist.php b/Flow/includes/SpamFilter/SpamBlacklist.php
new file mode 100644
index 00000000..9b4e9d74
--- /dev/null
+++ b/Flow/includes/SpamFilter/SpamBlacklist.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use BaseBlacklist;
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Status;
+use Title;
+
+class SpamBlacklist implements SpamFilter {
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ $spamObj = BaseBlacklist::getInstance( 'spam' );
+ if ( !$spamObj instanceof \SpamBlacklist ) {
+ wfWarn( __METHOD__ . ': Expected a SpamBlacklist instance but instead received: ' . get_class( $spamObj ) );
+ return Status::newFatal( 'something' );
+ }
+ $links = $this->getLinks( $newRevision, $title );
+ $matches = $spamObj->filter( $links, $title );
+
+ if ( $matches !== false ) {
+ $status = Status::newFatal( 'spamprotectiontext' );
+
+ foreach ( $matches as $match ) {
+ $status->fatal( 'spamprotectionmatch', $match );
+ }
+
+ return $status;
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @param Title $title
+ * @return array
+ */
+ public function getLinks( AbstractRevision $revision, Title $title ) {
+ global $wgParser;
+ $options = new \ParserOptions;
+ $output = $wgParser->parse( $revision->getContent( 'wikitext' ), $title, $options );
+ return array_keys( $output->getExternalLinks() );
+ }
+
+ /**
+ * Checks if SpamBlacklist is enabled.
+ *
+ * @return bool
+ */
+ public function enabled() {
+ return class_exists( 'BaseBlacklist' );
+ }
+}
diff --git a/Flow/includes/SpamFilter/SpamFilter.php b/Flow/includes/SpamFilter/SpamFilter.php
new file mode 100644
index 00000000..12841e28
--- /dev/null
+++ b/Flow/includes/SpamFilter/SpamFilter.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Status;
+use Title;
+
+interface SpamFilter {
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title );
+
+ /**
+ * @return bool
+ */
+ public function enabled();
+}
diff --git a/Flow/includes/SpamFilter/SpamRegex.php b/Flow/includes/SpamFilter/SpamRegex.php
new file mode 100644
index 00000000..f3472f08
--- /dev/null
+++ b/Flow/includes/SpamFilter/SpamRegex.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow\SpamFilter;
+
+use Flow\Model\AbstractRevision;
+use IContextSource;
+use Status;
+use Title;
+
+class SpamRegex implements SpamFilter {
+ /**
+ * @param IContextSource $context
+ * @param AbstractRevision $newRevision
+ * @param AbstractRevision|null $oldRevision
+ * @param Title $title
+ * @return Status
+ */
+ public function validate( IContextSource $context, AbstractRevision $newRevision, AbstractRevision $oldRevision = null, Title $title ) {
+ global $wgSpamRegex;
+
+ /*
+ * This should not roundtrip to Parsoid; SpamRegex checks will be
+ * performed upon submitting new content, and content is always
+ * submitted in wikitext. It will only be transformed once it's being
+ * saved to DB.
+ */
+ $text = $newRevision->getContent( 'wikitext' );
+
+ // back compat, $wgSpamRegex may be a single string or an array of regexes
+ $regexes = (array) $wgSpamRegex;
+
+ foreach ( $regexes as $regex ) {
+ if ( preg_match( $regex, $text, $matches ) ) {
+ return Status::newFatal( 'spamprotectionmatch', $matches[0] );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Checks if SpamRegex is enabled.
+ *
+ * @return bool
+ */
+ public function enabled() {
+ global $wgSpamRegex;
+ return (bool) $wgSpamRegex;
+ }
+}
diff --git a/Flow/includes/Specials/SpecialEnableFlow.php b/Flow/includes/Specials/SpecialEnableFlow.php
new file mode 100644
index 00000000..d6f22fb9
--- /dev/null
+++ b/Flow/includes/Specials/SpecialEnableFlow.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Flow\Specials;
+
+use FormSpecialPage;
+use Status;
+use Title;
+use Flow\Container;
+
+/**
+ * A special page that allows users with the flow-create-board right to create
+ * boards where there no page exists
+ */
+class SpecialEnableFlow extends FormSpecialPage {
+ /**
+ * @var \Flow\WorkflowLoaderFactory $loaderFactory
+ */
+ protected $loaderFactory;
+
+ /** @var \Flow\TalkpageManager $controller */
+ protected $occupationController;
+
+ /**
+ * @var string $page Full page name that was converted to a board
+ */
+ protected $page;
+
+ public function __construct() {
+ parent::__construct( 'EnableFlow', 'flow-create-board' );
+
+ $this->loaderFactory = Container::get( 'factory.loader.workflow' );
+ $this->occupationController = Container::get( 'occupation_controller' );
+ }
+
+ protected function getFormFields() {
+ return array(
+ 'page' => array(
+ 'type' => 'text',
+ 'label-message' => 'flow-special-enableflow-page',
+ ),
+ 'header' => array(
+ 'type' => 'textarea',
+ 'label-message' => 'flow-special-enableflow-header'
+ ),
+ );
+ }
+
+ protected function getDisplayFormat() {
+ return 'vform';
+ }
+
+ protected function getMessagePrefix() {
+ return 'flow-special-enableflow';
+ }
+
+ /**
+ * Check that Flow board does not exist, then create it
+ *
+ * @param array $data Form data
+ * @return Status Status indicating result
+ */
+ public function onSubmit( array $data ) {
+ $page = $data['page'];
+ $title = Title::newFromText( $page );
+ if ( !$title ) {
+ return Status::newFatal( 'flow-special-enableflow-invalid-title', $page );
+ }
+
+ // Canonicalize so the error or confirmation message looks nicer (no underscores).
+ $page = $title->getPrefixedText();
+
+ if ( $this->occupationController->isTalkpageOccupied( $title, true ) ) {
+ return Status::newFatal( 'flow-special-enableflow-board-already-exists', $page );
+ }
+
+ if ( !$this->occupationController->allowCreation( $title, $this->getUser() ) ) {
+ // This is the only plausible reason this method would return false here.
+ // If there is another possible reason, we should have the method return a
+ // Status.
+ return Status::newFatal( 'flow-special-enableflow-page-already-exists', $page );
+ }
+
+ $loader = $this->loaderFactory->createWorkflowLoader( $title );
+ $blocks = $loader->getBlocks();
+
+ $action = 'edit-header';
+
+ $params = array(
+ 'header' => array(
+ 'content' => $data['header'],
+ 'format' => 'wikitext',
+ ),
+ );
+
+ $blocksToCommit = $loader->handleSubmit(
+ $this->getContext(),
+ $action,
+ $params
+ );
+
+ $status = Status::newGood();
+
+ foreach( $blocks as $block ) {
+ if ( $block->hasErrors() ) {
+ $errors = $block->getErrors();
+
+ foreach( $errors as $errorKey ) {
+ $status->fatal( $block->getErrorMessage( $errorKey ) );
+ }
+ }
+ }
+
+ $loader->commit( $blocksToCommit );
+
+ $this->page = $data['page'];
+ return $status;
+ }
+
+ public function onSuccess() {
+ $confirmationMessage = $this->msg( 'flow-special-enableflow-confirmation', $this->page )->parse();
+ $this->getOutput()->addHTML( $confirmationMessage );
+ }
+}
diff --git a/Flow/includes/Specials/SpecialFlow.php b/Flow/includes/Specials/SpecialFlow.php
new file mode 100644
index 00000000..fa1cdec4
--- /dev/null
+++ b/Flow/includes/Specials/SpecialFlow.php
@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * A special page that redirects to a workflow or PostRevision given a UUID
+ */
+
+namespace Flow\Specials;
+
+use Flow\Data\ObjectManager;
+use Flow\Exception\FlowException;
+use Flow\Model\Workflow;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use FormSpecialPage;
+use HTMLForm;
+use Status;
+
+class SpecialFlow extends FormSpecialPage {
+
+ /**
+ * The type of content, e.g. 'post', 'workflow'
+ * @var string $type
+ */
+ protected $type;
+
+ /**
+ * Flow UUID
+ * @var string $uuid
+ */
+ protected $uuid;
+
+ function __construct() {
+ parent::__construct( 'Flow' );
+ }
+
+ /**
+ * Initialize $this->type and $this-uuid using the subpage string.
+ * @param string $par
+ */
+ protected function setParameter( $par ) {
+ $tokens = explode( '/', $par, 2 );
+ $this->type = $tokens[0];
+ if ( count( $tokens ) > 1 ) {
+ $this->uuid = $tokens[1];
+ }
+ }
+
+ /**
+ * Get the mapping between display text and value for the type dropdown.
+ * @return array
+ */
+ protected function getTypes() {
+ $mapping = array(
+ 'flow-special-type-post' => 'post',
+ 'flow-special-type-workflow' => 'workflow',
+ );
+
+ $types = array();
+ foreach ( $mapping as $msgKey => $option ) {
+ $types[$this->msg( $msgKey )->escaped()] = $option;
+ }
+ return $types;
+ }
+
+ protected function getFormFields() {
+ return array(
+ 'type' => array(
+ 'id' => 'mw-flow-special-type',
+ 'name' => 'type',
+ 'type' => 'select',
+ 'label-message' => 'flow-special-type',
+ 'options' => $this->getTypes(),
+ 'default' => empty( $this->type ) ? 'post' : $this->type,
+ ),
+ 'uuid' => array(
+ 'id' => 'mw-flow-special-uuid',
+ 'name' => 'uuid',
+ 'type' => 'text',
+ 'label-message' => 'flow-special-uuid',
+ 'default' => $this->uuid,
+ ),
+ );
+ }
+
+ /**
+ * Description shown at the top of the page
+ * @return string
+ */
+ protected function preText() {
+ return '<p>' . $this->msg( 'flow-special-desc' )->escaped() . '</p>';
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setMethod( 'get' ); // This also submits the form every time the page loads.
+ }
+
+ /**
+ * @return string
+ */
+ protected function getDisplayFormat() {
+ return 'vform';
+ }
+
+ /**
+ * Get the URL of a UUID for a PostRevision.
+ * @return string|null
+ */
+ protected function getPostUrl() {
+ try {
+ $postId = UUID::create( $this->uuid );
+ /** @var TreeRepository $treeRepo */
+ $treeRepo = Container::get( 'repository.tree' );
+ $rootId = $treeRepo->findRoot( $postId );
+ /** @var ObjectManager $om */
+ $om = Container::get( 'storage.workflow' );
+ $workflow = $om->get( $rootId );
+ if ( $workflow instanceof Workflow ) {
+ /** @var UrlGenerator $urlGenerator */
+ $urlGenerator = Container::get( 'url_generator' );
+ return $urlGenerator->postLink(
+ null,
+ $rootId,
+ $postId
+ )->getFullUrl();
+ } else {
+ return null;
+ }
+ } catch ( FlowException $e ) {
+ return null; // The UUID is invalid or has no root post.
+ }
+ }
+
+ /**
+ * Get the URL of a UUID for a workflow.
+ * @return string|null
+ */
+ protected function getWorkflowUrl() {
+ try {
+ $rootId = UUID::create( $this->uuid );
+ /** @var ObjectManager $om */
+ $om = Container::get( 'storage.workflow' );
+ $workflow = $om->get( $rootId );
+ if ( $workflow instanceof Workflow ) {
+ /** @var UrlGenerator $urlGenerator */
+ $urlGenerator = Container::get( 'url_generator' );
+ return $urlGenerator->workflowLink(
+ null,
+ $rootId
+ )->getFullUrl();
+ } else {
+ return null;
+ }
+ } catch ( FlowException $e ) {
+ return null; // The UUID is invalid or has no root post.
+ }
+ }
+
+ /**
+ * Set redirect and return true if $data['uuid'] or $this->par exists and is
+ * a valid UUID; otherwise return false or a Status object encapsulating any
+ * error, which causes the form to be shown.
+ * @param array $data
+ * @return bool|Status
+ */
+ public function onSubmit( array $data ) {
+ if ( !empty( $data['type'] ) && !empty( $data['uuid'] ) ) {
+ $this->setParameter( $data['type'] . '/' . $data['uuid'] );
+ }
+
+ // Assume no data has been passed in if there is no UUID.
+ if ( empty( $this->uuid ) ) {
+ return false; // Display the form.
+ }
+
+ switch ( $this->type ) {
+ case 'post':
+ $url = $this->getPostUrl();
+ break;
+ case 'workflow':
+ $url = $this->getWorkflowUrl();
+ break;
+ default:
+ $url = null;
+ break;
+ }
+
+ if ( $url ) {
+ $this->getOutput()->redirect( $url );
+ return true;
+ } else {
+ $this->getOutput()->setStatusCode( 404 );
+ return Status::newFatal( 'flow-special-invalid-uuid' );
+ }
+ }
+}
diff --git a/Flow/includes/SubmissionHandler.php b/Flow/includes/SubmissionHandler.php
new file mode 100644
index 00000000..6d54f2c7
--- /dev/null
+++ b/Flow/includes/SubmissionHandler.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Flow;
+
+use DeferredUpdates;
+use Flow\Block\AbstractBlock;
+use Flow\Block\Block;
+use Flow\Data\BufferedCache;
+use Flow\Data\ManagerGroup;
+use Flow\Exception\InvalidDataException;
+use Flow\Exception\InvalidActionException;
+use Flow\Model\Workflow;
+use IContextSource;
+use SplQueue;
+
+class SubmissionHandler {
+
+ /**
+ * @var ManagerGroup $storage
+ */
+ protected $storage;
+
+ /**
+ * @var DbFactory $dbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @var BufferedCache $bufferedCache
+ */
+ protected $bufferedCache;
+
+ /**
+ * @var SplQueue Updates to add to DeferredUpdates post-commit
+ */
+ protected $deferredQueue;
+
+ public function __construct(
+ ManagerGroup $storage,
+ DbFactory $dbFactory,
+ BufferedCache $bufferedCache,
+ SplQueue $deferredQueue
+ ) {
+ $this->storage = $storage;
+ $this->dbFactory = $dbFactory;
+ $this->bufferedCache = $bufferedCache;
+ $this->deferredQueue = $deferredQueue;
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @param IContextSource $context
+ * @param AbstractBlock[] $blocks
+ * @param string $action
+ * @param array $parameters
+ * @return AbstractBlock[]
+ * @throws InvalidActionException
+ * @throws InvalidDataException
+ */
+ public function handleSubmit(
+ Workflow $workflow,
+ IContextSource $context,
+ array $blocks,
+ $action,
+ array $parameters
+ ) {
+ // since this is a submit force dbFactory to always return master
+ $this->dbFactory->forceMaster();
+
+ /** @var Block[] $interestedBlocks */
+ $interestedBlocks = array();
+ foreach ( $blocks as $block ) {
+ // This is just a check whether the block understands the action,
+ // Doesn't consider permissions
+ if ( $block->canSubmit( $action ) ) {
+ $block->init( $context, $action );
+ $interestedBlocks[] = $block;
+ }
+ }
+
+ if ( !$interestedBlocks ) {
+ if ( !$blocks ) {
+ throw new InvalidDataException( 'No Blocks?!?', 'fail-load-data' );
+ }
+ $type = array();
+ foreach ( $blocks as $block ) {
+ $type[] = get_class( $block );
+ }
+ // All blocks returned null, nothing knows how to handle this action
+ throw new InvalidActionException( "No block accepted the '$action' action: " . implode( ',', array_unique( $type ) ), 'invalid-action' );
+ }
+
+ // Check mediawiki core permissions for title protection, blocked
+ // status, etc.
+ if ( !$workflow->userCan( 'edit', $context->getUser() ) ) {
+ reset( $interestedBlocks )->addError( 'block', wfMessage( 'blockedtitle' ) );
+ return array();
+ }
+
+ $success = true;
+ foreach ( $interestedBlocks as $block ) {
+ $name = $block->getName();
+ $data = isset( $parameters[$name] ) ? $parameters[$name] : array();
+ $success &= $block->onSubmit( $data );
+ }
+
+ return $success ? $interestedBlocks : array();
+ }
+
+ /**
+ * @param Workflow $workflow
+ * @param AbstractBlock[] $blocks
+ * @return array Map from committed block name to an array of metadata returned
+ * about inserted objects.
+ * @throws \Exception
+ */
+ public function commit( Workflow $workflow, array $blocks ) {
+ $cache = $this->bufferedCache;
+ $dbw = $this->dbFactory->getDB( DB_MASTER );
+
+ try {
+ $dbw->begin();
+ $cache->begin();
+ // @todo doesn't feel right to have this here
+ $this->storage->getStorage( 'Workflow' )->put( $workflow );
+ $results = array();
+ foreach ( $blocks as $block ) {
+ $results[$block->getName()] = $block->commit();
+ }
+ $dbw->commit();
+
+ // Now commit to cache. If this fails, cache keys should have been
+ // invalidated, but still log the failure.
+ if ( !$cache->commit() ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Committed to database but failed applying to cache' );
+ }
+ } catch ( \Exception $e ) {
+ while( !$this->deferredQueue->isEmpty() ) {
+ $this->deferredQueue->dequeue();
+ }
+ $dbw->rollback();
+ $cache->rollback();
+ throw $e;
+ }
+
+ while( !$this->deferredQueue->isEmpty() ) {
+ DeferredUpdates::addCallableUpdate( $this->deferredQueue->dequeue() );
+ }
+
+ $workflow->getArticleTitle()->purgeSquid();
+
+ return $results;
+ }
+}
diff --git a/Flow/includes/TalkpageManager.php b/Flow/includes/TalkpageManager.php
new file mode 100644
index 00000000..0d2dc0f2
--- /dev/null
+++ b/Flow/includes/TalkpageManager.php
@@ -0,0 +1,271 @@
+<?php
+
+namespace Flow;
+
+use Flow\Content\BoardContent;
+use Flow\Exception\FlowException;
+use Flow\Exception\InvalidInputException;
+use Flow\Model\Workflow;
+use Article;
+use Revision;
+use Title;
+use User;
+
+// I got the feeling NinetyNinePercentController was a bit much.
+interface OccupationController {
+ /**
+ * @param Title $title
+ * @return bool
+ */
+ public function isTalkpageOccupied( $title, $checkContentModel = true );
+
+ /**
+ * @param Article $title
+ * @param Workflow $workflow
+ * @return Revision|null
+ */
+ public function ensureFlowRevision( Article $title, Workflow $workflow );
+
+ /**
+ * @param Title $title
+ * @param User $user
+ * @return bool Returns true when the provided user has the rights to
+ * convert $title from whatever it is now to a flow board.
+ */
+ public function allowCreation( Title $title, User $user );
+
+ /**
+ * Gives a user object used to manage talk pages
+ *
+ * @return User User to manage talkpages
+ * @throws MWException If a user cannot be created.
+ */
+ public function getTalkpageManager();
+}
+
+class TalkpageManager implements OccupationController {
+ /**
+ * @var int[]
+ */
+ protected $occupiedNamespaces;
+
+ /**
+ * @var string[]
+ */
+ protected $occupiedPages;
+
+ /**
+ * @var Title[]
+ */
+ protected $allowCreation = array();
+
+ /**
+ * @param int[] $occupiedNamespaces See documentation for $wgFlowOccupyNamespaces
+ * @param string[] $occupiedPages See documentation for $wgFlowOccupyPages
+ */
+ public function __construct( array $occupiedNamespaces, array $occupiedPages ) {
+ $this->occupiedNamespaces = $occupiedNamespaces;
+ $this->occupiedPages = $occupiedPages;
+ }
+
+ /**
+ * Determines whether or not a talk page is "occupied" by Flow.
+ *
+ * Internally, determines whether or not 1% of the talk page contains
+ * 99% of the discussions.
+ *
+ * @param Title $title Title object to check for occupation status
+ * @param boolean $checkContentModel
+ * @return boolean True if the talk page is occupied, False otherwise.
+ */
+ public function isTalkpageOccupied( $title, $checkContentModel = true ) {
+ if ( !$title || !is_object( $title ) ) {
+ // Invalid parameter
+ return false;
+ }
+
+ if ( $title->isRedirect() ) {
+ return false;
+ }
+
+ if ( in_array( $title->getPrefixedText(), $this->occupiedPages ) ) {
+ return true;
+ }
+ if ( !$title->isSubpage() && in_array( $title->getNamespace(), $this->occupiedNamespaces ) ) {
+ return true;
+ }
+
+ // If it was saved as a flow board, lets just believe the database.
+ if ( $checkContentModel && $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * When a page is taken over by Flow, add a revision.
+ *
+ * First, it provides a clearer history should Flow be disabled again later,
+ * and a descriptive message when people attempt to use regular API to fetch
+ * data for this "Page", which will no longer contain any useful content,
+ * since Flow has taken over.
+ *
+ * Also: Parsoid performs an API call to fetch page information, so we need
+ * to make sure a page actually exists ;)
+ *
+ * This method does not do any security checks regarding content model changes
+ * or the like. Those happen much earlier in the request and should be checked
+ * before even attempting to create revisions which, when written to the database,
+ * trigger this method through the OccupationListener.
+ *
+ * @param \Article $article
+ * @param Workflow $workflow
+ * @return Revision|null
+ * @throws InvalidInputException
+ */
+ public function ensureFlowRevision( Article $article, Workflow $workflow ) {
+ // Break loops (because doEditContent requires rendering, which will load the workflow, which will call this function)
+ static $doing = false;
+ if ( $doing ) {
+ return null;
+ }
+
+ // Comment to add to the Revision to indicate Flow taking over
+ $comment = '/* Taken over by Flow */';
+
+ $page = $article->getPage();
+ $revision = $page->getRevision();
+
+ if ( $revision !== null ) {
+ if ( $revision->getComment( Revision::RAW ) == $comment ) {
+ // Revision was created by this process
+ return null;
+ }
+ $content = $revision->getContent();
+ if ( $content instanceof BoardContent && $content->getWorkflowId() ) {
+ // Revision is already a valid BoardContent
+ return null;
+ }
+ }
+
+ $doing = true;
+ $status = $page->doEditContent(
+ new BoardContent( CONTENT_MODEL_FLOW_BOARD, $workflow ),
+ $comment,
+ EDIT_FORCE_BOT | EDIT_SUPPRESS_RC,
+ false,
+ $this->getTalkpageManager()
+ );
+ $doing = false;
+
+ if ( $status->isGood() && isset( $status->value['revision'] ) ) {
+ return $status->value['revision'];
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks whether the given user is allowed to create a board at the given
+ * title and allows it to be created.
+ *
+ * @param Title $title Title to check
+ * @param User $user User who wants to create a board
+ * @return bool
+ */
+ public function allowCreation( Title $title, User $user ) {
+ global $wgContentHandlerUseDB;
+
+ // Arbitrary pages can only be enabled when content handler
+ // can store that content model in the database.
+ if ( !$wgContentHandlerUseDB ) {
+ return false;
+ }
+
+ // Only allow converting a non-existent page to flow
+ if ( $title->exists() ) {
+ return false;
+ }
+
+ // Gate this on the flow-create-board right, essentially giving
+ // wiki communities control over if flow board creation is allowed
+ // to everyone or just a select few.
+ if ( !$user->isAllowedAll( 'flow-create-board' ) ) {
+ return false;
+ }
+
+ /*
+ * tracks which titles are allowed so that when
+ * BoardContentHandler::canBeUsedOn is called for this title, it
+ * can call self::isTalkpageOccupied and get a successful result.
+ */
+ $this->allowCreation[] = $title->getPrefixedDBkey();
+
+ return true;
+ }
+
+ /**
+ * Before creating a flow board, BoardContentHandler::canBeUsedOn will be
+ * called to verify it's ok to create it.
+ * That, in turn, will call this, which will check if the title we want to
+ * turn into a Flow board was allowed to create (with static::allowCreation)
+ *
+ * @param Title $title
+ * @return bool
+ */
+ public function canBeUsedOn( Title $title ) {
+ return in_array( $title->getPrefixedDBkey(), $this->allowCreation );
+ }
+
+ /**
+ * Gives a user object used to manage talk pages
+ *
+ * @return User User to manage talkpages
+ * @throws MWException If both of the names already exist, but are not properly
+ * configured.
+ */
+ public function getTalkpageManager() {
+ $userNameCandidates = array(
+ wfMessage( 'flow-talk-username' )->inContentLanguage()->text(),
+ 'Flow talk page manager',
+ );
+
+ $user = null;
+
+ foreach ( $userNameCandidates as $name ) {
+ $candidateUser = User::newFromName( $name );
+
+ if ( $candidateUser->getId() === 0 ) {
+ $user = User::createNew( $name );
+ $user->addGroup( 'bot' );
+ break;
+ } else {
+ // Exists
+ $groups = $candidateUser->getGroups();
+ if ( in_array( 'bot', $groups ) ) {
+ // We created this user earlier.
+ $user = $candidateUser;
+ break;
+ }
+
+ // If it exists, but is not a bot, someone created this
+ // without setting it up as expected, so go on to the next
+ // user. Except unit tests which get a free pass.
+ if ( defined( 'MW_PHPUNIT_TEST' ) ) {
+ $candidateUser->addGroup( 'bot' );
+ $user = $candidateUser;
+ break;
+ }
+ }
+ }
+
+ if ( $user === null ) {
+ throw new FlowException( 'All of the candidate usernames exist, but they are not configured as expected.' );
+ }
+
+ // Some specialist permissions (like flow-create-board) apply
+ $user->addGroup( 'flow-bot' );
+ return $user;
+ }
+}
diff --git a/Flow/includes/TemplateHelper.php b/Flow/includes/TemplateHelper.php
new file mode 100644
index 00000000..79b0af7e
--- /dev/null
+++ b/Flow/includes/TemplateHelper.php
@@ -0,0 +1,805 @@
+<?php
+
+namespace Flow;
+
+use Flow\Exception\FlowException;
+use Flow\Exception\WrongNumberArgumentsException;
+use Flow\Model\UUID;
+use Closure;
+use HTML;
+use LightnCandy;
+use MWTimestamp;
+use RequestContext;
+use Title;
+
+class TemplateHelper {
+
+ /**
+ * @var string
+ */
+ protected $templateDir;
+
+ /**
+ * @var callable[]
+ */
+ protected $renderers;
+
+ /**
+ * @var bool Always compile template files
+ */
+ protected $forceRecompile = false;
+
+ /**
+ * @param string $templateDir
+ * @param boolean $forceRecompile
+ */
+ public function __construct( $templateDir, $forceRecompile = false ) {
+ $this->templateDir = $templateDir;
+ $this->forceRecompile = $forceRecompile;
+ }
+
+ /**
+ * Constructs the location of the the source handlebars template
+ * and the compiled php code that goes with it.
+ *
+ * @param string $templateName
+ *
+ * @return string[]
+ * @throws FlowException Disallows upwards directory traversal via $templateName
+ */
+ public function getTemplateFilenames( $templateName ) {
+ // Prevent upwards directory traversal using same methods as Title::secureAndSplit,
+ // which is implemented in MediaWikiTitleCodec::splitTitleString.
+ if (
+ strpos( $templateName, '.' ) !== false &&
+ (
+ $templateName === '.' || $templateName === '..' ||
+ strpos( $templateName, './' ) === 0 ||
+ strpos( $templateName, '../' ) === 0 ||
+ strpos( $templateName, '/./' ) !== false ||
+ strpos( $templateName, '/../' ) !== false ||
+ substr( $templateName, -2 ) === '/.' ||
+ substr( $templateName, -3 ) === '/..'
+ )
+ ) {
+ throw new FlowException( "Malformed \$templateName: $templateName" );
+ }
+
+ return array(
+ 'template' => "{$this->templateDir}/{$templateName}.handlebars",
+ 'compiled' => "{$this->templateDir}/compiled/{$templateName}.handlebars.php",
+ );
+ }
+
+ /**
+ * Returns a given template function if found, otherwise throws an exception.
+ *
+ * @param string $templateName
+ *
+ * @return Closure
+ * @throws FlowException
+ * @throws \Exception
+ */
+ public function getTemplate( $templateName ) {
+ if ( isset( $this->renderers[$templateName] ) ) {
+ return $this->renderers[$templateName];
+ }
+
+ $filenames = $this->getTemplateFilenames( $templateName );
+
+ if ( $this->forceRecompile ) {
+ if ( !file_exists( $filenames['template'] ) ) {
+ throw new FlowException( "Could not locate template: {$filenames['template']}" );
+ }
+
+ $code = self::compile( file_get_contents( $filenames['template'] ), $this->templateDir );
+
+ if ( !$code ) {
+ throw new FlowException( "Failed to compile template '$templateName'." );
+ }
+ $success = @file_put_contents( $filenames['compiled'], $code );
+
+ // failed to recompile template (OS permissions?); unless the
+ // content hasn't changes, throw an exception!
+ if ( !$success && file_get_contents( $filenames['compiled'] ) !== $code ) {
+ throw new FlowException( "Failed to save updated compiled template '$templateName'" );
+ }
+ }
+
+ /** @var callable $renderer */
+ $renderer = require $filenames['compiled'];
+ return $this->renderers[$templateName] = function( $args, array $scopes = array() ) use ( $templateName, $renderer ) {
+ return $renderer( $args, $scopes );
+ };
+ }
+
+ /**
+ * @param string $code Handlebars code
+ * @param string $templateDir Directory templates are stored in
+ *
+ * @return string PHP code
+ */
+ static public function compile( $code, $templateDir ) {
+ return LightnCandy::compile(
+ $code,
+ array(
+ 'flags' => LightnCandy::FLAG_ERROR_EXCEPTION
+ | LightnCandy::FLAG_EXTHELPER
+ | LightnCandy::FLAG_SPVARS
+ // Commented LightnCandy::FLAG_HANDLEBARS because it includes
+ // FLAG_MUSTACHEPAIN, which currently causes issues. Below
+ // line can be uncommented & the one below (spelling out all
+ // options excluding FLAG_MUSTACHEPAIN) can be removed once
+ // https://github.com/zordius/lightncandy/pull/126 or similar
+ // lands.
+// | LightnCandy::FLAG_HANDLEBARS // FLAG_THIS + FLAG_WITH + FLAG_PARENT + FLAG_JSQUOTE + FLAG_ADVARNAME + FLAG_SPACECTL + FLAG_NAMEDARG + FLAG_SPVARS + FLAG_SLASH + FLAG_ELSE + FLAG_MUSTACHESP + FLAG_MUSTACHEPAIN
+ | LightnCandy::FLAG_THIS | LightnCandy::FLAG_WITH | LightnCandy::FLAG_PARENT | LightnCandy::FLAG_JSQUOTE | LightnCandy::FLAG_ADVARNAME | LightnCandy::FLAG_SPACECTL | LightnCandy::FLAG_NAMEDARG | LightnCandy::FLAG_SPVARS | LightnCandy::FLAG_SLASH | LightnCandy::FLAG_ELSE | LightnCandy::FLAG_MUSTACHESP
+ | LightnCandy::FLAG_RUNTIMEPARTIAL,
+ 'basedir' => array( $templateDir ),
+ 'fileext' => array( '.partial.handlebars' ),
+ 'helpers' => array(
+ 'l10n' => 'Flow\TemplateHelper::l10n',
+ 'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
+ 'timestamp' => 'Flow\TemplateHelper::timestampHelper',
+ 'html' => 'Flow\TemplateHelper::htmlHelper',
+ 'block' => 'Flow\TemplateHelper::block',
+ 'author' => 'Flow\TemplateHelper::author',
+ 'post' => 'Flow\TemplateHelper::post',
+ 'historyTimestamp' => 'Flow\TemplateHelper::historyTimestamp',
+ 'historyDescription' => 'Flow\TemplateHelper::historyDescription',
+ 'showCharacterDifference' => 'Flow\TemplateHelper::showCharacterDifference',
+ 'l10nParse' => 'Flow\TemplateHelper::l10nParse',
+ 'diffRevision' => 'Flow\TemplateHelper::diffRevision',
+ 'diffUndo' => 'Flow\TemplateHelper::diffUndo',
+ 'moderationAction' => 'Flow\TemplateHelper::moderationAction',
+ 'concat' => 'Flow\TemplateHelper::concat',
+ 'user' => 'Flow\TemplateHelper::user',
+ 'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
+ 'escapeContent' => 'Flow\TemplateHelper::escapeContent',
+ ),
+ 'hbhelpers' => array(
+ 'eachPost' => 'Flow\TemplateHelper::eachPost',
+ 'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
+ 'ifCond' => 'Flow\TemplateHelper::ifCond',
+ 'tooltip' => 'Flow\TemplateHelper::tooltip',
+ 'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
+ ),
+ )
+ );
+ }
+
+ /**
+ * Returns HTML for a given template by calling the template function with the given args.
+ *
+ * @param string $templateName
+ * @param array $args
+ * @param array $scopes
+ *
+ * @return string
+ */
+ static public function processTemplate( $templateName, $args, array $scopes = array() ) {
+ // Undesirable, but lightncandy helpers have to be static methods
+ /** @var TemplateHelper $lightncandy */
+ $lightncandy = Container::get( 'lightncandy' );
+ $template = $lightncandy->getTemplate( $templateName );
+ // @todo ugly hack...remove someday. Requires switching to newest version
+ // of lightncandy which supports recursive partial templates.
+ if ( !array_key_exists( 'rootBlock', $args ) ) {
+ $args['rootBlock'] = $args;
+ }
+ return call_user_func( $template, $args, $scopes );
+ }
+
+ // Helpers
+
+ /**
+ * Generates a timestamp using the UUID, then calls the timestamp helper with it.
+ *
+ * @param array $args Expects string $uuid, string $str, bool $timeAgoOnly = false
+ * @param array $named No named arguments expected
+ *
+ * @return null|string
+ * @throws WrongNumberArgumentsException
+ */
+ static public function uuidTimestamp( array $args, array $named ) {
+ if ( count( $args ) !== 1 ) {
+ throw new WrongNumberArgumentsException( $args, 'one' );
+ }
+ $uuid = $args[0];
+
+ $obj = UUID::create( $uuid );
+ if ( !$obj ) {
+ return null;
+ }
+
+ // timestamp helper expects ms timestamp
+ $timestamp = $obj->getTimestampObj()->getTimestamp() * 1000;
+ return self::timestamp( $timestamp );
+ }
+
+ /**
+ * @param array $args Expects string $timestamp, string $str, bool $timeAgoOnly = false
+ * @param array $named No named arguments expected
+ *
+ * @return string
+ * @throws WrongNumberArgumentsException
+ */
+ static public function timestampHelper( array $args, array $named ) {
+ if ( count( $args ) < 1 || count( $args ) > 2 ) {
+ throw new WrongNumberArgumentsException( $args, 'one', 'two' );
+ }
+ return self::timestamp(
+ $args[0],
+ isset( $args[1] ) ? $args[1] : false
+ );
+ }
+
+ /**
+ * @param integer $timestamp milliseconds since the unix epoch
+ *
+ * @return string|false
+ */
+ static protected function timestamp( $timestamp ) {
+ global $wgLang, $wgUser;
+
+ if ( !$timestamp ) {
+ return false;
+ }
+
+ // source timestamps are in ms
+ $timestamp /= 1000;
+ $ts = new MWTimestamp( $timestamp );
+
+ return self::html( self::processTemplate(
+ 'timestamp',
+ array(
+ 'time_iso' => $timestamp,
+ 'time_ago' => $ts->getHumanTimestamp(),
+ 'time_readable' => $wgLang->userTimeAndDate( $timestamp, $wgUser ),
+ 'guid' => null, //generated client-side
+ )
+ ) );
+ }
+
+ /**
+ * Takes in HTML string, returns array that tells lightncandy to skip escaping.
+ * Only works for values returned from helpers, does not work when passing
+ * variable into a template or helper.
+ *
+ * @param string $string
+ *
+ * @return string[] array(html, 'raw')
+ */
+ static protected function html( $string ) {
+ return array( $string, 'raw' );
+ }
+
+ /**
+ * @param array $args Expects one string argument to be output unescaped.
+ * @param array $named unused
+ *
+ * @return string[] array(html, 'raw')
+ */
+ static public function htmlHelper( array $args, array $named ) {
+ return self::html( isset( $args[0] ) ? $args[0] : 'undefined' );
+ }
+
+ /**
+ * @param array $args Expects one array $block
+ * @param array $named No named arguments expected
+ *
+ * @return string[]
+ * @throws WrongNumberArgumentsException
+ */
+ static public function block( array $args, array $named ) {
+ if ( !isset( $args[0] ) ) {
+ throw new WrongNumberArgumentsException( $args, 'one' );
+ }
+ $block = $args[0];
+ $template = "flow_block_" . $block['type'];
+ if ( $block['block-action-template'] ) {
+ $template .= '_' . $block['block-action-template'];
+ }
+ return self::html( self::processTemplate(
+ $template,
+ $block
+ ) );
+ }
+
+ /**
+ * @param array $context The 'this' value of the calling context
+ * @param array $postIds List of ids (roots)
+ * @param array $options blockhelper specific invocation options
+ *
+ * @return null|string HTML
+ * @throws FlowException When callbacks are not Closure instances
+ */
+ static public function eachPost( $context, $postIds, $options ) {
+ /** @var callable $inverse */
+ $inverse = isset( $options['inverse'] ) ? $options['inverse'] : null;
+ /** @var callable $fn */
+ $fn = $options['fn'];
+
+ if ( $postIds && !is_array( $postIds ) ) {
+ $postIds = array( $postIds );
+ } elseif ( count( $postIds ) === 0 ) {
+ // Failure callback, if any
+ if ( !$inverse ) {
+ return null;
+ }
+ if ( !$inverse instanceof Closure ) {
+ throw new FlowException( 'Invalid inverse callback, expected Closure' );
+ }
+ return $inverse( $options['cx'], array() );
+ } else {
+ return null;
+ }
+
+ if ( !$fn instanceof Closure ) {
+ throw new FlowException( 'Invalid callback, expected Closure' );
+ }
+ $html = array();
+ foreach ( $postIds as $id ) {
+ $revId = $context['posts'][$id][0];
+
+ if ( !isset( $context['revisions'][$revId] ) ) {
+ throw new FlowException( "Revision not available: $revId" );
+ }
+
+ // $fn is always safe return value, it's the inner template content.
+ $html[] = $fn( $context['revisions'][$revId] );
+ }
+
+ // Return the resulting HTML
+ return implode( '', $html );
+ }
+
+ /**
+ * Required to prevent recursion loop rendering nested posts
+ *
+ * @param array $args Expects array $rootBlock, array $revision
+ * @param array $named No named arguments expected
+ *
+ * @return string[]
+ * @throws WrongNumberArgumentsException
+ */
+ static public function post( array $args, array $named ) {
+ if ( count( $args ) !== 2 ) {
+ throw new WrongNumberArgumentsException( $args, 'two' );
+ }
+ list( $rootBlock, $revision ) = $args;
+ return self::html( self::processTemplate( 'flow_post', array(
+ 'revision' => $revision,
+ 'rootBlock' => $rootBlock,
+ ) ) );
+ }
+
+ /**
+ * @param array $args Expects array $revision, string $key = 'timeAndDate'
+ * @param array $named No named arguments expected
+ *
+ * @return string[]
+ * @throws WrongNumberArgumentsException
+ */
+ static public function historyTimestamp( array $args, array $named ) {
+ if ( !$args ) {
+ throw new WrongNumberArgumentsException( $args, 'one', 'two' );
+ }
+ $revision = $args[0];
+ $raw = false;
+ $formattedTime = $revision['dateFormats']['timeAndDate'];
+ $linkKeys = array( 'header-revision', 'topic-revision', 'post-revision', 'summary-revision' );
+ foreach ( $linkKeys as $linkKey ) {
+ if ( isset( $revision['links'][$linkKey] ) ) {
+ $link = $revision['links'][$linkKey];
+ $formattedTime = Html::element(
+ 'a',
+ array(
+ 'href' => $link['url'],
+ 'title' => $link['title'],
+ ),
+ $formattedTime
+ );
+ $raw = true;
+ break;
+ }
+ }
+
+ if ( $raw === false ) {
+ $formattedTime = htmlspecialchars( $formattedTime );
+ }
+
+ $class = array( 'mw-changeslist-date' );
+ if ( $revision['isModerated'] ) {
+ $class[] = 'history-deleted';
+ }
+
+ return self::html(
+ '<span class="plainlinks">'
+ . Html::rawElement( 'span', array( 'class' => $class ), $formattedTime )
+ . '</span>'
+ );
+ }
+
+ /**
+ * @param array $args Expects array $revision
+ * @param array $named No named arguments expected
+ *
+ * @return string[]
+ * @throws WrongNumberArgumentsException
+ */
+ static public function historyDescription( array $args, array $named ) {
+ if ( count( $args ) !== 1 ) {
+ throw new WrongNumberArgumentsException( $args, 'one' );
+ }
+ $revision = $args[0];
+ if ( !isset( $revision['properties']['_key'] ) ) {
+ return '';
+ }
+
+ $i18nKey = $revision['properties']['_key'];
+ unset( $revision['properties']['_key'] );
+
+ // a variety of the i18n history messages contain wikitext and require ->parse()
+ return self::html( wfMessage( $i18nKey, $revision['properties'] )->parse() );
+ }
+
+ /**
+ * @param array $args Expects string $old, string $new
+ * @param array $named No named arguments expected
+ *
+ * @return string[]
+ * @throws WrongNumberArgumentsException
+ */
+ static public function showCharacterDifference( array $args, array $named ) {
+ if ( count( $args ) !== 2 ) {
+ throw new WrongNumberArgumentsException( $args, 'two' );
+ }
+ list( $old, $new ) = $args;
+ return self::html( \ChangesList::showCharacterDifference( $old, $new ) );
+ }
+
+ /**
+ * Creates a special script tag to be processed client-side. This contains extra template HTML, which allows
+ * the front-end to "progressively enhance" the page with more content which isn't needed in a non-JS state.
+ *
+ * @see FlowHandlebars.prototype.progressiveEnhancement in flow-handlebars.js for more details.
+ *
+ * @param array $options
+ *
+ * @return string[]
+ */
+ static public function progressiveEnhancement( array $options ) {
+ $fn = $options['fn'];
+ $input = $options['hash'];
+ $insertionType = empty( $input['type'] ) ? 'insert' : htmlspecialchars( $input['type'] );
+ $target = empty( $input['target'] ) ? '' : 'data-target="' . htmlspecialchars( $input['target'] ) . '"';
+ $sectionId = empty( $input['id'] ) ? '' : 'id="' . htmlspecialchars( $input['id'] ) . '"';
+
+ return self::html(
+ '<script name="handlebars-template-progressive-enhancement"' .
+ ' type="text/x-handlebars-template-progressive-enhancement"' .
+ ' data-type="' . $insertionType . '"' .
+ ' ' . $target .
+ ' ' . $sectionId .
+ '>' .
+ // Replace the nested script tag with a placeholder tag for recursive progressiveEnhancement
+ str_replace( '</script>', '</flowprogressivescript>', $fn() ) .
+ '</script>'
+ );
+ }
+
+ /**
+ * @param array $args one or more arguments, i18n key and parameters
+ * @param array $named unused
+ *
+ * @return string Plaintext
+ */
+ static public function l10n( array $args, array $named ) {
+ $message = null;
+ $str = array_shift( $args );
+
+ return wfMessage( $str )->params( $args )->text();
+ }
+ /**
+ * @param array $args one or more arguments, i18n key and parameters
+ * @param array $named unused
+ *
+ * @return string[] HTML
+ */
+ static public function l10nParse( array $args, array $named ) {
+ $str = array_shift( $args );
+ return self::html( wfMessage( $str, $args )->parse() );
+ }
+
+ /**
+ * @param array $args Expects seven arguments as follows:
+ * array $named No named arguments expected
+ * string $diffContent Plain text output of DifferenceEngine::getDiffBody
+ * string $oldTimestamp Time when the `old` content was created
+ * string $newTimestamp Time when the `new` content was created
+ * string $oldAuthor Creator of the `old` content
+ * string $newAuthor Creator of the `new` content
+ * string $oldLink Url pointing to `old` content
+ * string $newLink Url pointing to `new` content
+ * string $prevLink Url pointing to diff between `old` and its previous revision
+ * string $nextLink Url pointing to diff between `new` and its next revision
+ * @param array $named No named arguments expected
+ *
+ * @return string[] HTML wrapped in array to prevent lightncandy from escaping
+ * @throws WrongNumberArgumentsException
+ */
+ static public function diffRevision( array $args, array $named ) {
+ if ( count( $args ) !== 9 ) {
+ throw new WrongNumberArgumentsException( $args, 'nine' );
+ }
+ list ( $diffContent, $oldTimestamp, $newTimestamp, $oldAuthor, $newAuthor, $oldLink, $newLink, $prevLink, $nextLink ) = $args;
+ $differenceEngine = new \DifferenceEngine();
+ $multi = $differenceEngine->getMultiNotice();
+ // Display a message when the diff is empty
+ $notice = '';
+ if ( $diffContent === '' ) {
+ $notice .= '<div class="mw-diff-empty">' .
+ wfMessage( 'diff-empty' )->parse() .
+ "</div>\n";
+ }
+ $differenceEngine->showDiffStyle();
+
+ $renderer = Container::get( 'lightncandy' )->getTemplate( 'flow_revision_diff_header' );
+
+ return self::html( $differenceEngine->addHeader(
+ $diffContent,
+ $renderer( array(
+ 'timestamp' => $oldTimestamp,
+ 'author' => $oldAuthor,
+ 'link' => $oldLink,
+ 'previous' => $prevLink,
+ ) ),
+ $renderer( array(
+ 'timestamp' => $newTimestamp,
+ 'author' => $newAuthor,
+ 'link' => $newLink,
+ 'next' => $nextLink,
+ ) ),
+ $multi,
+ $notice
+ ) );
+ }
+
+ static public function diffUndo( array $args, array $named ) {
+ if ( count( $args ) !== 1 ) {
+ throw new WrongNumberArgumentsException( $args, 'one' );
+ }
+ list( $diffContent ) = $args;
+
+ $differenceEngine = new \DifferenceEngine();
+ $multi = $differenceEngine->getMultiNotice();
+ $notice = '';
+ if ( $diffContent === '' ) {
+ $notice = '<div class="mw-diff-empty">' .
+ wfMessage( 'diff-empty' )->parse() .
+ "</div>\n";
+ }
+ $differenceEngine->showDiffStyle();
+
+ return self::html( $differenceEngine->addHeader(
+ $diffContent,
+ wfMessage( 'flow-undo-latest-revision' ),
+ wfMessage( 'flow-undo-your-text' ),
+ $multi,
+ $notice
+ ) );
+ }
+
+ /**
+ * @param array $args Expects array $actions, string $moderationState
+ * @param array $named No named arguments expected
+ *
+ * @return string
+ * @throws WrongNumberArgumentsException
+ */
+ static public function moderationAction( array $args, array $named ) {
+ if ( count( $args ) !== 2 ) {
+ throw new WrongNumberArgumentsException( $args, 'two' );
+ }
+ list( $actions, $moderationState ) = $args;
+ return isset( $actions[$moderationState] ) ? $actions[$moderationState]['url'] : '';
+ }
+
+ /**
+ * @param array $args Expects one or more strings to join
+ * @param array $named No named arguments expected
+ *
+ * @return string all unnamed arguments joined together
+ */
+ static public function concat( array $args, array $named ) {
+ return implode( '', $args );
+ }
+
+ /**
+ * Return information about given user
+ *
+ * @param string[] $args Expects string $feature e.g. name, id
+ * @param array $named No named arguments expected
+ *
+ * @return string value of property
+ */
+ static public function user( array $args, array $named ) {
+ $feature = isset( $args[0] ) ? $args[0] : 'name';
+ $user = RequestContext::getMain()->getUser();
+ $userInfo = array(
+ 'id' => $user->getId(),
+ 'name' => $user->getName(),
+ );
+
+ return $userInfo[$feature];
+ }
+
+ /**
+ * Runs a callback when user is anonymous
+ *
+ * @param array $options which must contain fn and inverse key mapping to functions.
+ *
+ * @return mixed result of callback
+ * @throws FlowException Fails when callbacks are not Closure instances
+ */
+ static public function ifAnonymous( $options ) {
+ if ( RequestContext::getMain()->getUser()->isAnon() ) {
+ $fn = $options['fn'];
+ if ( !$fn instanceof Closure ) {
+ throw new FlowException( 'Expected callback to be Closuire instance' );
+ }
+ } elseif ( isset( $options['inverse'] ) ) {
+ $fn = $options['inverse'];
+ if ( !$fn instanceof Closure ) {
+ throw new FlowException( 'Expected inverse callback to be Closuire instance' );
+ }
+ } else {
+ return '';
+ }
+
+ return $fn();
+ }
+
+ /**
+ * Adds returnto parameter pointing to current page to existing URL
+ *
+ * @param string $url to modify
+ *
+ * @return string modified url
+ */
+ static protected function addReturnTo( $url ) {
+ $ctx = RequestContext::getMain();
+ $returnTo = $ctx->getTitle();
+ if ( !$returnTo ) {
+ return $url;
+ }
+ // We can't get only the query parameters from
+ $returnToQuery = $ctx->getRequest()->getQueryValues();
+
+ unset( $returnToQuery['title'] );
+
+ $args = array(
+ 'returnto' => $returnTo->getPrefixedUrl(),
+ );
+ if ( $returnToQuery ) {
+ $args['returntoquery'] = wfArrayToCgi( $returnToQuery );
+ }
+ return wfAppendQuery( $url, wfArrayToCgi( $args ) );
+ }
+
+ /**
+ * Adds returnto parameter pointing to given Title to an existing URL
+ *
+ * @param string[] $args Expects string $title
+ * @param array $named No named arguments expected
+ *
+ * @return string modified url
+ * @throws WrongNumberArgumentsException
+ */
+ static public function linkWithReturnTo( array $args, array $named ) {
+ if ( count( $args ) !== 1 ) {
+ throw new WrongNumberArgumentsException( $args, 'one' );
+ }
+ $title = Title::newFromText( $args[0] );
+ if ( !$title ) {
+ return '';
+ }
+ // FIXME: This should use local url to avoid redirects on mobile. See bug 66746.
+ $url = $title->getFullUrl();
+
+ return self::addReturnTo( $url );
+ }
+
+ /**
+ * Accepts the contentType and content properties returned from the api
+ * for individual revisions and ensures that content is included in the
+ * final html page in an xss safe maner.
+ *
+ * It is expected that all content with contentType of html has been
+ * processed by parsoid and is safe for direct output into the document.
+ *
+ * @param string[] $args Expects string $contentType, string $content
+ * @param array $named No named arguments expected
+ *
+ * @return string
+ * @throws WrongNumberArgumentsException
+ */
+ static public function escapeContent( array $args, array $named ) {
+ if ( count( $args ) !== 2 ) {
+ throw new WrongNumberArgumentsException( $args, 'two' );
+ }
+ list( $contentType, $content ) = $args;
+ return $contentType === 'html' ? self::html( $content ) : $content;
+ }
+
+ /**
+ * Only perform action when conditions match
+ *
+ * @param string $value
+ * @param string $operator e.g. 'or'
+ * @param string $value2 to compare with
+ * @param array $options lightncandy hbhelper options
+ *
+ * @return mixed result of callback
+ * @throws FlowException Fails when callbacks are not Closure instances
+ */
+ static public function ifCond( $value, $operator, $value2, $options ) {
+ $doCallback = false;
+
+ // Perform operator
+ // FIXME: Rename to || to be consistent with other operators
+ if ( $operator === 'or' ) {
+ if ( $value || $value2 ) {
+ $doCallback = true;
+ }
+ } elseif ( $operator === '===' ) {
+ if ( $value === $value2 ) {
+ $doCallback = true;
+ }
+ } elseif ( $operator === '!==' ) {
+ if ( $value !== $value2 ) {
+ $doCallback = true;
+ }
+ } else {
+ return '';
+ }
+
+ if ( $doCallback ) {
+ $fn = $options['fn'];
+ if ( !$fn instanceof Closure ) {
+ throw new FlowException( 'Expected callback to be Closure instance' );
+ }
+ return $fn();
+ } elseif ( isset( $options['inverse'] ) ) {
+ $inverse = $options['inverse'];
+ if ( !$inverse instanceof Closure ) {
+ throw new FlowException( 'Expected inverse callback to be Closure instance' );
+ }
+ return $inverse();
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * @param array $options
+ *
+ * @return string tooltip
+ */
+ static public function tooltip( $options ) {
+ $fn = $options['fn'];
+ $params = $options['hash'];
+
+ return (
+ self::processTemplate( 'flow_tooltip', array(
+ 'positionClass' => $params['positionClass'] ? 'flow-ui-tooltip-' . $params['positionClass'] : null,
+ 'contextClass' => $params['contextClass'] ? 'mw-ui-' . $params['contextClass'] : null,
+ 'extraClass' => $params['extraClass'] ?: '',
+ 'blockClass' => $params['isBlock'] ? 'flow-ui-tooltip-block' : null,
+ 'content' => $fn(),
+ ) )
+ );
+ }
+}
+
diff --git a/Flow/includes/Templating.php b/Flow/includes/Templating.php
new file mode 100644
index 00000000..3393dad4
--- /dev/null
+++ b/Flow/includes/Templating.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Flow;
+
+use Flow\Repository\UserNameBatch;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Parsoid\ContentFixer;
+use OutputPage;
+// These don't really belong here
+use Linker;
+use Message;
+
+/**
+ * This class is slowly being deprecated. It used to house a minimalist
+ * php templating system, it is now just a few of the helpers that were
+ * reused in the new api responses and other parts of Flow.
+ */
+class Templating {
+ /**
+ * @var UserNameBatch
+ */
+ protected $usernames;
+
+ /**
+ * @var UrlGenerator
+ */
+ public $urlGenerator;
+
+ /**
+ * @var OutputPage
+ */
+ protected $output;
+
+ /**
+ * @var RevisionActionPermissions
+ */
+ protected $permissions;
+
+ /**
+ * @var ContentFixer
+ */
+ protected $contentFixer;
+
+ /**
+ * @param UserNameBatch $usernames
+ * @param UrlGenerator $urlGenerator
+ * @param OutputPage $output
+ * @param ContentFixer $contentFixer
+ * @param RevisionActionPermissions $permissions
+ */
+ public function __construct(
+ UserNameBatch $usernames,
+ UrlGenerator $urlGenerator,
+ OutputPage $output,
+ ContentFixer $contentFixer,
+ RevisionActionPermissions $permissions
+ ) {
+ $this->usernames = $usernames;
+ $this->urlGenerator = $urlGenerator;
+ $this->output = $output;
+ $this->contentFixer = $contentFixer;
+ $this->permissions = $permissions;
+ }
+
+ /**
+ * @return OutputPage
+ */
+ public function getOutput() {
+ return $this->output;
+ }
+
+ public function getUrlGenerator() {
+ return $this->urlGenerator;
+ }
+
+ /**
+ * Returns pretty-printed user links + user tool links for history and
+ * RecentChanges pages.
+ *
+ * Moderation-aware.
+ *
+ * @param AbstractRevision $revision Revision to display
+ * @return string HTML
+ * @throws FlowException
+ */
+ public function getUserLinks( AbstractRevision $revision ) {
+ if ( !$revision->isModerated() && !$this->permissions->isAllowed( $revision, 'history' ) ) {
+ throw new FlowException( 'Insufficient permissions to see userlinks for rev_id = ' . $revision->getRevisionId()->getAlphadecimal() );
+ }
+
+ // if this specific revision is moderated, its usertext can always be
+ // displayed, since it will be the moderator user
+ static $cache;
+ $userid = $revision->getUserId();
+ $userip = $revision->getUserIp();
+ if ( isset( $cache[$userid][$userip] ) ) {
+ return $cache[$userid][$userip];
+ }
+ $username = $this->usernames->get( wfWikiId(), $userid, $userip );
+ return $cache[$userid][$userip] = Linker::userLink( $userid, $username ) . Linker::userToolLinks( $userid, $username );
+ }
+
+ /**
+ * Usually the revisions's content can just be displayed. In the event
+ * of moderation, however, that info should not be exposed.
+ *
+ * If a specific i18n message is available for a certain moderation level,
+ * that message will be returned (well, unless the user actually has the
+ * required permissions to view the full content). Otherwise, in normal
+ * cases, the full content will be returned.
+ *
+ * The content-type of the return value varys on the $format parameter.
+ * Further processing in the final output stage must escape all formats
+ * other than the default 'html'.
+ *
+ * @param AbstractRevision $revision Revision to display content for
+ * @param string[optional] $format Format to output content in (html|wikitext)
+ * @return string HTML if requested, otherwise plain text
+ */
+ public function getContent( AbstractRevision $revision, $format = 'html' ) {
+ $allowed = $this->permissions->isAllowed( $revision, 'view' );
+ // Posts require view access to the topic title as well
+ if ( $allowed && $revision instanceof PostRevision && !$revision->isTopicTitle() ) {
+ $allowed = $this->permissions->isAllowed(
+ $revision->getRootPost(),
+ 'view'
+ );
+ }
+
+ if ( $allowed ) {
+ // html format
+ if ( $format === 'html' ) {
+ // Parsoid doesn't render redlinks & doesn't strip bad images
+ try {
+ $content = $this->contentFixer->getContent( $revision );
+ } catch ( \Exception $e ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failed fix content for rev_id = ' . $revision->getRevisionId()->getAlphadecimal() );
+ \MWExceptionHandler::logException( $e );
+
+ $content = wfMessage( 'flow-stub-post-content' )->parse();
+ }
+ // all other formats
+ } else {
+ $content = $revision->getContent( $format );
+ }
+
+ return $content;
+ } else {
+ // @todo: I think this block of code is currently unused - can we get rid of it? (perhaps just return empty string?)
+
+ $revision = $this->getModeratedRevision( $revision );
+ $username = $this->usernames->get(
+ wfWikiId(),
+ $revision->getModeratedByUserId(),
+ $revision->getModeratedByUserIp()
+ );
+
+ // get revision type to make more precise message
+ $state = $revision->getModerationState();
+ $type = $revision->getRevisionType();
+ if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) {
+ $type = 'title';
+ }
+
+ $historyLink = $this->urlGenerator
+ ->workflowHistoryLink( null, $revision->getRootPost()->getPostId() )
+ ->getLinkURL();
+
+ // Messages: flow-hide-post-content, flow-delete-post-content, flow-suppress-post-content
+ // flow-hide-title-content, flow-delete-title-content, flow-suppress-title-content
+ $message = wfMessage( "flow-$state-$type-content", $username )
+ ->rawParams( $this->getUserLinks( $revision ) )
+ ->params( $historyLink );
+
+ if ( !$message->exists() ) {
+ wfDebugLog( 'Flow', __METHOD__ . ': Failed to locate message for moderated content: ' . $message->getKey() );
+
+ $message = wfMessage( 'flow-error-other' );
+ }
+
+ if ( $format === 'html' ) {
+ return $message->escaped();
+ } else {
+ return $message->text();
+ }
+ }
+ }
+
+ public function getModeratedRevision( AbstractRevision $revision ) {
+ if ( $revision->isModerated() ) {
+ return $revision;
+ } else {
+ try {
+ return Container::get( 'collection.cache' )->getLastRevisionFor( $revision );
+ } catch ( FlowException $e ) {
+ wfDebugLog( 'Flow', "Failed loading last revision for revid " . $revision->getRevisionId()->getAlphadecimal() . " with collection id " . $revision->getCollectionId()->getAlphadecimal() );
+ throw $e;
+ }
+ }
+ }
+}
diff --git a/Flow/includes/UrlGenerator.php b/Flow/includes/UrlGenerator.php
new file mode 100644
index 00000000..4bcb8d23
--- /dev/null
+++ b/Flow/includes/UrlGenerator.php
@@ -0,0 +1,834 @@
+<?php
+
+namespace Flow;
+
+use Flow\Exception\InvalidInputException;
+use Flow\Exception\FlowException;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Anchor;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use SpecialPage;
+use Title;
+
+/**
+ * Provides url generation capabilities for Flow. Ties together an
+ * i18n message with a specific Title, query parameters and fragment.
+ *
+ * URL generation methods mostly accept either a Title or a UUID
+ * representing the Workflow. URL generation methods all return
+ * Anchor instances..
+ */
+class UrlGenerator {
+ /**
+ * @var Workflow[] Map from alphadecimal workflow id to Workflow instance
+ */
+ protected $workflows = array();
+
+ /**
+ * @param Workflow $workflow
+ */
+ public function withWorkflow( Workflow $workflow ) {
+ $this->workflows[$workflow->getId()->getAlphadecimal()] = $workflow;
+ }
+
+ /**
+ * @param Title|null $title
+ * @param UUID|null $workflowId
+ * @return Title
+ * @throws FlowException
+ */
+ protected function resolveTitle( Title $title = null, UUID $workflowId = null ) {
+ if ( $title !== null ) {
+ return $title;
+ }
+ if ( $workflowId === null ) {
+ throw new FlowException( 'No title or workflow given' );
+ }
+
+ $alpha = $workflowId->getAlphadecimal();
+ if ( !isset( $this->workflows[$alpha] ) ) {
+ throw new InvalidInputException( 'Unloaded workflow:' . $alpha, 'invalid-workflow' );
+ }
+
+ return $this->workflows[$alpha]->getArticleTitle();
+ }
+
+ /**
+ * Link to create new topic on a topiclist.
+ *
+ * @param Title|null $title
+ * @param UUID|null $workflowId
+ * @return Anchor
+ */
+ public function newTopicLink( Title $title = null, UUID $workflowId = null ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-new' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'new-topic' )
+ );
+ }
+
+ /**
+ * Edit the header at the specified workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function editHeaderLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-edit-header' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'edit-header' )
+ );
+ }
+
+ /**
+ * Edit the title of a topic workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function editTitleLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-edit-title' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'edit-title' )
+ );
+ }
+
+ /**
+ * View a specific revision of a header workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function headerRevisionLink( Title $title = null, UUID $workflowId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-header-revision' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'header_revId' => $revId->getAlphadecimal(),
+ 'action' => 'view-header'
+ )
+ );
+ }
+
+ /**
+ * View a specific revision of a topic title
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function topicRevisionLink( Title $title = null, UUID $workflowId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-topic-revision' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'topic_revId' => $revId->getAlphadecimal(),
+ 'action' => 'single-view'
+ )
+ );
+ }
+
+ /**
+ * View a specific revision of a post within a topic workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function postRevisionLink( Title $title = null, UUID $workflowId, UUID $postId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-post-revision' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'topic_postId' => $postId->getAlphadecimal(),
+ 'topic_revId' => $revId->getAlphadecimal(),
+ 'action' => 'single-view'
+ )
+ );
+ }
+
+ /**
+ * View a specific revision of topic summary.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function summaryRevisionLink( Title $title = null, UUID $workflowId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-summary-revision' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'topicsummary_revId' => $revId->getAlphadecimal(),
+ 'action' => 'view-topic-summary'
+ )
+ );
+ }
+
+ /**
+ * View the topic at the specified workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function topicLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-topic' ),
+ $this->resolveTitle( $title, $workflowId )
+ );
+ }
+
+ /**
+ * View a topic scrolled down to the provided post at the
+ * specified workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @return Anchor
+ */
+ public function postLink( Title $title = null, UUID $workflowId, UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-link-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ // If the post is moderated this will flag the backend to still
+ // include the content in the html response.
+ 'topic_showPostId' => $postId->getAlphadecimal()
+ ),
+ '#flow-post-' . $postId->getAlphadecimal()
+ );
+ }
+
+ /**
+ * Show the history of a specific post within a topic workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @return Anchor
+ */
+ public function postHistoryLink( Title $title = null, UUID $workflowId, UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-post-history' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'history',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * Show the history of a workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function workflowHistoryLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-history' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'history' )
+ );
+ }
+
+ /**
+ * Show the history of a flow board.
+ *
+ * @param Title $title
+ * @return Anchor
+ */
+ public function boardHistoryLink( Title $title ) {
+ return new Anchor(
+ wfMessage( 'hist' ),
+ $title,
+ array( 'action' => 'history' )
+ );
+ }
+
+ /**
+ * Generate a link to undo the specified revision. Note that this will only work if
+ * that is the most recent content edit against the revision type.
+ *
+ * @param AbstractRevision $revision The revision to undo.
+ * @param Title|null $title The title the revision belongs to
+ * @param UUID $workflowId The workflow id the revision belongs to
+ * @return Anchor
+ * @throws FlowException When the provided revision is not known
+ */
+ public function undoAction( AbstractRevision $revision, Title $title = null, UUID $workflowId ) {
+ $startId = $revision->getPrevRevisionId();
+ $endId = $revision->getRevisionId();
+ if ( $revision instanceof PostRevision ) {
+ return $this->undoEditPostAction( $title, $workflowId, $startId, $endId );
+ } elseif ( $revision instanceof Header ) {
+ return $this->undoEditHeaderAction( $title, $workflowId, $startId, $endId );
+ } elseif ( $revision instanceof PostSummary ) {
+ return $this->undoEditSummaryAction( $title, $workflowId, $startId, $endId );
+ } else {
+ throw new FlowException( 'Unknown revision type: ' . get_class( $revision ) );
+ }
+ }
+
+ /**
+ * @param Title|null $title The title the post belongs to, or null
+ * @param UUID $workflowId The workflowId the post belongs to
+ * @param UUID $startId The revision to start undo from.
+ * @param UUID $endId The revision to stop undoing at
+ * @return Anchor
+ */
+ public function undoEditPostAction( Title $title = null, UUID $workflowId, UUID $startId, UUID $endId ) {
+ return new Anchor(
+ wfMessage( 'flow-undo' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'undo-edit-post',
+ 'topic_startId' => $startId->getAlphadecimal(),
+ 'topic_endId' => $endId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * @param Title|null $title The title the header belongs to, or null
+ * @param UUID $workflowId The workflowId the header belongs to
+ * @param UUID $startId The revision to start undo from.
+ * @param UUID $endId The revision to stop undoing at
+ * @return Anchor
+ */
+ public function undoEditHeaderAction( Title $title = null, UUID $workflowId, UUID $startId, UUID $endId ) {
+ return new Anchor(
+ wfMessage( 'flow-undo' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'undo-edit-header',
+ 'header_startId' => $startId->getAlphadecimal(),
+ 'header_endId' => $endId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * @param Title|null $title The title the summary belongs to, or null
+ * @param UUID $workflowId The workflowId the summary belongs to
+ * @param UUID $startId The revision to start undo from.
+ * @param UUID $endId The revision to stop undoing at
+ * @return Anchor
+ */
+ public function undoEditSummaryAction( Title $title = null, UUID $workflowId, UUID $startId, UUID $endId ) {
+ return new Anchor(
+ wfMessage( 'flow-undo' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'undo-edit-topic-summary',
+ 'topicsummary_startId' => $startId->getAlphadecimal(),
+ 'topicsummary_endId' => $endId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * @param AbstractRevision $revision
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $oldRevId
+ * @return Anchor
+ * @throws FlowException When $revision is not PostRevision, Header or PostSummary
+ */
+ public function diffLink( AbstractRevision $revision, Title $title = null, UUID $workflowId, UUID $oldRevId = null ) {
+ if ( $revision instanceof PostRevision ) {
+ return $this->diffPostLink( $title, $workflowId, $revision->getRevisionId(), $oldRevId );
+ } elseif ( $revision instanceof Header ) {
+ return $this->diffHeaderLink( $title, $workflowId, $revision->getRevisionId(), $oldRevId );
+ } elseif ( $revision instanceof PostSummary ) {
+ return $this->diffSummaryLink( $title, $workflowId, $revision->getRevisionId(), $oldRevId );
+ } else {
+ throw new FlowException( 'Unknown revision type: ' . get_class( $revision ) );
+ }
+ }
+
+ /**
+ * Show the differences between two revisions of a header.
+ *
+ * When $oldRevId is null shows the differences between $revId and the revision
+ * immediately prior. If $oldRevId is provided shows the differences between
+ * $oldRevId and $revId.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @param UUID|null $oldRevId
+ * @return Anchor
+ */
+ public function diffHeaderLink( Title $title = null, UUID $workflowId, UUID $revId, UUID $oldRevId = null ) {
+ return new Anchor(
+ wfMessage( 'diff' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'compare-header-revisions',
+ 'header_newRevision' => $revId->getAlphadecimal(),
+ ) + ( $oldRevId === null ? array() : array(
+ 'header_oldRevision' => $oldRevId->getAlphadecimal(),
+ ) )
+ );
+ }
+
+ /**
+ * Show the differences between two revisions of a post.
+ *
+ * When $oldRevId is null shows the differences between $revId and the revision
+ * immediately prior. If $oldRevId is provided shows the differences between
+ * $oldRevId and $revId.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @param UUID|null $oldRevId
+ * @return Anchor
+ */
+ public function diffPostLink( Title $title = null, UUID $workflowId, UUID $revId, UUID $oldRevId = null ) {
+ return new Anchor(
+ wfMessage( 'diff' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'compare-post-revisions',
+ 'topic_newRevision' => $revId->getAlphadecimal(),
+ ) + ( $oldRevId === null ? array() : array(
+ 'topic_oldRevision' => $oldRevId->getAlphadecimal(),
+ ) )
+ );
+ }
+
+ /**
+ * Show the differences between two revisions of a summary.
+ *
+ * When $oldRevId is null shows the differences between $revId and the revision
+ * immediately prior. If $oldRevId is provided shows the differences between
+ * $oldRevId and $revId.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @param UUID|null $oldRevId
+ * @return Anchor
+ */
+ public function diffSummaryLink( Title $title = null, UUID $workflowId, UUID $revId, UUID $oldRevId = null ) {
+ return new Anchor(
+ wfMessage( 'diff' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'compare-postsummary-revisions',
+ 'topicsummary_newRevision' => $revId->getAlphadecimal(),
+ ) + ( $oldRevId === null ? array() : array(
+ 'topicsummary_oldRevision' => $oldRevId->getAlphadecimal(),
+ ) )
+ );
+ }
+
+ /**
+ * View the specified workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function workflowLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-workflow' ),
+ $this->resolveTitle( $title, $workflowId )
+ );
+ }
+
+ /**
+ * Watch topic link
+ * @todo - replace title with a flow topic namespace topic
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function watchTopicLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'watch' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'watch' )
+ );
+ }
+
+ /**
+ * Unwatch topic link
+ * @todo - replace title with a flow topic namespace topic
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function unwatchTopicLink( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'unwatch' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'unwatch' )
+ );
+ }
+
+ /**
+ * View the flow board at the specified title
+ *
+ * Makes the assumption the title is flow-enabled.
+ *
+ * @param Title $title
+ * @param string|null $sortBy
+ * @param bool $saveSortBy
+ * @return Anchor
+ */
+ public function boardLink( Title $title, $sortBy = null, $saveSortBy = false ) {
+ $options = array();
+
+ if ( $sortBy !== null ) {
+ $options['topiclist_sortby'] = $sortBy;
+ if ( $saveSortBy ) {
+ $options['topiclist_savesortby'] = '1';
+ }
+ }
+
+ return new Anchor(
+ $title->getPrefixedText(),
+ $title,
+ $options
+ );
+ }
+
+ /**
+ * Reply to an individual post in a topic workflow.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @param bool $isTopLevelReply
+ * @return Anchor
+ */
+ public function replyAction(
+ Title $title = null,
+ UUID $workflowId,
+ UUID $postId,
+ $isTopLevelReply
+ ) {
+ $hash = "#flow-post-{$postId->getAlphadecimal()}";
+ if ( $isTopLevelReply ) {
+ $hash .= "-form-content";
+ }
+ return new Anchor(
+ wfMessage( 'flow-reply-link' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'reply',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ ),
+ $hash
+ );
+ }
+
+
+ /**
+ * Edit the specified topic summary
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function editTopicSummaryAction( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-summarize-topic-submit' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'edit-topic-summary' )
+ );
+ }
+
+ /**
+ * Lock the specified topic
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function lockTopicAction( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-lock-topic' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'lock-topic',
+ 'flow_moderationState' => AbstractRevision::MODERATED_LOCKED,
+ )
+ );
+ }
+
+ /**
+ * Restore the specified topic to unmoderated status.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param string $moderationAction
+ * @param string $flowAction
+ * @return Anchor
+ */
+ public function restoreTopicAction( Title $title = null, UUID $workflowId, $moderationAction, $flowAction = 'moderate-topic' ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-' . $moderationAction . '-topic' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => $flowAction,
+ 'flow_moderationState' => $moderationAction,
+ )
+ );
+ }
+
+ /**
+ * Restore the specified post to unmoderated status.
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @param string $moderationAction
+ * @param string $flowAction
+ * @return Anchor
+ */
+ public function restorePostAction( Title $title = null, UUID $workflowId, UUID $postId, $moderationAction, $flowAction = 'moderate-post' ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-' . $moderationAction . '-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => $flowAction,
+ 'topic_moderationState' => $moderationAction,
+ 'topic_postId' => $postId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * Create a header for the specified page
+ *
+ * @param Title $title
+ * @return Anchor
+ */
+ public function createHeaderAction( Title $title ) {
+ return new Anchor(
+ wfMessage( 'flow-edit-header-link' ),
+ $title,
+ array( 'action' => 'edit-header' )
+ );
+ }
+
+ /**
+ * Edit the specified header
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function editHeaderAction( Title $title = null, UUID $workflowId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-edit-header-link' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array( 'action' => 'edit-header' )
+ );
+ }
+
+ /**
+ * Edit the specified topic title
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function editTitleAction( Title $title = null, UUID $workflowId, UUID $postId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-edit-title' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'edit-title',
+ // @todo not necessary?
+ 'topic_revId' => $revId->getAlphadecimal(),
+ )
+ );
+ }
+
+ /**
+ * Edit the specified post within the specified workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @param UUID $revId
+ * @return Anchor
+ */
+ public function editPostAction( Title $title = null, UUID $workflowId, UUID $postId, UUID $revId ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-edit-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'edit-post',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ // @todo not necessary?
+ 'topic_revId' => $revId->getAlphadecimal(),
+ ),
+ '#flow-post-' . $postId->getAlphadecimal()
+
+ );
+ }
+
+ /**
+ * Hide the specified topic
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function hideTopicAction( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-hide-topic' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-topic',
+ 'topic_moderationState' => AbstractRevision::MODERATED_HIDDEN,
+ )
+ );
+ }
+
+ /**
+ * Hide the specified post within the specified workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @return Anchor
+ */
+ public function hidePostAction( Title $title = null, UUID $workflowId, UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-hide-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-post',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ 'topic_moderationState' => AbstractRevision::MODERATED_HIDDEN,
+ )
+ );
+ }
+
+ /**
+ * Delete the specified topic workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function deleteTopicAction( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-delete-topic' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-topic',
+ 'topic_moderationState' => AbstractRevision::MODERATED_DELETED,
+ )
+ );
+ }
+
+ /**
+ * Delete the specified post within the specified workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @return Anchor
+ */
+ public function deletePostAction( Title $title = null, UUID $workflowId, UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-delete-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-post',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ 'topic_moderationState' => AbstractRevision::MODERATED_DELETED,
+ )
+ );
+ }
+
+ /**
+ * Suppress the specified topic workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @return Anchor
+ */
+ public function suppressTopicAction( Title $title = null, UUID $workflowId ) {
+ return new Anchor(
+ wfMessage( 'flow-topic-action-suppress-topic' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-topic',
+ 'topic_moderationState' => AbstractRevision::MODERATED_SUPPRESSED,
+ )
+ );
+ }
+
+ /**
+ * Suppress the specified post within the specified workflow
+ *
+ * @param Title|null $title
+ * @param UUID $workflowId
+ * @param UUID $postId
+ * @return Anchor
+ */
+ public function suppressPostAction( Title $title = null, UUID $workflowId, UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-post-action-suppress-post' ),
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'moderate-post',
+ 'topic_postId' => $postId->getAlphadecimal(),
+ 'topic_moderationState' => AbstractRevision::MODERATED_SUPPRESSED,
+ )
+ );
+ }
+
+ public function newTopicAction( Title $title = null, UUID $workflowId = null ) {
+ return new Anchor(
+ wfMessage( 'flow-newtopic-start-placeholder' ),
+ // resolveTitle doesn't accept null uuid
+ $this->resolveTitle( $title, $workflowId ),
+ array(
+ 'action' => 'new-topic'
+ )
+ );
+ }
+
+ public function thankAction( UUID $postId ) {
+ return new Anchor(
+ wfMessage( 'flow-thank-link' ),
+ SpecialPage::getTitleFor( 'Thanks', 'Flow/' . $postId->getAlphadecimal() ),
+ array(),
+ null,
+ wfMessage( 'flow-thank-link-title' )
+ );
+ }
+}
diff --git a/Flow/includes/Utils/NamespaceIterator.php b/Flow/includes/Utils/NamespaceIterator.php
new file mode 100644
index 00000000..a52a3564
--- /dev/null
+++ b/Flow/includes/Utils/NamespaceIterator.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Flow\Utils;
+
+use DatabaseBase;
+use EchoBatchRowIterator;
+use EchoCallbackIterator;
+use Iterator;
+use IteratorAggregate;
+use RecursiveIteratorIterator;
+use Title;
+
+/**
+ * Iterates over all titles within the specified namespace. Batches
+ * queries into 500 titles at a time starting with the lowest page id.
+ */
+class NamespaceIterator implements IteratorAggregate {
+ /**
+ * @var DatabaseBase A wiki database to read from
+ */
+ protected $db;
+
+ /**
+ * @var int An NS_* namespace to iterate over
+ */
+ protected $namespace;
+
+ /**
+ * @param DatabaseBase $db A wiki database to read from
+ * @param int $namespace An NS_* namespace to iterate over
+ */
+ public function __construct( DatabaseBase $db, $namespace ) {
+ $this->db = $db;
+ $this->namespace = $namespace;
+ }
+
+ /**
+ * @return Iterator<Title>
+ */
+ public function getIterator() {
+ $it = new EchoBatchRowIterator(
+ $this->db,
+ /* tables */ array( 'page' ),
+ /* pk */ 'page_id',
+ /* rows per batch */ 500
+ );
+ $it->addConditions( array(
+ 'page_namespace' => $this->namespace,
+ ) );
+ $it->setFetchColumns( array( 'page_title' ) );
+ $it = new RecursiveIteratorIterator( $it );
+
+ $namespace = $this->namespace;
+ return new EchoCallbackIterator( $it, function( $row ) use ( $namespace ) {
+ return Title::makeTitle( $namespace, $row->page_title );
+ } );
+ }
+}
diff --git a/Flow/includes/Utils/PagesWithPropertyIterator.php b/Flow/includes/Utils/PagesWithPropertyIterator.php
new file mode 100644
index 00000000..c6cc33ba
--- /dev/null
+++ b/Flow/includes/Utils/PagesWithPropertyIterator.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Flow\Utils;
+
+use DatabaseBase;
+use EchoBatchRowIterator;
+use EchoCallbackIterator;
+use IteratorAggregate;
+use RecursiveIteratorIterator;
+use Title;
+
+/**
+ * Iterates over all titles that have the specified page property
+ */
+class PagesWithPropertyIterator implements IteratorAggregate {
+ /**
+ * @var DatabaseBase
+ */
+ protected $db;
+
+ /**
+ * @var string
+ */
+ protected $propName;
+
+ /**
+ * Page id to start at (inclusive)
+ *
+ * @var int|null
+ */
+ protected $startId = null;
+
+ /**
+ * Page id to stop at (exclusive)
+ *
+ * @var int|null
+ */
+ protected $stopId = null;
+
+ /**
+ * @param DatabaseBase $db
+ * @param string $propName
+ * @param int|null $startId Page id to start at (inclusive)
+ * @param int|null $stopId Page id to stop at (exclusive)
+ */
+ public function __construct( DatabaseBase $db, $propName, $startId = null, $stopId = null ) {
+ $this->db = $db;
+ $this->propName = $propName;
+ $this->startId = $startId;
+ $this->stopId = $stopId;
+ }
+
+ /**
+ * @return Iterator<Title>
+ */
+ public function getIterator() {
+ $it = new EchoBatchRowIterator(
+ $this->db,
+ /* tables */ array( 'page_props', 'page' ),
+ /* pk */ 'pp_page',
+ /* rows per batch */ 500
+ );
+
+ $conditions = array( 'pp_propname' => $this->propName );
+ if ( $this->startId !== null ) {
+ $conditions[] = 'pp_page >= ' . $this->db->addQuotes( $this->startId );
+ }
+ if ( $this->stopId !== null ) {
+ $conditions[] = 'pp_page < ' . $this->db->addQuotes( $this->stopId );
+ }
+ $it->addConditions( $conditions );
+
+ $it->addJoinConditions( array(
+ 'page' => array( 'JOIN', 'pp_page=page_id' ),
+ ) );
+ $it->setFetchColumns( array( 'page_namespace', 'page_title' ) );
+ $it = new RecursiveIteratorIterator( $it );
+
+ return new EchoCallbackIterator( $it, function( $row ) {
+ return Title::makeTitle( $row->page_namespace, $row->page_title );
+ } );
+ }
+}
diff --git a/Flow/includes/View.php b/Flow/includes/View.php
new file mode 100644
index 00000000..9e529951
--- /dev/null
+++ b/Flow/includes/View.php
@@ -0,0 +1,291 @@
+<?php
+
+namespace Flow;
+
+use ContextSource;
+use Flow\Block\AbstractBlock;
+use Flow\Exception\InvalidActionException;
+use Flow\Model\Anchor;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Html;
+use IContextSource;
+use Message;
+use OutputPage;
+use Title;
+use WebRequest;
+
+
+class View extends ContextSource {
+ /**
+ * @var UrlGenerator
+ */
+ protected $urlGenerator;
+
+ /**
+ * @var TemplateHelper
+ */
+ protected $lightncandy;
+
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ function __construct(
+ UrlGenerator $urlGenerator,
+ TemplateHelper $lightncandy,
+ IContextSource $requestContext,
+ FlowActions $actions
+ ) {
+ $this->urlGenerator = $urlGenerator;
+ $this->lightncandy = $lightncandy;
+ $this->setContext( $requestContext );
+ $this->actions = $actions;
+ }
+
+ public function show( WorkflowLoader $loader, $action ) {
+ wfProfileIn( __CLASS__ . '-init' );
+
+ $blocks = $loader->getBlocks();
+ wfProfileOut( __CLASS__ . '-init' );
+
+ $parameters = $this->extractBlockParameters( $action, $blocks );
+ foreach ( $loader->getBlocks() as $block ) {
+ $block->init( $this, $action );
+ }
+
+ if ( $this->getRequest()->wasPosted() ) {
+ $retval = $this->handleSubmit( $loader, $action, $parameters );
+ // successfull submission
+ if ( $retval === true ) {
+ $this->redirect( $loader->getWorkflow(), 'view' );
+ return;
+ // only render the returned subset of blocks
+ } elseif ( is_array( $retval ) ) {
+ $blocks = $retval;
+ }
+ }
+
+ $apiResponse = $this->buildApiResponse( $loader, $blocks, $action, $parameters );
+
+ /**
+ header( 'Content-Type: application/json; content=utf-8' );
+ $data = json_encode( $apiResponse );
+ //return;
+ die( $data );
+ **/
+
+ $output = $this->getOutput();
+ $this->addModules( $output, $action );
+ // Please note that all blocks can set page title, which may cause them
+ // to override one another's titles
+ foreach ( $blocks as $block ) {
+ $block->setPageTitle( $output );
+ }
+
+
+ $this->renderApiResponse( $apiResponse );
+ }
+
+ protected function addModules( OutputPage $out, $action ) {
+ if ( $this->actions->hasValue( $action, 'modules' ) ) {
+ $out->addModuleStyles( $this->actions->getValue( $action, 'modules' ) );
+ } else {
+ $out->addModules( array( 'ext.flow' ) );
+ }
+
+ if ( $this->actions->hasValue( $action, 'moduleStyles' ) ) {
+ $out->addModuleStyles( $this->actions->getValue( $action, 'moduleStyles' ) );
+ } else {
+ $out->addModuleStyles( array(
+ 'mediawiki.ui',
+ 'mediawiki.ui.anchor',
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.text',
+ 'ext.flow.styles.base' ,
+ 'ext.flow.styles' ,
+ 'ext.flow.mediawiki.ui.tooltips',
+ 'ext.flow.mediawiki.ui.form',
+ 'ext.flow.mediawiki.ui.modal',
+ 'ext.flow.mediawiki.ui.text',
+ 'ext.flow.icons.styles',
+ 'ext.flow.board.styles',
+ 'ext.flow.board.topic.styles',
+ ) );
+ }
+
+ // Allow other extensions to add modules
+ wfRunHooks( 'FlowAddModules', array( $out ) );
+ }
+
+ protected function handleSubmit( WorkflowLoader $loader, $action, array $parameters ) {
+ $this->getOutput()->enableClientCache( false );
+
+ $blocksToCommit = $loader->handleSubmit( $this, $action, $parameters );
+ if ( !$blocksToCommit ) {
+ return false;
+ }
+
+ if ( !$this->getUser()->matchEditToken( $this->getRequest()->getVal( 'wpEditToken' ) ) ) {
+ // this uses the above $blocksToCommit reference to only render the failed blocks
+ foreach ( $blocksToCommit as $block ) {
+ $block->addError( 'edit-token', $this->msg( 'sessionfailure' ) );
+ }
+ return $blocksToCommit;
+ }
+
+ $loader->commit( $blocksToCommit );
+ return true;
+ }
+
+ protected function buildApiResponse( WorkflowLoader $loader, array $blocks, $action, array $parameters ) {
+ $workflow = $loader->getWorkflow();
+ $title = $workflow->getArticleTitle();
+ $user = $this->getUser();
+
+ // @todo This and API should use same code
+ $apiResponse = array(
+ 'title' => $title->getPrefixedText(),
+ 'workflow' => $workflow->isNew() ? '' : $workflow->getId()->getAlphadecimal(),
+ 'blocks' => array(),
+ 'isWatched' => $user->isWatched( $title ),
+ 'watchable' => !$user->isAnon(),
+ 'links' => array(
+ 'watch-board' => array(
+ 'url' => $title->getLocalUrl( 'action=watch' ),
+ ),
+ 'unwatch-board' => array(
+ 'url' => $title->getLocalUrl( 'action=unwatch' ),
+ ),
+ ),
+ );
+
+ $editToken = $user->getEditToken();
+ $wasPosted = $this->getRequest()->wasPosted();
+ foreach ( $blocks as $block ) {
+ if ( $wasPosted ? $block->canSubmit( $action ) : $block->canRender( $action ) ) {
+ $apiResponse['blocks'][] = $block->renderApi( $parameters[$block->getName()] )
+ + array(
+ 'title' => $apiResponse['title'],
+ 'block-action-template' => $block->getTemplate( $action ),
+ 'editToken' => $editToken,
+ );
+ }
+ }
+
+ if ( count( $apiResponse['blocks'] ) === 0 ) {
+ throw new InvalidActionException( "No blocks accepted action: $action" );
+ }
+
+ array_walk_recursive( $apiResponse, function( &$value ) {
+ if ( $value instanceof Anchor ) {
+ $anchor = $value;
+ $value = $value->toArray();
+
+ // TODO: We're looking into another approach for this
+ // using a parser function, so the URL doesn't have to be
+ // fully qualified.
+ // See https://bugzilla.wikimedia.org/show_bug.cgi?id=66746
+ $value['url'] = $anchor->getFullURL();
+
+ } elseif ( $value instanceof Message ) {
+ $value = $value->text();
+ } elseif ( $value instanceof UUID ) {
+ $value = $value->getAlphadecimal();
+ }
+ } );
+
+ return $apiResponse;
+ }
+
+ protected function renderApiResponse( array $apiResponse ) {
+ // Render the flow-component wrapper
+ if ( empty( $apiResponse['blocks'] ) ) {
+ return array();
+ }
+
+ $out = $this->getOutput();
+ $renderedBlocks = array();
+ foreach ( $apiResponse['blocks'] as $block ) {
+ // @todo find a better way to do this; potentially make all blocks their own components
+ switch ( $block['type'] ) {
+ case 'board-history':
+ $flowComponent = 'boardHistory';
+ break;
+ default:
+ $flowComponent = 'board';
+ }
+
+ // Don't re-render a block type twice in one page
+ if ( isset( $renderedBlocks[$flowComponent] ) ) {
+ continue;
+ }
+ $renderedBlocks[$flowComponent] = true;
+
+ // Get the block loop template
+ $template = $this->lightncandy->getTemplate( 'flow_block_loop' );
+ // Output the component, with the rendered blocks inside it
+ $out->addHTML( Html::rawElement(
+ 'div',
+ array(
+ 'class' => 'flow-component',
+ 'data-flow-component' => $flowComponent,
+ 'data-flow-id' => $apiResponse['workflow'],
+ ),
+ $template( $apiResponse )
+ ) );
+ }
+ }
+
+ protected function redirect( Workflow $workflow ) {
+ $link = $this->urlGenerator->workflowLink(
+ $workflow->getArticleTitle(),
+ $workflow->getId()
+ );
+ $this->getOutput()->redirect( $link->getFullURL() );
+ }
+
+ /**
+ * Helper function extracts parameters from a WebRequest.
+ *
+ * @param string $action
+ * @param WebRequest $request
+ * @param AbstractBlock[] $blocks
+ * @return array
+ */
+ public function extractBlockParameters( $action, array $blocks ) {
+ $request = $this->getRequest();
+ $result = array();
+ // BC for old parameters enclosed in square brackets
+ foreach ( $blocks as $block ) {
+ $name = $block->getName();
+ $result[$name] = $request->getArray( $name, array() );
+ }
+ // BC for topic_list renamed to topiclist
+ if ( isset( $result['topiclist'] ) && !$result['topiclist'] ) {
+ $result['topiclist'] = $request->getArray( 'topic_list', array() );
+ }
+ $globalData = array( 'action' => $action );
+ foreach ( $request->getValues() as $name => $value ) {
+ // between urls only allowing [-_.] as unencoded special chars and
+ // php mangling all of those into '_', we have to split on '_'
+ if ( false !== strpos( $name, '_' ) ) {
+ list( $block, $var ) = explode( '_', $name, 2 );
+ // flow_xxx is global data for all blocks
+ if ( $block === 'flow' ) {
+ $globalData[$var] = $value;
+ } else {
+ $result[$block][$var] = $value;
+ }
+ }
+ }
+
+ foreach ( $blocks as $block ) {
+ $result[$block->getName()] += $globalData;
+ }
+
+ return $result;
+ }
+}
diff --git a/Flow/includes/WatchedTopicItems.php b/Flow/includes/WatchedTopicItems.php
new file mode 100644
index 00000000..f57c4c6c
--- /dev/null
+++ b/Flow/includes/WatchedTopicItems.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Flow;
+
+use DatabaseBase;
+use Flow\Exception\DataModelException;
+use Title;
+use User;
+
+/**
+ * Is there a core object for retrieving multiple watchlist items?
+ */
+class WatchedTopicItems {
+
+ protected $user;
+ protected $watchListDb;
+ protected $overrides = array();
+
+ public function __construct( User $user, DatabaseBase $watchListDb ) {
+ $this->user = $user;
+ $this->watchListDb = $watchListDb;
+ }
+
+ /**
+ * Helps prevent reading our own writes. If we have explicitly
+ * watched this title in this request set it here instead of
+ * querying a slave and possibly not noticing due to slave lag.
+ */
+ public function addOverrideWatched( Title $title ) {
+ $this->overrides[$title->getNamespace()][$title->getDBkey()] = true;
+ }
+
+ /**
+ * @param string[] array of UUID string
+ * @return array
+ * @throws Exception\DataModelException
+ */
+ public function getWatchStatus( array $titles ) {
+ $titles = array_unique( $titles );
+ $result = array_fill_keys( $titles, false );
+
+ if ( !$this->user->getId() ) {
+ return $result;
+ }
+
+ $queryTitles = array();
+ foreach ( $titles as $id ) {
+ $obj = Title::makeTitleSafe( NS_TOPIC, $id );
+ if ( $obj ) {
+ $key = $obj->getDBkey();
+ if ( isset( $this->overrides[$obj->getNamespace()][$key] ) ) {
+ $result[strtolower( $key )] = true;
+ } else {
+ $queryTitles[$key] = $obj->getDBkey();
+ }
+ }
+ }
+
+ if ( !$queryTitles ) {
+ return $result;
+ }
+
+ $res = $this->watchListDb->select(
+ array( 'watchlist' ),
+ array( 'wl_title' ),
+ array(
+ 'wl_user' => $this->user->getId(),
+ 'wl_namespace' => NS_TOPIC,
+ 'wl_title' => $queryTitles
+ ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ throw new DataModelException( 'query failure', 'process-data' );
+ }
+ foreach ( $res as $row ) {
+ $result[strtolower( $row->wl_title )] = true;
+ }
+ return $result;
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * @return DatabaseBase
+ */
+ public function getWatchlistDb() {
+ return $this->watchListDb;
+ }
+}
diff --git a/Flow/includes/WorkflowLoader.php b/Flow/includes/WorkflowLoader.php
new file mode 100644
index 00000000..9952cf4c
--- /dev/null
+++ b/Flow/includes/WorkflowLoader.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Flow;
+
+use Flow\Block\Block;
+use Flow\Model\Workflow;
+use IContextSource;
+
+class WorkflowLoader {
+ /**
+ * @var Workflow
+ */
+ protected $workflow;
+
+ /**
+ * @var Block[]
+ */
+ protected $blocks;
+
+ /**
+ * @var SubmissionHandler
+ */
+ protected $submissionHandler;
+
+ /**
+ * @param Workflow $workflow
+ * @param Block[] $blocks
+ * @param SubmissionHandler $submissionHandler
+ */
+ public function __construct(
+ Workflow $workflow,
+ array $blocks,
+ SubmissionHandler $submissionHandler
+ ) {
+ $this->blocks = $blocks;
+ $this->submissionHandler = $submissionHandler;
+ $this->workflow = $workflow;
+ }
+
+ /**
+ * @return Workflow
+ */
+ public function getWorkflow() {
+ return $this->workflow;
+ }
+
+ /**
+ * @return Block[]
+ */
+ public function getBlocks() {
+ return $this->blocks;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param string $action
+ * @param array $parameters
+ * @return Block[]
+ */
+ public function handleSubmit( IContextSource $context, $action, array $parameters ) {
+ return $this->submissionHandler
+ ->handleSubmit( $this->workflow, $context, $this->blocks, $action, $parameters );
+ }
+
+ public function commit( array $blocks ) {
+ return $this->submissionHandler->commit( $this->workflow, $blocks );
+ }
+}
diff --git a/Flow/includes/WorkflowLoaderFactory.php b/Flow/includes/WorkflowLoaderFactory.php
new file mode 100644
index 00000000..32598f94
--- /dev/null
+++ b/Flow/includes/WorkflowLoaderFactory.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Flow;
+
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Data\ManagerGroup;
+use Flow\Exception\CrossWikiException;
+use Flow\Exception\InvalidInputException;
+use Flow\Exception\InvalidDataException;
+use Flow\Exception\InvalidTopicUuidException;
+use Flow\Exception\UnknownWorkflowIdException;
+use Title;
+
+class WorkflowLoaderFactory {
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var BlockFactory
+ */
+ protected $blockFactory;
+
+ /**
+ * @var SubmissionHandler
+ */
+ protected $submissionHandler;
+
+ /**
+ * @var string
+ */
+ protected $defaultWorkflowName;
+
+ /**
+ * @param ManagerGroup $storage
+ * @param BlockFactory $blockFactory
+ * @param SubmissionHandler $submissionHandler
+ * @param string $defaultWorkflowName
+ */
+ function __construct(
+ ManagerGroup $storage,
+ BlockFactory $blockFactory,
+ SubmissionHandler $submissionHandler,
+ $defaultWorkflowName
+ ) {
+ $this->storage = $storage;
+ $this->blockFactory = $blockFactory;
+ $this->submissionHandler = $submissionHandler;
+ $this->defaultWorkflowName = $defaultWorkflowName;
+ }
+
+ /**
+ * @param Title $pageTitle
+ * @param UUID|null $workflowId
+ * @return WorkflowLoader
+ * @throws InvalidInputException
+ * @throws CrossWikiException
+ */
+ public function createWorkflowLoader( Title $pageTitle, $workflowId = null ) {
+ if ( $pageTitle === null ) {
+ throw new InvalidInputException( 'Invalid article requested', 'invalid-title' );
+ }
+
+ if ( $pageTitle && $pageTitle->isExternal() ) {
+ throw new CrossWikiException( 'Interwiki to ' . $pageTitle->getInterwiki() . ' not implemented ', 'default' );
+ }
+
+ if ( $pageTitle->getNamespace() === NS_TOPIC ) {
+ $workflowId = self::uuidFromTitle( $pageTitle );
+ }
+ if ( $workflowId !== null ) {
+ $workflow = $this->loadWorkflowById( $pageTitle, $workflowId );
+ } else {
+ $workflow = $this->loadWorkflow( $pageTitle );
+ }
+
+ return new WorkflowLoader(
+ $workflow,
+ $this->blockFactory->createBlocks( $workflow ),
+ $this->submissionHandler
+ );
+ }
+
+ /**
+ * @param Title $title
+ * @return Workflow
+ * @throws InvalidDataException
+ */
+ protected function loadWorkflow( \Title $title ) {
+ $storage = $this->storage->getStorage( 'Workflow' );
+
+ $found = $storage->find( array(
+ 'workflow_type' => $this->defaultWorkflowName,
+ 'workflow_wiki' => $title->isLocal() ? wfWikiId() : $title->getTransWikiID(),
+ 'workflow_namespace' => $title->getNamespace(),
+ 'workflow_title_text' => $title->getDBkey(),
+ ) );
+ if ( $found ) {
+ $workflow = reset( $found );
+ } else {
+ $workflow = Workflow::create( $this->defaultWorkflowName, $title );
+ }
+
+ return $workflow;
+ }
+
+ /**
+ * @param Title|false $title
+ * @param UUID $workflowId
+ * @return Workflow
+ * @throws InvalidInputException
+ */
+ protected function loadWorkflowById( /* Title or false */ $title, $workflowId ) {
+ /** @var Workflow $workflow */
+ $workflow = $this->storage->getStorage( 'Workflow' )->get( $workflowId );
+ if ( !$workflow ) {
+ throw new UnknownWorkflowIdException( 'Invalid workflow requested by id', 'invalid-input' );
+ }
+ if ( $title !== false && !$workflow->matchesTitle( $title ) ) {
+ throw new InvalidInputException( 'Flow workflow is for different page', 'invalid-input' );
+ }
+
+ return $workflow;
+ }
+
+ /**
+ * Create a UUID for a Title object
+ *
+ * @param Title $title
+ * @return UUID
+ * @throws InvalidInputException When the Title does not represent a valid uuid
+ */
+ public static function uuidFromTitle( Title $title ) {
+ return self::uuidFromTitlePair( $title->getNamespace(), $title->getDbKey() );
+ }
+
+ /**
+ * Create a UUID for a ns/dbkey title pair
+ *
+ * @param integer $ns
+ * @param string $dbKey
+ * @return UUID
+ * @throws InvalidInputException When the pair does not represent a valid uuid
+ */
+ public static function uuidFromTitlePair( $ns, $dbKey ) {
+ if ( $ns !== NS_TOPIC ) {
+ throw new InvalidInputException( "Title is not from NS_TOPIC: $ns", 'invalid-input' );
+ }
+
+ try {
+ return UUID::create( strtolower( $dbKey ) );
+ } catch ( InvalidInputException $e ) {
+ throw new InvalidTopicUuidException( "$dbKey is not a valid UUID", 0, $e );
+ }
+ }
+}
diff --git a/Flow/maintenance/FlowAddMissingModerationLogs.php b/Flow/maintenance/FlowAddMissingModerationLogs.php
new file mode 100644
index 00000000..9236d175
--- /dev/null
+++ b/Flow/maintenance/FlowAddMissingModerationLogs.php
@@ -0,0 +1,107 @@
+<?php
+
+use Flow\Container;
+use Flow\Data\Listener\ModerationLoggingListener;
+use Flow\Model\UUID;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Adjusts edit counts for all existing Flow data.
+ *
+ * @ingroup Maintenance
+ */
+class FlowAddMissingModerationLogs extends LoggedUpdateMaintenance {
+ public function __construct() {
+ parent::__construct();
+
+ $this->mDescription = 'Backfills missing moderation logs from flow_revision. Must be run separately for each affected wiki.';
+
+ $this->addOption( 'start', 'rev_id of last moderation revision that was logged correctly before regression.', true, true );
+ $this->addOption( 'stop', 'rev_id of first revision that was logged correctly after moderation logging fix.', true, true );
+
+ $this->setBatchSize( 300 );
+ }
+
+ protected function getUpdateKey() {
+ return 'FlowAddMissingModerationLogs';
+ }
+
+ protected function doDBUpdates() {
+ $container = Container::getContainer();
+
+ $dbFactory = $container['db.factory'];
+ $dbw = $dbFactory->getDb( DB_MASTER );
+
+ $storage = $container['storage'];
+
+ $moderationLoggingListener = $container['storage.post.listeners.moderation_logging'];
+
+ $rowIterator = new EchoBatchRowIterator(
+ $dbw,
+ /* table = */'flow_revision',
+ /* primary key = */'rev_id',
+ $this->mBatchSize
+ );
+
+ $rowIterator->setFetchColumns( array(
+ 'rev_id',
+ 'rev_type',
+ ) );
+
+ // Fetch rows that are a moderation action
+ $rowIterator->addConditions( array(
+ 'rev_change_type' => ModerationLoggingListener::getModerationChangeTypes(),
+ 'rev_user_wiki' => wfWikiID(),
+ ) );
+
+ $start = $this->getOption( 'start' );
+ $startId = UUID::create( $start );
+ $rowIterator->addConditions( array(
+ 'rev_id > ' . $dbw->addQuotes( $startId->getBinary() ),
+ ) );
+
+ $stop = $this->getOption( 'stop' );
+ $stopId = UUID::create( $stop );
+ $rowIterator->addConditions( array(
+ 'rev_id < ' . $dbw->addQuotes( $stopId->getBinary() ),
+ ) );
+
+ $total = $fail = 0;
+ foreach ( $rowIterator as $batch ) {
+ $dbw->begin();
+ foreach ( $batch as $row ) {
+ $total++;
+ $objectManager = $storage->getStorage( $row->rev_type );
+ $revId = UUID::create( $row->rev_id );
+ $obj = $objectManager->get( $revId );
+ if ( !$obj ) {
+ $this->error( 'Could not load revision: ' . $revId->getAlphadecimal() );
+ $fail++;
+ continue;
+ }
+
+ $workflow = $obj->getCollection()->getWorkflow();
+ $moderationLoggingListener->onAfterInsert( $obj, array(), array(
+ 'workflow' => $workflow,
+ ) );
+ }
+
+ $dbw->commit();
+ $storage->clear();
+ $dbFactory->waitForSlaves();
+ }
+
+ $this->output( "Processed a total of $total moderation revisions.\n" );
+ if ( $fail !== 0 ) {
+ $this->error( "Errors were encountered while processing $fail of them.\n" );
+ }
+
+ return true;
+ }
+}
+
+$maintClass = 'FlowAddMissingModerationLogs';
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowFixEditCount.php b/Flow/maintenance/FlowFixEditCount.php
new file mode 100644
index 00000000..224b8d60
--- /dev/null
+++ b/Flow/maintenance/FlowFixEditCount.php
@@ -0,0 +1,129 @@
+<?php
+
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\UUID;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Adjusts edit counts for all existing Flow data.
+ *
+ * @ingroup Maintenance
+ */
+class FlowFixEditCount extends LoggedUpdateMaintenance {
+ /**
+ * Array of [username => increased edit count]
+ *
+ * @var array
+ */
+ protected $updates = array();
+
+ public function __construct() {
+ parent::__construct();
+
+ $this->mDescription = 'Adjusts edit counts for all existing Flow data';
+
+ $this->addOption( 'start', 'Timestamp to start counting revisions at', false, true );
+ $this->addOption( 'stop', 'Timestamp to stop counting revisions at', false, true );
+
+ $this->setBatchSize( 300 );
+ }
+
+ protected function getUpdateKey() {
+ return 'FlowFixEditCount';
+ }
+
+ protected function doDBUpdates() {
+ /** @var DatabaseBase $dbr */
+ $dbr = Container::get( 'db.factory' )->getDB( DB_SLAVE );
+ $countableActions = $this->getCountableActions();
+
+ // defaults = date of first Flow commit up until now
+ $continue = UUID::getComparisonUUID( $this->getOption( 'start', '20130710230511' ) );
+ $stop = UUID::getComparisonUUID( $this->getOption( 'stop', time() ) );
+ while ( $continue !== false ) {
+ $continue = $this->refreshBatch( $dbr, $continue, $countableActions, $stop );
+
+ // wait for core (we're updating user table) slaves to catch up
+ wfWaitForSlaves();
+ }
+
+ $this->output( "Done increasing edit counts. Increased:\n" );
+ foreach ( $this->updates as $userId => $count ) {
+ $userName = User::newFromId( $userId )->getName();
+ $this->output( " User $userId ($userName): +$count\n" );
+ }
+
+ return true;
+ }
+
+ public function refreshBatch( DatabaseBase $dbr, UUID $continue, $countableActions, UUID $stop ) {
+ $rows = $dbr->select(
+ 'flow_revision',
+ array( 'rev_id', 'rev_user_id' ),
+ array(
+ 'rev_id > ' . $dbr->addQuotes( $continue->getBinary() ),
+ 'rev_id <= ' . $dbr->addQuotes( $stop->getBinary() ),
+ 'rev_user_id > 0',
+ 'rev_user_wiki' => wfWikiID(),
+ 'rev_change_type' => $countableActions,
+ ),
+ __METHOD__,
+ array(
+ 'ORDER BY' => 'rev_id ASC',
+ 'LIMIT' => $this->mBatchSize,
+ )
+ );
+
+ // end of data
+ if ( !$rows || $rows->numRows() === 0 ) {
+ return false;
+ }
+
+ foreach ( $rows as $row ) {
+ // User::incEditCount only allows for edit count to be increased 1
+ // at a time. It'd be better to immediately be able to increase the
+ // edit count by the exact number it should be increased with, but
+ // I'd rather re-use existing code, especially in a run-once script,
+ // where performance is not the most important thing ;)
+ $user = User::newFromId( $row->rev_user_id );
+ $user->incEditCount();
+
+ // save updates so we can print them when the script is done running
+ if ( !isset( $this->updates[$user->getId()] ) ) {
+ $this->updates[$user->getId()] = 0;
+ }
+ $this->updates[$user->getId()]++;
+
+ // set value for next batch to continue at
+ $continue = $row->rev_id;
+ }
+
+ return UUID::create( $continue );
+ }
+
+ /**
+ * Returns list of rev_change_type values that warrant an editcount increase.
+ *
+ * @return array
+ */
+ protected function getCountableActions() {
+ $allowedActions = array();
+
+ /** @var FlowActions $actions */
+ $actions = \Flow\Container::get( 'flow_actions' );
+ foreach ( $actions->getActions() as $action ) {
+ if ( $actions->getValue( $action, 'editcount' ) ) {
+ $allowedActions[] = $action;
+ }
+ }
+
+ return $allowedActions;
+ }
+}
+
+$maintClass = 'FlowFixEditCount';
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowFixLog.php b/Flow/maintenance/FlowFixLog.php
new file mode 100644
index 00000000..518f8cb5
--- /dev/null
+++ b/Flow/maintenance/FlowFixLog.php
@@ -0,0 +1,189 @@
+<?php
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Exception\InvalidDataException;
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Collection\PostCollection;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+require_once( __DIR__ . '/../../Echo/includes/BatchRowUpdate.php' );
+
+/**
+ * Fixes Flow log entries.
+ *
+ * @ingroup Maintenance
+ */
+class FlowFixLog extends LoggedUpdateMaintenance {
+ public function __construct() {
+ parent::__construct();
+
+ $this->mDescription = 'Fixes Flow log entries';
+
+ $this->setBatchSize( 300 );
+ }
+
+ protected function getUpdateKey() {
+ return 'FlowFixLog';
+ }
+
+ protected function doDBUpdates() {
+ $iterator = new EchoBatchRowIterator( wfGetDB( DB_SLAVE ), 'logging', 'log_id', $this->mBatchSize );
+ $iterator->setFetchColumns( array( 'log_id', 'log_params' ) );
+ $iterator->addConditions( array(
+ 'log_type' => array( 'delete', 'suppress' ),
+ 'log_action' => array(
+ 'flow-delete-post', 'flow-suppress-post', 'flow-restore-post',
+ 'flow-delete-topic', 'flow-suppress-topic', 'flow-restore-topic',
+ ),
+ ) );
+
+ $updater = new EchoBatchRowUpdate(
+ $iterator,
+ new EchoBatchRowWriter( wfGetDB( DB_MASTER ), 'logging' ),
+ new LogRowUpdateGenerator( $this )
+ );
+ $updater->setOutput( array( $this, 'output' ) );
+ $updater->execute();
+
+ return true;
+ }
+
+ /**
+ * parent::output() is a protected method, only way to access it from a
+ * callback in php5.3 is to make a public function. In 5.4 can replace with
+ * a Closure.
+ *
+ * @param string $out
+ * @param mixed $channel
+ */
+ public function output( $out, $channel = null ) {
+ parent::output( $out, $channel );
+ }
+
+ /**
+ * parent::error() is a protected method, only way to access it from the
+ * outside is to make it public.
+ *
+ * @param string $err
+ * @param int $die
+ */
+ public function error( $err, $die = 0 ) {
+ parent::error( $err, $die );
+ }
+}
+
+class LogRowUpdateGenerator implements EchoRowUpdateGenerator {
+ /**
+ * @var FlowFixLog
+ */
+ protected $maintenance;
+
+ /**
+ * @param FlowFixLog $maintenance
+ */
+ public function __construct( FlowFixLog $maintenance ) {
+ $this->maintenance = $maintenance;
+ }
+
+ public function update( $row ) {
+ $updates = array();
+
+ $params = unserialize( $row->log_params );
+ if ( !$params ) {
+ $this->maintenance->error( "Failed to unserialize log_params for log_id {$row->log_id}" );
+ return array();
+ }
+
+ $topic = false;
+ $post = false;
+ if ( isset( $params['topicId'] ) ) {
+ $topic = $this->loadTopic( UUID::create( $params['topicId'] ) );
+ }
+ if ( isset( $params['postId'] ) ) {
+ $post = $this->loadPost( UUID::create( $params['postId'] ) );
+ $topic = $topic ?: $post->getRoot();
+ }
+
+ if ( !$topic ) {
+ $this->maintenance->error( "Missing topicId & postId for log_id {$row->log_id}" );
+ return array();
+ }
+
+ try {
+ // log_namespace & log_title used to be board, should be topic
+ $updates['log_namespace'] = $topic->getTitle()->getNamespace();
+ $updates['log_title'] = $topic->getTitle()->getDBkey();
+ } catch ( \Exception $e ) {
+ $this->maintenance->error( "Couldn't load Title for log_id {$row->log_id}" );
+ $updates = array();
+ }
+
+ if ( isset( $params['postId'] ) && $post ) {
+ // posts used to save revision id instead of post id, let's make
+ // sure it's actually the post id that's being saved!...
+ $params['postId'] = $post->getId();
+ }
+
+ if ( !isset( $params['topicId'] ) ) {
+ // posts didn't use to also store topicId, but we'll be using it to
+ // enrich log entries' output - might as well store it right away
+ $params['topicId'] = $topic->getId();
+ }
+
+ // re-serialize params (UUID used to serialize more verbose; might
+ // as well shrink that down now that we're updating anyway...)
+ $updates['log_params'] = serialize( $params );
+
+ return $updates;
+ }
+
+ /**
+ * @param UUID $topicId
+ * @return PostCollection
+ */
+ protected function loadTopic( UUID $topicId ) {
+ return PostCollection::newFromId( $topicId );
+ }
+
+ /**
+ * @param UUID $postId
+ * @return PostCollection
+ */
+ protected function loadPost( UUID $postId ) {
+ try {
+ $collection = PostCollection::newFromId( $postId );
+
+ // validate collection by attempting to fetch latest revision - if
+ // this fails (likely will for old data), catch will be invoked
+ $collection->getLastRevision();
+ return $collection;
+ } catch ( InvalidDataException $e ) {
+ // posts used to mistakenly store revision ID instead of post ID
+
+ /** @var ManagerGroup $storage */
+ $storage = Container::get( 'storage' );
+ $result = $storage->find(
+ 'PostRevision',
+ array( 'rev_id' => $postId ),
+ array( 'LIMIT' => 1 )
+ );
+
+ if ( $result ) {
+ /** @var PostRevision $revision */
+ $revision = reset( $result );
+
+ // now build collection from real post ID
+ return $this->loadPost( $revision->getPostId() );
+ }
+ }
+
+ return false;
+ }
+}
+
+$maintClass = 'FlowFixLog';
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowFixUserIp.php b/Flow/maintenance/FlowFixUserIp.php
new file mode 100644
index 00000000..b9288976
--- /dev/null
+++ b/Flow/maintenance/FlowFixUserIp.php
@@ -0,0 +1,163 @@
+<?php
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Model\UUID;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Sets *_user_ip to null when *_user_id is > 0
+ *
+ * @ingroup Maintenance
+ */
+class FlowFixUserIp extends LoggedUpdateMaintenance {
+ /**
+ * The number of entries completed
+ *
+ * @var int
+ */
+ private $completeCount = 0;
+
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ static private $types = array(
+ 'post' => 'Flow\Model\PostRevision',
+ 'header' => 'Flow\Model\Header',
+ 'post-summary' => 'Flow\Model\PostSummary',
+ );
+
+ protected function doDBUpdates() {
+ $this->storage = $storage = Container::get( 'storage' );
+ $dbf = Container::get( 'db.factory' );
+ $dbw = $dbf->getDB( DB_MASTER );
+
+ $runUpdate = function( $callback ) use ( $dbf, $dbw, $storage ) {
+ $continue = "\0";
+ do {
+ $dbw->begin();
+ $continue = call_user_func( $callback, $dbw, $continue );
+ $dbw->commit();
+ $dbf->waitForSlaves();
+ $storage->clear();
+ } while ( $continue !== null );
+ };
+
+ $runUpdate( array( $this, 'updateTreeRevision' ) );
+ $self = $this;
+ foreach ( array( 'rev_user', 'rev_mod_user', 'rev_edit_user' ) as $prefix ){
+ $runUpdate( function( $dbw, $continue ) use ( $self, $prefix ) {
+ return $self->updateRevision( $prefix, $dbw, $continue );
+ } );
+ }
+
+ return true;
+ }
+
+ public function updateTreeRevision( DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'flow_tree_revision',
+ /* select */array( 'tree_rev_id' ),
+ array(
+ 'tree_rev_id > ' . $dbw->addQuotes( $continue ),
+ 'tree_orig_user_ip IS NOT NULL',
+ 'tree_orig_user_id > 0',
+ ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'tree_rev_id' )
+ );
+
+ $om = Container::get( 'storage' )->getStorage( 'PostRevision' );
+ $objs = $ids = array();
+ foreach ( $rows as $row ) {
+ $id = UUID::create( $row->tree_rev_id );
+ $found = $om->get( $id );
+ if ( $found ) {
+ $ids[] = $row->tree_rev_id;
+ $objs[] = $found;
+ } else {
+ $this->error( __METHOD__ . ': Failed loading Flow\Model\PostRevision: ' . $id->getAlphadecimal() );
+ }
+ }
+ if ( !$ids ) {
+ return null;
+ }
+ $dbw->update(
+ /* table */'flow_tree_revision',
+ /* update */array( 'tree_orig_user_ip' => null ),
+ /* conditions */array( 'tree_rev_id' => $ids ),
+ __METHOD__
+ );
+ foreach ( $objs as $obj ) {
+ $om->cachePurge( $obj );
+ }
+
+ $this->completeCount += count( $ids );
+
+ return end( $ids );
+ }
+
+ public function updateRevision( $columnPrefix, DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'flow_revision',
+ /* select */array( 'rev_id', 'rev_type' ),
+ /* conditions */ array(
+ 'rev_id > ' . $dbw->addQuotes( $continue ),
+ "{$columnPrefix}_id > 0",
+ "{$columnPrefix}_ip IS NOT NULL",
+ ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'rev_id' )
+ );
+
+ $ids = $objs = array();
+ foreach ( $rows as $row ) {
+ $id = UUID::create( $row->rev_id );
+ $type = self::$types[$row->rev_type];
+ $om = $this->storage->getStorage( $type );
+ $obj = $om->get( $id );
+ if ( $obj ) {
+ $om->merge( $obj );
+ $ids[] = $row->rev_id;
+ $objs[] = $obj;
+ } else {
+ $this->error( __METHOD__ . ": Failed loading $type: " . $id->getAlphadecimal() );
+ }
+ }
+ if ( !$ids ) {
+ return null;
+ }
+
+ $dbw->update(
+ /* table */ 'flow_revision',
+ /* update */ array( "{$columnPrefix}_ip" => null ),
+ /* conditions */ array( 'rev_id' => $ids ),
+ __METHOD__
+ );
+
+ foreach ( $objs as $obj ) {
+ $this->storage->cachePurge( $obj );
+ }
+
+ $this->completeCount += count( $ids );
+
+ return end( $ids );
+ }
+
+ /**
+ * Get the update key name to go in the update log table
+ *
+ * @return string
+ */
+ protected function getUpdateKey() {
+ return 'FlowFixUserIp';
+ }
+}
+
+$maintClass = 'FlowFixUserIp'; // Tells it to run the class
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowPopulateLinksTables.php b/Flow/maintenance/FlowPopulateLinksTables.php
new file mode 100644
index 00000000..5e1220ec
--- /dev/null
+++ b/Flow/maintenance/FlowPopulateLinksTables.php
@@ -0,0 +1,115 @@
+<?php
+
+use Flow\Container;
+use Flow\Model\UUID;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Currently iterates through all revisions for debugging purposes, the
+ * production version will want to only process the most recent revision
+ * of each object.
+ *
+ * @ingroup Maintenance
+ */
+class FlowPopulateLinksTables extends LoggedUpdateMaintenance {
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = "Populates links tables for wikis deployed before change 110090";
+ }
+
+ public function getUpdateKey() {
+ return "FlowPopulateLinksTables";
+ }
+
+ public function doDBUpdates() {
+ $this->output( "Populating links tables...\n" );
+ $recorder = Container::get( 'reference.recorder' );
+ $this->processHeaders( $recorder );
+ $this->processPosts( $recorder );
+
+ return true;
+ }
+
+ protected function processHeaders( $recorder ) {
+ $storage = Container::get( 'storage.header' );
+ $count = $this->mBatchSize;
+ $id = '';
+ $dbf = Container::get( 'db.factory' );
+ $dbr = $dbf->getDB( DB_SLAVE );
+ while ( $count === $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_revision' ),
+ array( 'rev_type_id' ),
+ array( 'rev_type' => 'header', 'rev_type_id > ' . $dbr->addQuotes( $id ) ),
+ __METHOD__,
+ array( 'ORDER BY' => 'rev_type_id ASC', 'LIMIT' => $this->mBatchSize )
+ );
+ if ( !$res ) {
+ throw new \MWException( 'SQL error in maintenance script ' . __METHOD__ );
+ }
+ foreach ( $res as $row ) {
+ $count++;
+ $id = $row->rev_type_id;
+ $uuid = UUID::create( $id );
+ $alpha = $uuid->getAlphadecimal();
+ $header = $storage->get( $uuid );
+ if ( $header ) {
+ echo "Processing header $alpha\n";
+ $recorder->onAfterInsert(
+ $header, array(),
+ array(
+ 'workflow' => $header->getCollection()->getWorkflow()
+ )
+ );
+ }
+ }
+ $dbf->waitForSlaves();
+ }
+ }
+
+ protected function processPosts( $recorder ) {
+ $storage = Container::get( 'storage.post' );
+ $count = $this->mBatchSize;
+ $id = '';
+ $dbr = Container::get( 'db.factory' )->getDB( DB_SLAVE );
+ while ( $count === $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_tree_revision' ),
+ array( 'tree_rev_id' ),
+ array(
+ 'tree_parent_id IS NOT NULL',
+ 'tree_rev_id > ' . $dbr->addQuotes( $id ),
+ ),
+ __METHOD__,
+ array( 'ORDER BY' => 'tree_rev_id ASC', 'LIMIT' => $this->mBatchSize )
+ );
+ if ( !$res ) {
+ throw new \MWException( 'SQL error in maintenance script ' . __METHOD__ );
+ }
+ foreach ( $res as $row ) {
+ $count++;
+ $id = $row->tree_rev_id;
+ $uuid = UUID::create( $id );
+ $alpha = $uuid->getAlphadecimal();
+ $post = $storage->get( $uuid );
+ if ( $post ) {
+ echo "Processing post $alpha\n";
+ $recorder->onAfterInsert(
+ $post, array(),
+ array(
+ 'workflow' => $post->getCollection()->getWorkflow()
+ )
+ );
+ }
+ }
+ }
+ }
+}
+
+$maintClass = "FlowPopulateLinksTables";
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowSetUserIp.php b/Flow/maintenance/FlowSetUserIp.php
new file mode 100644
index 00000000..a04314e7
--- /dev/null
+++ b/Flow/maintenance/FlowSetUserIp.php
@@ -0,0 +1,183 @@
+<?php
+
+use Flow\Container;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Populate the *_user_ip fields within flow. This only updates
+ * the database and not the cache. The model loading layer handles
+ * cached old values.
+ *
+ * @ingroup Maintenance
+ */
+class FlowSetUserIp extends LoggedUpdateMaintenance {
+ /**
+ * The number of entries completed
+ *
+ * @var int
+ */
+ private $completeCount = 0;
+
+ protected function doDBUpdates() {
+ $dbf = Container::get( 'db.factory' );
+ $dbw = $dbf->getDB( DB_MASTER );
+ $hasRun = false;
+
+ $runUpdate = function( $callback ) use ( $dbf, $dbw, &$hasRun ) {
+ $hasRun = true;
+ $continue = "\0";
+ do {
+ $continue = call_user_func( $callback, $dbw, $continue );
+ $dbf->waitForSlaves();
+ } while ( $continue !== null );
+ };
+
+ // run updates only if we have the required source data
+ if ( $dbw->fieldExists( 'flow_workflow', 'workflow_user_text' ) ) {
+ $runUpdate( array( $this, 'updateWorkflow' ) );
+ }
+ if ( $dbw->fieldExists( 'flow_tree_revision', 'tree_orig_user_text' ) ) {
+ $runUpdate( array( $this, 'updateTreeRevision' ) );
+ }
+ if (
+ $dbw->fieldExists( 'flow_revision', 'rev_user_text' ) &&
+ $dbw->fieldExists( 'flow_revision', 'rev_mod_user_text' ) &&
+ $dbw->fieldExists( 'flow_revision', 'rev_edit_user_text' )
+ ) {
+ $runUpdate( array( $this, 'updateRevision' ) );
+ }
+
+ if ( $hasRun ) {
+ $dbw->sourceFile( __DIR__ . '/../db_patches/patch-remove_usernames_2.sql' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Refreshes a batch of recentchanges entries
+ *
+ * @param DatabaseBase $dbw
+ * @param int[optional] $continue The next batch starting at rc_id
+ * @return int Start id for the next batch
+ */
+ public function updateWorkflow( DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'flow_workflow',
+ /* select */array( 'workflow_id', 'workflow_user_text' ),
+ /* conds */array(
+ 'workflow_id > ' . $dbw->addQuotes( $continue ),
+ 'workflow_user_ip IS NULL',
+ 'workflow_user_id = 0'
+ ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'workflow_id' )
+ );
+
+ $continue = null;
+
+ foreach ( $rows as $row ) {
+ $continue = $row->workflow_id;
+ $dbw->update(
+ /* table */'flow_workflow',
+ /* update */array( 'workflow_user_ip' => $row->workflow_user_text ),
+ /* conditions */array( 'workflow_id' => $row->workflow_id ),
+ __METHOD__
+ );
+
+ $this->completeCount++;
+ }
+
+ return $continue;
+ }
+
+ public function updateTreeRevision( DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'flow_tree_revision',
+ /* select */array( 'tree_rev_id', 'tree_orig_user_text' ),
+ array(
+ 'tree_rev_id > ' . $dbw->addQuotes( $continue ),
+ 'tree_orig_user_ip IS NULL',
+ 'tree_orig_user_id = 0',
+ ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'tree_rev_id' )
+ );
+
+ $continue = null;
+ foreach ( $rows as $row ) {
+ $continue = $row->tree_rev_id;
+ $dbw->update(
+ /* table */'flow_tree_revision',
+ /* update */array( 'tree_orig_user_ip' => $row->tree_orig_user_text ),
+ /* conditions */array( 'tree_rev_id' => $row->tree_rev_id ),
+ __METHOD__
+ );
+
+ $this->completeCount++;
+ }
+
+ return $continue;
+ }
+
+ public function updateRevision( DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'flow_revision',
+ /* select */array( 'rev_id', 'rev_user_id', 'rev_user_text', 'rev_mod_user_id', 'rev_mod_user_text', 'rev_edit_user_id', 'rev_edit_user_text' ),
+ /* conditions */ array(
+ 'rev_id > ' . $dbw->addQuotes( $continue ),
+ $dbw->makeList(
+ array(
+ 'rev_user_id' => 0,
+ 'rev_mod_user_id' => 0,
+ 'rev_edit_user_id' => 0,
+ ),
+ LIST_OR
+ ),
+ ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'rev_id' )
+ );
+
+ $continue = null;
+ foreach ( $rows as $row ) {
+ $continue = $row->rev_id;
+ $updates = array();
+
+ if ( $row->rev_user_id == 0 ) {
+ $updates['rev_user_ip'] = $row->rev_user_text;
+ }
+ if ( $row->rev_mod_user_id == 0 ) {
+ $updates['rev_mod_user_ip'] = $row->rev_mod_user_text;
+ }
+ if ( $row->rev_edit_user_id == 0 ) {
+ $updates['rev_edit_user_ip'] = $row->rev_edit_user_text;
+ }
+ if ( $updates ) {
+ $dbw->update(
+ /* table */ 'flow_revision',
+ /* update */ $updates,
+ /* conditions */ array( 'rev_id' => $row->rev_id ),
+ __METHOD__
+ );
+ }
+ }
+
+ return $continue;
+ }
+
+ /**
+ * Get the update key name to go in the update log table
+ *
+ * @return string
+ */
+ protected function getUpdateKey() {
+ return 'FlowSetUserIp';
+ }
+}
+
+$maintClass = 'FlowSetUserIp'; // Tells it to run the class
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowUpdateRecentChanges.php b/Flow/maintenance/FlowUpdateRecentChanges.php
new file mode 100644
index 00000000..d1e11c3d
--- /dev/null
+++ b/Flow/maintenance/FlowUpdateRecentChanges.php
@@ -0,0 +1,175 @@
+<?php
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Updates recentchanges entries to contain information to build the
+ * AbstractBlock objects.
+ *
+ * @ingroup Maintenance
+ */
+class FlowUpdateRecentChanges extends LoggedUpdateMaintenance {
+ /**
+ * The number of entries completed
+ *
+ * @var int
+ */
+ private $completeCount = 0;
+
+ /**
+ * Max number of records to process at a time
+ *
+ * @var int
+ */
+ protected $batchSize = 300;
+
+ protected function doDBUpdates() {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $continue = 0;
+
+ while ( $continue !== null ) {
+ $continue = $this->refreshBatch( $dbw, $continue );
+ wfWaitForSlaves();
+ }
+
+ return true;
+ }
+
+ /**
+ * Refreshes a batch of recentchanges entries
+ *
+ * @param DatabaseBase $dbw
+ * @param int[optional] $continue The next batch starting at rc_id
+ * @return int Start id for the next batch
+ */
+ public function refreshBatch( DatabaseBase $dbw, $continue = null ) {
+ $rows = $dbw->select(
+ /* table */'recentchanges',
+ /* select */array( 'rc_id', 'rc_params' ),
+ /* conds */array( "rc_id > $continue", 'rc_type' => RC_FLOW ),
+ __METHOD__,
+ /* options */array( 'LIMIT' => $this->mBatchSize, 'ORDER BY' => 'rc_id' )
+ );
+
+ $continue = null;
+
+ foreach ( $rows as $row ) {
+ $continue = $row->rc_id;
+
+ // build params
+ wfSuppressWarnings();
+ $params = unserialize( $row->rc_params );
+ wfRestoreWarnings();
+ if ( !$params ) {
+ $params = array();
+ }
+
+ // Don't fix entries that have been dealt with already
+ if ( !isset( $params['flow-workflow-change']['type'] ) ) {
+ continue;
+ }
+
+ // Set action, based on older 'type' values
+ switch ( $params['flow-workflow-change']['type'] ) {
+ case 'flow-rev-message-edit-title':
+ case 'flow-edit-title':
+ $params['flow-workflow-change']['action'] = 'edit-title';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-new-post':
+ case 'flow-new-post':
+ $params['flow-workflow-change']['action'] = 'new-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-edit-post':
+ case 'flow-edit-post':
+ $params['flow-workflow-change']['action'] = 'edit-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-reply':
+ case 'flow-reply':
+ $params['flow-workflow-change']['action'] = 'reply';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-restored-post':
+ case 'flow-post-restored':
+ $params['flow-workflow-change']['action'] = 'restore-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-hid-post':
+ case 'flow-post-hidden':
+ $params['flow-workflow-change']['action'] = 'hide-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-deleted-post':
+ case 'flow-post-deleted':
+ $params['flow-workflow-change']['action'] = 'delete-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-censored-post':
+ case 'flow-post-censored':
+ $params['flow-workflow-change']['action'] = 'suppress-post';
+ $params['flow-workflow-change']['block'] = 'topic';
+ $params['flow-workflow-change']['revision_type'] = 'PostRevision';
+ break;
+
+ case 'flow-rev-message-edit-header':
+ case 'flow-edit-summary':
+ $params['flow-workflow-change']['action'] = 'edit-header';
+ $params['flow-workflow-change']['block'] = 'header';
+ $params['flow-workflow-change']['revision_type'] = 'Header';
+ break;
+
+ case 'flow-rev-message-create-header':
+ case 'flow-create-summary':
+ case 'flow-create-header':
+ $params['flow-workflow-change']['action'] = 'create-header';
+ $params['flow-workflow-change']['block'] = 'header';
+ $params['flow-workflow-change']['revision_type'] = 'Header';
+ break;
+ }
+
+ unset( $params['flow-workflow-change']['type'] );
+
+ // update log entry
+ $dbw->update(
+ 'recentchanges',
+ array( 'rc_params' => serialize( $params ) ),
+ array( 'rc_id' => $row->rc_id )
+ );
+
+ $this->completeCount++;
+ }
+
+ return $continue;
+ }
+
+ /**
+ * Get the update key name to go in the update log table
+ *
+ * @return string
+ */
+ protected function getUpdateKey() {
+ return 'FlowUpdateRecentChanges';
+ }
+}
+
+$maintClass = 'FlowUpdateRecentChanges'; // Tells it to run the class
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowUpdateRevisionContentLength.php b/Flow/maintenance/FlowUpdateRevisionContentLength.php
new file mode 100644
index 00000000..e4a9c45a
--- /dev/null
+++ b/Flow/maintenance/FlowUpdateRevisionContentLength.php
@@ -0,0 +1,154 @@
+<?php
+
+use Flow\Container;
+use Flow\Model\AbstractRevision;
+use Flow\Model\UUID;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * @ingroup Maintenance
+ */
+class FlowUpdateRevisionContentLength extends LoggedUpdateMaintenance {
+ /**
+ * Map from AbstractRevision::getRevisionType() to the class that holds
+ * that type.
+ * @todo seems this should be elsewhere for access by any code
+ *
+ * @var string[]
+ */
+ static private $revisionTypes = array(
+ 'post' => 'Flow\Model\PostRevision',
+ 'header' => 'Flow\Model\Header',
+ 'post-summary' => 'Flow\Model\PostSummary',
+ );
+
+ /**
+ * @var Flow\DbFactory
+ */
+ protected $dbFactory;
+
+ /**
+ * @var Flow\Data\ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var ReflectionProperty
+ */
+ protected $contentLengthProperty;
+
+ /**
+ * @var ReflectionProperty
+ */
+ protected $previousContentLengthProperty;
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = "Updates content length for revisions with unset content length.";
+ }
+
+ public function getUpdateKey() {
+ return __CLASS__ . ':version2';
+ }
+
+ public function doDBUpdates() {
+ // Can't be done in constructor, happens too early in
+ // boot process
+ $this->dbFactory = Container::get( 'db.factory' );
+ $this->storage = Container::get( 'storage' );
+ // Since this is a one-shot maintenance script just reach in via reflection
+ // to change lenghts
+ $this->contentLengthProperty = new ReflectionProperty(
+ 'Flow\Model\AbstractRevision',
+ 'contentLength'
+ );
+ $this->contentLengthProperty->setAccessible( true );
+ $this->previousContentLengthProperty = new ReflectionProperty(
+ 'Flow\Model\AbstractRevision',
+ 'previousContentLength'
+ );
+ $this->previousContentLengthProperty->setAccessible( true );
+
+ $dbw = $this->dbFactory->getDb( DB_MASTER );
+ // Walk through the flow_revision table
+ $it = new EchoBatchRowIterator(
+ $dbw,
+ /* table = */'flow_revision',
+ /* primary key = */'rev_id',
+ $this->mBatchSize
+ );
+ // Only fetch rows created by users from the current wiki.
+ $it->addConditions( array(
+ 'rev_user_wiki' => wfWikiId(),
+ ) );
+ // We only need the id and type field
+ $it->setFetchColumns( array( 'rev_id', 'rev_type' ) );
+
+ $total = $fail = 0;
+ foreach ( $it as $batch ) {
+ $dbw->begin();
+ foreach ( $batch as $row ) {
+ $total++;
+ if ( !isset( self::$revisionTypes[$row->rev_type] ) ) {
+ $this->output( 'Unknown revision type: ' . $row->rev_type );
+ $fail++;
+ continue;
+ }
+ $om = $this->storage->getStorage( self::$revisionTypes[$row->rev_type] );
+ $revId = UUID::create( $row->rev_id );
+ $obj = $om->get( $revId );
+ if ( !$obj ) {
+ $this->output( 'Could not load revision: ' . $revId->getAlphadecimal() );
+ $fail++;
+ continue;
+ }
+ if ( $obj->isFirstRevision() ) {
+ $previous = null;
+ } else {
+ $previous = $om->get( $obj->getPrevRevisionId() );
+ if ( !$previous ) {
+ $this->output( 'Could not locate previous revision: ' . $obj->getPrevRevisionId()->getAlphadecimal() );
+ $fail++;
+ continue;
+ }
+ }
+
+ $this->updateRevision( $obj, $previous );
+ $om->put( $obj );
+ $this->output( '.' );
+ }
+ $dbw->commit();
+ $this->storage->clear();
+ $this->dbFactory->waitForSlaves();
+ }
+
+ return true;
+ }
+
+ protected function updateRevision( AbstractRevision $revision, AbstractRevision $previous = null ) {
+ $this->contentLengthProperty->setValue(
+ $revision,
+ $this->calcContentLength( $revision )
+ );
+ if ( $previous !== null ) {
+ $this->previousContentLengthProperty->setValue(
+ $revision,
+ $this->calcContentLength( $previous )
+ );
+ }
+ }
+
+ protected function calcContentLength( AbstractRevision $revision ) {
+ if ( $revision->isModerated() && !$revision->isLocked() ) {
+ return 0;
+ } else {
+ return $revision->getContentLength() ?: mb_strlen( $revision->getContent( 'wikitext' ) );
+ }
+ }
+}
+
+$maintClass = 'FlowUpdateRevisionContentLength';
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/FlowUpdateRevisionTypeId.php b/Flow/maintenance/FlowUpdateRevisionTypeId.php
new file mode 100644
index 00000000..e0637a29
--- /dev/null
+++ b/Flow/maintenance/FlowUpdateRevisionTypeId.php
@@ -0,0 +1,105 @@
+<?php
+
+use Flow\Container;
+
+$IP = getenv( 'MW_INSTALL_PATH' );
+if ( $IP === false ) {
+ $IP = __DIR__ . '/../../..';
+}
+require_once( "$IP/maintenance/Maintenance.php" );
+
+/**
+ * Update flow_revision.rev_type_id
+ *
+ * @ingroup Maintenance
+ */
+class FlowUpdateRevisionTypeId extends LoggedUpdateMaintenance {
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = "Update flow_revision.rev_type_id";
+ $this->setBatchSize( 300 );
+ }
+
+ protected function doDBUpdates() {
+ $revId = '';
+ $count = $this->mBatchSize;
+ $dbFactory = Container::get( 'db.factory' );
+ $dbr = $dbFactory->getDB( DB_SLAVE );
+ $dbw = $dbFactory->getDB( DB_MASTER );
+
+ // If table flow_header_revision does not exist, that means the wiki
+ // has run the data migration before or the wiki starts from scratch,
+ // there is no point to run the script against invalid tables
+ if ( !$dbr->tableExists( 'flow_header_revision', __METHOD__ ) ) {
+ return true;
+ }
+
+ while ( $count == $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_revision', 'flow_tree_revision', 'flow_header_revision' ),
+ array( 'rev_id', 'rev_type', 'tree_rev_descendant_id', 'header_workflow_id' ),
+ array( 'rev_id > ' . $dbr->addQuotes( $revId ) ),
+ __METHOD__,
+ array( 'ORDER BY' => 'rev_id ASC', 'LIMIT' => $this->mBatchSize ),
+ array(
+ 'flow_tree_revision' => array( 'LEFT JOIN', 'rev_id=tree_rev_id' ),
+ 'flow_header_revision' => array( 'LEFT JOIN', 'rev_id=header_rev_id' )
+ )
+ );
+
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $count++;
+ $revId = $row->rev_id;
+ switch ( $row->rev_type ) {
+ case 'header':
+ $this->updateRevision( $dbw, $row->rev_id, $row->header_workflow_id );
+ break;
+ case 'post':
+ $this->updateRevision( $dbw, $row->rev_id, $row->tree_rev_descendant_id );
+ break;
+ }
+ }
+ } else {
+ throw new MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ $dbFactory->waitForSlaves();
+ }
+
+ $dbw->dropTable( 'flow_header_revision', __METHOD__ );
+
+ return true;
+ }
+
+ private function updateRevision( $dbw, $revId, $revTypeId ) {
+ if ( $revTypeId === null ) {
+ // this shouldn't actually be happening, but if it is, ignoring it
+ // will not make things worse - the revision is lost already
+ return;
+ }
+
+ $res = $dbw->update(
+ 'flow_revision',
+ array( 'rev_type_id' => $revTypeId ),
+ array( 'rev_id' => $revId ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ throw new MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ }
+
+ /**
+ * Get the update key name to go in the update log table
+ *
+ * @return string
+ */
+ protected function getUpdateKey() {
+ return 'FlowUpdateRevisionTypeId';
+ }
+}
+
+$maintClass = 'FlowUpdateRevisionTypeId';
+require_once( DO_MAINTENANCE );
diff --git a/Flow/maintenance/FlowUpdateUserWiki.php b/Flow/maintenance/FlowUpdateUserWiki.php
new file mode 100644
index 00000000..1f208897
--- /dev/null
+++ b/Flow/maintenance/FlowUpdateUserWiki.php
@@ -0,0 +1,253 @@
+<?php
+
+use Flow\Container;
+use Flow\Model\UUID;
+use Flow\Model\PostRevision;
+
+$IP = getenv( 'MW_INSTALL_PATH' );
+if ( $IP === false ) {
+ $IP = dirname( __FILE__ ) . '/../../..';
+}
+require_once( "$IP/maintenance/Maintenance.php" );
+
+/**
+ * Update all xxx_user_wiki field to have the correct wiki name
+ *
+ * @ingroup Maintenance
+ */
+class FlowUpdateUserWiki extends LoggedUpdateMaintenance {
+
+ /**
+ * Used to track the number of current updated count
+ */
+ private $updatedCount = 0;
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = "Update xxx_user_wiki field in tables: flow_workflow, flow_tree_revision, flow_revision";
+ $this->setBatchSize( 300 );
+ }
+
+ /**
+ * This is a top-to-bottom update, the process is like this:
+ * workflow -> header -> header revision -> history
+ * workflow -> topic list -> post tree revision -> post revision -> history
+ *
+ * Some side effect, the script will also update those *_user_wiki fields with
+ * empty *_user_id and *_user_ip, but this doesn't hurt. Alternatively, we could
+ * add a check user_id != 0 and user_ip is not null to the query, but this will
+ * result in more db queries
+ *
+ */
+ protected function doDBUpdates() {
+ $id = '';
+ $count = $this->mBatchSize;
+ $dbr = Container::get( 'db.factory' )->getDB( DB_SLAVE );
+
+ // If table flow_header_revision does not exist, that means the wiki
+ // has run the data migration before or the wiki starts from scratch,
+ // there is no point to run the script againt invalid tables
+ if ( !$dbr->tableExists( 'flow_header_revision', __METHOD__ ) ) {
+ return true;
+ }
+
+ while ( $count == $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_workflow' ),
+ array( 'workflow_wiki', 'workflow_id', 'workflow_type' ),
+ array(
+ 'workflow_id > ' . $dbr->addQuotes( $id ),
+ ),
+ __METHOD__,
+ array( 'ORDER BY' => 'workflow_id ASC', 'LIMIT' => $this->mBatchSize )
+ );
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $count++;
+ $id = $row->workflow_id;
+ $uuid = UUID::create( $row->workflow_id );
+ $workflow = Container::get( 'storage.workflow' )->get( $uuid );
+ if ( $workflow ) {
+ // definition type 'topic' is always under a 'discussion' and they
+ // will be handled while processing 'discussion'
+ if ( $row->workflow_type == 'discussion' ) {
+ $this->updateHeader( $workflow, $row->workflow_wiki );
+ $this->updateTopicList( $workflow, $row->workflow_wiki );
+ }
+ }
+ }
+ } else {
+ throw new \MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Update header
+ */
+ private function updateHeader( $workflow, $wiki ) {
+ $id = '';
+ $count = $this->mBatchSize;
+ $dbr = Container::get( 'db.factory' )->getDB( DB_SLAVE );
+
+ while ( $count == $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_header_revision', 'flow_revision' ),
+ array( 'rev_id', 'rev_type' ),
+ array(
+ 'rev_id > ' . $dbr->addQuotes( $id ),
+ 'header_rev_id = rev_id',
+ 'header_workflow_id' => $workflow->getId()->getBinary()
+ ),
+ __METHOD__,
+ array( 'ORDER BY' => 'header_rev_id ASC', 'LIMIT' => $this->mBatchSize )
+ );
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $count++;
+ $id = $row->rev_id;
+ $revision = Container::get( 'storage.header' )->get( UUID::create( $row->rev_id ) );
+ if ( $revision ) {
+ $this->updateRevision( $revision, $wiki );
+ }
+ }
+ } else {
+ throw new \MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+
+ }
+ }
+
+ /**
+ * Update topic list
+ */
+ private function updateTopicList( $workflow, $wiki ) {
+ $id = '';
+ $count = $this->mBatchSize;
+ $dbr = Container::get( 'db.factory' )->getDB( DB_SLAVE );
+
+ while ( $count == $this->mBatchSize ) {
+ $count = 0;
+ $res = $dbr->select(
+ array( 'flow_topic_list' ),
+ array( 'topic_id' ),
+ array(
+ 'topic_list_id' => $workflow->getId()->getBinary(),
+ 'topic_id > ' . $dbr->addQuotes( $id ),
+ ),
+ __METHOD__,
+ array( 'ORDER BY' => 'topic_id ASC', 'LIMIT' => $this->mBatchSize )
+ );
+ if ( $res ) {
+ $index = 0;
+ foreach ( $res as $row ) {
+ $count++;
+ $index++;
+ $id = $row->topic_id;
+ $post = Container::get( 'loader.root_post' )->get( UUID::create( $row->topic_id ) );
+ if ( $post ) {
+ $this->updatePost( $post, $wiki );
+ }
+ }
+ } else {
+ throw new \MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ }
+ }
+
+ /**
+ * Update post
+ */
+ private function updatePost( $post, $wiki ) {
+ $this->updateHistory( $post, $wiki );
+ $this->updateRevision( $post, $wiki );
+ foreach ( $post->getChildren() as $child ) {
+ $this->updatePost( $child, $wiki );
+ }
+ }
+
+ /**
+ * Update history revision
+ */
+ private function updateHistory( PostRevision $post, $wiki ) {
+ if ( $post->getPrevRevisionId() ) {
+ $parent = Container::get( 'storage.post' )->get( UUID::create( $post->getPrevRevisionId() ) );
+ if ( $parent ) {
+ $this->updateRevision( $parent, $wiki );
+ $this->updateHistory( $parent, $wiki );
+ }
+ }
+ }
+
+ /**
+ * Update either header or post revision
+ */
+ private function updateRevision( $revision, $wiki ) {
+ if ( !$revision ) {
+ return;
+ }
+ $type = $revision->getRevisionType();
+
+ $dbw = Container::get( 'db.factory' )->getDB( DB_MASTER );
+ $res = $dbw->update(
+ 'flow_revision',
+ array(
+ 'rev_user_wiki' => $wiki,
+ 'rev_mod_user_wiki' => $wiki,
+ 'rev_edit_user_wiki' => $wiki,
+ ),
+ array(
+ 'rev_id' => $revision->getRevisionId()->getBinary(),
+ ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ throw new \MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ $this->checkForSlave();
+
+ if ( $type === 'post' ) {
+ $res = $dbw->update(
+ 'flow_tree_revision',
+ array(
+ 'tree_orig_user_wiki' => $wiki,
+ ),
+ array(
+ 'tree_rev_id' => $revision->getRevisionId()->getBinary(),
+ ),
+ __METHOD__
+ );
+ if ( !$res ) {
+ throw new \MWException( 'SQL error in maintenance script ' . __CLASS__ . '::' . __METHOD__ );
+ }
+ $this->checkForSlave();
+ }
+
+ }
+
+ private function checkForSlave() {
+ global $wgFlowCluster;
+
+ $this->updatedCount++;
+ if ( $this->updatedCount > $this->mBatchSize ) {
+ wfWaitForSlaves( false, false, $wgFlowCluster );
+ $this->updatedCount = 0;
+ }
+ }
+
+ /**
+ * Get the update key name to go in the update log table
+ *
+ * @return string
+ */
+ protected function getUpdateKey() {
+ return 'FlowUpdateUserWiki';
+ }
+}
+
+$maintClass = "FlowUpdateUserWiki";
+require_once( DO_MAINTENANCE );
diff --git a/Flow/maintenance/MaintenanceDebugLogger.php b/Flow/maintenance/MaintenanceDebugLogger.php
new file mode 100644
index 00000000..375f537a
--- /dev/null
+++ b/Flow/maintenance/MaintenanceDebugLogger.php
@@ -0,0 +1,63 @@
+<?php
+
+use Flow\Exception\FlowException;
+use Psr\Log\LogLevel;
+
+class MaintenanceDebugLogger extends Psr\Log\AbstractLogger {
+ /**
+ * @var Maintenance The maintenance script to perform output through
+ */
+ protected $maintenance;
+
+ /**
+ * @var int The maximum logLevelPosition to output to the
+ * maintenance object. Defaults to LogLevel::INFO
+ */
+ protected $maxLevel = 7;
+
+ /**
+ * @var int[string] Map from LogLevel constant to its position relative
+ * to other constants.
+ */
+ protected $logLevelPosition;
+
+ public function __construct( Maintenance $maintenance ) {
+ $this->maintenance = $maintenance;
+ $this->logLevelPosition = array(
+ LogLevel::EMERGENCY => 1,
+ LogLevel::ALERT => 2,
+ LogLevel::CRITICAL => 3,
+ LogLevel::ERROR => 4,
+ LogLevel::WARNING => 5,
+ LogLevel::NOTICE => 6,
+ LogLevel::INFO => 7,
+ LogLevel::DEBUG => 8
+ );
+ }
+
+
+ /**
+ * @param string $level A LogLevel constant. Logged messages less
+ * severe than this level will not be output.
+ */
+ public function setMaximumLevel( $level ) {
+ if ( !isset( $this->logLevelPosition[$level] ) ) {
+ throw new FlowException( "Invalid LogLevel: $level" );
+ }
+ $this->maxLevel = $this->logLevelPosition[$level];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function log( $level, $message, array $context = array() ) {
+ $position = $this->logLevelPosition[$level];
+ if ( $position > $this->maxLevel ) {
+ return;
+ }
+
+ // TS_DB is used as it is a consistent length every time
+ $ts = '[' . wfTimestamp( TS_DB ) . ']';
+ $this->maintenance->outputChanneled( "$ts $message" );
+ }
+}
diff --git a/Flow/maintenance/benchUuidTimestampConversion.php b/Flow/maintenance/benchUuidTimestampConversion.php
new file mode 100644
index 00000000..b0450388
--- /dev/null
+++ b/Flow/maintenance/benchUuidTimestampConversion.php
@@ -0,0 +1,131 @@
+<?php
+
+use Flow\Model\UUID;
+
+require_once __DIR__ . '/../../../maintenance/benchmarks/Benchmarker.php';
+
+/**
+ * @ingroup Benchmark
+ */
+class BenchUuidConversions extends \Benchmarker {
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = 'Benchmark uuid timstamp extraction implementations';
+ }
+
+ public function execute() {
+ // sample values slightly different to avoid
+ // UUID internal caching
+ $alpha = UUID::hex2alnum( '0523f95ac547ab45266762' );
+ $binary = UUID::hex2bin( '0523f95af547ab45266762' );
+ $hex = '0423f95af547ab45266762';
+
+ // benchmarker requires we pass an object
+ $id = UUID::create();
+
+ $this->bench( array(
+ array(
+ 'function' => array( $id, 'bin2hex' ),
+ 'args' => array( $binary ),
+ ),
+ array(
+ 'function' => array( $id, 'alnum2hex' ),
+ 'args' => array( $alpha ),
+ ),
+ array(
+ 'function' => array( $id, 'hex2bin' ),
+ 'args' => array( $hex ),
+ ),
+ array(
+ 'function' => array( $id, 'hex2alnum' ),
+ 'args' => array( $hex ),
+ ),
+ array(
+ 'function' => array( $id, 'hex2timestamp' ),
+ 'args' => array( $hex ),
+ ),
+ array(
+ 'function' => array( $this, 'oldhex2timestamp' ),
+ 'args' => array( $hex ),
+ ),
+ array(
+ 'function' => array( $this, 'oldalphadecimal2timestamp' ),
+ 'args' => array( $alpha ),
+ ),
+ array(
+ 'function' => array( $this, 'case1' ),
+ 'args' => array( $binary ),
+ ),
+ array(
+ 'function' => array( $this, 'case2' ),
+ 'args' => array( $binary ),
+ ),
+ array(
+ 'function' => array( $this, 'case3' ),
+ 'args' => array( $alpha ),
+ ),
+ array(
+ 'function' => array( $this, 'case4' ),
+ 'args' => array( $alpha ),
+ ),
+ ) );
+
+ $this->output( $this->getFormattedResults() );
+ }
+
+ public function oldhex2timestamp( $hex ) {
+ $bits = wfBaseConvert( $hex, 16, 2, 88 );
+ $msTimestamp = wfBaseConvert( substr( $bits, 0, 46 ), 2, 10 );
+ return intval( $msTimestamp / 1000 );
+ }
+
+ public function oldalphadecimal2timestamp( $alpha ) {
+ $bits = wfBaseConvert( $alpha, 36, 2, 88 );
+ $msTimestamp = wfBaseConvert( substr( $bits, 0, 46 ), 2, 10 );
+ return intval( $msTimestamp / 1000 );
+ }
+
+ /**
+ * Common case 1: binary from database to alpha and timestamp.
+ */
+ public function case1( $binary ) {
+ // clone to avoid internal object caching
+ $id = clone UUID::create( $binary );
+ $id->getAlphadecimal();
+ $id->getTimestampObj();
+ }
+
+ /**
+ * Common case 2: binary from database to timestamp and alpha.
+ * Probably same as case 1, but who knows.
+ */
+ public function case2( $binary ) {
+ // clone to avoid internal object caching
+ $id = clone UUID::create( $binary );
+ $id->getTimestampObj();
+ $id->getAlphadecimal();
+ }
+
+ /**
+ * Common case 3: alphadecimal from cache to timestamp and binary.
+ */
+ public function case3( $alpha ) {
+ // clone to avoid internal object caching
+ $id = clone UUID::create( $alpha );
+ $id->getBinary();
+ $id->getTimestampObj();
+ }
+
+ /**
+ * Common case 4: alphadecimal from cache to bianry and timestamp.
+ */
+ public function case4( $alpha ) {
+ // clone to avoid internal object caching
+ $id = clone UUID::create( $alpha );
+ $id->getTimestampObj();
+ $id->getBinary();
+ }
+}
+
+$maintClass = 'BenchUuidConversions';
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/Flow/maintenance/compileLightncandy.php b/Flow/maintenance/compileLightncandy.php
new file mode 100644
index 00000000..a686c0f8
--- /dev/null
+++ b/Flow/maintenance/compileLightncandy.php
@@ -0,0 +1,66 @@
+<?php
+
+use Flow\Container;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Populate the *_user_ip fields within flow. This only updates
+ * the database and not the cache. The model loading layer handles
+ * cached old values.
+ *
+ * @ingroup Maintenance
+ */
+class CompileLightncandy extends Maintenance {
+ protected $lightncandy;
+
+ public function execute() {
+ global $wgFlowServerCompileTemplates;
+
+ // make sure we are actually compiling
+ $wgFlowServerCompileTemplates = true;
+ $this->lightncandy = Container::get( 'lightncandy' );
+
+ // looking for 664 permissions on the final files
+ umask( 0002 );
+
+ $dir = __DIR__ . '/../handlebars';
+
+ // clean out the compiled directory
+ foreach ( glob( $dir . '/compiled/*' ) as $file ) {
+ if ( !unlink( $file ) ) {
+ $this->error( "Failed to unlink previously compiled code: $file" );
+ }
+ }
+
+ // compile all non-partials
+ $skipPrefix = '.partial.handlebars';
+ $len = strlen( $skipPrefix );
+ foreach ( glob( $dir . '/*.handlebars' ) as $file ) {
+ if ( substr( $file, -$len ) !== $skipPrefix ) {
+ $this->compile( basename( $file, '.handlebars' ) );
+ }
+ }
+ $this->output( "\n" );
+ }
+
+ protected function compile( $templateName ) {
+ $filenames = $this->lightncandy->getTemplateFilenames( $templateName );
+
+ if ( !file_exists( $filenames['template'] ) ) {
+ $this->error( "Could not find template at: {$filenames['template']}" );
+ }
+
+ $this->lightncandy->getTemplate( $templateName );
+ if ( !file_exists( $filenames['compiled'] ) ) {
+ $this->error( "Template compilation completed, but no compiled code found on disk" );
+ } else {
+ $this->output( "Successfully compiled $templateName to {$filenames['compiled']}\n" );
+ }
+ }
+}
+
+$maintClass = 'CompileLightncandy'; // Tells it to run the class
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/convertLqt.php b/Flow/maintenance/convertLqt.php
new file mode 100644
index 00000000..30d875f0
--- /dev/null
+++ b/Flow/maintenance/convertLqt.php
@@ -0,0 +1,72 @@
+<?php
+
+use Flow\Container;
+use Flow\Import\FileImportSourceStore;
+use Flow\Import\LiquidThreadsApi\ConversionStrategy;
+use Flow\Import\LiquidThreadsApi\LocalApiBackend;
+use Flow\Utils\PagesWithPropertyIterator;
+use Psr\Log\LogLevel;
+use Psr\Log\NullLogger;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Converts all LiquidThreads pages on a wiki to Flow. When using the logfile
+ * option this process is idempotent.It may be run many times and will only import
+ * one copy of each item.
+ */
+class ConvertLqt extends Maintenance {
+ public function __construct() {
+ parent::__construct();
+ $this->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 );
+ }
+ } else {
+ $logger = new NullLogger;
+ }
+ $importer = Flow\Container::get( 'importer' );
+ $talkpageManagerUser = FlowHooks::getOccupationController()->getTalkpageManager();
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $strategy = new ConversionStrategy(
+ $dbr,
+ new FileImportSourceStore( $this->getOption( 'logfile' ) ),
+ new LocalApiBackend( $talkpageManagerUser ),
+ Container::get( 'url_generator' ),
+ $talkpageManagerUser
+ );
+
+ $converter = new \Flow\Import\Converter(
+ $dbr,
+ $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 );
+ $converter->convert( $titles );
+ }
+}
+
+$maintClass = "ConvertLqt";
+require_once ( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/maintenance/convertLqtPage.php b/Flow/maintenance/convertLqtPage.php
new file mode 100644
index 00000000..cf57ebc5
--- /dev/null
+++ b/Flow/maintenance/convertLqtPage.php
@@ -0,0 +1,104 @@
+<?php
+
+use Flow\Import\FileImportSourceStore;
+use Flow\Import\NullImportSourceStore;
+use Flow\Import\LiquidThreadsApi\LocalApiBackend;
+use Flow\Import\LiquidThreadsApi\RemoteApiBackend;
+use Flow\Import\LiquidThreadsApi\ImportSource as LiquidThreadsApiImportSource;
+use Flow\Import\Postprocessor\LqtRedirector;
+use Psr\Log\LogLevel;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+class ConvertLqtPage extends Maintenance {
+ public function __construct() {
+ parent::__construct();
+ $this->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' );
+ }
+
+ 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->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/Flow/maintenance/convertNamespaceFromWikitext.php b/Flow/maintenance/convertNamespaceFromWikitext.php
new file mode 100644
index 00000000..297e05c1
--- /dev/null
+++ b/Flow/maintenance/convertNamespaceFromWikitext.php
@@ -0,0 +1,79 @@
+<?php
+
+use Flow\Utils\NamespaceIterator;
+use Psr\Log\NullLogger;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+/**
+ * Converts a single namespace from wikitext talk pages to flow talk pages. Does not
+ * modify liquid threads pages it comes across, use convertLqt.php for that. Does not
+ * modify sub-pages. Does not modify LiquidThreads enabled pages.
+ */
+class ConvertNamespaceFromWikitext extends Maintenance {
+ public function __construct() {
+ parent::__construct();
+ $this->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();
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $converter = new \Flow\Import\Converter(
+ $dbr,
+ 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 );
+ // 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/Flow/maintenance/convertToText.php b/Flow/maintenance/convertToText.php
new file mode 100644
index 00000000..c42195a0
--- /dev/null
+++ b/Flow/maintenance/convertToText.php
@@ -0,0 +1,179 @@
+<?php
+
+use Flow\Parsoid\Utils;
+
+require_once ( getenv( 'MW_INSTALL_PATH' ) !== false
+ ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php'
+ : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' );
+
+class ConvertToText extends Maintenance {
+ /**
+ * @var Title
+ */
+ protected $pageTitle;
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = "Converts a specific Flow page to text";
+
+ $this->addArg( 'page', 'The page to convert', true /*required*/ );
+ }
+
+ public function execute() {
+ $pageName = $this->getArg( 0 );
+ $this->pageTitle = Title::newFromText( $pageName );
+
+ if ( ! $this->pageTitle ) {
+ $this->error( 'Invalid page title', true );
+ }
+
+ $continue = true;
+ $pagerParams = array( 'vtllimit' => 1 );
+ $topics = array();
+ $headerContent = '';
+
+ $headerData = $this->flowApi( $this->pageTitle, 'view-header', array( 'vhcontentFormat' => 'wikitext' ), 'header' );
+
+ $headerRevision = $headerData['header']['revision'];
+ if ( isset( $headerRevision['content'] ) ) {
+ $headerContent = $headerRevision['content'];
+ }
+
+ while( $continue ) {
+ $continue = false;
+ $flowData = $this->flowApi( $this->pageTitle, 'view-topiclist', $pagerParams, 'topiclist' );
+
+ $topicListBlock = $flowData['topiclist'];
+
+ foreach( $topicListBlock['roots'] as $rootPostId ) {
+ $revisionId = reset( $topicListBlock['posts'][$rootPostId] );
+ $revision = $topicListBlock['revisions'][$revisionId];
+
+ $topicOutput = '==' . $revision['content']['content'] . '==' . "\n";
+ $topicOutput .= $this->processPostCollection( $topicListBlock, $revision['replies'] );
+
+ $topics[] = $topicOutput;
+ }
+
+ $paginationLinks = $topicListBlock['links']['pagination'];
+ if ( isset( $paginationLinks['fwd'] ) ) {
+ list( $junk, $query ) = explode( '?', $paginationLinks['fwd']['url'] );
+ $queryParams = wfCGIToArray( $query );
+
+ $pagerParams = array(
+ 'vtloffset-id' => $queryParams['topiclist_offset-id'],
+ 'vtloffset-dir' => 'fwd',
+ 'vtloffset-limit' => '1',
+ );
+ $continue = true;
+ }
+ }
+
+ print $headerContent . implode( "\n", array_reverse( $topics ) );
+ }
+
+ /**
+ * @param Title $title
+ * @param string $submodule
+ * @param array $request
+ * @param bool $requiredBlock
+ * @return array
+ * @throws MWException
+ */
+ public function flowApi( Title $title, $submodule, array $request, $requiredBlock = false ) {
+ $request = new FauxRequest( $request + array(
+ 'action' => 'flow',
+ 'submodule' => $submodule,
+ 'page' => $title->getPrefixedText(),
+ ) );
+
+ $api = new ApiMain( $request );
+ $api->execute();
+
+ $flowData = $api->getResult()->getResultData( array( 'flow', $submodule, 'result' ) );
+ if ( $flowData === null ) {
+ throw new MWException( "API response has no Flow data" );
+ }
+ $flowData = ApiResult::stripMetadata( $flowData );
+
+ if( $requiredBlock !== false && ! isset( $flowData[$requiredBlock] ) ) {
+ throw new MWException( "No $requiredBlock block in API response" );
+ }
+
+ return $flowData;
+ }
+
+ public function processPostCollection( array $context, array $collection, $indentLevel = 0 ) {
+ $indent = str_repeat( ':', $indentLevel );
+ $output = '';
+
+ foreach( $collection as $postId ) {
+ $revisionId = reset( $context['posts'][$postId] );
+ $revision = $context['revisions'][$revisionId];
+
+ // Skip moderated posts
+ if ( $revision['isModerated'] ) {
+ continue;
+ }
+
+ $user = User::newFromName( $revision['author']['name'], false );
+ $postId = Flow\Model\UUID::create( $postId );
+
+ $content = $revision['content']['content'];
+ $contentFormat = $revision['content']['format'];
+
+ if ( $contentFormat !== 'wikitext' ) {
+ $content = Utils::convert( $contentFormat, 'wikitext', $content, $this->pageTitle );
+ }
+
+ $thisPost = $indent . trim( $content ) . ' ' .
+ $this->getSignature( $user, $postId->getTimestamp() ) . "\n";
+
+ if ( $indentLevel > 0 ) {
+ $thisPost = preg_replace( "/\n+/", "\n", $thisPost );
+ }
+ $output .= str_replace( "\n", "\n$indent", trim( $thisPost ) ) . "\n";
+
+ if ( isset( $revision['replies'] ) ) {
+ $output .= $this->processPostCollection( $context, $revision['replies'], $indentLevel + 1 );
+ }
+
+ if ( $indentLevel == 0 ) {
+ $output .= "\n";
+ }
+ }
+
+ return $output;
+ }
+
+ public function getSignature( $user, $timestamp ) {
+ global $wgContLang, $wgParser;
+
+ // Force unstub
+ StubObject::unstub( $wgParser );
+
+ $timestamp = MWTimestamp::getLocalInstance( $timestamp );
+ $ts = $timestamp->format( 'YmdHis' );
+ $tzMsg = $timestamp->format( 'T' ); # might vary on DST changeover!
+
+ # Allow translation of timezones through wiki. format() can return
+ # whatever crap the system uses, localised or not, so we cannot
+ # ship premade translations.
+ $key = 'timezone-' . strtolower( trim( $tzMsg ) );
+ $msg = wfMessage( $key )->inContentLanguage();
+ if ( $msg->exists() ) {
+ $tzMsg = $msg->text();
+ }
+
+ $d = $wgContLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
+
+ if ( $user ) {
+ return $wgParser->getUserSig( $user, false, false ) . ' ' . $d;
+ } else {
+ return "[Unknown user] $d";
+ }
+ }
+}
+
+$maintClass = "ConvertToText";
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/Flow/modules/contributions/base.js b/Flow/modules/contributions/base.js
new file mode 100644
index 00000000..b68fed53
--- /dev/null
+++ b/Flow/modules/contributions/base.js
@@ -0,0 +1,31 @@
+/*!
+ * This file provides a shim to load Flow when clicking an interactive
+ * Flow link.
+ */
+(function( $, mw ) {
+ function clickedFlowLink( event ) {
+ var $container = $( event.delegateTarget ),
+ onComplete = function() {
+ $( event.target ).click();
+ };
+
+ event.preventDefault();
+
+ $container.
+ addClass( 'flow-component' ).
+ data( 'flow-component', 'boardHistory' );
+
+ // if successfull, flow will now handle clicking the target
+ // If that failed still run the onComplete, it will not trigger
+ // our handler and be a normal click this time.
+ mw.loader.using(
+ [ 'ext.flow', 'ext.flow.mediawiki.ui.modal', 'mediawiki.ui.input' ],
+ onComplete,
+ onComplete
+ );
+ }
+
+ $( document ).ready( function() {
+ $( '#bodyContent' ).one( 'click', '.flow-click-interactive', clickedFlowLink );
+ } );
+} )( jQuery, mediaWiki );
diff --git a/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js b/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js
new file mode 100644
index 00000000..838bff00
--- /dev/null
+++ b/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js
@@ -0,0 +1,41 @@
+( function ( mw, OO ) {
+ 'use strict';
+
+ mw.flow = mw.flow || {};
+
+ /**
+ * Abstract editor class for Flow content
+ * Sets certain defaults, but most have to be implemented in subclasses
+ *
+ * @class
+ * @abstract
+ *
+ * @constructor
+ */
+ mw.flow.editors = {
+ AbstractEditor: function () {}
+ };
+
+ OO.initClass( mw.flow.editors.AbstractEditor );
+
+ // Static methods
+
+ /**
+ * Returns whether this editor uses a preview mode
+ *
+ * @return {boolean}
+ */
+ mw.flow.editors.AbstractEditor.static.usesPreview = function () {
+ return true;
+ };
+
+ /**
+ * Determines if this editor is supported for the current user and
+ * environment (browser, etc.)
+ *
+ * @return {boolean}
+ */
+ mw.flow.editors.AbstractEditor.static.isSupported = function () {
+ return true;
+ };
+}( mediaWiki, OO ) );
diff --git a/Flow/modules/editor/editors/ext.flow.editors.none.js b/Flow/modules/editor/editors/ext.flow.editors.none.js
new file mode 100644
index 00000000..c8699438
--- /dev/null
+++ b/Flow/modules/editor/editors/ext.flow.editors.none.js
@@ -0,0 +1,165 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ /**
+ * Editor class that uses a simple wikitext textarea
+ *
+ * @class
+ * @constructor
+ *
+ * @param {jQuery} $node
+ * @param {string} [content='']
+ */
+ mw.flow.editors.none = function ( $node, content ) {
+ this.$node = $node;
+ this.$node.val( content || '' );
+
+ this.$node.css( 'overflow', 'hidden' );
+ this.$node.css( 'resize', 'none' );
+
+ // auto-expansion shouldn't shrink too much; set default height as min
+ this.$node.css( 'min-height', this.$node.outerHeight() );
+
+ // initialize at height of existing content & update on every keyup
+ this.$node.keyup( this.autoExpand );
+ this.autoExpand.call( this.$node.get( 0 ) );
+
+ // only attach switcher if VE is actually supported
+ // code to figure out if that VE is supported is in that module
+ mw.loader.using( 'ext.flow.editors.visualeditor', $.proxy( this.attachControls, this ) );
+ };
+
+ OO.inheritClass( mw.flow.editors.none, mw.flow.editors.AbstractEditor );
+
+ // Static properties
+ /**
+ * Type of content to use (html or wikitext)
+ *
+ * @var string
+ */
+ mw.flow.editors.none.static.format = 'wikitext';
+
+ /**
+ * Name of this editor
+ *
+ * @var string
+ */
+ mw.flow.editors.none.static.name = 'none';
+
+ mw.flow.editors.none.prototype.destroy = function () {
+ // remove the help+switcher information
+ this.$node.siblings( '.flow-switcher-controls' ).remove();
+ // unset min-height that was set for auto-expansion
+ this.$node.css( 'min-height', '' );
+ // unset height that was set by auto-expansion
+ this.$node.css( 'height', '' );
+ // clear content
+ this.$node.val( '' );
+ };
+
+ /**
+ * @return {string}
+ */
+ mw.flow.editors.none.prototype.getRawContent = function () {
+ return this.$node.val();
+ };
+
+ /**
+ * Checks whether the field is empty
+ *
+ * @return {boolean} True if and only if it's empty
+ */
+ mw.flow.editors.none.prototype.isEmpty = function () {
+ return this.getRawContent() === '';
+ };
+
+ /**
+ * Auto-expand/shrink as content changes.
+ */
+ mw.flow.editors.none.prototype.autoExpand = function() {
+ var scrollHeight, $form, formBottom, windowBottom, maxHeightIncrease,
+ $this = $( this ),
+ height = $this.height(),
+ padding = $this.outerHeight() - $this.height() + 5;
+
+ /*
+ * Collapse to 0 height to get accurate scrollHeight for the content,
+ * then restore height.
+ * Without collapsing, scrollHeight would be the highest of:
+ * * the content height
+ * * the height the textarea already has
+ * Since we're looking to also shrink the textarea when content shrinks,
+ * we want to ignore that last case (hence the collapsing)
+ */
+ $this.height( 0 );
+ scrollHeight = this.scrollHeight;
+ $this.height( height );
+
+ /*
+ * Only animate height change if there actually is a change; we don't
+ * want every keystroke firing a 50ms animation.
+ */
+ if ( scrollHeight === $this.data( 'flow-prev-scroll-height' ) ) {
+ // no change
+ return;
+ }
+ $this.data( 'flow-prev-scroll-height', scrollHeight );
+
+ $form = $this.closest( 'form' );
+ formBottom = $form.offset().top + $form.outerHeight( true );
+ windowBottom = $( window ).scrollTop() + $( window ).height();
+ // additional padding of 20px so the targeted form has breathing room
+ maxHeightIncrease = windowBottom - formBottom - 20;
+
+ if ( scrollHeight - height - padding >= maxHeightIncrease ) {
+ // If we can't expand ensure overflow-y is set to auto
+ $this.css( 'overflow-y', 'auto' );
+ } else if ( scrollHeight !== $this.height() ) {
+ $this.css( {
+ height: scrollHeight,
+ 'overflow-y': 'hidden'
+ } );
+ }
+ };
+
+ mw.flow.editors.none.prototype.attachControls = function() {
+ var $preview, $controls, templateArgs,
+ board = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( this.$node );
+
+ if ( mw.flow.editors.visualeditor.static.isSupported() ) {
+ $preview = $( '<a>' ).attr( {
+ href: '#',
+ 'data-flow-interactive-handler': 'switchEditor',
+ 'data-flow-target': '< form textarea'
+ } ).text( mw.message( 'flow-wikitext-editor-help-preview-the-result' ).text() );
+
+ templateArgs = {
+ enable_switcher: true,
+ help_text: mw.message( 'flow-wikitext-editor-help-and-preview' ).params( [
+ mw.message( 'flow-wikitext-editor-help-uses-wikitext' ).parse(),
+ $preview[0].outerHTML
+ ] ).parse()
+ };
+ } else {
+ // render just a basic help text
+ templateArgs = {
+ enable_switcher: false,
+ help_text: mw.message( 'flow-wikitext-editor-help' ).params( [
+ mw.message( 'flow-wikitext-editor-help-uses-wikitext' ).parse()
+ ] ).parse()
+ };
+ }
+
+ $controls = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_editor_switcher.partial',
+ templateArgs
+ ) ).children();
+
+ // insert help information + editor switcher, and make it interactive
+ board.emitWithReturn( 'makeContentInteractive', $controls.insertAfter( this.$node ) );
+ };
+
+ mw.flow.editors.none.prototype.focus = function() {
+ return this.$node.focus();
+ };
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js b/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js
new file mode 100644
index 00000000..91eb1a13
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js
@@ -0,0 +1,208 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * @param {jQuery} $node Node to replace with a VisualEditor
+ * @param {string} [content='']
+ */
+ mw.flow.editors.visualeditor = function ( $node, content ) {
+ // node the editor is associated with.
+ this.$node = $node;
+
+ // Replace the node with a spinner
+ $node.hide();
+ $node.injectSpinner( {
+ 'size' : 'large',
+ 'type' : 'block',
+ 'id' : 'flow-editor-loading'
+ } );
+
+ // load dependencies & init editor
+ mw.loader.using( 'ext.flow.visualEditor', $.proxy( this.init, this, content || '' ) );
+ };
+
+ OO.inheritClass( mw.flow.editors.visualeditor, mw.flow.editors.AbstractEditor );
+
+ /**
+ * List of callbacks to execute when VE is fully loaded
+ */
+ mw.flow.editors.visualeditor.prototype.initCallbacks = [];
+
+ /**
+ * Callback function, executed after all VE dependencies have been loaded.
+ *
+ * @param {string} [content='']
+ */
+ mw.flow.editors.visualeditor.prototype.init = function ( content ) {
+ var $veNode, htmlDoc, dmDoc, target,
+ $focusedElement = $( ':focus' ),
+ flowEditor = this;
+
+ // ve.createDocumentFromHtml documents support for an empty string
+ // to create an empty document, but does not mention other falsy values.
+ content = content || '';
+
+ // add i18n messages to VE
+ ve.init.platform.addMessages( mw.messages.values );
+
+ $.removeSpinner( 'flow-editor-loading' );
+
+ target = this.target = new mw.flow.ve.Target();
+
+ htmlDoc = ve.createDocumentFromHtml( content ); // HTMLDocument
+
+ // Based on ve.init.mw.Target.prototype.setupSurface
+ dmDoc = this.dmDoc = ve.dm.converter.getModelFromDom(
+ htmlDoc,
+ null,
+ mw.config.get( 'wgVisualEditor' ).pageLanguageCode,
+ mw.config.get( 'wgVisualEditor' ).pageLanguageDir
+ );
+
+ setTimeout( function () {
+ var surface = target.addSurface( dmDoc ),
+ surfaceView = surface.getView(),
+ $documentNode = surfaceView.getDocument().getDocumentNode().$element;
+
+ $( target.$element ).insertAfter( flowEditor.$node );
+
+ $documentNode.addClass(
+ // Add appropriately mw-content-ltr or mw-content-rtl class
+ 'mw-content-' + mw.config.get( 'wgVisualEditor' ).pageLanguageDir
+ );
+
+ setTimeout( function () {
+ // focus VE instance if textarea had focus
+ if ( !$focusedElement.length || flowEditor.$node.is( $focusedElement ) ) {
+ surface.getView().focus();
+ } else {
+ $focusedElement.focus();
+ }
+
+ $veNode = surface.$element.find( '.ve-ce-documentNode' );
+
+ $veNode.addClass( 'mw-ui-input' );
+
+ // simulate a keyup event on the original node, so the validation code will
+ // pick up changes in the new node
+ $veNode.keyup( $.proxy( function () {
+ this.$node.keyup();
+ }, flowEditor ) );
+
+ $.each( flowEditor.initCallbacks, $.proxy( function( k, callback ) {
+ callback.apply( this );
+ }, flowEditor ) );
+
+ } );
+ } );
+ };
+
+ mw.flow.editors.visualeditor.prototype.destroy = function () {
+ if ( this.target ) {
+ this.target.destroy();
+ }
+
+ // re-display original node
+ this.$node.show();
+ };
+
+ /**
+ * Gets HTML of Flow field
+ *
+ * @return {string}
+ */
+ mw.flow.editors.visualeditor.prototype.getRawContent = function () {
+ var doc, html;
+
+ // If we haven't fully loaded yet, just return nothing.
+ if ( !this.target ) {
+ return '';
+ }
+
+ // get document from ve
+ doc = ve.dm.converter.getDomFromModel( this.dmDoc );
+
+ // document content will include html, head & body nodes; get only content inside body node
+ html = ve.properInnerHtml( $( doc.documentElement ).find( 'body' )[0] );
+ return html;
+ };
+
+ /**
+ * Checks if the document is empty
+ *
+ * @return {boolean} True if and only if it's empty
+ */
+ mw.flow.editors.visualeditor.prototype.isEmpty = function () {
+ if ( !this.dmDoc ) {
+ return true;
+ }
+
+ // Per Roan
+ return this.dmDoc.data.countNonInternalElements() <= 2;
+ };
+
+ mw.flow.editors.visualeditor.prototype.focus = function() {
+ if ( !this.target ) {
+ this.initCallbacks.push( function() {
+ this.focus();
+ } );
+ return;
+ }
+
+ this.target.surface.getView().focus();
+ };
+
+ mw.flow.editors.visualeditor.prototype.moveCursorToEnd = function () {
+ if ( !this.target ) {
+ this.initCallbacks.push( function() {
+ this.moveCursorToEnd();
+ } );
+ return;
+ }
+
+ var data = this.target.surface.getModel().getDocument().data,
+ cursorPos = data.getNearestContentOffset( data.getLength(), -1 );
+
+ this.target.surface.getModel().setSelection( new ve.Range( cursorPos ) );
+ };
+
+ // Static fields
+
+ /**
+ * Type of content to use (html or wikitext)
+ *
+ * @var {string}
+ */
+ mw.flow.editors.visualeditor.static.format = 'html';
+
+ /**
+ * Name of this editor
+ *
+ * @var string
+ */
+ mw.flow.editors.visualeditor.static.name = 'visualeditor';
+
+ // Static methods
+
+ mw.flow.editors.visualeditor.static.isSupported = function () {
+ return !!(
+ // ES5 support, from es5-skip.js
+ ( function () {
+ // This test is based on 'use strict',
+ // which is inherited from the top-level function.
+ return !this && !!Function.prototype.bind;
+ }() ) &&
+
+ // Since VE commit e2fab2f1ebf2a28f18b8ead08c478c4fc95cd64e, SVG is required
+ document.createElementNS &&
+ document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ).createSVGRect &&
+
+ // ve needs to be turned on as a valid editor
+ mw.config.get( 'wgFlowEditorList' ).indexOf( 'visualeditor' ) !== -1
+ );
+ };
+
+ mw.flow.editors.visualeditor.static.usesPreview = function () {
+ return false;
+ };
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js
new file mode 100644
index 00000000..e9c90794
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js
@@ -0,0 +1,22 @@
+( function ( ve ) {
+ 'use strict';
+
+ ve.ui.commandRegistry.register(
+ new ve.ui.Command(
+ 'flowMention',
+ 'window',
+ 'open',
+ { args: ['flowMention'] },
+ { supportedSelections: ['linear'] }
+ )
+ );
+
+ ve.ui.commandRegistry.register(
+ new ve.ui.Command(
+ 'flowSwitchEditor',
+ 'flowSwitchEditor',
+ 'switch', // method to call on action
+ { args: [] } // arguments to pass to action
+ )
+ );
+} ( ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js
new file mode 100644
index 00000000..8a2f1c02
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js
@@ -0,0 +1,12 @@
+( function ( ve ) {
+ 'use strict';
+
+ ve.ui.sequenceRegistry.register(
+ new ve.ui.Sequence(
+ 'flowAtCharMention',
+ 'flowMention',
+ '@',
+ 1
+ )
+ );
+} ( ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js
new file mode 100644
index 00000000..2925164f
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js
@@ -0,0 +1,65 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ mw.flow.ve = {
+ ui: {}
+ };
+
+ /**
+ * Flow-specific target, inheriting from the stand-alone target
+ *
+ * @class
+ * @extends ve.init.sa.Target
+ */
+ mw.flow.ve.Target = function FlowVeTarget() {
+ mw.flow.ve.Target.parent.call(
+ this,
+ 'desktop',
+ { floatable: false }
+ );
+ };
+
+ OO.inheritClass( mw.flow.ve.Target, ve.init.sa.Target );
+
+ // Static
+
+ mw.flow.ve.Target.static.toolbarGroups = [
+ {
+ type: 'list',
+ icon: 'text-style',
+ title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
+ include: [ 'bold', 'italic' ],
+ forceExpand: [ 'bold', 'italic' ]
+ },
+
+ { include: [ 'link' ] },
+
+ { include: [ 'flowMention' ] }
+ ];
+
+ if ( mw.flow.editors.none.static.isSupported() ) {
+ mw.flow.ve.Target.static.actionGroups = [
+ { include: [ 'flowSwitchEditor' ] }
+ ];
+ }
+
+ // Methods
+
+ mw.flow.ve.Target.prototype.attachToolbar = function() {
+ this.getToolbar().$element.insertAfter( this.getToolbar().getSurface().$element );
+ };
+
+ // This is a workaround.
+ //
+ // We need to make sure the MW platform wins (we need it for e.g. linkCache), because our
+ // dependencies do not agree.
+ //
+ // ext.visualEditor.data depends on ext.visualEditor.mediawiki, which provides
+ // ve.init.mw.Platform.js. However, we also use ext.visualEditor.standalone, which
+ // provides ve.init.sa.Platform. Both of these self-initialize ve.init.platform.
+ ve.init.platform = new ve.init.mw.Platform();
+
+ OO.ui.getUserLanguages = ve.init.platform.getUserLanguages.bind( ve.init.platform );
+
+ OO.ui.msg = ve.init.platform.getMessage.bind( ve.init.platform );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less
new file mode 100644
index 00000000..6076edf8
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less
@@ -0,0 +1,49 @@
+@import 'mediawiki.mixins';
+
+// Override another * rule in common.less back to browser default
+// box-sizing is not inherited.
+.flow-component {
+ .ve-init-target {
+ .box-sizing(content-box);
+ border: 1px solid #CCC;
+
+ * {
+ .box-sizing(content-box);
+ }
+
+ // Core, VE and OOjs UI do use other models in some cases, so we
+ // have to re-override them again as needed.
+ .oo-ui-textInputWidget input,
+ .mw-ui-input {
+ .box-sizing(border-box);
+ }
+ }
+
+ .ve-ce-documentNode {
+ // this border comes from .mw-ui-input on the same div, but i dont think
+ // we want to remove this for all inputs just the documentNode
+ border: none;
+ // This creates a space for the toolbar, a matching negative margin-top
+ // shifts the toolbar into this location.
+ // @todo where did this 40 come from, could it be calculated?
+ padding-bottom: 40px;
+ }
+
+ .oo-ui-toolbar {
+ // The -40 matches the padding-bottom on .ve-ce-documentNode to put the toolbar inside
+ // the editing area. The 2px of positive margin gives room for the blue border of the
+ // documentNode (via .mw-ui-input)
+ margin: -40px 2px 0 2px;
+ }
+
+ // Due to this being floated, it needs a matching top margin to still display inside the bar
+ .oo-ui-toolbar-actions {
+ margin-top: 38px;
+ }
+
+ .oo-ui-toolbar-bar {
+ // The default border is only appropriate in the default ve, with
+ // the toolbar above the editing surface.
+ border: none;
+ }
+}
diff --git a/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js b/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js
new file mode 100644
index 00000000..5d2c396e
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js
@@ -0,0 +1,55 @@
+( function ( mw, OO, ve ) {
+
+/**
+ * Action to switch from VisualEditor to the Wikitext editing interface
+ * within Flow.
+ *
+ * @class
+ * @extends ve.ui.Action
+ *
+ * @constructor
+ * @param {ve.ui.Surface} surface Surface to act on
+ */
+mw.flow.ve.ui.SwitchEditorAction = function MwFlowVeUiSwitchEditorAction( surface ) {
+ // Parent constructor
+ ve.ui.Action.call( this, surface );
+};
+
+/* Inheritance */
+
+OO.inheritClass( mw.flow.ve.ui.SwitchEditorAction, ve.ui.Action );
+
+/* Static Properties */
+
+/**
+ * Name of this action
+ *
+ * @static
+ * @property
+ */
+mw.flow.ve.ui.SwitchEditorAction.static.name = 'flowSwitchEditor';
+
+/**
+ * List of allowed methods for the action.
+ *
+ * @static
+ * @property
+ */
+mw.flow.ve.ui.SwitchEditorAction.static.methods = [ 'switch' ];
+
+/* Methods */
+
+/**
+ * Switch to wikitext editing.
+ *
+ * @method
+ */
+mw.flow.ve.ui.SwitchEditorAction.prototype.switch = function () {
+ var $node = this.surface.$element.closest( 'form' ).find( 'textarea' );
+
+ mw.flow.editor.switchEditor( $node, 'none' );
+};
+
+ve.ui.actionFactory.register( mw.flow.ve.ui.SwitchEditorAction );
+
+}( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js b/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js
new file mode 100644
index 00000000..b3f79071
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js
@@ -0,0 +1,58 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Context item for user mentions
+ *
+ * @class
+ * @extends ve.ui.ContextItem
+ *
+ * @param {ve.ui.Context} context Context item is in
+ * @param {ve.dm.Model} model Model item is related to
+ * @param {Object} config Configuration options
+ */
+ mw.flow.ve.ui.MentionContextItem = function FlowVeMentionContextItem( context, model, config ) {
+ mw.flow.ve.ui.MentionContextItem.parent.call( this, context, model, config );
+
+ this.$element.addClass( 'flow-ve-ui-mentionContextItem' );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionContextItem, ve.ui.MWTransclusionContextItem );
+
+ // Static
+ mw.flow.ve.ui.MentionContextItem.static.name = 'flowMention';
+
+ mw.flow.ve.ui.MentionContextItem.static.icon = 'flow-mention';
+
+ mw.flow.ve.ui.MentionContextItem.static.label = OO.ui.deferMsg( 'flow-ve-mention-context-item-label' );
+
+ mw.flow.ve.ui.MentionContextItem.static.commandName = 'flowMention';
+
+ // Make sure the inspector uses an arrow, rather than trying to fit in the template.
+ // Wouldn't fit anyway, though, most likely.
+
+ mw.flow.ve.ui.MentionContextItem.static.embeddable = false;
+ /**
+ * @static
+ * @localdoc Sharing implementation with mw.flow.ve.ui.MentionInspectorTool
+ */
+ mw.flow.ve.ui.MentionContextItem.static.isCompatibleWith =
+ mw.flow.ve.ui.MentionInspectorTool.static.isCompatibleWith;
+
+
+ // Instance Methods
+
+ /**
+ * Returns a short description emphasizing the relevant data (currently just the user name)
+ *
+ * @return string User name
+ */
+ mw.flow.ve.ui.MentionContextItem.prototype.getDescription = function () {
+ var key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+
+ // Is there a more intuitive way to do this?
+ return this.model.element.attributes.mw.parts[0].template.params[key].wt;
+ };
+
+ ve.ui.contextItemFactory.register( mw.flow.ve.ui.MentionContextItem );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg
new file mode 100644
index 00000000..b5a70dbb
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ version="1.1"
+ width="24"
+ height="24"
+ id="svg2">
+ <defs
+ id="defs4" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ transform="translate(0,-1028.3622)"
+ id="layer1">
+ <g
+ transform="translate(0,-4)"
+ id="g3048">
+ <g
+ transform="translate(-0.263648,0)"
+ id="g3061">
+ <g
+ transform="translate(-13.472704,1038.1992)"
+ id="g3">
+ <polygon
+ points="20,11 20,7 24,7 24,5 20,5 20,1 18,1 18,5 14,5 14,7 18,7 18,11 "
+ id="polygon5" />
+ </g>
+ <g
+ transform="translate(7.5,1035.8622)"
+ id="g5-1">
+ <g
+ id="g7"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g9"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g11"
+ style="fill:#000000;fill-opacity:1">
+ <path
+ d="M 9,9 C 6.7,9 4.8,7.1 4.8,4.7 4.8,2.3 6.7,0.5 9,0.5 c 2.3,0 4.2,1.9 4.2,4.2 C 13.2,7 11.4,9 9,9 z"
+ id="path13"
+ style="fill:#000000;fill-opacity:1" />
+ </g>
+ </g>
+ </g>
+ <g
+ id="g15"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g17"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g19"
+ style="fill:#000000;fill-opacity:1">
+ <path
+ d="m 16.5,16.5 -15,0 0,-0.6 c 0,-1.1 0.2,-2 0.5,-2.8 0.3,-0.8 0.8,-1.4 1.4,-2 C 4,10.6 4.8,10.2 5.7,9.9 L 6,9.8 6.4,10 c 0.8,0.5 1.7,0.8 2.7,0.8 0.9,0 1.9,-0.3 2.7,-0.8 L 12,9.8 12.3,9.9 c 0.9,0.3 1.6,0.7 2.3,1.2 0.6,0.5 1.1,1.2 1.4,2 0.3,0.8 0.5,1.7 0.5,2.8 z"
+ id="path21"
+ style="fill:#000000;fill-opacity:1" />
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg
new file mode 100644
index 00000000..80561ac8
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ version="1.1"
+ width="24"
+ height="24"
+ id="svg2"
+ inkscape:version="0.48.4 r9939"
+ sodipodi:docname="flow-switch-editor.svg">
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="640"
+ inkscape:window-height="480"
+ id="namedview3262"
+ showgrid="false"
+ inkscape:zoom="9.8333333"
+ inkscape:cx="12"
+ inkscape:cy="7.9322034"
+ inkscape:window-x="0"
+ inkscape:window-y="24"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2" />
+ <defs
+ id="defs4" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ transform="matrix(3.1730191,0,0,6.5378108,-0.51387626,-7.0405915)"
+ style="font-size:4px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans;-inkscape-font-specification:Sans"
+ id="flowRoot3264">
+ <path
+ d="m 2.8339844,2.2369966 -2.01562502,0.7167969 2.01562502,0.7128906 0,0.3554688 -2.50390627,-0.9082031 0,-0.3242188 2.50390627,-0.9082031 0,0.3554687"
+ style=""
+ id="path3277" />
+ <path
+ d="m 4.2734375,1.289731 0.3320312,0 -1.0156249,3.2871094 -0.3320313,0 1.015625,-3.2871094"
+ style=""
+ id="path3279" />
+ <path
+ d="m 5.0332031,2.2369966 0,-0.3554687 2.5039063,0.9082031 0,0.3242188 -2.5039063,0.9082031 0,-0.3554688 L 7.0449219,2.9537935 5.0332031,2.2369966"
+ style=""
+ id="path3281" />
+ </g>
+</svg>
diff --git a/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js b/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js
new file mode 100644
index 00000000..e5d74c7f
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js
@@ -0,0 +1,372 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ // Based partly on ve.ui.MWTemplateDialog
+ /**
+ * Inspector for editing Flow mentions. This is a friendly
+ * UI for a transclusion (e.g. {{ping}}, template varies by wiki).
+ *
+ * @class
+ * @extends ve.ui.NodeInspector
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+ mw.flow.ve.ui.MentionInspector = function FlowVeMentionInspector( config ) {
+ mw.flow.ve.ui.MentionInspector.parent.call( this, config );
+
+ // this.selectedNode is the ve.dm.MWTransclusionNode, which we inherit
+ // from ve.ui.NodeInspector.
+ //
+ // The templateModel (used locally some places) is a sub-part of the transclusion
+ // model.
+ this.transclusionModel = null;
+ this.loaded = false;
+ this.altered = false;
+
+ this.targetInput = null;
+ this.errorWidget = null;
+ this.errorFieldsetLayout = null;
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionInspector, ve.ui.NodeInspector );
+
+ // Static
+
+ mw.flow.ve.ui.MentionInspector.static.name = 'flowMention';
+ mw.flow.ve.ui.MentionInspector.static.icon = 'flow-mention';
+ mw.flow.ve.ui.MentionInspector.static.title = OO.ui.deferMsg( 'flow-ve-mention-inspector-title' );
+ mw.flow.ve.ui.MentionInspector.static.modelClasses = [ ve.dm.MWTransclusionNode ];
+
+ mw.flow.ve.ui.MentionInspector.static.template = mw.config.get( 'wgFlowMentionTemplate' );
+ mw.flow.ve.ui.MentionInspector.static.templateParameterKey = '1'; // 1-indexed positional parameter
+
+ // Buttons
+ mw.flow.ve.ui.MentionInspector.static.actions = [
+ {
+ action: 'remove',
+ label: OO.ui.deferMsg( 'flow-ve-mention-inspector-remove-label' ),
+ flags: ['destructive'],
+ modes: 'edit'
+ }
+ ].concat( mw.flow.ve.ui.MentionInspector.parent.static.actions );
+
+ // Instance Methods
+
+ /**
+ * Handle changes to the input widget
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTargetInputChange = function () {
+ var templateModel, parameterModel, key, value, inspector;
+
+ this.hideErrors();
+
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+ value = this.targetInput.getValue();
+ inspector = this;
+
+ this.pushPending();
+ this.targetInput.isValid().done( function ( isValid ) {
+ if ( isValid ) {
+ // After the updates are done, we'll get onTransclusionModelChange
+ templateModel = inspector.transclusionModel.getParts()[0];
+ if ( templateModel.hasParameter( key ) ) {
+ parameterModel = templateModel.getParameter( key );
+ parameterModel.setValue( value );
+ } else {
+ parameterModel = new ve.dm.MWParameterModel(
+ templateModel,
+ key,
+ value
+ );
+ templateModel.addParameter( parameterModel );
+ }
+ } else {
+ // Disable save button
+ inspector.setApplicableStatus();
+ }
+ } ).always( function () {
+ inspector.popPending();
+ } );
+ };
+
+ /**
+ * Handle the transclusion becoming ready
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTransclusionReady = function () {
+ var templateModel, key;
+
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+
+ this.loaded = true;
+ this.$element.addClass( 'flow-ve-ui-mentionInspector-ready' );
+ this.popPending();
+
+ templateModel = this.transclusionModel.getParts()[0];
+ if ( templateModel.hasParameter( key ) ) {
+ this.targetInput.setValue( templateModel.getParameter( key ).getValue() );
+ }
+ };
+
+ /**
+ * Handles the transclusion model changing. This should only happen when we change
+ * the parameter, then get a callback.
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTransclusionModelChange = function () {
+ if ( this.loaded ) {
+ this.altered = true;
+ this.setApplicableStatus();
+ }
+ };
+
+ /**
+ * Sets the abiliities based on the current status
+ *
+ * If it's empty or invalid, it can not be inserted or updated.
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.setApplicableStatus = function () {
+ var parts = this.transclusionModel.getParts(),
+ templateModel = parts[0],
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey,
+ inspector = this;
+
+ // The template should always be there; the question is whether the first/only
+ // positional parameter is.
+ //
+ // If they edit an existing mention, and make it invalid, they should be able
+ // to cancel, but not save.
+ if ( templateModel.hasParameter( key ) ) {
+ this.pushPending();
+ this.targetInput.isValid().done( function ( isValid ) {
+ inspector.actions.setAbilities( { done: isValid } );
+ } ).always( function () {
+ inspector.popPending();
+ } );
+ } else {
+ inspector.actions.setAbilities( { done: false } );
+ }
+ };
+
+ /**
+ * Initialize UI of inspector
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.initialize = function () {
+ var flowBoard, overlay, indicatorWidget;
+
+ mw.flow.ve.ui.MentionInspector.parent.prototype.initialize.call( this );
+
+ // I would much prefer to use dependency injection to get the list of topic posters
+ // into the inspector, but I haven't been able to figure out how to pass it through
+ // yet.
+
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )(
+ this.$element
+ );
+
+ // Properties
+ overlay = this.manager.getOverlay();
+ this.targetInput = new mw.flow.ve.ui.MentionTargetInputWidget( {
+ $: this.$,
+ $overlay: overlay ? overlay.$element : this.$frame,
+ topicPosters: flowBoard.getTopicPosters( this.$element )
+ } );
+ indicatorWidget = new OO.ui.IndicatorWidget( {
+ indicator: 'alert'
+ } );
+ this.errorWidget = new OO.ui.FieldLayout( indicatorWidget, {
+ align: 'inline'
+ } );
+ this.errorFieldsetLayout = new OO.ui.FieldsetLayout( {
+ items: [
+ this.errorWidget
+ ]
+ } );
+
+ // Initialization
+ this.$content.addClass( 'flow-ve-ui-mentionInspector-content' );
+ this.errorFieldsetLayout.toggle( false );
+ this.form.addItems( [
+ this.errorFieldsetLayout
+ ] );
+ this.form.$element.append( this.targetInput.$element );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getActionProcess = function ( action ) {
+ var surfaceModel = this.getFragment().getSurface(), dfd, inspector;
+
+ if ( action === 'done' ) {
+ dfd = $.Deferred();
+ inspector = this;
+
+ this.targetInput.isValid().done( function ( isValid ) {
+ var transclusionModelPlain;
+
+ if ( isValid ) {
+ transclusionModelPlain = inspector.transclusionModel.getPlainObject();
+
+ // Should be either null or the right template
+ if ( inspector.selectedNode instanceof ve.dm.MWTransclusionNode ) {
+ inspector.transclusionModel.updateTransclusionNode( surfaceModel, inspector.selectedNode );
+ } else if ( transclusionModelPlain !== null ) {
+ inspector.fragment = inspector.getFragment().collapseToEnd();
+ inspector.transclusionModel.insertTransclusionNode( inspector.getFragment() );
+ surfaceModel.setSelection( surfaceModel.getSelection().collapseToEnd() );
+ }
+
+ inspector.close( { action: action } );
+ dfd.resolve();
+ } else {
+ dfd.reject( new OO.ui.Error( OO.ui.msg( 'flow-ve-mention-inspector-invalid-user', inspector.targetInput.getValue() ) ) );
+ }
+ } );
+
+ return new OO.ui.Process( dfd.promise() );
+ } else if ( action === 'remove' ) {
+ return new OO.ui.Process( function () {
+ var doc, nodeRange;
+
+ doc = surfaceModel.getDocument();
+ nodeRange = this.selectedNode.getOuterRange();
+
+ surfaceModel.change(
+ ve.dm.Transaction.newFromRemoval( doc, nodeRange )
+ );
+
+ this.close( { action: action } );
+ }, this );
+ }
+
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getActionProcess.call( this, action );
+ };
+
+ // Technically, these are private. However, it's necessary to override them (and not call
+ // the parent), since otherwise this UI (which was probably designed for dialogs) does not fit the inspector.
+ // Only handles on error at a time for now.
+ //
+ // It would be nice to implement a general solution for this that covers all inspectors (or
+ // maybe a mixin for inline errors next to form elements).
+ /**
+ * @inherit
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.showErrors = function ( errors ) {
+ var errorText;
+
+ if ( errors instanceof OO.ui.Error ) {
+ errors = [errors];
+ }
+
+ errorText = errors[0].getMessageText();
+ this.errorWidget.setLabel( errorText );
+ this.errorFieldsetLayout.toggle( true );
+ this.setSize( 'large' );
+ };
+
+ /**
+ * @inherit
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.hideErrors = function () {
+ this.errorFieldsetLayout.toggle( false );
+ this.errorWidget.setLabel( '' );
+ this.setSize( 'medium' );
+ };
+
+ /**
+ * Pre-populate the username based on the node
+ *
+ * @param {Object} [data] Inspector initial data
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getSetupProcess = function ( data ) {
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getSetupProcess.call( this, data )
+ .next( function () {
+ var templateModel, promise;
+
+ this.loaded = false;
+ this.altered = false;
+ // MWTransclusionModel has some unnecessary behavior for our use
+ // case, mainly templatedata lookups.
+ this.transclusionModel = new ve.dm.MWTransclusionModel();
+
+ // Events
+ this.transclusionModel.connect( this, {
+ change: 'onTransclusionModelChange'
+ } );
+
+ this.targetInput.connect( this, {
+ change: 'onTargetInputChange'
+ } );
+
+ // Initialization
+ if ( !this.selectedNode ) {
+ this.actions.setMode( 'insert' );
+ templateModel = ve.dm.MWTemplateModel.newFromName(
+ this.transclusionModel,
+ mw.flow.ve.ui.MentionInspector.static.template
+ );
+ promise = this.transclusionModel.addPart( templateModel );
+ } else {
+ this.actions.setMode( 'edit' );
+
+ // Load existing ping
+ promise = this.transclusionModel
+ .load( ve.copy( this.selectedNode.getAttribute( 'mw' ) ) );
+ }
+
+ // Don't allow saving until we're sure it's valid.
+ this.actions.setAbilities( { done: false } );
+ this.pushPending();
+ promise.always( this.onTransclusionReady.bind( this ) );
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getReadyProcess = function ( data ) {
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getReadyProcess.call( this, data )
+ .next( function () {
+ this.targetInput.focus();
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getTeardownProcess = function ( data ) {
+ data = data || {};
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getTeardownProcess.call( this, data )
+ .first( function () {
+ // Cleanup
+ this.$element.removeClass( 'flow-ve-ui-mentionInspector-ready' );
+ this.transclusionModel.disconnect( this );
+ this.transclusionModel.abortRequests();
+ this.transclusionModel = null;
+
+ this.targetInput.disconnect( this );
+
+ this.targetInput.setValue( '' );
+ }, this );
+ };
+
+ /**
+ * Gets the transclusion node representing this mention
+ *
+ * @param {Object} [data] Inspector opening data
+ * @return {ve.dm.Node} Selected node
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getSelectedNode = function () {
+ // Checks the model class
+ var node = mw.flow.ve.ui.MentionInspector.parent.prototype.getSelectedNode.call( this );
+ if ( node !== null ) {
+ if ( node.isSingleTemplate( mw.flow.ve.ui.MentionInspector.static.template ) ) {
+ return node;
+ }
+ }
+
+ return null;
+ };
+
+ ve.ui.windowFactory.register( mw.flow.ve.ui.MentionInspector );
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less b/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less
new file mode 100644
index 00000000..4e38f7cb
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less
@@ -0,0 +1,9 @@
+@import 'mediawiki.mixins';
+
+.oo-ui-icon-flow-mention {
+ .background-image('images/icons/flow-mention.svg');
+}
+
+.oo-ui-icon-flow-switch-editor {
+ .background-image('images/icons/flow-switch-editor.svg');
+}
diff --git a/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js
new file mode 100644
index 00000000..641f9fbf
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js
@@ -0,0 +1,40 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Tool for user mentions
+ *
+ * @class
+ * @extends ve.ui.InspectorTool
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+
+ mw.flow.ve.ui.MentionInspectorTool = function FlowVeMentionInspectorTool( toolGroup, config ) {
+ mw.flow.ve.ui.MentionInspectorTool.parent.call( this, toolGroup, config );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionInspectorTool, ve.ui.InspectorTool );
+
+ // Static
+ mw.flow.ve.ui.MentionInspectorTool.static.commandName = 'flowMention';
+ mw.flow.ve.ui.MentionInspectorTool.static.name = 'flowMention';
+ mw.flow.ve.ui.MentionInspectorTool.static.icon = 'flow-mention';
+ mw.flow.ve.ui.MentionInspectorTool.static.title = OO.ui.deferMsg( 'flow-ve-mention-tool-title' );
+
+ mw.flow.ve.ui.MentionInspectorTool.static.template = mw.flow.ve.ui.MentionInspector.static.template;
+
+ /**
+ * Checks whether the model represents a user mention
+ *
+ * @return boolean
+ */
+ mw.flow.ve.ui.MentionInspectorTool.static.isCompatibleWith = function ( model ) {
+ return model instanceof ve.dm.MWTransclusionNode &&
+ model.isSingleTemplate( mw.flow.ve.ui.MentionInspectorTool.static.template );
+ };
+
+ ve.ui.toolFactory.register( mw.flow.ve.ui.MentionInspectorTool );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js
new file mode 100644
index 00000000..865f8883
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js
@@ -0,0 +1,29 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Tool for switching editors
+ *
+ * @class
+ * @extends ve.ui.Tool
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+
+ mw.flow.ve.ui.SwitchEditorTool = function FlowVeSwitchEditorTool( toolGroup, config ) {
+ mw.flow.ve.ui.SwitchEditorTool.parent.call( this, toolGroup, config );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.SwitchEditorTool, ve.ui.Tool );
+
+ // Static
+ mw.flow.ve.ui.SwitchEditorTool.static.commandName = 'flowSwitchEditor';
+ mw.flow.ve.ui.SwitchEditorTool.static.name = 'flowSwitchEditor';
+ mw.flow.ve.ui.SwitchEditorTool.static.icon = 'flow-switch-editor';
+ mw.flow.ve.ui.SwitchEditorTool.static.title = OO.ui.deferMsg( 'flow-ve-switch-editor-tool-title' );
+
+
+ ve.ui.toolFactory.register( mw.flow.ve.ui.SwitchEditorTool );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js b/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js
new file mode 100644
index 00000000..eb9870e5
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js
@@ -0,0 +1,170 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Creates an input widget with auto-completion for users to be mentioned
+ *
+ * @class
+ * @extends oo.ui.TextInputWidget
+ * @mixins OO.ui.LookupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @param {Array} [config.topicPosters] Array of usernames representing posters to this thread,
+ * without duplicates.
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget = function FlowVeUiMentionTargetInputWidget( config ) {
+ mw.flow.ve.ui.MentionTargetInputWidget.parent.call( this, config );
+
+ // Mixin constructor
+ config.allowSuggestionsWhenEmpty = true;
+ OO.ui.LookupElement.call( this, config );
+
+ // Properties
+ // Exclude anonymous users, since they do not receive pings.
+ this.loggedInTopicPosters = $.grep( config.topicPosters || [], function ( poster ) {
+ return !mw.util.isIPAddress( poster, false );
+ } );
+ this.username = null;
+ // Username to validity promise (promise resolves with true/false for existent/non-existent
+ this.isUsernameValidCache = {};
+
+ this.$element.addClass( 'flow-ve-ui-mentionTargetInputWidget' );
+ this.lookupMenu.$element.addClass( 'flow-ve-ui-mentionTargetInputWidget-menu' );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionTargetInputWidget, OO.ui.TextInputWidget );
+
+ OO.mixinClass( mw.flow.ve.ui.MentionTargetInputWidget, OO.ui.LookupElement );
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.isValid = function () {
+ var api = new mw.Api(),
+ dfd = $.Deferred(),
+ promise = dfd.promise(),
+ username = this.getValue(),
+ widget = this,
+ isValid;
+
+ if ( $.trim( username ) === '' ) {
+ dfd.resolve( false );
+ return promise;
+ }
+
+ username = username[0].toUpperCase() + username.slice( 1 );
+ if ( this.isUsernameValidCache[username] !== undefined ) {
+ return this.isUsernameValidCache[username];
+ }
+
+ // Note that we delete this below if it turns out to get an error.
+ this.isUsernameValidCache[username] = promise;
+
+ api.get( {
+ action: 'query',
+ list: 'users',
+ ususers: username
+ } ).done( function ( resp ) {
+ if (
+ resp &&
+ resp.query &&
+ resp.query.users &&
+ resp.query.users.length > 0
+ ) {
+ // This is the normal path for either existent or non-existent users.
+ isValid = resp.query.users[0].missing === undefined;
+ dfd.resolve( isValid );
+ } else {
+ // This means part of the response is missing, which again shouldn't
+ // happen (it could for empty string user, but we're not supposed to
+ // send the request at all then). See explanation under fail.
+ dfd.resolve( true );
+ delete widget.isUsernameValidCache[username];
+ }
+ } ).fail( function () {
+ // This should only happen on error cases. Even if the user doesn't exist,
+ // we should still enter done. Since this is an unforseen error, return true
+ // so we don't block submission, and evict cache.
+ dfd.resolve( true );
+ delete widget.isUsernameValidCache[username];
+ } );
+
+ return promise;
+ };
+
+ /**
+ * Gets a promise representing the auto-complete.
+ * Right now, the auto-complete is based on the users who have already posted to the topic.
+ *
+ * It does a case-insensitive search for a string (anywhere in the poster's username)
+ * matching what the user has typed in so far.
+ *
+ * E.g. if one of the posters is "Mary Jane Smith", that will be a suggestion if the user has
+ * entered e.g. "Mary", "jane", or 'Smi'.
+ *
+ * @method
+ * @returns {jQuery.Promise}
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupRequest = function () {
+ var abortObject = { abort: $.noop }, dfd = $.Deferred(),
+ lowerValue = this.value.toLowerCase(), matches;
+
+ matches = $.grep( this.loggedInTopicPosters, function ( poster ) {
+ return poster.toLowerCase().indexOf( lowerValue ) >= 0;
+ } );
+
+ dfd.resolve( matches );
+ return dfd.promise( abortObject );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupCacheDataFromResponse = function ( data ) {
+ return data;
+ };
+
+ /**
+ * Converts the raw data to UI objects
+ *
+ * @param Array list of users
+ * @return {OO.ui.MenuOptionWidget[]} Menu items
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupMenuOptionsFromData = function ( users ) {
+ var items = [], user, i;
+
+ for ( i = 0; i < users.length; i++ ) {
+ user = users[i];
+
+ items.push( new OO.ui.MenuOptionWidget( {
+ $: this.lookupMenu.$,
+ data: user,
+ label: user
+ } ) );
+ }
+
+ return items;
+ };
+
+ // Based on ve.ui.MWLinkTargetInputWidget.prototype.initializeLookupMenuSelection
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.initializeLookupMenuSelection = function () {
+ var item;
+ if ( this.username ) {
+ this.lookupMenu.selectItem( this.lookupMenu.getItemFromData( this.username ) );
+ }
+
+ item = this.lookupMenu.getSelectedItem();
+ if ( !item ) {
+ OO.ui.LookupElement.prototype.initializeLookupMenuSelection.call( this );
+ }
+
+ item = this.lookupMenu.getSelectedItem();
+ if ( item ) {
+ this.username = item.getData();
+ }
+ };
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/ext.flow.editor.js b/Flow/modules/editor/ext.flow.editor.js
new file mode 100644
index 00000000..95af71d7
--- /dev/null
+++ b/Flow/modules/editor/ext.flow.editor.js
@@ -0,0 +1,272 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ // This is more of an EditorFacade/EditorDispatcher or something, and should be renamed.
+ // It's not the base class, nor is it an actual editor.
+ mw.flow.editor = {
+ /**
+ * Specific editor to be used.
+ *
+ * @property {object}
+ */
+ editor: null,
+
+ /**
+ * Array of target instances.
+ *
+ * The first entry is null to make sure that the reference saved to a data-
+ * attribute is never index 0; a 0-value will make :data(flow-editor)
+ * selector not find a result.
+ *
+ * @property {object}
+ */
+ editors: [null],
+
+ init: function () {
+ var editorList = mw.config.get( 'wgFlowEditorList' ),
+ index = editorList.indexOf( mw.user.options.get( 'flow-editor' ) );
+
+ // determine editor instance to use, depending on availability
+ mw.flow.editor.loadEditor( index );
+ },
+
+ loadEditor: function ( editorIndex ) {
+ var editorList = mw.config.get( 'wgFlowEditorList' ),
+ editor;
+
+ if ( !editorIndex || editorIndex < 0 || editorIndex >= editorList.length ) {
+ editorIndex = 0;
+ }
+
+ if ( editorList[editorIndex] ) {
+ editor = editorList[editorIndex];
+ } else {
+ editor = 'none';
+ }
+
+ mw.loader.using( 'ext.flow.editors.' + editor, function () {
+ // Some editors only work under certain circumstances
+ if ( !mw.flow.editors[editor].static.isSupported() ) {
+ mw.flow.editor.loadEditor( editorIndex + 1 );
+ } else {
+ mw.flow.editor.editor = mw.flow.editors[editor];
+ }
+ } );
+ },
+
+ /**
+ * @param {jQuery} $node
+ * @param {string} [content] Existing content to load, in any format
+ * @param {string} [contentFormat] The format that content is in, or null (defaults to wikitext)
+ * @return {jQuery.Promise} Will resolve once editor instance is loaded
+ */
+ load: function ( $node, content, contentFormat ) {
+ /**
+ * When calling load(), init() may not yet have completed loading the
+ * dependencies. To make sure it doesn't break, this will in interval,
+ * check for it and only start loading once initialization is complete.
+ */
+ var load = function ( $node, content, contentFormat ) {
+ if ( mw.flow.editor.editor === null ) {
+ return;
+ } else {
+ clearTimeout( interval );
+ }
+
+ if ( contentFormat === undefined ) {
+ contentFormat = 'wikitext';
+ }
+
+ // quit early if editor is already loaded
+ if ( mw.flow.editor.getEditor( $node ) ) {
+ deferred.resolve();
+ return;
+ }
+
+ mw.flow.parsoid.convert( contentFormat, mw.flow.editor.getFormat( $node ), content )
+ .done( function( content ) {
+ mw.flow.editor.create( $node, content );
+ deferred.resolve();
+ })
+ .fail( function() {
+ deferred.reject();
+ });
+ },
+ deferred = $.Deferred(),
+ interval = setInterval( $.proxy( load, this, $node, content, contentFormat ), 10 );
+
+ return deferred.promise();
+ },
+
+ /**
+ * @param {jQuery} $node
+ */
+ destroy: function ( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor ) {
+ editor.destroy();
+
+ // destroy reference
+ mw.flow.editor.editors[$.inArray( editor, mw.flow.editor.editors )] = null;
+ $node
+ .removeData( 'flow-editor' )
+ .show()
+ .closest( '.flow-editor' ).removeClass( 'flow-editor-' + editor.constructor.static.name );
+ }
+ },
+
+ /**
+ * Get the editor's text format.
+ *
+ * @param {jQuery} $node
+ * @return {string}
+ */
+ getFormat: function ( $node ) {
+ var editor;
+
+ if ( $node ) {
+ editor = mw.flow.editor.getEditor( $node );
+ }
+
+ if ( editor ) {
+ return editor.constructor.static.format;
+ } else {
+ return mw.flow.editor.editor.static.format;
+ }
+ },
+
+ /**
+ * Get the raw, unconverted, content, in the current editor's format.
+ *
+ * @param {jQuery} $node
+ * @return {string}
+ */
+ getRawContent: function ( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+ return editor.getRawContent() || '';
+ },
+
+ /**
+ * Initialize an editor object with given content & tie it to the given node.
+ *
+ * @param {jQuery} $node
+ * @param {string} content
+ * @return {object}
+ */
+ create: function ( $node, content ) {
+ $node.data( 'flow-editor', mw.flow.editor.editors.length )
+ .closest( '.flow-editor' ).addClass( 'flow-editor-' + mw.flow.editor.editor.static.name );
+
+ mw.flow.editor.editors.push( new mw.flow.editor.editor( $node, content ) );
+ return mw.flow.editor.getEditor( $node );
+ },
+
+ /**
+ * Returns editor object associated with a given node.
+ *
+ * @param {jQuery} $node
+ * @return {object}
+ */
+ getEditor: function ( $node ) {
+ return mw.flow.editor.editors[$node.data( 'flow-editor' )];
+ },
+
+ /**
+ * Returns true if the given $node has an associated editor instance.
+ *
+ * @param {jQuery} $node
+ * @return {bool}
+ */
+ exists: function ( $node ) {
+ return mw.flow.editor.editors.hasOwnProperty( $node.data( 'flow-editor' ) );
+ },
+
+ /**
+ * Changes the default editor to desiredEditor and converts $node to that
+ * type of editor.
+ *
+ * @todo Should support $node containing multiple editing nodes, such
+ * as selecting all active editors in the page and switching all of
+ * them to the desiredEditor. Currently you will need to $node.each()
+ * and call switchEditor for each iteration.
+ *
+ * @param {jQuery} $node
+ * @param {string} desiredEditor
+ * @return {jQuery.Promise} Will resolve once editor instance is loaded
+ */
+ switchEditor: function ( $node, desiredEditor ) {
+ var content, format,
+ editorList = mw.config.get( 'wgFlowEditorList' ),
+ editor = mw.flow.editor.getEditor( $node ),
+ deferred = $.Deferred(),
+ performSwitch = function () {
+ if ( mw.flow.editors[desiredEditor].static.isSupported() ) {
+ content = editor.getRawContent();
+ format = editor.constructor.static.format;
+
+ mw.flow.editor.editor = mw.flow.editors[desiredEditor];
+
+ mw.flow.editor.destroy( $node );
+ mw.flow.editor.load( $node, content, format );
+
+ deferred.resolve();
+ } else {
+ deferred.reject( 'editor-not-supported' );
+ }
+ };
+
+ if ( !editor ) {
+ // $node is not an editor
+ deferred.reject( 'not-an-editor' );
+ } else if ( editorList.indexOf( desiredEditor ) === -1 ) {
+ // desiredEditor does not exist
+ deferred.reject( 'unknown-editor-type' );
+ } else {
+ mw.loader.using( 'ext.flow.editors.' + desiredEditor ).then(
+ performSwitch,
+ function() {
+ deferred.reject( 'fail-loading-editor' );
+ }
+ );
+ }
+
+ deferred.then(
+ function() {
+ if ( !mw.user.isAnon() ) {
+ // update the user preferences; no preferences for anons
+ new mw.Api().saveOption( 'flow-editor', desiredEditor );
+ // ensure we also see that preference in the current page
+ mw.user.options.set( 'flow-editor', desiredEditor );
+ }
+ },
+ function( rejectionCode ) {
+ mw.flow.debug( '[switchEditor] Could not switch to ' + desiredEditor + ' : ' + rejectionCode );
+ }
+ );
+
+ return deferred.promise();
+ },
+
+ focus: function( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor && editor.focus ) {
+ editor.focus();
+ } else {
+ $node.focus();
+ }
+ },
+
+ moveCursorToEnd : function( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor && editor.moveCursorToEnd ) {
+ return editor.moveCursorToEnd();
+ } else {
+ $node.selectRange( $node.val().length );
+ }
+ }
+ };
+ $( mw.flow.editor.init );
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/editor/ext.flow.parsoid.js b/Flow/modules/editor/ext.flow.parsoid.js
new file mode 100644
index 00000000..837c442c
--- /dev/null
+++ b/Flow/modules/editor/ext.flow.parsoid.js
@@ -0,0 +1,46 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ mw.flow = mw.flow || {}; // create mw.flow globally
+ mw.flow.parsoid = {
+ /**
+ * @param {string} from Input format: html|wikitext
+ * @param {string} to Desired output format: html|wikitext
+ * @param {string} content Content to convert
+ * @param {string} [title] Page title
+ * @return {jQuery.Promise} Will resolve with converted content as data
+ */
+ convert: function ( from, to, content, title ) {
+ var deferred = $.Deferred(),
+ api = new mw.Api();
+
+ if ( content === '' ) {
+ return deferred.resolve( content );
+ }
+
+ if ( from === to ) {
+ return deferred.resolve( content );
+ }
+
+ if ( !title ) {
+ title = mw.config.get( 'wgPageName' );
+ }
+
+ api.post( {
+ action: 'flow-parsoid-utils',
+ from: from,
+ to: to,
+ content: content,
+ title: title
+ } )
+ .done( function ( data ) {
+ deferred.resolve( data['flow-parsoid-utils'].content );
+ } )
+ .fail( function () {
+ deferred.reject();
+ } );
+
+ return deferred.promise();
+ }
+ };
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-api-events.js b/Flow/modules/engine/components/board/base/flow-board-api-events.js
new file mode 100644
index 00000000..015cc5c9
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-api-events.js
@@ -0,0 +1,920 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentApiEventsMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentApiEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentApiEventsMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentApiEventsMixin.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * Textareas are turned into editor objects, so we can't rely on
+ * textareas to properly return the real content we're looking for (the
+ * real editor can be anything, depending on the type of editor)
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditor = function ( event ) {
+ var $textareas = $( this ).closest( 'form' ).find( 'textarea' ),
+ override = {};
+
+ $textareas.each( function() {
+ var $editor = $( this );
+
+ // Doublecheck that this textarea is actually an editor instance
+ // (the editor may have added a textarea itself...)
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ override[$editor.attr( 'name' )] = mw.flow.editor.getRawContent( $editor );
+ override.flow_format = mw.flow.editor.getFormat( $editor );
+ }
+
+ // @todo: we have to make sure we get rid of all unwanted data
+ // in the form (whatever "editor instance" may have added)
+ // because we'll $form.serializeArray() to get the content.
+ // This is currently not an issue since we only have "none"
+ // editor type, which just uses the existing textarea. Someday,
+ // however, we may have VE (or wikieditor or ...) which could
+ // add its own nodes, which may be picked up by serializeArray()
+ } );
+
+ return override;
+ };
+
+ /**
+ * When presented with an error conflict, the conflicting content can
+ * subsequently be re-submitted (to overwrite the conflicting content)
+ * This will prepare the data-to-be-submitted so that the override is
+ * submitted against the most current revision ID.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditConflict = function ( event ) {
+ var $form = $( this ).closest( 'form' ),
+ prevRevisionId = $form.data( 'flow-prev-revision' );
+
+ if ( !prevRevisionId ) {
+ return {};
+ }
+
+ // Get rid of the temp-saved new revision ID
+ $form.removeData( 'flow-prev-revision' );
+
+ /*
+ * This is prev_revision in "generic" form. Each Flow API has its
+ * own unique prefix, which (in FlowApi.prototype.getQueryMap) will
+ * be properly applied for the respective API call; e.g.
+ * epprev_revision (for edit post)
+ */
+ return {
+ flow_prev_revision: prevRevisionId
+ };
+ };
+
+ /**
+ * Before activating header, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditHeader = function () {
+ return {
+ submodule: 'view-header', // href submodule is edit-header
+ vhcontentFormat: 'wikitext' // href does not have this param
+ };
+ };
+
+ /**
+ * Before activating topic, sends an overrideObject to the API to modify the request params.
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditTitle = function ( event ) {
+ // Use view-post API for topic as well; we only want this on
+ // particular (title) post revision, not the full topic
+ return {
+ submodule: "view-post",
+ vppostId: $( this ).closest( '.flow-topic' ).data( 'flow-id' ),
+ vpcontentFormat: "wikitext"
+ };
+ };
+
+ /**
+ * Before activating post, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditPost = function ( event ) {
+ return {
+ submodule: 'view-post',
+ vppostId: $( this ).closest( '.flow-post' ).data( 'flow-id' ),
+ vpcontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Adjusts query params to use global watch action, and specifies it should use a watch token.
+ * @param {Event} event
+ * @returns {Function}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.watchItem = function ( event ) {
+ return function ( queryMap ) {
+ var params = {
+ action: 'watch',
+ titles: queryMap.page,
+ _internal: {
+ tokenType: 'watch'
+ }
+ };
+ if ( queryMap.submodule === 'unwatch' ) {
+ params.unwatch = 1;
+ }
+ return params;
+ };
+ };
+
+ /**
+ * Before activating summarize topic, sends an overrideObject to the
+ * API to modify the request params.
+ * @param {Event} event
+ * @param {Object} info
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateSummarizeTopic = function ( event, info ) {
+ if ( info.$target.find( 'form' ).length ) {
+ // Form already open; cancel the old form
+ var flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ flowBoard.emitWithReturn( 'cancelForm', info.$target );
+ return false;
+ }
+
+ return {
+ // href submodule is edit-topic-summary
+ submodule: 'view-topic-summary',
+ // href does not have this param
+ vtscontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Before activating lock/unlock edit form, sends an overrideObject
+ * to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateLockTopic = function ( event ) {
+ return {
+ // href submodule is lock-topic
+ submodule: 'view-post',
+ // href does not have this param
+ vpcontentFormat: 'wikitext',
+ // request just the data for this topic
+ vppostId: $( this ).data( 'flow-id' )
+ };
+ };
+
+ //
+ // api callback handlers
+ //
+
+ /**
+ * On complete board reprocessing through view-topiclist (eg. change topic sort order), re-render any given blocks.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.board = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = info.component,
+ dfd = $.Deferred();
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return dfd.reject().promise();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-topiclist' ].result }
+ )
+ ).children();
+
+ // Run this on a short timeout so that the other board handler in FlowBoardComponentLoadMoreFeatureMixin can run
+ // TODO: Using a timeout doesn't seem like the right way to do this.
+ setTimeout( function () {
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+ dfd.resolve();
+ }, 50 );
+
+ return dfd.promise();
+ };
+
+ /**
+ * @returns {$.Promise}
+
+ return $.Deferred().resolve().promise();
+ * Renders the editable board header with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $oldBoardNodes;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Change "header" to "header_edit" so that it loads up flow_block_header_edit
+ data.flow[ 'view-header' ].result.header.type = 'header_edit';
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-header' ].result }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns the old content back if needed
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $rendered.find( 'form' ), function () {
+ flowBoard.reinitializeContainer( $oldBoardNodes );
+ } );
+
+ // Reinitialize the whole board with these nodes, and hold onto the replaced header
+ $oldBoardNodes = flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the board header edit form, process the new header data.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Deferred}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'edit-header' ].result }
+ )
+ ).children();
+
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * Renders the editable lock/unlock text area with the given API response.
+ * Allows a user to lock or unlock an entire topic.
+ * @param {Object} info
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateLockTopic = function ( info, data ) {
+ var result, revision, postId, revisionId,
+ $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ $( this ).closest( '.flow-menu' ).removeClass( 'focus' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // FIXME: API should take care of this for me.
+ result = data.flow[ 'view-post' ].result.topic;
+ postId = result.roots[0];
+ revisionId = result.posts[postId];
+ revision = result.revisions[revisionId];
+
+ // Enable the editable summary
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar_lock.partial', revision
+ ) ).children();
+
+ // Ensure that on a cancel the form gets destroyed.
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function () {
+ // xxx: Can this use replaceWith()? If so, use it because it saves the browser
+ // from having to reflow the document view twice (once with both elements on the
+ // page and then again after its removed, which causes bugs like losing your
+ // scroll offset on long pages).
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the lock/unlock topic form, process the new summary data and re-render
+ * the title bar.
+ * @param {String} status
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.lockTopic = function ( info, data ) {
+ var $replacement,
+ $target = info.$target,
+ $this = $( this ),
+ $deferred = $.Deferred(),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ flowId = $this.closest( '.flow-topic' ).data( 'flow-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $deferred.reject().promise();
+ }
+
+ // We couldn't make lock-topic to return topic data after a successful
+ // post submission because lock-topic is used for no-js support as well.
+ // If we make it return topic data, that means it has to return wikitext format
+ // for edit form in no-js mode. This is a performance problem for wikitext
+ // conversion since topic data returns all children data as well. So we need to
+ // make lock-topic return a single post for topic then fire
+ // another request to topic data in html format
+ //
+ // @todo the html could json encode the parameters including topics, the js
+ // could then import that and continuously update it with new revisions from
+ // api calls. Rendering a topic would then just be pointing the template at
+ // the right part of that data instead of requesting it.
+ flowBoard.Api.apiCall( {
+ action: 'flow',
+ submodule: 'view-topic',
+ workflow: flowId,
+ // Flow topic title, in Topic:<topicId> format (2600 is topic namespace id)
+ page: mw.Title.newFromText( flowId, 2600 ).getPrefixedDb()
+ // @todo fixme
+ // - mw.Title.newFromText can return null. If you're not going to check its return
+ // value, use 'new mw.Title' instead so that you get an exception for 'invalid title'
+ // instead of an exception for 'property of null'.
+ // - The second parameter to mw.Title is 'defaultNamespace' not 'namespace'.
+ // E.g. mw.Title.newFromText( 'User:Example', 6 ) -> 'User:Example', not 'File:
+ // If you need to prefix/enforce a namespace, use the canonical prefix instead.
+ } ).done( function( result ) {
+ // Update view of the full topic
+ $replacement = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ result.flow['view-topic'].result.topic
+ ) ).children();
+
+ $target.replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+
+ $deferred.resolve();
+ } ).fail( function( code, result ) {
+ /*
+ * At this point, the lock/unlock actually worked, but failed
+ * fetching the new data to be displayed.
+ */
+ flowBoard.emitWithReturn( 'removeError', $target );
+ var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ errorMsg = mw.msg( 'flow-error-fetch-after-open-lock', errorMsg );
+ flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+
+ $deferred.reject();
+ } );
+
+ return $deferred.promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitTopicTitle = function( info, data, jqxhr ) {
+ var
+ topicData,
+ rootId,
+ revisionId,
+ $this = $( this ),
+ $topic = info.$target,
+ $oldTopicTitleBar, $newTopicTitleBar,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $oldTopicTitleBar = $topic.find( '.flow-topic-titlebar' );
+ topicData = data.flow['edit-title'].result.topic;
+ rootId = topicData.roots[0];
+ revisionId = topicData.posts[rootId][0];
+ $newTopicTitleBar = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar.partial',
+ topicData.revisions[revisionId]
+ ) ).children();
+
+ $oldTopicTitleBar
+ .replaceWith( $newTopicTitleBar );
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newTopicTitleBar );
+
+ $newTopicTitleBar.conditionalScrollIntoView();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the topic title edit form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitEditPost = function( info, data, jqxhr ) {
+ var result;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ result = data.flow['edit-post'].result.topic;
+ // clear out submitted data, otherwise it would re-trigger an edit
+ // form in the refreshed topic
+ result.submitted = {};
+
+ _flowBoardComponentRefreshTopic( info.$target, result );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submitting a new topic, process the response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.newTopic = function ( info, data, jqxhr ) {
+ var result, fragment,
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ result = data.flow['new-topic'].result.topiclist;
+
+ // render only the new topic
+ result.roots = [result.roots[0]];
+ fragment = mw.flow.TemplateEngine.processTemplateGetFragment( 'flow_topiclist_loop.partial', result );
+
+ flowBoard.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // Everything must be reset before re-initializing
+ // @todo un-hardcode
+ flowBoard.reinitializeContainer(
+ flowBoard.$container.find( '.flow-topics' ).prepend( fragment )
+ );
+
+ // remove focus - title input field may still have focus
+ // (submitted via enter key), which it needs to lose:
+ // the form will only re-activate if re-focused
+ document.activeElement.blur();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitReply = function ( info, data, jqxhr ) {
+ var $form = $( this ).closest( 'form' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ // Execute cancel callback to destroy form
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ // Target should be flow-topic
+ _flowBoardComponentRefreshTopic( info.$target, data.flow.reply.result.topic );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.watchItem = function ( info, data, jqxhr ) {
+ var watchUrl, unwatchUrl,
+ watchType, watchLinkTemplate, $newLink,
+ $target = $( this ),
+ $tooltipTarget = $target.closest( '.flow-watch-link' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $tooltipTarget ),
+ isWatched = false,
+ url = $( this ).prop( 'href' ),
+ links = {};
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ if ( $tooltipTarget.is( '.flow-topic-watchlist' ) ) {
+ watchType = 'topic';
+ watchLinkTemplate = 'flow_topic_titlebar_watch.partial';
+ }
+
+ if ( data.watch[0].watched !== undefined ) {
+ unwatchUrl = url.replace( 'watch', 'unwatch' );
+ watchUrl = url;
+ isWatched = true;
+ } else {
+ watchUrl = url.replace( 'unwatch', 'watch' );
+ unwatchUrl = url;
+ }
+ links['unwatch-' + watchType] = { url : unwatchUrl };
+ links['watch-' + watchType] = { url : watchUrl };
+
+ // Render new icon
+ // This will hide any tooltips if present
+ $newLink = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ watchLinkTemplate,
+ {
+ isWatched: isWatched,
+ links: links,
+ watchable: true
+ }
+ )
+ ).children();
+ $tooltipTarget.replaceWith( $newLink );
+
+ if ( data.watch[0].watched !== undefined ) {
+ // Successful watch: show tooltip
+ flowBoard.emitWithReturn( 'showSubscribedTooltip', $newLink.find( '.wikiglyph' ), watchType );
+ }
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Activate the editable summarize topic form with given api request
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateSummarizeTopic = function ( info, data, jqxhr ) {
+ var $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Create the new topic_summary_edit template
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_topicsummary_edit',
+ data.flow[ 'view-topic-summary' ].result.topicsummary
+ ) ).children();
+
+ // On cancel, put the old topicsummary back
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function() {
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the summarize topic edit form, process the new topic summary data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.summarizeTopic = function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ _flowBoardComponentRefreshTopic(
+ info.$target,
+ data.flow['edit-topic-summary'].result.topic,
+ '.flow-topic-titlebar'
+ );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Shows the form for editing a topic title, it's not already showing.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditTitle = function ( info, data, jqxhr ) {
+ var flowBoard, $form, cancelCallback,
+ $link = $( this ),
+ activeClass = 'flow-topic-title-activate-edit',
+ rootBlock = data.flow['view-post'].result.topic,
+ revision = rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]];
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $form = info.$target.find( 'form' );
+
+ if ( $form.length === 0 ) {
+ // Add class to identify title is being edited (so we can hide the
+ // current title in CSS)
+ info.$target.addClass( activeClass );
+
+ cancelCallback = function() {
+ $form.remove();
+ info.$target.removeClass( activeClass );
+ };
+
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $link );
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_topic_title.partial',
+ {
+ 'actions' : {
+ 'edit' : {
+ 'url' : $link.attr( 'href' )
+ }
+ },
+ 'content': {
+ 'content' : revision.content.content
+ },
+ 'revisionId' : revision.revisionId
+ }
+ ) ).children();
+
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form, cancelCallback );
+ $form
+ .data( 'flow-initial-state', 'hidden' )
+ .prependTo( info.$target );
+ }
+
+ $form.find( '.mw-ui-input' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Renders the editable post with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditPost = function ( info, data, jqxhr ) {
+ var $rendered, rootBlock,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $post = info.$target;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // The API returns with the entire topic, but we only want to render the edit form
+ // for a singular post
+ rootBlock = data.flow['view-post'].result.topic;
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_post_ajax.partial',
+ {
+ revision: rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]],
+ rootBlock: rootBlock
+ }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns to the post
+ flowBoard.emitWithReturn( 'addFormCancelCallback',
+ $rendered.find( 'form' ).addBack( 'form' ),
+ function () {
+ $rendered.replaceWith( $post );
+ }
+ );
+
+ $post.replaceWith( $rendered );
+ $rendered.find( 'textarea' ).conditionalScrollIntoView().focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Callback from the topic moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderateTopic = _genModerateHandler(
+ 'moderate-topic',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ if ( revision.isModerated && !flowBoard.constructor.static.inTopicNamespace( $target ) ) {
+ $replacement = $( $.parseHTML( mw.flow.TemplateEngine.processTemplate(
+ 'flow_moderate_topic_confirmation.partial',
+ revision
+ ) ) );
+
+ $target.closest( '.flow-topic' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ /**
+ * Callback from the post moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderatePost = _genModerateHandler(
+ 'moderate-post',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( revision.isModerated ) {
+ $replacement = $( $.parseHTML( flowBoard.constructor.static.TemplateEngine.processTemplate(
+ 'flow_moderate_post_confirmation.partial',
+ revision
+ ) ) );
+ $target.closest( '.flow-post-main' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Generate a moderation handler callback
+ *
+ * @param {string} Action to expect in api response
+ * @param {Function} Method to call on api success
+ */
+ function _genModerateHandler( action, successCallback ) {
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ return function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var result = data.flow[action].result.topic,
+ $this = $( this ),
+ $form = $this.closest( 'form' ),
+ id = result.submitted.postId || result.postId || result.roots[0],
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ successCallback.call(
+ this,
+ $form.data( 'flow-dialog-owner' ) || $form,
+ result.revisions[result.posts[id]],
+ result
+ );
+
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ return $.Deferred().resolve().promise();
+ };
+ }
+
+ /**
+ * Refreshes the titlebar of a topic given an API response.
+ * @param {jQuery} $targetElement An element in the topic.
+ * @param {Object} apiResult Plain object containing the API response to build from.
+ * @param {String} [selector] Select specific element to replace
+ */
+ function _flowBoardComponentRefreshTopic( $targetElement, apiResult, selector ) {
+ var $target = $targetElement.closest( '.flow-topic' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $targetElement ),
+ $newContent = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ apiResult
+ ) ).children();
+
+ if ( selector ) {
+ $newContent = $newContent.find( selector );
+ $target = $target.find( selector );
+ }
+
+ $target.replaceWith( $newContent );
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newContent );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentApiEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-interactive-events.js b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
new file mode 100644
index 00000000..6e10caae
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
@@ -0,0 +1,213 @@
+/*!
+ * Implements element interactive handler callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element interactive (click) handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentInteractiveEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentInteractiveEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentInteractiveEventsMixin );
+
+ FlowBoardComponentInteractiveEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // interactive handlers
+ //
+
+ /**
+ * Toggles collapse state
+ *
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.collapserCollapsibleToggle = function ( event ) {
+ var $target = $( this ).closest( '.flow-element-collapsible' ),
+ $deferred = $.Deferred();
+
+ if ( $target.is( '.flow-element-collapsed' ) ) {
+ $target.removeClass( 'flow-element-collapsed' ).addClass( 'flow-element-expanded' );
+ } else {
+ $target.addClass( 'flow-element-collapsed' ).removeClass( 'flow-element-expanded' );
+ }
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyTopic = function ( event ) {
+ var $topic = $( this ).closest( '.flow-topic' ),
+ topicId = $topic.data( 'flow-id' ),
+ component;
+
+ // The reply form is used in multiple places. This will check if it was
+ // triggered from inside the topic reply form.
+ if ( $( this ).closest( '#flow-reply-' + topicId ).length === 0 ) {
+ // Not in topic reply form
+ return $.Deferred().reject();
+ }
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( !$( this ).hasClass( 'flow-input-compressed' ) ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'reply-bottom',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-reply-form [data-role="cancel"],' +
+ '< .flow-reply-form [data-role="action"][name="preview"],' +
+ '< .flow-reply-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateNewTopic = function ( event ) {
+ var $form = $( this ).closest( '.flow-newtopic-form' ),
+ component;
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( $form.find( '.flow-input-compressed' ).length === 0 ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'new-topic',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-newtopic-form [data-role="cancel"],' +
+ '< .flow-newtopic-form [data-role="action"][name="preview"],' +
+ '< .flow-newtopic-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyPost = function ( event ) {
+ event.preventDefault();
+
+ var $form,
+ $this = $( this ),
+ topicId = $this.closest( '.flow-topic' ).data( 'flow-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ $post = $this.closest( '.flow-post' ),
+ href = $this.attr( 'href' ),
+ uri = new mw.Uri( href ),
+ postId = uri.query.topic_postId,
+ $targetPost = $( '#flow-post-' + postId ),
+ topicTitle = $post.closest( '.flow-topic' ).find( '.flow-topic-title' ).text(),
+ replyToContent = $post.find( '.flow-post-content' ).filter( ':first' ).text() || topicTitle,
+ author = $.trim( $post.find( '.flow-author' ).filter( ':first' ).find( '.mw-userlink' ).text() ),
+ $deferred = $.Deferred();
+
+ if ( $targetPost.length === 0 ) {
+ $targetPost = $( '#flow-topic-' + postId );
+ }
+
+ // forward all top level replys to the topic reply box
+ if ( $targetPost.is( '.flow-topic' ) ) {
+ $targetPost.find( '#flow-post-' + postId + '-form-content' ).trigger( 'focus' );
+ return $deferred.resolve().promise();
+ }
+
+ // Check if reply form has already been opened
+ if ( $post.data( 'flow-replying' ) ) {
+ return $deferred.reject().promise();
+ }
+ $post.data( 'flow-replying', true );
+
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_reply_form.partial',
+ // arguments can be empty: we just want an empty reply form
+ {
+ actions: {
+ reply: {
+ url: href,
+ text: mw.msg( 'flow-reply-link', author )
+ }
+ },
+ postId: postId,
+ author: {
+ name: author
+ },
+ // text for flow-reply-topic-title-placeholder placeholder
+ properties: {
+ 'topic-of-post': $.trim( replyToContent ).substr( 0, 200 )
+ },
+ // Topic:UUID
+ articleTitle: mw.config.get( 'wgFormattedNamespaces' )[2600] + ':' + topicId[0].toUpperCase() + topicId.slice(1)
+ }
+ ) ).children();
+
+ // Set the cancel callback on this form so that it gets rid of the form.
+ // We have to make sure the data attribute is added to the form; the
+ // addBack is failsafe for when form is actually the root node in $form
+ // already (there may or may not be parent containers)
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form.find( 'form' ).addBack( 'form' ), function () {
+ $post.removeData( 'flow-replying' );
+ $form.remove();
+ } );
+
+ // Add reply form below the post being replied to (WRT max depth)
+ $targetPost.children( '.flow-replies' ).append( $form );
+ $form.conditionalScrollIntoView();
+
+ // focus the input
+ $form.find('textarea').focus();
+
+ return $deferred.resolve().promise();
+ };
+
+ // @todo remove these data-flow handler forwarder callbacks when data-mwui handlers are implemented
+ $( [ 'close', 'prevOrClose', 'nextOrSubmit', 'prev', 'next' ] ).each( function ( i, fn ) {
+ // Assigns each handler with the prefix 'modal', eg. 'close' becomes 'modalClose'
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers[ 'modal' + fn.charAt(0).toUpperCase() + fn.substr( 1 ) ] = function ( event ) {
+ event.preventDefault();
+
+ // eg. call mw.Modal.close( this );
+ mw.Modal[ fn ]( this );
+ };
+ } );
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentInteractiveEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-load-events.js b/Flow/modules/engine/components/board/base/flow-board-load-events.js
new file mode 100644
index 00000000..9fe19d78
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-load-events.js
@@ -0,0 +1,42 @@
+/*!
+ * Implements element on-load callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element load handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentLoadEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadEventsMixin );
+
+ FlowBoardComponentLoadEventsMixin.UI = {
+ events: {
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Replaces $time with a new flow-timestamp element generated by TemplateEngine
+ * @param {jQuery} $time
+ */
+ FlowBoardComponentLoadEventsMixin.UI.events.loadHandlers.timestamp = function ( $time ) {
+ $time.replaceWith(
+ mw.flow.TemplateEngine.callHelper(
+ 'timestamp',
+ parseInt( $time.attr( 'datetime' ), 10) * 1000
+ )
+ );
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-misc.js b/Flow/modules/engine/components/board/base/flow-board-misc.js
new file mode 100644
index 00000000..3eb7ebcb
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-misc.js
@@ -0,0 +1,127 @@
+/*!
+ * Contains miscellaneous functionality needed for FlowBoardComponents.
+ * @todo Find a better place for this code.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentMiscMixin( $container ) {
+ }
+ OO.initClass( FlowBoardComponentMiscMixin );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Removes the preview and unhides the form fields.
+ * @param {jQuery} $cancelButton
+ * @return {bool} true if success
+ * @todo genericize into FlowComponent
+ */
+ function flowBoardComponentResetPreview( $cancelButton ) {
+ var $form = $cancelButton.closest( 'form' ),
+ $button = $form.find( '[name=preview]' ),
+ oldData = $button.data( 'flow-return-to-edit' );
+
+ if ( oldData ) {
+ // We're in preview mode. Revert it back.
+ $button.text( oldData.text );
+
+ // Show the inputs again
+ $form.find( '.flow-preview-target-hidden' ).removeClass( 'flow-preview-target-hidden' ).focus();
+
+ // Remove the preview
+ oldData.$nodes.remove();
+
+ // Remove this reset info
+ $button.removeData( 'flow-return-to-edit' );
+
+ return true;
+ }
+ return false;
+ }
+ FlowBoardComponentMiscMixin.prototype.resetPreview = flowBoardComponentResetPreview;
+
+ /**
+ * This will trigger an eventLog call to the given schema with the given
+ * parameters (along with other info about the user & page.)
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one node to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * @param {string} schemaName
+ * @param {object} data Data to be logged
+ * @param {string} data.action Schema's action parameter. Always required!
+ * @param {string} [data.entrypoint] Schema's entrypoint parameter (can be
+ * omitted if already logged in funnel - will inherit)
+ * @param {string} [data.funnelId] Schema's funnelId parameter (can be
+ * omitted if starting new funnel - will be generated)
+ * @param {jQuery} [$forward] Nodes to forward funnel to
+ * @returns {object} Logged data
+ */
+ function logEvent( schemaName, data, $forward ) {
+ var // Get existing (forwarded) funnel id, or generate a new one if it does not yet exist
+ funnelId = data.funnelId || mw.flow.FlowEventLogRegistry.generateFunnelId(),
+ // Fetch existing EventLog object for this funnel (if any)
+ eventLog = mw.flow.FlowEventLogRegistry.funnels[funnelId];
+
+ // Optional argument, may not want/need to forward funnel to other nodes
+ $forward = $forward || $();
+
+ if ( !eventLog ) {
+ // Add some more data to log!
+ data = $.extend( data, {
+ isAnon: mw.user.isAnon(),
+ sessionId: mw.user.sessionId(),
+ funnelId: funnelId,
+ pageNs: mw.config.get( 'wgNamespaceNumber' ),
+ pageTitle: ( new mw.Title( mw.config.get( 'wgPageName' ) ) ).getMain()
+ } );
+
+ // A funnel with this id does not yet exist, create it!
+ eventLog = new mw.flow.EventLog( schemaName, data );
+
+ // Store this particular eventLog - we may want to log more things
+ // in this funnel
+ mw.flow.FlowEventLogRegistry.funnels[funnelId] = eventLog;
+ }
+
+ // Log this action
+ eventLog.logEvent( { action: data.action } );
+
+ // Forward the event
+ this.forwardEvent( $forward, schemaName, funnelId );
+
+ return data;
+ }
+ FlowBoardComponentMiscMixin.prototype.logEvent = logEvent;
+
+ /**
+ * Forward funnel data to other places.
+ *
+ * @param {jQuery} $forward Nodes to forward funnel to
+ * @param {string} schemaName
+ * @param {string} funnelId Schema's funnelId parameter
+ */
+ function forwardEvent( $forward, schemaName, funnelId ) {
+ // Not using data() - it somehow gets lost on some nodes
+ $forward.attr( {
+ 'data-flow-eventlog-schema': schemaName,
+ 'data-flow-eventlog-funnel-id': funnelId
+ } );
+ }
+ FlowBoardComponentMiscMixin.prototype.forwardEvent = forwardEvent;
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentMiscMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
new file mode 100644
index 00000000..5d3c7085
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
@@ -0,0 +1,190 @@
+/*!
+ * Contains the base class for both FlowBoardComponent and FlowBoardHistoryComponent.
+ * This is functionality that is used by both types of page, but not any other components.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @constructor
+ */
+ function FlowBoardAndHistoryComponentBase( $container ) {
+ this.bindNodeHandlers( FlowBoardAndHistoryComponentBase.UI.events );
+ }
+ OO.initClass( FlowBoardAndHistoryComponentBase );
+
+ FlowBoardAndHistoryComponentBase.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ // Register
+ mw.flow.registerComponent( 'boardAndHistoryBase', FlowBoardAndHistoryComponentBase );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {boolean|jQuery}
+ */
+ FlowBoardAndHistoryComponentBase.prototype.reinitializeContainer = function ( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Progressively enhance the board and its forms
+ // @todo Needs a ~"liveUpdateComponents" method, since the functionality in makeContentInteractive needs to also run when we receive new content or update old content.
+ // @todo move form stuff
+ if ( $container.data( 'flow-component' ) !== 'board' ) {
+ // Don't do this for FlowBoardComponent, because that runs makeContentInteractive in its own reinit
+ this.emitWithReturn( 'makeContentInteractive', this );
+ }
+
+ // We don't replace anything with this method (we do with flowBoardComponentReinitializeContainer)
+ return $();
+ };
+
+ //
+ // Interactive handlers
+ //
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.moderationDialog = function ( event ) {
+ var $form,
+ $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $this ),
+ // hide, delete, suppress
+ // @todo this could just be detected from the url
+ role = $this.data( 'role' ),
+ template = $this.data( 'flow-template' ),
+ params = {
+ editToken: mw.user.tokens.get( 'editToken' ), // might be unnecessary
+ submitted: {
+ moderationState: role
+ },
+ actions: {}
+ },
+ $deferred = $.Deferred(),
+ modal;
+
+ event.preventDefault();
+
+ params.actions[role] = { url: $this.attr( 'href' ), title: $this.attr( 'title' ) };
+
+ // Render the modal itself with mw-ui-modal
+ modal = mw.Modal( {
+ open: $( mw.flow.TemplateEngine.processTemplateGetFragment( template, params ) ).children(),
+ disableCloseOnOutsideClick: true
+ } );
+
+ // @todo remove this data-flow handler forwarder when data-mwui handlers are implemented
+ // Have the events begin bubbling up from $board
+ flowComponent.assignSpawnedNode( modal.getNode(), flowComponent.$board );
+
+ // Run loadHandlers
+ flowComponent.emitWithReturn( 'makeContentInteractive', modal.getContentNode() );
+
+ // Set flowDialogOwner for API callback @todo find a better way of doing this with mw.Modal
+ $form = modal.getContentNode().find( 'form' ).data( 'flow-dialog-owner', $this );
+ // Bind the cancel callback on the form
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function () {
+ mw.Modal.close( this );
+ } );
+
+ modal = null; // avoid permanent reference
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * Cancels and closes a form. If text has been entered, issues a warning first.
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.cancelForm = function ( event ) {
+ var target = this,
+ $form = $( this ).closest( 'form' ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $form ),
+ $fields = $form.find( 'textarea, :text' ),
+ changedFieldCount = 0,
+ $deferred = $.Deferred(),
+ callbacks = $form.data( 'flow-cancel-callback' ) || [],
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ event.preventDefault();
+
+ // Only log cancel attempt if it was user-initiated, not when the cancel
+ // was triggered by code (as part of a post-submit form destroy)
+ if ( event.which ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-attempt', funnelId: funnelId } );
+ }
+
+ // Check for non-empty fields of text
+ $fields.each( function () {
+ if ( $( this ).val() !== this.defaultValue ) {
+ changedFieldCount++;
+ return false;
+ }
+ } );
+
+ // Only log if user had already entered text (= confirmation was requested)
+ if ( changedFieldCount ) {
+ if ( confirm( flowComponent.constructor.static.TemplateEngine.l10n( 'flow-cancel-warning' ) ) ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-success', funnelId: funnelId } );
+ } else {
+ flowComponent.logEvent( schemaName, { action: 'cancel-abort', funnelId: funnelId } );
+
+ // User aborted cancel, quit this function & don't destruct the form!
+ return $deferred.reject().promise();
+ }
+ }
+
+ // Reset the form content
+ $form[0].reset();
+
+ // Trigger for flow-actions-disabler
+ $form.find( 'textarea, :text' ).trigger( 'keyup' );
+
+ // Hide the form
+ flowComponent.emitWithReturn( 'hideForm', $form );
+
+ // Get rid of existing error messages
+ flowComponent.emitWithReturn( 'removeError', $form );
+
+ // Trigger the cancel callback
+ $.each( callbacks, function ( idx, fn ) {
+ fn.call( target, event );
+ } );
+
+ return $deferred.resolve().promise();
+ };
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Return true page is in topic namespace,
+ * and if $el is given, that if $el is also within .flow-post.
+ * @param {jQuery} [$el]
+ * @returns {boolean}
+ */
+ function flowBoardInTopicNamespace( $el ) {
+ return inTopicNamespace && ( !$el || $el.closest( '.flow-post' ).length === 0 );
+ }
+ FlowBoardAndHistoryComponentBase.static.inTopicNamespace = flowBoardInTopicNamespace;
+
+ var inTopicNamespace = mw.config.get( 'wgNamespaceNumber' ) === mw.config.get( 'wgNamespaceIds' ).topic;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-loadmore.js b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
new file mode 100644
index 00000000..10daaf42
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
@@ -0,0 +1,664 @@
+/*!
+ * Contains loadMore, jumpToTopic, and topic titles list functionality.
+ */
+
+( function ( $, mw, moment ) {
+ /**
+ * Bind UI events and infinite scroll handler for load more and titles list functionality.
+ * @param {jQuery} $container
+ * @this FlowBoardComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadMoreFeatureMixin( $container ) {
+ /** Stores a reference to each topic element currently on the page */
+ this.renderedTopics = {};
+ /** Stores a list of all topics titles by ID */
+ this.topicTitlesById = {};
+ /** Stores a list of all topic IDs in order */
+ this.orderedTopicIds = [];
+
+ this.bindNodeHandlers( FlowBoardComponentLoadMoreFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadMoreFeatureMixin );
+
+ FlowBoardComponentLoadMoreFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Scrolls up or down to a specific topic, and loads any topics it needs to.
+ * 1. If topic is rendered, scrolls to it.
+ * 2. Otherwise, we load the topic itself
+ * 3b. When the user scrolls up, we begin loading the topics in between.
+ * @param {String} topicId
+ */
+ function flowBoardComponentLoadMoreFeatureJumpTo( topicId ) {
+ /** @type FlowBoardComponent*/
+ var flowBoard = this, apiParameters,
+ // Scrolls to the given topic, but disables infinite scroll loading while doing so
+ _scrollWithoutInfinite = function () {
+ var $renderedTopic = flowBoard.renderedTopics[ topicId ];
+
+ if ( $renderedTopic && $renderedTopic.length ) {
+ flowBoard.infiniteScrollDisabled = true;
+
+ // Get out of the way of the affixed navigation
+ // Not going the full $( '.flow-board-navigation' ).height()
+ // because then the load more button (above the new topic)
+ // would get in sight and any scroll would fire it
+ $( 'html, body' ).scrollTop( $renderedTopic.offset().top - 20 );
+
+ // Focus on given topic
+ $renderedTopic.click().focus();
+
+ /*
+ * Re-enable infinite scroll. Only doing that after a couple
+ * of milliseconds because we've just executed some
+ * scrolling (to the selected topic) and the very last
+ * scroll event may only just still be getting fired.
+ * To prevent an immediate scroll (above the new topic),
+ * let's only re-enable infinite scroll until we're sure
+ * that event has been fired.
+ */
+ setTimeout( function() {
+ delete flowBoard.infiniteScrollDisabled;
+ }, 1 );
+ } else {
+ flowBoard.debug( 'Rendered topic not found when attempting to scroll!' );
+ }
+ };
+
+ // 1. Topic is already on the page; just scroll to it
+ if ( flowBoard.renderedTopics[ topicId ] ) {
+ _scrollWithoutInfinite();
+ return;
+ }
+
+ // 2a. Topic is not rendered; do we know about this topic ID?
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // We don't. Abort!
+ return flowBoard.debug( 'Unknown topicId', arguments );
+ }
+
+ // 2b. Load that topic and jump to it
+ apiParameters = {
+ action: 'flow',
+ submodule: 'view-topiclist',
+ 'vtloffset-dir': 'fwd', // @todo support "middle" dir
+ 'vtlinclude-offset': true,
+ vtlsortby: this.topicIdSort
+ };
+
+ if ( this.topicIdSort === 'newest' ) {
+ apiParameters['vtloffset-id'] = topicId;
+ } else {
+ // TODO: It would seem to be safer to pass 'offset-id' for both (what happens
+ // if there are two posts at the same timestamp?). (Also, that would avoid needing
+ // the timestamp in the TOC-only API response). However,
+ // apparently, we must pass 'offset' for 'updated' order to get valid
+ // results (e.g. by passing offset-id for 'updated', it doesn't even include
+ // the item requested despite include-offset). However, the server
+ // does not throw an exception for 'offset-id' + 'sortby'='updated', which it
+ // should if this analysis is correct.
+
+ apiParameters.vtloffset = moment.utc( this.updateTimestampsByTopicId[ topicId ] ).format( 'YYYYMMDDHHmmss' );
+ }
+
+ flowBoard.Api.apiCall( apiParameters )
+ // TODO: Finish this error handling or remove the empty functions.
+ // Remove the load indicator
+ .always( function () {
+ // @todo support for multiple indicators on same target
+ //$target.removeClass( 'flow-api-inprogress' );
+ //$this.removeClass( 'flow-api-inprogress' );
+ } )
+ // On success, render the topic
+ .done( function( data ) {
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ data.flow[ 'view-topiclist' ].result.topiclist,
+ false,
+ null,
+ '',
+ '',
+ 'flow_topiclist_loop' // @todo clean up the way we pass these 3 params ^
+ );
+
+ _scrollWithoutInfinite();
+ } )
+ // On fail, render an error
+ .fail( function( code, data ) {
+ flowBoard.debug( true, 'Failed to load topics: ' + code );
+ // Failed fetching the new data to be displayed.
+ // @todo render the error at topic position and scroll to it
+ // @todo how do we render this?
+ // $target = ????
+ // flowBoard.emitWithReturn( 'removeError', $target );
+ // var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ // errorMsg = mw.msg( '????', errorMsg );
+ // flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.prototype.jumpToTopic = flowBoardComponentLoadMoreFeatureJumpTo;
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * On before board reloading (eg. change sort).
+ * This method only clears the storage in preparation for it to be reloaded.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiPreHandler( event, info ) {
+ // Backup the topic data
+ info.component.renderedTopicsBackup = info.component.renderedTopics;
+ info.component.topicTitlesByIdBackup = info.component.topicTitlesById;
+ info.component.orderedTopicIdsBackup = info.component.orderedTopicIds;
+ // Reset the topic data
+ info.component.renderedTopics = {};
+ info.component.topicTitlesById = {};
+ info.component.orderedTopicIds = [];
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiPreHandler;
+
+ //
+ // API callback handlers
+ //
+
+ /**
+ * On failed board reloading (eg. change sort), restore old data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.renderedTopics = info.component.renderedTopicsBackup;
+ info.component.topicTitlesById = info.component.topicTitlesByIdBackup;
+ info.component.orderedTopicIds = info.component.orderedTopicIdsBackup;
+ }
+
+ // Delete the backups
+ delete info.component.renderedTopicsBackup;
+ delete info.component.topicTitlesByIdBackup;
+ delete info.component.orderedTopicIdsBackup;
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiCallback;
+
+ /**
+ * Loads more content
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicsApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var $this = $( this ),
+ $target = info.$target,
+ flowBoard = info.component,
+ scrollTarget = $this.data( 'flow-scroll-target' ),
+ $scrollContainer = $.findWithParent( $this, $this.data( 'flow-scroll-container' ) ),
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ readingTopicPosition;
+
+ if ( scrollTarget === 'window' && flowBoard.readingTopicId ) {
+ // Store the current position of the topic you are reading
+ readingTopicPosition = { id: flowBoard.readingTopicId };
+ // Where does the topic start?
+ readingTopicPosition.topicStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ // Where am I within the topic?
+ readingTopicPosition.topicPlace = $( window ).scrollTop() - readingTopicPosition.topicStart;
+ }
+
+ // Render topics
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ topicsData,
+ flowBoard.$container.find( flowBoard.$loadMoreNodes ).last()[ 0 ] === this, // if this is the last load more button
+ $target,
+ scrollTarget,
+ $this.data( 'flow-scroll-container' ),
+ $this.data( 'flow-template' )
+ );
+
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $target.remove();
+
+ if ( scrollTarget === 'window' ) {
+ scrollTarget = $( window );
+
+ if ( readingTopicPosition ) {
+ readingTopicPosition.anuStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ if ( readingTopicPosition.anuStart > readingTopicPosition.topicStart ) {
+ // Looks like the topic we are reading got pushed down. Let's jump to where we were before
+ scrollTarget.scrollTop( readingTopicPosition.anuStart + readingTopicPosition.topicPlace );
+ }
+ }
+ } else {
+ scrollTarget = $.findWithParent( this, scrollTarget );
+ }
+
+ /*
+ * Fire infinite scroll check again - if no (or few) topics were
+ * added (e.g. because they're moderated), we should immediately
+ * fetch more instead of waiting for the user to scroll again (when
+ * there's no reason to scroll)
+ */
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( flowBoard, $scrollContainer, scrollTarget );
+ return $.Deferred().resolve().promise();
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.loadMoreTopics = flowBoardComponentLoadMoreFeatureTopicsApiCallback;
+
+ /**
+ * Loads up the topic titles list.
+ * Saves the topic titles to topicTitlesById and orderedTopicIds, and adds timestamps
+ * to updateTimestampsByTopicId.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicListApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var i = 0,
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ topicId, revisionId,
+ flowBoard = info.component;
+
+ // Iterate over every topic
+ for ( ; i < topicsData.roots.length; i++ ) {
+ // Get the topic ID
+ topicId = topicsData.roots[ i ];
+ // Get the revision ID
+ revisionId = topicsData.posts[ topicId ][0];
+
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ // Append to the end, we will sort after the insert loop.
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // Store the title from the revision object
+ flowBoard.topicTitlesById[ topicId ] = topicsData.revisions[ revisionId ].content.content;
+ }
+
+ if ( flowBoard.updateTimestampsByTopicId[ topicId ] === undefined ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[ revisionId ].last_updated;
+ }
+ }
+
+ _flowBoardSortTopicIds( flowBoard );
+
+ // we need to re-trigger scroll.flow-load-more if there are not enough items in the
+ // toc for it to scroll and trigger on its own. Without this TOC never triggers
+ // the initial loadmore to expand from the number of topics on page to topics
+ // available from the api.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentLoadMoreFeatureTopicListApiCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Stores the load more button for use with infinite scroll.
+ * @example <button data-flow-scroll-target="< ul"></button>
+ * @param {jQuery} $button
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadCallback( $button ) {
+ var scrollTargetSelector = $button.data( 'flow-scroll-target' ),
+ $target,
+ scrollContainerSelector = $button.data( 'flow-scroll-container' ),
+ $scrollContainer = $.findWithParent( $button, scrollContainerSelector ),
+ board = this;
+
+ if ( !this.$loadMoreNodes ) {
+ // Create a new $loadMoreNodes list
+ this.$loadMoreNodes = $();
+ } else {
+ // Remove any loadMore nodes that are no longer in the body
+ this.$loadMoreNodes = this.$loadMoreNodes.filter( function () {
+ var $this = $( this );
+
+ // @todo unbind scroll handlers
+ if ( !$this.closest( 'body' ).length ) {
+ // Get rid of this and its handlers
+ $this.remove();
+ // Delete from list
+ return false;
+ }
+
+ return true;
+ } );
+ }
+
+ // Store this new loadMore node
+ this.$loadMoreNodes = this.$loadMoreNodes.add( $button );
+
+ // Make sure we didn't already bind to this element's scroll previously
+ if ( $scrollContainer.data( 'scrollIsBound' ) ) {
+ return;
+ }
+ $scrollContainer.data( 'scrollIsBound', true );
+
+ // Bind the event for this
+ if ( scrollTargetSelector === 'window' ) {
+ this.on( 'windowScroll', function () {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $( window ) );
+ } );
+ } else {
+ $target = $.findWithParent( $button, scrollTargetSelector );
+ $target.on( 'scroll.flow-load-more', $.throttle( 50, function ( evt ) {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $target );
+ } ) );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.loadMore = flowBoardComponentLoadMoreFeatureElementLoadCallback;
+
+ /**
+ * Stores a list of all topics currently visible on the page.
+ * @param {jQuery} $topic
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopic( $topic ) {
+ var self = this,
+ currentTopicId = $topic.data( 'flow-id' );
+
+ // Store this topic by ID
+ this.renderedTopics[ currentTopicId ] = $topic;
+
+ // Remove any topics that are no longer on the page, just in case
+ $.each( this.renderedTopics, function ( topicId, $topic ) {
+ if ( !$topic.closest( self.$board ).length ) {
+ delete self.renderedTopics[ topicId ];
+ }
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topic = flowBoardComponentLoadMoreFeatureElementLoadTopic;
+
+ /**
+ * Stores a list of all topics titles currently visible on the page.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+
+ // If topic doesn't exist in topic titles list, add it (only happens at page load)
+ // @todo this puts the wrong order
+ if ( this.topicTitlesById[ currentTopicId ] === undefined ) {
+ this.topicTitlesById[ currentTopicId ] = $topicTitle.data( 'flow-topic-title' );
+
+ if ( $.inArray( currentTopicId, this.orderedTopicIds ) === -1 ) {
+ this.orderedTopicIds.push( currentTopicId );
+ _flowBoardSortTopicIds( this );
+ }
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentLoadMoreFeatureElementLoadTopicTitle;
+
+ //
+ // Private functions
+ //
+
+
+ /**
+ * Re-sorts the orderedTopicIds after insert
+ *
+ * @param {Object} flowBoard
+ */
+ function _flowBoardSortTopicIds( flowBoard ) {
+ if ( flowBoard.topicIdSortCallback ) {
+ // Custom sorts
+ flowBoard.orderedTopicIds.sort( flowBoard.topicIdSortCallback );
+ } else {
+ // Default sort, takes advantage of topic ids monotonically increasing
+ // which allows for the newest sort to be the default utf-8 string sort
+ // in reverse.
+ // TODO: This can be optimized (to avoid two in-place operations that affect
+ // the whole array by doing a descending sort (with a custom comparator)
+ // rather than sorting then reversing.
+ flowBoard.orderedTopicIds.sort().reverse();
+ }
+ }
+
+ /**
+ * Called on scroll. Checks to see if a FlowBoard needs to have more content loaded.
+ * @param {jQuery} $searchContainer Container to find 'load more' buttons in
+ * @param {jQuery} $calculationContainer Container to do scroll calculations on (height, scrollTop, offset, etc.)
+ */
+ function _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck( $searchContainer, $calculationContainer ) {
+ if ( this.infiniteScrollDisabled ) {
+ // This happens when the topic navigation is used to jump to a topic
+ // We should not infinite-load anything when we are scrolling to a topic
+ return;
+ }
+
+ var calculationContainerHeight = $calculationContainer.height(),
+ calculationContainerScroll = $calculationContainer.scrollTop(),
+ calculationContainerThreshold = ( $calculationContainer.offset() || { top: calculationContainerScroll } ).top;
+
+ // Find load more buttons within our search container, and they must be visible
+ $searchContainer.find( this.$loadMoreNodes ).filter( ':visible' ).each( function () {
+ var $this = $( this ),
+ nodeOffset = $this.offset().top,
+ nodeHeight = $this.outerHeight( true );
+
+ // First, is this element above or below us?
+ if ( nodeOffset <= calculationContainerThreshold ) {
+ // Top of element is above the viewport; don't use it.
+ return;
+ }
+
+ // @todo: this ignores that TOC also obscures the button: load more
+ // also shouldn't be triggered if it's still behind TOC!
+
+ // Is this element in the viewport?
+ if ( nodeOffset - nodeHeight <= calculationContainerThreshold + calculationContainerHeight ) {
+ // Element is almost in viewport, click it.
+ $( this ).trigger( 'click' );
+ }
+ } );
+ }
+
+ /**
+ * Renders and inserts a list of new topics.
+ * @param {FlowBoardComponent} flowBoard
+ * @param {Object} topicsData
+ * @param {boolean} [forceShowLoadMore]
+ * @param {jQuery} [$insertAt]
+ * @param {String} [scrollTarget]
+ * @param {String} [scrollContainer]
+ * @param {String} [scrollTemplate]
+ * @private
+ */
+ function _flowBoardComponentLoadMoreFeatureRenderTopics( flowBoard, topicsData, forceShowLoadMore, $insertAt, scrollTarget, scrollContainer, scrollTemplate ) {
+ if ( !topicsData.roots.length ) {
+ flowBoard.debug( 'No topics returned from API', arguments );
+ return;
+ }
+
+ /** @private
+ */
+ function _createRevPagination( $target ) {
+ if ( !topicsData.links.pagination.fwd && !topicsData.links.pagination.rev ) {
+ return;
+ }
+
+ if ( !topicsData.links.pagination.rev && topicsData.links.pagination.fwd ) {
+ // This is a fix for the fact that a "rev" is not available here (TODO: Why not?)
+ // We can create one by overriding dir=rev
+ topicsData.links.pagination.rev = $.extend( true, {}, topicsData.links.pagination.fwd, { title: 'rev' } );
+ topicsData.links.pagination.rev.url = topicsData.links.pagination.rev.url.replace( '_offset-dir=fwd', '_offset-dir=rev' );
+ }
+
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.rev,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertBefore( $target.first() )
+ );
+ }
+
+ /** @private
+ */
+ function _createFwdPagination( $target ) {
+ if ( forceShowLoadMore || topicsData.links.pagination.fwd ) {
+ // Add the load more to the end of the stack
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.fwd,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertAfter( $target.last() )
+ );
+ }
+ }
+
+ /**
+ * Renders topics by IDs from topicsData, and returns the elements.
+ * @param {Array} toRender List of topic IDs in topicsData
+ * @returns {jQuery}
+ * @private
+ */
+ function _render( toRender ) {
+ var rootsBackup = topicsData.roots,
+ $newTopics;
+
+ // Temporarily set roots to our subset to be rendered
+ topicsData.roots = toRender;
+
+ try {
+ $newTopics = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ scrollTemplate,
+ topicsData
+ ) ).children();
+ } catch( e ) {
+ flowBoard.debug( true, 'Failed to render new topic' );
+ $newTopics = $();
+ }
+
+ topicsData.roots = rootsBackup;
+
+ return $newTopics;
+ }
+
+ var i, j, $topic, topicId,
+ $allRendered = $( [] ),
+ toInsert = [];
+
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ topicId = topicsData.roots[ i ];
+
+ if ( !flowBoard.renderedTopics[ topicId ] ) {
+ flowBoard.renderedTopics[ topicId ] = _render( [ topicId ] );
+ $allRendered.push( flowBoard.renderedTopics[ topicId ][0] );
+ toInsert.push( topicId );
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+ // @todo this is already done elsewhere, but it runs after insert
+ // to the DOM instead of before. Not sure how to fix ordering.
+ if ( !flowBoard.updateTimestampsByTopicId[ topicId ] ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[topicsData.posts[topicId][0]].last_updated;
+ }
+ }
+ }
+
+ if ( toInsert.length ) {
+ _flowBoardSortTopicIds( flowBoard );
+
+ // This uses the assumption that there will be at least one pre-existing
+ // topic above the topics to be inserted. This should hold true as the
+ // initial page load starts at the begining.
+ for ( i = 1; i < flowBoard.orderedTopicIds.length; i++ ) {
+ // topic is not to be inserted yet.
+ if ( $.inArray( flowBoard.orderedTopicIds[ i ], toInsert ) === -1 ) {
+ continue;
+ }
+
+ // find the most recent topic in the list that exists and insert after it.
+ for ( j = i - 1; j >= 0; j-- ) {
+ $topic = flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ j ] ];
+ if ( $topic && $topic.length && $.contains( document.body, $topic[0] ) ) {
+ break;
+ }
+ }
+
+ // Put the new topic after the found topic above it
+ if ( j >= 0 ) {
+ $topic.after( flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i ] ] );
+ }
+ }
+
+ // This works because orderedTopicIds includes not only the topics on
+ // page but also the ones loaded by the toc. If these topics are due
+ // to a jump rather than forward auto-pagination the prior topic will
+ // not be rendered.
+ i = $.inArray( topicsData.roots[0], flowBoard.orderedTopicIds );
+ if ( i > 0 && flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i - 1 ] ] === undefined ) {
+ _createRevPagination( flowBoard.renderedTopics[ topicsData.roots[0] ] );
+ }
+ // Same for forward pagination, if we jumped and then scrolled backwards the
+ // topic after the last will already be rendered, and forward pagination
+ // will not be necessary.
+ i = $.inArray( topicsData.roots[ topicsData.roots.length - 1 ], flowBoard.orderedTopicIds );
+ if ( i === flowBoard.orderedTopicIds.length - 1 || flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i + 1 ] ] === undefined ) {
+ _createFwdPagination( flowBoard.renderedTopics[ topicsData.roots[ topicsData.roots.length - 1 ] ] );
+ }
+ }
+
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $allRendered );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadMoreFeatureMixin );
+}( jQuery, mediaWiki, moment ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-navigation.js b/Flow/modules/engine/components/board/features/flow-board-navigation.js
new file mode 100644
index 00000000..7b82230f
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-navigation.js
@@ -0,0 +1,282 @@
+/*!
+ * Contains board navigation header, which affixes to the viewport on scroll.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for the board header itself.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentBoardHeaderFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentBoardHeaderFeatureMixin.UI.events );
+
+ /** {String} topic ID currently being read in viewport */
+ this.readingTopicId = null;
+
+ /** {Object} Map from topic id to its last update timestamp for sorting */
+ this.updateTimestampsByTopicId = {};
+ }
+ OO.initClass( FlowBoardComponentBoardHeaderFeatureMixin );
+
+ FlowBoardComponentBoardHeaderFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ //
+ // API pre-handlers
+ //
+
+ //
+ // On element-click handlers
+ //
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Bind the navigation header bar to the window.scroll event.
+ * @param {jQuery} $boardNavigation
+ */
+ function flowBoardLoadEventsBoardNavigation( $boardNavigation ) {
+ this
+ .off( 'windowScroll', _flowBoardAdjustTopicNavigationHeader )
+ .off( 'windowResize', _flowBoardAdjustTopicNavigationHeader )
+ .on( 'windowScroll', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] )
+ .on( 'windowResize', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] );
+
+ // remove any existing state about the affixed navigation, it has been replaced
+ // with a new $boardNavigation to clone from.
+ if ( this.$boardNavigationClone ) {
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+ // The topic navigation header becomes fixed to the window beyond its position
+ _flowBoardAdjustTopicNavigationHeader.call( this, $boardNavigation, {} );
+
+ // initialize the board topicId sorting callback. This expects to be rendered
+ // as a sibling of the topiclist component. The topiclist component includes
+ // information about how it is currently sorted, so we can maintain that in the
+ // TOC. This is typically either 'newest' or 'updated'.
+ this.topicIdSort = $boardNavigation.siblings('[data-flow-sortby]').data( 'flow-sortby' );
+ this.updateTopicIdSortCallback();
+
+ // This allows the toc to initialize eagerly before the user looks at it.
+ $boardNavigation.find( '[data-flow-api-handler=topicList]' )
+ .trigger( 'click', { skipMenuToggle: true, forceNavigationUpdate: true } );
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigation = flowBoardLoadEventsBoardNavigation;
+
+ /**
+ * Stores the board navigation title.
+ * @param {jQuery} $boardNavigationTitle
+ */
+ function flowBoardLoadEventsBoardNavigationTitle( $boardNavigationTitle ) {
+ this.boardNavigationOriginalTitle = $boardNavigationTitle.text();
+ this.$boardNavigationTitle = $boardNavigationTitle;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigationTitle = flowBoardLoadEventsBoardNavigationTitle;
+
+ /**
+ * @param {jQuery} $topic
+ */
+ function flowBoardLoadEventsTopic( $topic ) {
+ var id = $topic.data( 'flow-id' ),
+ updated = $topic.data( 'flow-topic-timestamp-updated' );
+
+ this.updateTimestampsByTopicId[id] = updated;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.topic = flowBoardLoadEventsTopic;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Initialize the topic id sort callback
+ */
+ function _flowBoardUpdateTopicIdSortCallback() {
+ if ( this.topicIdSort === 'newest' ) {
+ // the sort callback takes advantage of the default utf-8
+ // sort in this case
+ this.topicIdSortCallback = undefined;
+ } else if ( this.topicIdSort === 'updated' ) {
+ this.topicIdSortCallback = flowBoardTopicIdGenerateSortRecentlyActive( this );
+ } else {
+ throw new Error( 'this.topicIdSort has an invalid value' );
+ }
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.prototype.updateTopicIdSortCallback = _flowBoardUpdateTopicIdSortCallback;
+
+ /**
+ * Generates Array#sort callback for sorting a list of topic ids
+ * by the 'recently active' sort order. This is a numerical
+ * comparison of related timestamps held within the board object.
+ * Also note that this is a reverse sort from newest to oldest.
+ * @param {Object} board Object from which to source
+ * timestamps which map from topicId to its last updated timestamp
+ * @return {Function}
+ */
+ function flowBoardTopicIdGenerateSortRecentlyActive( board ) {
+ /**
+ * @param {String} a
+ * @param {String} b
+ * @return {integer} Per Array#sort callback rules
+ */
+ return function ( a, b ) {
+ var aTimestamp = board.updateTimestampsByTopicId[a],
+ bTimestamp = board.updateTimestampsByTopicId[b];
+
+ if ( aTimestamp === undefined && bTimestamp === undefined ) {
+ return 0;
+ } else if ( aTimestamp === undefined ) {
+ return 1;
+ } else if ( bTimestamp === undefined ) {
+ return -1;
+ } else {
+ return bTimestamp - aTimestamp;
+ }
+ };
+ }
+
+ // TODO: Let's look at decoupling the event handler part from the parts that actually do the
+ // work. (Already, event is not used.)
+ /**
+ * On window.scroll, we clone the nav header bar and fix the original to the window top.
+ * We clone so that we have one which always remains in the same place for calculation purposes,
+ * as it can vary depending on whether or not new content is rendered or the window is resized.
+ * @param {jQuery} $boardNavigation board navigation element
+ * @param {Event} event Event passed to windowScroll (unused)
+ * @param {Object} extraParameters
+ * @param {boolean} extraParameters.forceNavigationUpdate True to force a change to the
+ * active item and TOC scroll.
+ */
+ function _flowBoardAdjustTopicNavigationHeader( $boardNavigation, event, extraParameters ) {
+ var bottomScrollPosition, topicText, newReadingTopicId,
+ self = this,
+ boardNavigationPosition = ( this.$boardNavigationClone || $boardNavigation ).offset(),
+ windowVerticalScroll = $( window ).scrollTop();
+
+ extraParameters = extraParameters || {};
+
+ if ( windowVerticalScroll <= boardNavigationPosition.top ) {
+ // Board nav is still in view; don't affix it
+ if ( this.$boardNavigationClone ) {
+ // Un-affix this
+ $boardNavigation
+ .removeClass( 'flow-board-navigation-affixed' )
+ .css( 'left', '' );
+ // Remove the old clone if it exists
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+
+ if ( this.boardNavigationOriginalTitle && this.$boardNavigationTitle ) {
+ this.$boardNavigationTitle.text( this.boardNavigationOriginalTitle );
+ }
+
+ return;
+ }
+
+ if ( !this.$boardNavigationClone ) {
+ // Make a new clone
+ this.$boardNavigationClone = $boardNavigation.clone();
+
+ // Add new classes, and remove the main load handler so we don't trigger it again
+ this.$boardNavigationClone
+ .removeData( 'flow-load-handler' )
+ .removeClass( 'flow-load-interactive' )
+ // Also get rid of any menus, in case they were open
+ .find( '.flow-menu' )
+ .remove();
+
+ $boardNavigation
+ // Insert it
+ .before( this.$boardNavigationClone )
+ // Affix the original one
+ .addClass( 'flow-board-navigation-affixed' );
+
+ // After cloning a new navigation we must always update the sort
+ extraParameters.forceNavigationUpdate = true;
+ }
+
+ boardNavigationPosition = this.$boardNavigationClone.offset();
+
+ // The only thing that needs calculating is its left offset
+ if ( parseInt( $boardNavigation.css( 'left' ) ) !== boardNavigationPosition.left ) {
+ $boardNavigation.css( {
+ left: boardNavigationPosition.left
+ } );
+ }
+
+ // Find out what the bottom of the board nav is touching
+ // XXX: One of the IE 8 problems seems to be that $boardNavigation.outerHeight( true ) is about 10 times too big (35 on Firefox, 349 on IE 8).
+ // I think this also causes visual problems.
+ bottomScrollPosition = windowVerticalScroll + $boardNavigation.outerHeight( true );
+
+ $.each( this.orderedTopicIds || [], function ( idx, topicId, $topic ) {
+ $topic = self.renderedTopics[ topicId ];
+
+ if ( !$topic ) {
+ return;
+ }
+
+ var target = $topic.data( 'flow-toc-scroll-target' ),
+ $target = $.findWithParent( $topic, target );
+
+ if ( $target.offset().top - parseInt( $target.css( "padding-top" ) ) > bottomScrollPosition ) {
+ return false; // stop, this topic is too far
+ }
+
+ topicText = self.topicTitlesById[ topicId ];
+ newReadingTopicId = topicId;
+ } );
+
+ self.readingTopicId = newReadingTopicId;
+
+ if ( !this.$boardNavigationTitle ) {
+ return;
+ }
+
+ function calculateUpdatedTitleText( board, topicText ) {
+ // Find out if we need to change the title
+ if ( topicText !== undefined ) {
+ if ( board.$boardNavigationTitle.text() !== topicText ) {
+ // Change it
+ return topicText;
+ }
+ } else if ( board.$boardNavigationTitle.text() !== board.boardNavigationOriginalTitle ) {
+ return board.boardNavigationOriginalTitle;
+ }
+ }
+
+ // We still need to trigger movemnt when the topic title has not changed
+ // in instances where new data has been loaded.
+ topicText = calculateUpdatedTitleText( this, topicText );
+ if ( topicText !== undefined ) {
+ // We only reach this if the visible topic has changed
+ this.$boardNavigationTitle.text( topicText );
+ } else if ( extraParameters.forceNavigationUpdate !== true ) {
+ // If the visible topic has not changed and we are not forced
+ // to update(due to new items or other situations), exit early.
+ return;
+ }
+
+ this.scrollTocToActiveItem();
+ }
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentBoardHeaderFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-preview.js b/Flow/modules/engine/components/board/features/flow-board-preview.js
new file mode 100644
index 00000000..c706acf7
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-preview.js
@@ -0,0 +1,237 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentPreviewMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentPreviewMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentPreviewMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentPreviewMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * First, resets the previous preview (if any).
+ * Then, using the form fields, finds the content element to be sent to Parsoid by looking
+ * for one ending in "content", or, failing that, with data-role=content.
+ * @param {Event} event The event being handled
+ * @return {Function} Callback to modify the API request
+ * @todo genericize into FlowComponent
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiPreHandlers.preview = function ( event ) {
+ var callback,
+ $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-api-target' ) ),
+ previewTitleGenerator = $target.data( 'flow-preview-title-generator' ),
+ previewTitle = $target.data( 'flow-preview-title' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ schemaName = $this.data( 'flow-eventlog-schema' ),
+ funnelId = $this.data( 'flow-eventlog-funnel-id' ),
+ logAction = $this.data( 'flow-return-to-edit' ) ? 'keep-editing' : 'preview',
+ generators = {
+ newTopic: function() {
+ // Convert current timestamp to base-2
+ var namespace = mw.config.get( 'wgFormattedNamespaces' )[2600],
+ timestamp = mw.flow.baseConvert( Date.now(), 10, 2 );
+ // Pad base-2 out to 88 bits (@todo why 84?)
+ timestamp += [ 84 - timestamp.length ].join( '0' );
+ // convert base-2 to base-36
+ return namespace + ':' + mw.flow.baseConvert( timestamp, 2, 36 );
+ },
+ wgPageName: function() {
+ return mw.config.get( 'wgPageName' );
+ }
+ };
+
+ if ( !previewTitleGenerator || !generators.hasOwnProperty( previewTitleGenerator ) ) {
+ previewTitleGenerator = 'wgPageName';
+ }
+
+ flowBoard.logEvent( schemaName, { action: logAction, funnelId: funnelId } );
+
+ callback = function ( queryMap ) {
+ var content = null;
+
+ // XXX: Find the content parameter
+ $.each( queryMap, function( key, value ) {
+ var piece = key.slice( -7 );
+ if ( piece === 'content' || piece === 'summary' ) {
+ content = value;
+ return false;
+ }
+ } );
+
+ // If we fail to find a content param, look for a field that is the "content" role and use that
+ if ( content === null ) {
+ content = $this.closest( 'form' ).find( 'input, textarea' ).filter( '[data-role="content"]' ).val();
+ }
+
+ queryMap = {
+ 'action': 'flow-parsoid-utils',
+ 'from': 'wikitext',
+ 'to': 'html',
+ 'content': content
+ };
+
+ if ( previewTitle ) {
+ queryMap.title = previewTitle;
+ } else {
+ queryMap.title = generators[previewTitleGenerator]();
+ }
+
+ return queryMap;
+ };
+
+ // Reset the preview state if already in it
+ if ( flowBoard.resetPreview( $this ) ) {
+ // Special way of cancelling a request, other than returning false outright
+ callback._abort = true;
+ }
+
+ return callback;
+ };
+
+ /**
+ * Triggers a preview of the given content.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiHandlers.preview = function( info, data, jqxhr ) {
+ var revision, creator,
+ $previewContainer,
+ templateParams,
+ $button = $( this ),
+ $form = $button.closest( 'form' ),
+ $cancelButton = $form.find('.mw-ui-button[data-role="cancel"]'),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ $titleField = $form.find( 'input' ).filter( '[data-role=title]' ),
+ $target = info.$target,
+ username = $target.data( 'flow-creator' ) || mw.user.getName(),
+ id = Math.random(),
+ previewTemplate = $target.data( 'flow-preview-template' ),
+ contentNode = $target.data( 'flow-preview-node' ) || 'content';
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ creator = {
+ links: {
+ userpage: {
+ url: mw.util.getUrl( 'User:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ talk: {
+ url: mw.util.getUrl( 'User talk:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ contribs: {
+ url: mw.util.getUrl( 'Special:Contributions/' + username ),
+ exists: true,
+ title: username
+ }
+ },
+ name: username || flowBoard.constructor.static.TemplateEngine.l10n( 'flow-anonymous' )
+ };
+
+ revision = {
+ postId: id,
+ creator: creator,
+ replies: [ id ],
+ isPreview: true
+ };
+ templateParams = {};
+
+ // This is for most previews which expect a "revision" key
+ revision[contentNode] = {
+ content: data['flow-parsoid-utils'].content,
+ format: data['flow-parsoid-utils'].format
+ };
+ // This fixes summarize which expects a key "summary"
+ templateParams[contentNode] = revision[contentNode];
+
+ $.extend( templateParams, {
+ // This fixes titlebar which expects a key "content" for title
+ content: {
+ content: $titleField.val() || '',
+ format: 'content'
+ },
+ creator: creator,
+ posts: {},
+ // @todo don't do these. it's a catch-all for the templates which expect a revision key, and those that don't.
+ revision: revision,
+ reply_count: 1,
+ last_updated: +new Date(),
+ replies: [ id ],
+ revisions: {}
+ } );
+ templateParams.posts[id] = { 0: id };
+ templateParams.revisions[id] = revision;
+
+ // Render the preview warning
+ $previewContainer = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_preview_warning.partial'
+ ) ).children();
+
+ // @todo Perhaps this should be done in each template, and not here?
+ $previewContainer.addClass( 'flow-preview' );
+
+ // Render this template with the preview data
+ $previewContainer = $previewContainer.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ previewTemplate,
+ templateParams
+ ) ).children()
+ );
+
+ // Hide any input fields and anon warning
+ $form.find( 'input, textarea, .flow-anon-warning' )
+ .addClass( 'flow-preview-target-hidden' );
+
+ // Insert the new preview before the form
+ $target
+ .parent( 'form' )
+ .before( $previewContainer );
+
+ // Hide cancel button on preview screen
+ $cancelButton.hide();
+
+ // Assign the reset-preview information for later use
+ $button
+ .data( 'flow-return-to-edit', {
+ text: $button.text(),
+ $nodes: $previewContainer
+ } )
+ .text( flowBoard.constructor.static.TemplateEngine.l10n( 'flow-preview-return-edit-post' ) )
+ .one( 'click', function() {
+ $cancelButton.show();
+ } );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentPreviewMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-switcheditor.js b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
new file mode 100644
index 00000000..b08eb7bf
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
@@ -0,0 +1,52 @@
+/*!
+ * Handlers for the switching the editor from wikitext to visualeditor
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for switching from wikitext to visualeditor
+ *
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentSwitchEditorFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentSwitchEditorFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentSwitchEditorFeatureMixin );
+
+ FlowBoardComponentSwitchEditorFeatureMixin.UI = {
+ events: {
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ /**
+ * Toggle between possible editors.
+ *
+ * Currently the only options are visualeditor, and none. Visualeditor has its own
+ * code for switching, so this is only run by clicking the switch button from 'none'.
+ * If we add more editors later this will have to be revisited.
+ *
+ * @param {Event} event
+ * @returns {jQuery.Promise}
+ */
+ FlowBoardComponentSwitchEditorFeatureMixin.UI.events.interactiveHandlers.switchEditor = function ( event ) {
+ var $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-target' ) );
+
+ event.preventDefault();
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[switchEditor] No target located' );
+ return $.Deferred().reject().promise();
+ }
+
+ return mw.flow.editor.switchEditor( $target, 'visualeditor' );
+ };
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentSwitchEditorFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-toc.js b/Flow/modules/engine/components/board/features/flow-board-toc.js
new file mode 100644
index 00000000..4d64730a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-toc.js
@@ -0,0 +1,354 @@
+/*!
+ * Contains Table of Contents functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for TOC in board header.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentTocFeatureMixin( $container ) {
+ /** Stores a list of topic IDs rendered in our TOC */
+ this.topicIdsInToc = {};
+ /** Used to define an offset to optimize fetching of TOC when we already have some items in it */
+ this.lastTopicIdInToc = null;
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentTocFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentTocFeatureMixin );
+
+ FlowBoardComponentTocFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * Empties out TOC menu when board is being refreshed.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiPreHandler( event, info ) {
+ info.component.topicIdsInTocBackup = info.component.topicIdsInToc;
+ info.component.lastTopicIdInTocBackup = info.component.lastTopicIdInToc;
+
+ info.component.topicIdsInToc = {};
+ info.component.lastTopicIdInToc = null;
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenuChildBackup = info.component.$tocMenu.children().detach();
+ info.component.$tocMenuBackup = info.component.$tocMenu;
+ info.component.$tocMenu = null;
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentTocFeatureMixinBoardApiPreHandler;
+
+ /**
+ *
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiPreHandler( event, info, extraParameters ) {
+ var $this = $( this ),
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore',
+ overrides;
+
+ if ( !isLoadMoreButton && !( extraParameters || {} ).skipMenuToggle ) {
+ // Re-scroll the TOC (in case the scroll that tracks the page scroll failed
+ // due to insufficient elements making the desired scrollTop not work (T78572)).
+ info.component.scrollTocToActiveItem();
+
+ // Actually open/close the TOC menu on this node.
+ $this.trigger( 'click', { interactiveHandler: 'menuToggle' } );
+ }
+
+ if ( !isLoadMoreButton && info.component.doneInitialTocApiCall ) {
+ // Triggers load more if we didn't load enough content to fill the viewport
+ info.$target.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ return false;
+ }
+
+ // Send some overrides to this API request
+ overrides = {
+ topiclist_sortby: info.component.$board.data( 'flow-sortby' ),
+ topiclist_limit: 50,
+ topiclist_toconly: true
+ };
+
+ // @todo verify that this works
+ //if ( info.component.lastTopicIdInToc ) {
+ // overrides.topiclist_offset = false;
+ // overrides['topiclist_offset-id'] = info.component.lastTopicIdInToc;
+ //}
+
+ if ( !overrides.topiclist_sortby ) {
+ delete overrides.topiclist_sortby;
+ }
+
+ return overrides;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiPreHandler;
+
+ //
+ // API handlers
+ //
+
+ /**
+ * Restores TOC stuff if board reload fails.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.topicIdsInToc = info.component.topicIdsInTocBackup;
+ info.component.lastTopicIdInToc = info.component.lastTopicIdInTocBackup;
+ if ( info.component.$tocMenuBackup ) {
+ info.component.$tocMenu = info.component.$tocMenuBackup;
+ }
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenu.append( info.component.$tocMenuChildBackup );
+ }
+ }
+
+ // Delete the backups
+ delete info.component.topicIdsInTocBackup;
+ delete info.component.lastTopicIdInTocBackup;
+ info.component.$tocMenuChildBackup.remove();
+ delete info.component.$tocMenuChildBackup;
+ delete info.component.$tocMenuBackup;
+
+ // Allow reopening
+ info.component.doneInitialTocApiCall = false;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentTocFeatureMixinBoardApiCallback;
+
+ /**
+ * The actual storage is in FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiHandler( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var $kids, i,
+ $this = $( this ),
+ template = info.component.tocTemplate,
+ topicsData = data.flow['view-topiclist'].result.topiclist,
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore';
+
+ // Iterate over every topic
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ // Do this until we find a topic that is not in our TOC
+ if ( info.component.topicIdsInToc[ topicsData.roots[ i ] ] ) {
+ // And then remove all the ones that are already in our TOC
+ topicsData.roots.splice( i, 1 );
+ i--;
+ } else {
+ // For any other subsequent IDs, just mark them as being in the TOC now
+ info.component.topicIdsInToc[ topicsData.roots[ i ] ] = true;
+ }
+ }
+
+ if ( topicsData.roots.length ) {
+ // Store the last topic ID for optimal offset use
+ info.component.lastTopicIdInToc = topicsData.roots[i];
+ } // render even if we have no roots, because another load-more button could appear
+
+ // Render the topic titles
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ template,
+ topicsData
+ ) ).children();
+
+ if ( isLoadMoreButton ) {
+ // Insert the new topic titles
+ info.$target.replaceWith( $kids );
+ } else {
+ // Prevent this API call from happening again
+ info.component.doneInitialTocApiCall = true;
+
+ // Insert the new topic titles
+ info.$target.append( $kids );
+ }
+
+ info.component.emitWithReturn( 'makeContentInteractive', $kids );
+
+ if ( isLoadMoreButton ) {
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $this.remove();
+ }
+
+ // Triggers load more if we didn't load enough content to fill the viewport
+ $kids.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiHandler;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ *
+ * @param {Event} event
+ */
+ function flowBoardComponentTocFeatureMixinJumpToTopicCallback( event ) {
+ var $this = $( this ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ event.preventDefault();
+
+ // Load and scroll to topic
+ flowBoard.jumpToTopic( $this.data( 'flow-id' ) );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.interactiveHandlers.jumpToTopic = flowBoardComponentTocFeatureMixinJumpToTopicCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ // This is a confusing name since this.$tocMenu is set to flow-board-toc-list, whereas you
+ // would expect flow-board-toc-menu.
+ /**
+ * Stores the TOC menu for later use.
+ * @param {jQuery} $tocMenu
+ */
+ function flowBoardComponentTocFeatureTocMenuLoadCallback( $tocMenu ) {
+ this.$tocMenu = $tocMenu;
+ this.tocTarget = $tocMenu.data( 'flow-toc-target' );
+ this.tocTemplate = $tocMenu.data( 'flow-template' );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.tocMenu = flowBoardComponentTocFeatureTocMenuLoadCallback;
+
+ /**
+ * Checks if this title is already in TOC, and if not, adds it to the end of the stack.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentTocFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId, topicData, $kids, $target;
+
+ if ( !this.$tocMenu ) {
+ // No TOC (expected if we're on Topic page)
+
+ return;
+ }
+
+ currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+ topicData = {
+ posts: {},
+ revisions: {},
+ roots: [ currentTopicId ],
+ noLoadMore: true
+ };
+
+ if ( !this.topicIdsInToc[ currentTopicId ] ) {
+ // If we get in here, this must have been loaded by topics infinite scroll and NOT by jumpTo
+ this.topicIdsInToc[ currentTopicId ] = true;
+ this.lastTopicIdInToc = currentTopicId;
+
+ // Magic to set the revision data
+ topicData.posts[ currentTopicId ] = [ currentTopicId ];
+ topicData.revisions[ currentTopicId ] = {
+ content: {
+ content: $topicTitle.data( 'flow-topic-title' ),
+ format: 'plaintext'
+ }
+ };
+
+ // Render the topic title
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ this.tocTemplate,
+ topicData
+ ) ).children();
+
+ // Find out where/how to insert the title
+ $target = $.findWithParent( this.$tocMenu, this.tocTarget );
+ if ( !$target.length ) {
+ this.$tocMenu.append( $kids );
+ } else {
+ $target.after( $kids );
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', $kids );
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentTocFeatureElementLoadTopicTitle;
+
+ //
+ // Public functions
+ //
+
+ /**
+ * Scroll the TOC to the active item
+ */
+ function flowBoardComponentTocFeatureScrollTocToActiveItem() {
+ // Set TOC active item
+ var $tocContainer = this.$tocMenu,
+ requestedScrollTop, afterScrollTop, // For debugging
+ $scrollTarget = $tocContainer.find( 'a[data-flow-id]' )
+ .removeClass( 'active' )
+ .filter( '[data-flow-id=' + this.readingTopicId + ']' )
+ .addClass( 'active' )
+ .closest( 'li' )
+ .next();
+
+ if ( !$scrollTarget.length ) {
+ // we are at the last list item; use the current one instead
+ $scrollTarget = $scrollTarget.end();
+ }
+ // Scroll to the active item
+ if ( $scrollTarget.length ) {
+ requestedScrollTop = $scrollTarget.offset().top - $tocContainer.offset().top + $tocContainer.scrollTop();
+ $tocContainer.scrollTop( requestedScrollTop );
+ afterScrollTop = $tocContainer.scrollTop();
+ // the above may not trigger the scroll.flow-load-more event within the TOC if the $tocContainer
+ // does not have a scrollbar. If that happens you could have a TOC without a scrollbar
+ // that refuses to autoload anything else. Fire it again(wasteful) untill we find
+ // a better way.
+ // This does not seem to work for the initial load, that is handled in flow-boad-loadmore.js
+ // when it runs this same code. This seems to be required for subsequent loads after
+ // the initial call.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+
+ }
+
+ FlowBoardComponentTocFeatureMixin.prototype.scrollTocToActiveItem = flowBoardComponentTocFeatureScrollTocToActiveItem;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentTocFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-visualeditor.js b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
new file mode 100644
index 00000000..0fcb319a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
@@ -0,0 +1,37 @@
+/*!
+ * Expose some functionality on the board object that is needed for VisualEditor.
+ */
+
+( function ( $, mw, OO ) {
+ /**
+ * FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @this FlowBoardComponent
+ * @constructor
+ *
+ */
+ function FlowBoardComponentVisualEditorFeatureMixin( $container ) {
+ }
+
+ // This is not really VE-specific, but I'm not sure where best to put it.
+ // Also, should we pre-compute this in a loadHandler?
+ /**
+ * Finds topic authors for the given node
+ *
+ * @return Array List of usernames
+ */
+ function flowVisualEditorGetTopicPosters( $node ) {
+ var $topic = $node.closest( '.flow-topic' ),
+ duplicatedArray;
+
+ // Could use a data attribute to avoid trim.
+ duplicatedArray = $.map( $topic.find( '.flow-author .mw-userlink' ).get(), function ( el ) {
+ return $.trim( $( el ).text() );
+ } );
+ return OO.unique( duplicatedArray );
+ }
+
+ FlowBoardComponentVisualEditorFeatureMixin.prototype.getTopicPosters = flowVisualEditorGetTopicPosters;
+
+ mw.flow.mixinComponent( 'board', FlowBoardComponentVisualEditorFeatureMixin );
+}( jQuery, mediaWiki, OO ) );
diff --git a/Flow/modules/engine/components/board/flow-board.js b/Flow/modules/engine/components/board/flow-board.js
new file mode 100644
index 00000000..6f3839ae
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-board.js
@@ -0,0 +1,196 @@
+/*!
+ * Contains the base constructor for FlowBoardComponent.
+ * @todo Clean up the remaining code that may not need to be here.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Constructor class for instantiating a new Flow board.
+ * @example <div class="flow-component" data-flow-component="board" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowBoardComponentApiEventsMixin
+ * @mixins FlowBoardComponentInteractiveEventsMixin
+ * @mixins FlowBoardComponentLoadEventsMixin
+ * @mixins FlowBoardComponentMiscMixin
+ * @mixins FlowBoardComponentLoadMoreFeatureMixin
+ * @mixins FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @constructor
+ */
+ function FlowBoardComponent( $container ) {
+ var uri = new mw.Uri( location.href ),
+ uid = String( location.hash.match( /[0-9a-z]{16,19}$/i ) || '' );
+
+ // Default API submodule for FlowBoard URLs is to fetch a topiclist
+ this.Api.setDefaultSubmodule( 'view-topiclist' );
+
+ // Set up the board
+ if ( this.reinitializeContainer( $container ) === false ) {
+ // Failed to init for some reason
+ return false;
+ }
+
+ // Handle URL parameters
+ if ( uid ) {
+ if ( uri.query.fromnotif ) {
+ _flowHighlightPost( $container, uid, 'newer' );
+ } else {
+ _flowHighlightPost( $container, uid );
+ }
+ }
+
+ _overrideWatchlistNotification();
+ }
+ OO.initClass( FlowBoardComponent );
+
+ // Register
+ mw.flow.registerComponent( 'board', FlowBoardComponent, 'boardAndHistoryBase' );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {Boolean|jQuery}
+ */
+ function flowBoardComponentReinitializeContainer( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Trigger this on FlowBoardAndHistoryComponentBase
+ // @todo use EventEmitter to do this?
+ var $retObj = FlowBoardComponent.parent.prototype.reinitializeContainer.call( this, $container ),
+ // Find any new (or previous) elements
+ $header = $container.find( '.flow-board-header' ).addBack().filter( '.flow-board-header:first' ),
+ $boardNavigation = $container.find( '.flow-board-navigation' ).addBack().filter( '.flow-board-navigation:first' ),
+ $board = $container.find( '.flow-board' ).addBack().filter( '.flow-board:first' );
+
+ if ( $retObj === false ) {
+ return false;
+ }
+
+ // Remove any of the old elements that are still in use
+ if ( $header.length ) {
+ if ( this.$header ) {
+ $retObj = $retObj.add( this.$header.replaceWith( $header ) );
+ this.$header.remove();
+ }
+
+ this.$header = $header;
+ }
+ if ( $boardNavigation.length ) {
+ if ( this.$boardNavigation ) {
+ $retObj = $retObj.add( this.$boardNavigation.replaceWith( $boardNavigation ) );
+ this.$boardNavigation.remove();
+ }
+
+ this.$boardNavigation = $boardNavigation;
+ }
+ if ( $board.length ) {
+ if ( this.$board ) {
+ $retObj = $retObj.add( this.$board.replaceWith( $board ) );
+ this.$board.remove();
+ }
+
+ this.$board = $board;
+ }
+
+ // Second, verify that this board in fact exists
+ if ( !this.$board || !this.$board.length ) {
+ // You need a board, dammit!
+ this.debug( 'Could not find .flow-board', arguments );
+ return false;
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', this );
+
+ // Initialize editors, turning them from textareas into editor objects
+ if ( typeof this.editorTimer === 'undefined' ) {
+ /*
+ * When this method is first run, all page elements are initialized.
+ * We probably don't need editor immediately, so defer loading it
+ * to speed up the rest of the work that needs to be done.
+ */
+ this.editorTimer = setTimeout( $.proxy( function ( $container ) { this.emitWithReturn( 'initializeEditors', $container ); }, this, $container ), 20000 );
+ } else {
+ /*
+ * Subsequent calls here (e.g. when rendering the edit header form)
+ * should immediately initialize the editors!
+ */
+ clearTimeout( this.editorTimer );
+ this.emitWithReturn( 'initializeEditors', $container );
+ }
+
+ return $retObj;
+ }
+ FlowBoardComponent.prototype.reinitializeContainer = flowBoardComponentReinitializeContainer;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Helper receives
+ * @param {jQuery} $container
+ * @param {string} uid
+ * @param {string} option
+ * @return {jQuery}
+ */
+ function _flowHighlightPost( $container, uid, option ) {
+ var $target = $container.find( '#flow-post-' + uid );
+
+ // reset existing highlights
+ $container.find( '.flow-post-highlighted' ).removeClass( 'flow-post-highlighted' );
+
+ if ( option === 'newer' ) {
+ $target.addClass( 'flow-post-highlight-newer' );
+ if ( uid ) {
+ $container.find( '.flow-post' ).each( function( idx, el ) {
+ var $el = $( el ),
+ id = $el.data( 'flow-id' );
+ if ( id && id > uid ) {
+ $el.addClass( 'flow-post-highlight-newer' );
+ }
+ } );
+ }
+ } else {
+ $target.addClass( 'flow-post-highlighted' );
+ }
+
+ return $target;
+ }
+
+ /**
+ * We want the default behavior of watch/unwatch for page. However, we
+ * do want to show our own tooltip after this has happened.
+ * We'll override mw.notify, which is fired after successfully
+ * (un)watchlisting, to stop the notification from being displayed.
+ * If the action we just intercepted was after succesful watching, we'll
+ * want to show our own tooltip instead.
+ */
+ function _overrideWatchlistNotification() {
+ var _notify = mw.notify;
+ mw.notify = function( message, options ) {
+ // override message when we've just watched the board
+ if ( options.tag === 'watch-self' && $( '#ca-watch' ).length ) {
+ // Render a div telling the user that they have subscribed
+ message = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_subscribed.partial',
+ {
+ type: 'board',
+ username: mw.user.getName()
+ }
+ ) ).children();
+ }
+
+ _notify.apply( this, arguments );
+ };
+ }
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/flow-boardhistory.js b/Flow/modules/engine/components/board/flow-boardhistory.js
new file mode 100644
index 00000000..b9f63d07
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-boardhistory.js
@@ -0,0 +1,59 @@
+/*!
+ *
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @example <div class="flow-component" data-flow-component="boardHistory" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @constructor
+ */
+ function FlowBoardHistoryComponent( $container ) {
+ this.bindNodeHandlers( FlowBoardHistoryComponent.UI.events );
+ }
+ OO.initClass( FlowBoardHistoryComponent );
+
+ FlowBoardHistoryComponent.UI = {
+ events: {
+ apiHandlers: {}
+ }
+ };
+
+ mw.flow.registerComponent( 'boardHistory', FlowBoardHistoryComponent, 'boardAndHistoryBase' );
+
+ //
+ // API handlers
+ //
+
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardHistoryModerationCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var flowBoardHistory = mw.flow.getPrototypeMethod( 'boardHistory', 'getInstanceByElement' )( $( this ) );
+
+ // Clear the form so we can refresh without the confirmation dialog
+ flowBoardHistory.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // @todo implement dynamic updating of the history page instead of this
+ location.reload();
+
+ return $.Deferred().resolve().promise();
+ }
+
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderateTopic = flowBoardHistoryModerationCallback;
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderatePost = flowBoardHistoryModerationCallback;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-engines.js b/Flow/modules/engine/components/common/flow-component-engines.js
new file mode 100644
index 00000000..3d8a9282
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-engines.js
@@ -0,0 +1,39 @@
+/*!
+ * Initializes StorageEngine (Storer), TemplateEngine (Handlebars), and API (FlowApi).
+ */
+
+( function ( $, mw, initStorer ) {
+ /**
+ * Initializes Storer, Handlebars, and FlowApi.
+ * @constructor
+ */
+ function FlowComponentEnginesMixin() {}
+ OO.initClass( FlowComponentEnginesMixin );
+
+ /**
+ * Contains Storer.js's (fallback) storage engines.
+ * @type {{ cookieStorage: Storer.cookieStorage, memoryStorage: Storer.memoryStorage, sessionStorage: Storer.sessionStorage, localStorage: Storer.localStorage }}
+ */
+ mw.flow.StorageEngine = FlowComponentEnginesMixin.static.StorageEngine = initStorer( { 'prefix': '_WMFLOW_' } );
+
+ /**
+ * Contains the Flow templating engine translation class (in case we change templating engines).
+ * @type {FlowHandlebars}
+ */
+ mw.flow.TemplateEngine = FlowComponentEnginesMixin.static.TemplateEngine = new mw.flow.FlowHandlebars( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * Flow API singleton
+ * @type {FlowApi}
+ */
+ mw.flow.Api = new mw.flow.FlowApi( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * EventLogging wrapper
+ * @type {FlowEventLog}
+ */
+ mw.flow.EventLog = mw.flow.FlowEventLog;
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEnginesMixin );
+}( jQuery, mediaWiki, mediaWiki.flow.vendor.initStorer ) );
diff --git a/Flow/modules/engine/components/common/flow-component-events.js b/Flow/modules/engine/components/common/flow-component-events.js
new file mode 100644
index 00000000..f3b97701
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-events.js
@@ -0,0 +1,917 @@
+/*!
+ * Contains the code which registers and handles event callbacks.
+ * In addition, it contains some common callbacks (eg. apiRequest)
+ * @todo Find better places for a lot of the callbacks that have been placed here
+ */
+
+( function ( $, mw ) {
+ var _isGlobalBound;
+
+ /**
+ * This implements functionality for being able to capture the return value from a called event.
+ * In addition, this handles Flow event triggering and binding.
+ * @extends oo.EventEmitter
+ * @constructor
+ */
+ function FlowComponentEventsMixin( $container ) {
+ var self = this;
+
+ /**
+ * Stores event callbacks.
+ */
+ this.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ // Init EventEmitter
+ OO.EventEmitter.call( this );
+
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentEventsMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentEventsMixin.UI.events );
+
+ // Container handlers
+ // @todo move some to FlowBoardComponent events, rename the others to FlowComponent
+ $container
+ .off( '.FlowBoardComponent' )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandler' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandlerFocus' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'input.mw-ui-input, textarea',
+ this.getDispatchCallback( 'focusField' )
+ )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ '[data-flow-eventlog-action]',
+ this.getDispatchCallback( 'eventLogHandler' )
+ );
+
+ if ( _isGlobalBound ) {
+ // Don't bind window.scroll again.
+ return;
+ }
+ _isGlobalBound = true;
+
+ // Handle scroll and resize events globally
+ $( window )
+ .on(
+ // Normal scroll events on elements do not bubble. However, if they
+ // are triggered, jQuery will do so. To avoid this affecting the
+ // global scroll handler, trigger scroll events on elements only with
+ // scroll.flow-something, where 'something' is not 'window-scroll'.
+ 'scroll.flow-window-scroll',
+ $.throttle( 50, function ( evt ) {
+ if ( evt.target !== window && evt.target !== document ) {
+ throw new Error( 'Target is "' + evt.target.nodeName + '", not window or document.' );
+ }
+
+ self.getDispatchCallback( 'windowScroll' ).apply( self, arguments );
+ } )
+ )
+ .on(
+ 'resize.flow',
+ $.throttle( 50, this.getDispatchCallback( 'windowResize' ) )
+ );
+ }
+ OO.mixinClass( FlowComponentEventsMixin, OO.EventEmitter );
+
+ FlowComponentEventsMixin.eventHandlers = {};
+ FlowComponentEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Same as OO.EventEmitter.emit, except that it returns an array of results.
+ * If something returns false (or an object with _abort:true), we stop processing the rest of the callbacks, if any.
+ * @param {String} event Name of the event to trigger
+ * @param {...*} [args] Arguments to pass to event callback
+ * @returns {Array}
+ */
+ function emitWithReturn( event, args ) {
+ var i, len, binding, bindings, method,
+ returns = [], retVal;
+
+ if ( event in this.bindings ) {
+ // Slicing ensures that we don't get tripped up by event handlers that add/remove bindings
+ bindings = this.bindings[event].slice();
+ args = Array.prototype.slice.call( arguments, 1 );
+ for ( i = 0, len = bindings.length; i < len; i++ ) {
+ binding = bindings[i];
+
+ if ( typeof binding.method === 'string' ) {
+ // Lookup method by name (late binding)
+ method = binding.context[ binding.method ];
+ } else {
+ method = binding.method;
+ }
+
+ // Call function
+ retVal = method.apply(
+ binding.context || this,
+ binding.args ? binding.args.concat( args ) : args
+ );
+
+ // Add this result to our list of return vals
+ returns.push( retVal );
+
+ if ( retVal === false || ( retVal && retVal._abort === true ) ) {
+ // Returned false; stop running callbacks
+ break;
+ }
+ }
+ return returns;
+ }
+ return [];
+ }
+ FlowComponentEventsMixin.prototype.emitWithReturn = emitWithReturn;
+
+ /**
+ *
+ * @param {Object} handlers
+ */
+ function bindFlowComponentHandlers( handlers ) {
+ var self = this;
+
+ // Bind class event handlers, triggered by .emit
+ $.each( handlers, function ( key, fn ) {
+ self.on( key, function () {
+ // Trigger callback with class instance context
+ try {
+ return fn.apply( self, arguments );
+ } catch ( e ) {
+ mw.flow.debug( 'Error in component handler:', key, e, arguments );
+ return false;
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindComponentHandlers = bindFlowComponentHandlers;
+
+ /**
+ * handlers can have keys globalApiPreHandlers, apiPreHandlers, apiHandlers, interactiveHandlers, loadHandlers
+ * @param {Object} handlers
+ */
+ function bindFlowNodeHandlers( handlers ) {
+ var self = this;
+
+ // eg. { interactiveHandlers: { foo: Function } }
+ $.each( handlers, function ( type, callbacks ) {
+ // eg. { foo: Function }
+ $.each( callbacks, function ( name, fn ) {
+ // First time for this callback name, instantiate the callback list
+ if ( !self.UI.events[type][name] ) {
+ self.UI.events[type][name] = [];
+ }
+ if ( $.isArray( fn ) ) {
+ // eg. UI.events.interactiveHandlers.foo concat [Function, Function];
+ self.UI.events[type][name] = self.UI.events[type][name].concat( fn );
+ } else {
+ // eg. UI.events.interactiveHandlers.foo = [Function];
+ self.UI.events[type][name].push( fn );
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindNodeHandlers = bindFlowNodeHandlers;
+
+ /**
+ * Returns a callback function which passes off arguments to the emitter.
+ * This only exists to clean up the FlowComponentEventsMixin constructor,
+ * by preventing it from having too many anonymous functions.
+ * @param {String} name
+ * @returns {Function}
+ * @private
+ */
+ function flowComponentGetDispatchCallback( name ) {
+ var context = this;
+
+ return function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+
+ // Add event name as first arg of emit
+ args.unshift( name );
+
+ return context.emitWithReturn.apply( context, args );
+ };
+ }
+ FlowComponentEventsMixin.prototype.getDispatchCallback = flowComponentGetDispatchCallback;
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Utility to get error message for API result.
+ *
+ * @param string code
+ * @param Object result
+ * @returns string
+ */
+ function flowGetApiErrorMessage( code, result ) {
+ if ( result.error && result.error.info ) {
+ return result.error.info;
+ } else {
+ if ( code === 'http' ) {
+ // XXX: some network errors have English info in result.exception and result.textStatus.
+ return mw.msg( 'flow-error-http' );
+ } else {
+ return mw.msg( 'flow-error-external', code );
+ }
+ }
+ }
+ FlowComponentEventsMixin.static.getApiErrorMessage = flowGetApiErrorMessage;
+
+ //
+ // Interactive Handlers
+ //
+
+ /**
+ * Triggers an API request based on URL and form data, and triggers the callbacks based on flow-api-handler.
+ * @example <a data-flow-interactive-handler="apiRequest" data-flow-api-handler="loadMore" data-flow-api-target="< .flow-component div" href="...">...</a>
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ function flowEventsMixinApiRequestInteractiveHandler( event ) {
+ var $deferred,
+ $handlerDeferred,
+ handlerPromises = [],
+ $target,
+ preHandlerReturn,
+ self = event.currentTarget || event.delegateTarget || event.target,
+ $this = $( self ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ dataParams = $this.data(),
+ handlerName = dataParams.flowApiHandler,
+ preHandlerReturns = [],
+ info = {
+ $target: null,
+ status: null,
+ component: flowComponent
+ },
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ event.preventDefault();
+
+ // Find the target node
+ if ( dataParams.flowApiTarget ) {
+ // This fn supports finding parents
+ $target = $this.findWithParent( dataParams.flowApiTarget );
+ }
+ if ( !$target || !$target.length ) {
+ // Assign a target node if none
+ $target = $this;
+ }
+
+ info.$target = $target;
+ args.splice( 1, 0, info ); // insert info into args for prehandler
+
+ // Make sure an API call is not already in progress for this target
+ if ( $target.closest( '.flow-api-inprogress' ).length ) {
+ flowComponent.debug( false, 'apiRequest already in progress', arguments );
+ return $.Deferred().reject().promise();
+ }
+
+ // Mark the target node as "in progress" to disallow any further API calls until it finishes
+ $target.addClass( 'flow-api-inprogress' );
+ $this.addClass( 'flow-api-inprogress' );
+
+ // Let generic pre-handler take care of edit conflicts
+ $.each( flowComponent.UI.events.globalApiPreHandlers, function( key, callbackArray ) {
+ $.each( callbackArray, function ( i, callbackFn ) {
+ preHandlerReturns.push( callbackFn.apply( self, args ) );
+ } );
+ } );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // Use the pre-callback to find out if we should process this
+ if ( flowComponent.UI.events.apiPreHandlers[ handlerName ] ) {
+ // apiPreHandlers can return FALSE to prevent processing,
+ // nothing at all to proceed,
+ // or OBJECT to add param overrides to the API
+ // or FUNCTION to modify API params
+ $.each( flowComponent.UI.events.apiPreHandlers[ handlerName ], function ( i, callbackFn ) {
+ preHandlerReturn = callbackFn.apply( self, args );
+ preHandlerReturns.push( preHandlerReturn );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Callback returned false; break out of this loop
+ return false;
+ }
+ } );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Last callback returned false
+ flowComponent.debug( false, 'apiPreHandler returned false', handlerName, args );
+
+ // Abort any old request in flight; this is normally done automatically by requestFromNode
+ flowComponent.Api.abortOldRequestFromNode( self, null, null, preHandlerReturns );
+
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+
+ return $.Deferred().reject().promise();
+ }
+ }
+
+ // Make the request
+ $deferred = flowComponent.Api.requestFromNode( self, preHandlerReturns );
+ if ( !$deferred ) {
+ mw.flow.debug( '[FlowApi] [interactiveHandlers] apiRequest element is not anchor or form element' );
+ $deferred = $.Deferred();
+ $deferred.rejectWith( { error: { info: 'Not an anchor or form' } } );
+ }
+
+ // Remove the load indicator
+ $deferred.always( function () {
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+ } );
+
+ // Remove existing errors from previous attempts
+ flowComponent.emitWithReturn( 'removeError', $this );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // If this has a special api handler, bind it to the callback.
+ if ( flowComponent.UI.events.apiHandlers[ handlerName ] ) {
+ $deferred
+ .done( function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+ info.status = 'done';
+ args.unshift( info );
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .fail( function ( code, result ) {
+ var errorMsg,
+ args = Array.prototype.slice.call( arguments, 0 ),
+ $form = $this.closest( 'form' );
+
+ info.status = 'fail';
+ args.unshift( info );
+
+ /*
+ * In the event of edit conflicts, store the previous
+ * revision id so we can re-submit an edit against the
+ * current id later.
+ */
+ if ( result.error && result.error.prev_revision ) {
+ $form.data( 'flow-prev-revision', result.error.prev_revision.revision_id );
+ }
+
+ /*
+ * Generic error handling: displays error message in the
+ * nearest error container.
+ *
+ * Errors returned by MW/Flow should always be in the
+ * same format. If the request failed without a specific
+ * error message, just fall back to some default error.
+ */
+ errorMsg = flowComponent.constructor.static.getApiErrorMessage( code, result );
+ flowComponent.emitWithReturn( 'showError', $this, errorMsg );
+
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .always( function() {
+ // Resolve/reject the promised deferreds when all apiHandler
+ // deferreds have been resolved/rejected
+ $.when.apply( $, handlerPromises )
+ .done( $handlerDeferred.resolve )
+ .fail( $handlerDeferred.reject );
+ } );
+ }
+
+ // Return an aggregate promise that resolves when all are resolved, or
+ // rejects once one of them is rejected
+ return $handlerDeferred.promise();
+ }
+ FlowComponentEventsMixin.UI.events.interactiveHandlers.apiRequest = flowEventsMixinApiRequestInteractiveHandler;
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ *
+ * @param {FlowComponent|jQuery} $container or entire FlowComponent
+ * @todo Perhaps use name="flow-load-handler" for performance in older browsers
+ */
+ function flowMakeContentInteractiveCallback( $container ) {
+ if ( !$container.jquery ) {
+ $container = $container.$container;
+ }
+
+ if ( !$container.length ) {
+ // Prevent erroring out with an empty node set
+ return;
+ }
+
+ // Get the FlowComponent
+ var component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $container );
+
+ // Find all load-handlers and trigger them
+ $container.find( '.flow-load-interactive' ).add( $container.filter( '.flow-load-interactive' ) ).each( function () {
+ var $this = $( this ),
+ handlerName = $this.data( 'flow-load-handler' );
+
+ if ( $this.data( 'flow-load-handler-called' ) ) {
+ return;
+ }
+ $this.data( 'flow-load-handler-called', true );
+
+ // If this has a special load handler, run it.
+ component.emitWithReturn( 'loadHandler', handlerName, $this );
+ } );
+
+ // Find all the forms
+ // @todo move this into a flow-load-handler
+ $container.find( 'form' ).add( $container.filter( 'form' ) ).each( function () {
+ var $this = $( this );
+
+ // Trigger for flow-actions-disabler
+ $this.find( 'input, textarea' ).trigger( 'keyup' );
+
+ // Find this form's inputs
+ $this.find( 'textarea' ).filter( '[data-flow-expandable]' ).each( function () {
+ // Compress textarea if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$( this ).is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $( this ) );
+ }
+ } );
+
+ component.emitWithReturn( 'hideForm', $this );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.makeContentInteractive = flowMakeContentInteractiveCallback;
+
+ /**
+ * Triggers load handlers.
+ */
+ function flowLoadHandlerCallback( handlerName, args, context ) {
+ args = $.isArray( args ) ? args : ( args ? [args] : [] );
+ context = context || this;
+
+ if ( this.UI.events.loadHandlers[handlerName] ) {
+ $.each( this.UI.events.loadHandlers[handlerName], function ( i, fn ) {
+ fn.apply( context, args );
+ } );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.loadHandler = flowLoadHandlerCallback;
+
+ /**
+ * Executes interactive handlers.
+ *
+ * @param {array} args
+ * @param {jQuery} $context
+ * @param {string} interactiveHandlerName
+ * @param {string} apiHandlerName
+ */
+ function flowExecuteInteractiveHandler( args, $context, interactiveHandlerName, apiHandlerName ) {
+ var promises = [];
+
+ // Call any matching interactive handlers
+ if ( this.UI.events.interactiveHandlers[interactiveHandlerName] ) {
+ $.each( this.UI.events.interactiveHandlers[interactiveHandlerName], function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( this.UI.events.apiHandlers[apiHandlerName] ) {
+ // Call any matching API handlers
+ $.each( this.UI.events.interactiveHandlers.apiRequest, function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( interactiveHandlerName ) {
+ this.debug( 'Failed to find interactiveHandler', interactiveHandlerName, arguments );
+ } else if ( apiHandlerName ) {
+ this.debug( 'Failed to find apiHandler', apiHandlerName, arguments );
+ }
+
+ // Add aggregate deferred object as data attribute, so we can hook into
+ // the element when the handlers have run
+ $context.data( 'flow-interactive-handler-promise', $.when.apply( $, promises ) );
+ }
+
+ /**
+ * Triggers both API and interactive handlers.
+ * To manually trigger a handler on an element, you can use extraParameters via $el.trigger.
+ * @param {Event} event
+ * @param {Object} [extraParameters]
+ * @param {String} [extraParameters.interactiveHandler]
+ * @param {String} [extraParameters.apiHandler]
+ */
+ function flowInteractiveHandlerCallback( event, extraParameters ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ // Have either of these been forced via trigger extraParameters?
+ interactiveHandlerName = ( extraParameters || {} ).interactiveHandler || $context.data( 'flow-interactive-handler' ),
+ apiHandlerName = ( extraParameters || {} ).apiHandler || $context.data( 'flow-api-handler' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandler = flowInteractiveHandlerCallback;
+ FlowComponentEventsMixin.eventHandlers.apiRequest = flowInteractiveHandlerCallback;
+
+ /**
+ * Triggers both API and interactive handlers, on focus.
+ */
+ function flowInteractiveHandlerFocusCallback( event ) {
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ interactiveHandlerName = $context.data( 'flow-interactive-handler-focus' ),
+ apiHandlerName = $context.data( 'flow-api-handler-focus' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandlerFocus = flowInteractiveHandlerFocusCallback;
+
+ /**
+ * Callback function for when a [data-flow-eventlog-action] node is clicked.
+ * This will trigger a eventLog call to the given schema with the given
+ * parameters.
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one attribute to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * Required parameters (as data-attributes) are:
+ * * data-flow-eventlog-schema: The schema name
+ * * data-flow-eventlog-entrypoint: The schema's entrypoint parameter
+ * * data-flow-eventlog-action: The schema's action parameter
+ *
+ * Additionally:
+ * * data-flow-eventlog-forward: Selectors to forward funnel data to
+ */
+ function flowEventLogCallback( event ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var $context = $( event.currentTarget ),
+ data = $context.data(),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context ),
+ $promise = data.flowInteractiveHandlerPromise || $.Deferred().resolve().promise(),
+ eventInstance = {},
+ key, value;
+
+ // Fetch loggable data: everything prefixed flowEventlog except
+ // flowEventLogForward and flowEventLogSchema
+ for ( key in data ) {
+ if ( key.indexOf( 'flowEventlog' ) === 0 ) {
+ // @todo Either the data or this config should have separate prefixes,
+ // it shouldn't be shared and then handled here.
+ if ( key === 'flowEventlogForward' || key === 'flowEventlogSchema' ) {
+ continue;
+ }
+
+ // Strips "flowEventlog" and lowercases first char after that
+ value = data[key];
+ key = key.substr( 12, 1 ).toLowerCase() + key.substr( 13 );
+
+ eventInstance[key] = value;
+ }
+ }
+
+ // Log the event
+ eventInstance = component.logEvent( data.flowEventlogSchema, eventInstance );
+
+ // Promise resolves once all interactiveHandlers/apiHandlers are done,
+ // so all nodes we want to forward to are bound to be there
+ $promise.always( function() {
+ // Now find all nodes to forward to
+ var $forward = data.flowEventlogForward ? $context.findWithParent( data.flowEventlogForward ) : $();
+
+ // Forward the funnel
+ eventInstance = component.forwardEvent( $forward, data.flowEventlogSchema, eventInstance.funnelId );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.eventLogHandler = flowEventLogCallback;
+
+ /**
+ * When the whole class has been instantiated fully (after every constructor has been called).
+ * @param {FlowComponent} component
+ */
+ function flowEventsMixinInstantiationComplete( component ) {
+ $( window ).trigger( 'scroll.flow-window-scroll' );
+ }
+ FlowComponentEventsMixin.eventHandlers.instantiationComplete = flowEventsMixinInstantiationComplete;
+
+
+ /**
+ * Compress and hide a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinHideForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $form );
+
+ // Store state
+ $form.data( 'flow-state', 'hidden' );
+
+ // If any preview is visible cancel it
+ // Must be done before compressing text areas because
+ // the preview may have manipulated them.
+ if ( $form.parent().find( '.flow-preview-warning' ).length ) {
+ component.resetPreview(
+ $form.find( 'button[data-role="cancel"]' )
+ );
+ }
+
+ $form.find( 'textarea' ).each( function () {
+ var $editor = $( this );
+
+ // Kill editor instances
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+
+ // Drop the new input in place if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$editor.is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $editor );
+ }
+ } );
+
+ if ( initialState === 'collapsed' ) {
+ // Hide its actions
+ // @todo Use TemplateEngine to find and hide actions?
+ $form.find( '.flow-form-collapsible' ).hide();
+ $form.data( 'flow-form-collapse-state', 'collapsed' );
+ } else if ( initialState === 'hidden' ) {
+ // Hide the form itself
+ $form.hide();
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.hideForm = flowEventsMixinHideForm;
+
+ /**
+ * "Compresses" a textarea by adding a class to it, which CSS will pick up
+ * to force a smaller display size.
+ * @param {jQuery} $textarea
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinCompressTextarea( $textarea ) {
+ $textarea.addClass( 'flow-input-compressed' );
+ if ( mw.flow.editor && mw.flow.editor.exists( $textarea ) ) {
+ mw.flow.editor.destroy( $textarea );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.compressTextarea = flowEventsMixinCompressTextarea;
+
+ /**
+ * If input is focused, expand it if compressed (into textarea).
+ * Otherwise, trigger the form to unhide.
+ * @param {Event} event
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinFocusField( event ) {
+ var $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context );
+
+ // Show the form (and swap it for textarea if needed)
+ component.emitWithReturn( 'showForm', $context.closest( 'form' ) );
+ }
+ FlowComponentEventsMixin.eventHandlers.focusField = flowEventsMixinFocusField;
+
+ /**
+ * Expand and make visible a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ */
+ function flowEventsMixinShowForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ self = this;
+
+ if ( initialState === 'collapsed' ) {
+ // Show its actions
+ if ( $form.data( 'flow-form-collapse-state' ) === 'collapsed' ) {
+ $form.removeData( 'flow-form-collapse-state' );
+ $form.find( '.flow-form-collapsible' ).show();
+ }
+ } else if ( initialState === 'hidden' ) {
+ // Show the form itself
+ $form.show();
+ }
+
+ // Expand all textareas if needed
+ $form.find( '.flow-input-compressed' ).each( function () {
+ self.emitWithReturn( 'expandTextarea', $( this ) );
+ } );
+
+ // Initialize editors, turning them from textareas into editor objects
+ self.emitWithReturn( 'initializeEditors', $form );
+
+ // Store state
+ $form.data( 'flow-state', 'visible' );
+ }
+ FlowComponentEventsMixin.eventHandlers.showForm = flowEventsMixinShowForm;
+
+ /**
+ * Expand the textarea by removing the CSS class that will make it appear
+ * smaller.
+ * @param {jQuery} $textarea
+ */
+ function flowEventsMixinExpandTextarea( $textarea ) {
+ $textarea.removeClass( 'flow-input-compressed' );
+ }
+ FlowComponentEventsMixin.eventHandlers.expandTextarea = flowEventsMixinExpandTextarea;
+
+ /**
+ * Initialize all editors, turning them from textareas into editor objects.
+ *
+ * @param {jQuery} $container
+ */
+ function flowEventsMixinInitializeEditors( $container ) {
+ var flowComponent = this, $form;
+
+ mw.loader.using( 'ext.flow.editor', function() {
+ var $editors = $container.find( 'textarea:not(.flow-input-compressed)' );
+
+ $editors.each( function() {
+ var $editor = $( this );
+
+ $form = $editor.closest( 'form' );
+ // All editors already have their content in wikitext-format
+ // (mostly because we need to prefill them server-side so that
+ // JS-less users can interact)
+ mw.flow.editor.load( $editor, $editor.val(), 'wikitext' ).done( function () {
+ $form.toggleClass( 'flow-editor-supports-preview', mw.flow.editor.editor.static.usesPreview() );
+ } );
+
+ // Kill editor instance when the form it's in is cancelled
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function() {
+ if ( mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+ } );
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.initializeEditors = flowEventsMixinInitializeEditors;
+
+ /**
+ * Adds a flow-cancel-callback to a given form, to be triggered on click of the "cancel" button.
+ * @param {jQuery} $form
+ * @param {Function} callback
+ */
+ function flowEventsMixinAddFormCancelCallback( $form, callback ) {
+ var fns = $form.data( 'flow-cancel-callback' ) || [];
+ fns.push( callback );
+ $form.data( 'flow-cancel-callback', fns );
+ }
+ FlowComponentEventsMixin.eventHandlers.addFormCancelCallback = flowEventsMixinAddFormCancelCallback;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ */
+ function flowEventsMixinRemoveError( $node ) {
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).empty();
+ }
+ FlowComponentEventsMixin.eventHandlers.removeError = flowEventsMixinRemoveError;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ * @param {String} msg The error that occurred. Currently hardcoded.
+ */
+ function flowEventsMixinShowError( $node, msg ) {
+ var fragment = mw.flow.TemplateEngine.processTemplate( 'flow_errors.partial', { errors: [ { message: msg } ] } );
+
+ if ( !$node.jquery ) {
+ $node = $node.$container;
+ }
+
+ _flowFindUpward( $node, '.flow-content-preview' ).hide();
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).replaceWith( fragment );
+ }
+ FlowComponentEventsMixin.eventHandlers.showError = flowEventsMixinShowError;
+
+ /**
+ * Shows a tooltip telling the user that they have subscribed
+ * to this topic|board
+ * @param {jQuery} $tooltipTarget Element to attach tooltip to.
+ * @param {string} type 'topic' or 'board'
+ * @param {string} dir Direction to point the pointer. 'left' or 'up'
+ */
+ function flowEventsMixinShowSubscribedTooltip( $tooltipTarget, type, dir ) {
+ dir = dir || 'left';
+
+ mw.tooltip.show(
+ $tooltipTarget,
+ // tooltipTarget will not always be part of a FlowBoardComponent
+ $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_tooltip_subscribed.partial',
+ {
+ unsubscribe: false,
+ type: type,
+ direction: dir,
+ username: mw.user.getName()
+ }
+ )
+ ).children(),
+ {
+ tooltipPointing: dir
+ }
+ );
+
+ // Hide after 5s
+ setTimeout( function () {
+ mw.tooltip.hide( $tooltipTarget );
+ }, 5000 );
+ }
+ FlowComponentEventsMixin.eventHandlers.showSubscribedTooltip = flowEventsMixinShowSubscribedTooltip;
+
+ /**
+ * If a form has a cancelForm handler, we clear the form and trigger it. This allows easy cleanup
+ * and triggering of form events after successful API calls.
+ * @param {Element|jQuery} formElement
+ */
+ function flowEventsMixinCancelForm( formElement ) {
+ var $form = $( formElement ),
+ $button = $form.find( 'button, input, a' ).filter( '[data-flow-interactive-handler="cancelForm"]' );
+
+ if ( $button.length ) {
+ // Clear contents to not trigger the "are you sure you want to
+ // discard your text" warning
+ $form.find( 'textarea, :text' ).each( function() {
+ $( this ).val( this.defaultValue );
+ } );
+
+ // Trigger a click on cancel to have it destroy the form the way it should
+ $button.trigger( 'click' );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.cancelForm = flowEventsMixinCancelForm;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Given node & a selector, this will return the result closest to $node
+ * by first looking inside $node, then travelling up the DOM tree to
+ * locate the first result in a common ancestor.
+ *
+ * @param {jQuery} $node
+ * @param {String} selector
+ * @returns jQuery
+ */
+ function _flowFindUpward( $node, selector ) {
+ // first check if result can already be found inside $node
+ var $result = $node.find( selector );
+
+ // then keep looking up the tree until a result is found
+ while ( $result.length === 0 && $node.length !== 0 ) {
+ $node = $node.parent();
+ $result = $node.children( selector );
+ }
+
+ return $result;
+ }
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-menus.js b/Flow/modules/engine/components/common/flow-component-menus.js
new file mode 100644
index 00000000..3239a851
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-menus.js
@@ -0,0 +1,142 @@
+/*!
+ * Contains flow-menu functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for flow-menu.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowComponentMenusFeatureMixin( $container ) {
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentMenusFeatureMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentMenusFeatureMixin.UI.events );
+
+ // Bind special toggle menu handler
+ $container
+ .on(
+ 'click.FlowBoardComponent mousedown.FlowBoardComponent mouseup.FlowBoardComponent focusin.FlowBoardComponent focusout.FlowBoardComponent',
+ '.flow-menu',
+ this.getDispatchCallback( 'toggleHoverMenu' )
+ );
+ }
+ OO.initClass( FlowComponentMenusFeatureMixin );
+
+ FlowComponentMenusFeatureMixin.eventHandlers = {};
+ FlowComponentMenusFeatureMixin.UI = {
+ events: {
+ loadHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ * On click, focus, and blur of hover menu events, decides whether or not to hide or show the expanded menu
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureMixinToggleHoverMenuCallback( event ) {
+ var $this = $( event.target ),
+ $menu = $this.closest( '.flow-menu' );
+
+ if ( event.type === 'click' ) {
+ // If the caret was clicked, toggle focus
+ if ( $this.closest( '.flow-menu-js-drop' ).length ) {
+ $menu.toggleClass( 'focus' );
+
+ // This trick lets us wait for a blur event from A instead on body, to later hide the menu on outside click
+ if ( $menu.hasClass( 'focus' ) ) {
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ } else if ( $this.is( 'a, button' ) ) {
+ // Remove the focus from the menu so it can hide after clicking on a link or button
+ setTimeout( function () {
+ if ( $this.is( ':focus' ) ) {
+ $this.blur();
+ }
+ }, 50 );
+ }
+
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'mousedown' ) {
+ // Fix for Chrome: it triggers blur when you click on the scrollbar! Let's prevent that.
+ $menu.data( 'mousedown', true );
+ } else if ( event.type === 'mouseup' ) {
+ // Chrome fix ^
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'focusin' ) {
+ // If we are focused on a menu item (eg. tabbed in), open the whole menu
+ $menu.addClass( 'focus' );
+ } else if ( event.type === 'focusout' && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // If we lost focus, make sure no other element in this menu has focus, and then hide the menu
+ setTimeout( function () {
+ if ( !$menu.data( 'mousedown' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ $menu.removeClass( 'focus' );
+ }
+ }, 250 );
+ }
+ }
+ FlowComponentMenusFeatureMixin.eventHandlers.toggleHoverMenu = flowComponentMenusFeatureMixinToggleHoverMenuCallback;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ * Allows you to open a flow-menu from a secondary click handler elsewhere.
+ * Uses data-flow-menu-target="< foo .flow-menu"
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureElementMenuToggleCallback( event ) {
+ var $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ target = $this.data( 'flowMenuTarget' ),
+ $target = $.findWithParent( $this, target ),
+ $deferred = $.Deferred();
+
+
+ event.preventDefault();
+
+ if ( !$target || !$target.length ) {
+ flowComponent.debug( 'Could not find openFlowMenu target', arguments );
+ return $deferred.reject().promise();
+ }
+
+ $target.find( '.flow-menu-js-drop' ).trigger( 'click' );
+
+ return $deferred.resolve().promise();
+ }
+ FlowComponentMenusFeatureMixin.UI.events.interactiveHandlers.menuToggle = flowComponentMenusFeatureElementMenuToggleCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * When a menu appears, check if it's already got the focus class. If so, re-focus it.
+ * @param {jQuery} $menu
+ */
+ function flowComponentMenusFeatureElementLoadCallback( $menu ) {
+ // For some reason, this menu is visible, but lacks physical focus
+ // This happens when you clone an activated flow-menu
+ if ( $menu.hasClass( 'focus' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // Give it focus again
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ }
+ FlowComponentMenusFeatureMixin.UI.events.loadHandlers.menu = flowComponentMenusFeatureElementLoadCallback;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowComponentMenusFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-component.js b/Flow/modules/engine/components/flow-component.js
new file mode 100644
index 00000000..ffba356f
--- /dev/null
+++ b/Flow/modules/engine/components/flow-component.js
@@ -0,0 +1,251 @@
+/*!
+ * Contains base FlowComponent class.
+ */
+
+( function ( $, mw ) {
+ var _totalInstanceCount = 0;
+
+ /**
+ * Inherited base class. Stores the instance in the class's instance registry.
+ * @param {jQuery} $container
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowComponentMenusFeatureMixin
+ * @constructor
+ */
+ function FlowComponent( $container ) {
+ var parent = this.constructor.parent;
+
+ // Run progressive enhancements if any are needed by this container
+ mw.flow.TemplateEngine.processProgressiveEnhancement( $container );
+
+ // Store the container for later use
+ this.$container = $container;
+
+ // Get this component's ID
+ this.id = $container.data( 'flow-id' );
+ if ( !this.id ) {
+ // Generate an ID for this component
+ this.id = 'flow-generated-' + _totalInstanceCount;
+ $container.data( 'flow-id', this.id );
+ // @todo throw an exception here instead of generating an id?
+ } else if ( this.getInstanceByElement( $container ) ) {
+ // Check if this board was already instantiated, and return that instead
+ return this.getInstanceByElement( $container );
+ }
+
+ // Give this board its own API instance @todo do this with OOjs
+ this.Api = new mw.flow.FlowApi( FlowComponent.static.StorageEngine, this.id );
+
+ // Keep this in the registry to find it by other means
+ while ( parent ) {
+ parent._instanceRegistryById[this.id] = parent._instanceRegistry.push( this ) - 1;
+ parent = parent.parent; // and add it to every instance registry
+ }
+ _totalInstanceCount++;
+ }
+ OO.initClass( FlowComponent );
+
+ //
+ // PROTOTYPE METHODS
+ //
+
+ /**
+ * Takes any length of arguments, and passes it off to console.log.
+ * Only renders if window.flow_debug OR localStorage.flow_debug == true OR user is Admin or (WMF).
+ * @param {Boolean} [isError=true]
+ * @param {...*} args
+ */
+ mw.flow.debug = FlowComponent.prototype.debug = function ( isError, args ) {
+ if ( window.console ) {
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ if ( typeof isError === 'boolean' ) {
+ args.shift();
+ } else {
+ isError = true;
+ }
+
+ args.unshift( '[FLOW] ' );
+
+ if ( isError && console.error ) {
+ // If console.error is supported, send that, because it gives a stack trace
+ return console.error.apply( console, args );
+ }
+
+ // Otherwise, use console.log
+ console.log.apply( console, args );
+ }
+ };
+
+ /**
+ * Converts a Flow UUID to a UNIX timestamp.
+ * @param {String} uuid
+ * @return {int} UNIX time
+ */
+ mw.flow.uuidToTime = FlowComponent.prototype.uuidToTime = function ( uuid ) {
+ var timestamp = parseInt( uuid, 36 ).toString( 2 ); // Parse from base-36, then serialize to base-2
+ timestamp = Array( 88 + 1 - timestamp.length ).join( '0' ) + timestamp; // left pad 0 to 88 chars
+ timestamp = parseInt( timestamp.substr( 0, 46 ), 2 ); // first 46 chars base-2 to base-10
+
+ return timestamp;
+ };
+
+ /**
+ * Returns all the registered instances of a given FlowComponent.
+ * @returns {FlowComponent[]}
+ */
+ FlowComponent.prototype.getInstances = function () {
+ // Use the correct context (instance vs prototype)
+ return ( this.constructor.parent || this )._instanceRegistry;
+ };
+
+ /**
+ * Goes up the DOM tree to find which FlowComponent $el belongs to, via .flow-component[flow-id].
+ * @param {jQuery} $el
+ * @returns {FlowComponent|bool}
+ */
+ FlowComponent.prototype.getInstanceByElement = function ( $el ) {
+ var $container = $el.closest( '.flow-component' ),
+ context = this.constructor.parent || this, // Use the correct context (instance vs prototype)
+ id;
+
+ // This element isn't _within_ any actual component; was it spawned _by_ a component?
+ if ( !$container.length ) {
+ // Find any parents of this element with the flowSpawnedBy data attribute
+ $container = $el.parents().addBack().filter( function () {
+ return $( this ).data( 'flowSpawnedBy' );
+ } ).last()
+ // Get the flowSpawnedBy node
+ .data( 'flowSpawnedBy' );
+ // and then return the closest flow-component of it
+ $container = $container ? $container.closest( '.flow-component' ) : $();
+ }
+
+ // Still no parent component. Crap out!
+ if ( !$container.length ) {
+ mw.flow.debug( 'Failed to getInstanceByElement: no $container.length', arguments );
+ return false;
+ }
+
+ id = $container.data( 'flow-id' );
+
+ return context._instanceRegistry[ context._instanceRegistryById[ id ] ] || false;
+ };
+
+ /**
+ * Sets the FlowComponent's $container element as the data-flow-spawned-by attribute on $el.
+ * Fires ALL events from within $el onto $eventTarget, albeit with the whole event intact.
+ * This allows us to listen for events from outside of FlowComponent's nodes, but still trigger them within.
+ * @param {jQuery} $el
+ * @param {jQuery} [$eventTarget]
+ */
+ FlowComponent.prototype.assignSpawnedNode = function ( $el, $eventTarget ) {
+ // Target defaults to .flow-component
+ $eventTarget = $eventTarget || this.$container;
+
+ // Assign flowSpawnedBy data attribute
+ $el.data( 'flowSpawnedBy', $eventTarget );
+
+ // Forward all events (except mouse movement) to $eventTarget
+ $el.on(
+ 'blur change click dblclick error focus focusin focusout keydown keypress keyup load mousedown mouseenter mouseleave mouseup resize scroll select submit',
+ '*',
+ { flowSpawnedBy: this.$container, flowSpawnedFrom: $el },
+ function ( event ) {
+ // Let's forward these events in an unusual way, similar to how jQuery propagates events...
+ // First, only take the very first, top-level event, as the rest of the propagation is handled elsewhere
+ if ( event.target === this ) {
+ // Get all the parent nodes of our target,
+ // but do not include any nodes we will already be bubbling up to (eg. body)
+ var $nodes = $eventTarget.parents().addBack().not( $( this ).parents().addBack() ),
+ i = $nodes.length;
+
+ // For every node between $eventTarget and window that was not filtered out above...
+ while ( i-- ) {
+ // Trigger a bubbling event on each one, with the correct context
+ _eventForwardDispatch.call( $nodes[i], event, $el[0] );
+ }
+ }
+ }
+ );
+ };
+
+ //
+ // PRIVATE FUNCTIONS
+ //
+
+ /**
+ * This method is mostly cloned from jQuery.event.dispatch, except that it has been modified to use container
+ * as its base for finding event handlers (via jQuery.event.handlers). This allows us to trigger events on said
+ * container (and its parents, bubbling up), as if the event originated from within it.
+ * jQuery itself doesn't allow for this, as the context (this & event.currentTarget) become the actual element you
+ * are triggering an event on, instead of the element which matched the selector.
+ *
+ * @example _eventForwardDispatch.call( Element, Event, Element );
+ *
+ * @param {jQuery.Event} event
+ * @param {Element} container
+ * @returns {*}
+ * @private
+ */
+ function _eventForwardDispatch( event, container ) {
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event );
+
+ var i, ret, handleObj, matched, j,
+ handlerQueue = [],
+ args = Array.prototype.slice.call( arguments, 0 ),
+ handlers = ( jQuery._data( this, 'events' ) || {} )[ event.type ] || [],
+ special = jQuery.event.special[ event.type ] || {};
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[0] = event;
+ event.delegateTarget = this;
+
+ // Call the preDispatch hook for the mapped type, and let it bail if desired
+ if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+ return;
+ }
+
+ // Determine handlers
+ // The important modification: we use container instead of this as the context
+ handlerQueue = jQuery.event.handlers.call( container, event, handlers );
+
+ // Run delegates first; they may want to stop propagation beneath us
+ i = 0;
+ while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) {
+ event.currentTarget = matched.elem;
+
+ j = 0;
+ while ( ( handleObj = matched.handlers[ j++ ] ) && !event.isImmediatePropagationStopped() ) {
+ // Triggered event must either 1) have no namespace, or
+ // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace).
+ if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
+
+ event.handleObj = handleObj;
+ event.data = handleObj.data;
+
+ ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || handleObj.handler )
+ .apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ if ( ( event.result = ret ) === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ // Call the postDispatch hook for the mapped type
+ if ( special.postDispatch ) {
+ special.postDispatch.call( this, event );
+ }
+
+ return event.result;
+ }
+
+ mw.flow.registerComponent( 'component', FlowComponent );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-registry.js b/Flow/modules/engine/components/flow-registry.js
new file mode 100644
index 00000000..3b2a9498
--- /dev/null
+++ b/Flow/modules/engine/components/flow-registry.js
@@ -0,0 +1,165 @@
+/*!
+ * Creates and manages the component registry.
+ * We expand upon OOjs in several ways here:
+ * 1. Allow mixinClasses to have their constructor functions to be called (at initComponent).
+ * 2. Automatically call all parent constructors from inheritClasses (at initComponent).
+ * 3. Create a global instance registry of components on a page, and also create a registry for each component type.
+ * 4. Have the ability to fetch individual prototype methods from classes in the registry, as they are out of scope.
+ */
+
+( function ( $, mw ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _componentRegistry = new OO.Registry();
+
+ /**
+ * Instantiate one or more new FlowComponents.
+ * Uses data-flow-component to find the right class, and returns that new instance.
+ * Accepts one or more container elements in $container. If multiple, returns an array of FlowBoardComponents.
+ * @param {jQuery} $container
+ * @returns {FlowComponent|boolean|Array} The created FlowComponent instance, or an
+ * array of FlowComponent instances, or boolean false in case of an error.
+ */
+ function initFlowComponent( $container ) {
+ var a, i, componentName, componentBase,
+ /** @private
+ * Deep magic: This crazy little function becomes the "real" top-level constructor
+ * It recursively calls every parent so that we don't have to do it manually in a Component constructor
+ * @returns {FlowComponent}
+ */
+ _RecursiveConstructor = function () {
+ var constructors = [],
+ parent = this.constructor.parent,
+ i, j, parentReturn;
+
+ // Find each parent class
+ while ( parent ) {
+ constructors.push( parent );
+ parent = parent.parent;
+ }
+
+ // Call each parent in reverse (starting with the base class and moving up the chain)
+ for ( i = constructors.length; i--; ) {
+ // Call each mixin constructor
+ for ( j = 0; j < constructors[i].static.mixinConstructors.length; j++ ) {
+ constructors[i].static.mixinConstructors[j].apply( this, arguments );
+ }
+
+ // Call this class constructor
+ parentReturn = constructors[i].apply( this, arguments );
+
+ if ( parentReturn && parentReturn.constructor ) {
+ // If the parent returned an instantiated class (cached), return that instead
+ return parentReturn;
+ }
+ }
+
+ // Run any post-instantiation handlers
+ this.emitWithReturn( 'instantiationComplete', this );
+ };
+
+ if ( !$container || !$container.length ) {
+ // No containers found
+ mw.flow.debug( 'Will not instantiate: no $container.length', arguments );
+ return false;
+ } else if ( $container.length > 1 ) {
+ // Too many elements; instantiate them all
+ for ( a = [], i = $container.length; i--; ) {
+ a.push( initFlowComponent( $( $container[ i ] ) ) );
+ }
+ return a;
+ }
+
+ // Find out which component this is
+ componentName = $container.data( 'flow-component' );
+ // Get that component
+ componentBase = _componentRegistry.lookup( componentName );
+ if ( componentBase ) {
+ // Return the new instance of that FlowComponent, via our _RecursiveConstructor method
+ OO.inheritClass( _RecursiveConstructor, componentBase );
+ return new _RecursiveConstructor( $container );
+ }
+
+ // Don't know what kind of component this is.
+ mw.flow.debug( 'Unknown FlowComponent: ', componentName, arguments );
+ return false;
+ }
+ mw.flow.initComponent = initFlowComponent;
+
+ /**
+ * Registers a given FlowComponent into the component registry, and also has it inherit another class using the
+ * prototypeName argument (defaults to 'component', which returns FlowComponent).
+ * @param {String} name Name of component to register
+ * @param {Function} constructorClass Actual class to link to that name
+ * @param {String} [prototypeName='component'] A base class which this one will inherit
+ */
+ function registerFlowComponent( name, constructorClass, prototypeName ) {
+ if ( name !== 'component' ) {
+ // Inherit a base class; defaults to FlowComponent
+ OO.inheritClass( constructorClass, _componentRegistry.lookup( prototypeName || 'component' ) );
+ }
+
+ // Create the instance registry for this component
+ constructorClass._instanceRegistry = [];
+ constructorClass._instanceRegistryById = {};
+
+ // Assign the OOjs static name to this class
+ constructorClass.static.name = name;
+
+ // Allow mixins to use their constructor
+ constructorClass.static.mixinConstructors = [];
+
+ // Register the component class
+ _componentRegistry.register( name, constructorClass );
+ }
+ mw.flow.registerComponent = registerFlowComponent;
+
+ /**
+ * For when you want to call a specific function from a class's prototype.
+ * @example mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $el );
+ * @param {String} className
+ * @param {String} methodName
+ * @param {*} [context]
+ * @return {Function}
+ */
+ function getFlowPrototypeMethod( className, methodName, context ) {
+ var registeredClass = _componentRegistry.lookup( className ),
+ method;
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent.', arguments );
+ return $.noop;
+ }
+
+ method = registeredClass.prototype[methodName];
+ if ( !method ) {
+ mw.flow.debug( 'Failed to find FlowComponent method.', arguments );
+ return $.noop;
+ }
+
+ return $.proxy( method, context || registeredClass );
+ }
+ mw.flow.getPrototypeMethod = getFlowPrototypeMethod;
+
+ /**
+ * Mixes in the given mixinClass to be copied to an existing class, by name.
+ * @param {String} targetName Target component
+ * @param {Function} mixinClass Class with extension to add to target
+ */
+ function mixinFlowComponent( targetName, mixinClass ) {
+ var registeredClass = _componentRegistry.lookup( targetName );
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent to extend.', arguments );
+ return;
+ }
+
+ OO.mixinClass( registeredClass, mixinClass );
+
+ // Allow mixins to use their constructors (in init)
+ if ( typeof mixinClass === 'function' ) {
+ registeredClass.static.mixinConstructors.push( mixinClass );
+ }
+ }
+ mw.flow.mixinComponent = mixinFlowComponent;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/misc/flow-api.js b/Flow/modules/engine/misc/flow-api.js
new file mode 100644
index 00000000..229a2556
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-api.js
@@ -0,0 +1,411 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var apiTransformMap = {
+ // Map of API submodule name, block name, and prefix name
+ 'moderate-post': [ 'topic_', 'mp' ],
+ 'new-topic': [ 'topiclist_', 'nt' ],
+ 'edit-header': [ 'header_', 'eh' ],
+ 'edit-post': [ 'topic_', 'ep' ],
+ 'reply': [ 'topic_', 'rep' ],
+ 'moderate-topic': [ 'topic_', 'mt' ],
+ 'edit-title': [ 'topic_', 'et' ],
+ 'lock-topic': [ 'topic_', 'cot' ],
+ 'view-topiclist': [ 'topiclist_', 'vtl' ],
+ 'view-post': [ 'topic', 'vp' ],
+ 'view-topic': [ 'topic', 'vt' ],
+ 'view-header': [ 'header_', 'vh' ],
+ 'view-topic-summary': [ 'topicsummary_', 'vts' ],
+ 'edit-topic-summary': [ 'topicsummary_', 'ets' ]
+ };
+
+ /**
+ * Handles Flow API calls. Each FlowComponent has its own instance of FlowApi as component.Api,
+ * so that it can store a workflowId and pageName permanently for simplicity.
+ * @param {String} [workflowId]
+ * @param {String} [pageName]
+ * @returns {FlowApi}
+ * @constructor
+ */
+ function FlowApi( storageEngine, workflowId, pageName ) {
+ this.StorageEngine = storageEngine;
+ this.workflowId = workflowId;
+ this.pageName = pageName;
+
+ /**
+ * Makes the actual API call and returns
+ * @param {Object|String} [params] May be a JSON object string
+ * @param {String} [pageName]
+ * @returns {$.Deferred}
+ */
+ function flowApiCall( params, method ) {
+ var mwApi, tokenType,
+ $deferred = $.Deferred();
+
+ // IE8 caches POST under some conditions, prevent that here.
+ // IE8 is most likely the only browser we support that doesn't
+ // have addEventListener, and anything else that gets caught
+ // up isn't that bad off.
+ if ( !document.addEventListener ) {
+ mwApi = new mw.Api( { ajax: { cache: false } } );
+ } else {
+ mwApi = new mw.Api();
+ }
+
+ params = params || {};
+ // Server is using page instead of title
+ // @todo this should not be necessary
+ params.page = params.page || this.pageName || mw.config.get( 'wgPageName' );
+ method = method ? method.toUpperCase() : 'GET';
+
+ if ( !params.action ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing action string', arguments );
+ return $deferred.rejectWith({ error: 'Invalid action' });
+ }
+ if ( !params.page ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing page string', [ mw.config.get( 'wgPageName' ) ], arguments );
+ return $deferred.rejectWith({ error: 'Invalid title' });
+ }
+
+ if ( method === 'POST' ) {
+ if ( params._internal && params._internal.tokenType ) {
+ tokenType = params._internal.tokenType;
+ } else {
+ tokenType = 'edit';
+ }
+
+ delete params._internal;
+
+ return mwApi.postWithToken( tokenType, params );
+ } else if ( method !== 'GET' ) {
+ return $deferred.rejectWith({ error: 'Unknown submission method: ' + method });
+ } else {
+ return mwApi.get( params );
+ }
+ }
+
+ this.apiCall = flowApiCall;
+ }
+
+ /** @type {Storer} */
+ FlowApi.prototype.StorageEngine = null;
+ /** @type {String} */
+ FlowApi.prototype.pageName = null;
+ /** @type {String} */
+ FlowApi.prototype.workflowId = null;
+ /** @type {String} */
+ FlowApi.prototype.defaultSubmodule = null;
+
+ /**
+ * Sets the fixed pageName for this API instance.
+ * @param {String} pageName
+ */
+ function flowApiSetPageName( pageName ) {
+ this.pageName = pageName;
+ }
+
+ FlowApi.prototype.setPageName = flowApiSetPageName;
+
+ /**
+ * Sets the fixed workflowId for this API instance.
+ * @param {String} workflowId
+ */
+ function flowApiSetWorkflowId( workflowId ) {
+ this.workflowId = workflowId;
+ }
+
+ FlowApi.prototype.setWorkflowId = flowApiSetWorkflowId;
+
+ /**
+ * Transforms URL request parameters into API params
+ * @todo fix it server-side so we don't need this client-side
+ * @param {Object} queryMap
+ * @returns {Object}
+ */
+ function flowApiTransformMap( queryMap ) {
+ var key,
+ map = apiTransformMap[queryMap.submodule];
+ if ( !map ) {
+ return queryMap;
+ }
+ for ( key in queryMap ) {
+ if ( queryMap.hasOwnProperty( key ) ) {
+ if ( key.indexOf( map[0] ) === 0 ) {
+ queryMap[ key.replace( map[0], map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ if ( key.indexOf( 'flow_' ) === 0 ) {
+ queryMap[ key.replace( 'flow_', map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * Sets the fixed defaultSubmodule for this API instance.
+ * @param {String} defaultSubmodule
+ */
+ function flowApiSetDefaultSubmodule( defaultSubmodule ) {
+ this.defaultSubmodule = defaultSubmodule;
+ }
+
+ FlowApi.prototype.setDefaultSubmodule = flowApiSetDefaultSubmodule;
+
+ /**
+ * Overrides (values of) queryMap with a provided override, which can come
+ * in the form of an object (which the queryMap will be extended with) or as
+ * a function (whose return value will replace queryMap)
+ *
+ * @param {Object} [queryMap]
+ * @param {Function|Object} [override]
+ * @returns {Object}
+ */
+ function flowOverrideQueryMap( queryMap, override ) {
+ if ( override ) {
+ switch ( typeof override ) {
+ // If given an override object, extend our queryMap with it
+ case 'object':
+ $.extend( queryMap, override );
+ break;
+ // If given an override function, call it and make it return the new queryMap
+ case 'function':
+ queryMap = override( queryMap );
+ break;
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * With a url (a://b.c/d?e=f&g#h) will return an object of key-value pairs ({e:'f', g:''}).
+ * @param {String|Element} url
+ * @param {Object} [queryMap]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @returns {Object}
+ */
+ function flowApiGetQueryMap( url, queryMap, overrides ) {
+ var uri,
+ queryKey,
+ queryValue,
+ i = 0,
+ $node, $form, formData;
+
+ queryMap = queryMap || {};
+ overrides = overrides || [];
+
+ // If URL is an Element...
+ if ( typeof url !== 'string' ) {
+ $node = $( url );
+
+ // Get the data-flow-api-action override from the node itself
+ queryMap.submodule = $node.data( 'flow-api-action' );
+
+ if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ // We are processing a form
+ $form = $node.closest( 'form' );
+ formData = $form.serializeArray();
+
+ // Get the data-flow-api-action override from the form
+ queryMap.submodule = queryMap.submodule || $form.data( 'flow-api-action' );
+
+ // Build the queryMap manually from a serialized form
+ for ( i = 0; i < formData.length; i++ ) {
+ // skip wpEditToken, its handle independently
+ if ( formData[ i ].name !== 'wpEditToken' ) {
+ queryMap[ formData[ i ].name ] = formData[ i ].value;
+ }
+ }
+
+ // Add the given button to the queryMap as well
+ if ( $node.is( 'button, input' ) && $node.prop( 'name' ) ) {
+ queryMap[ $node.prop( 'name' ) ] = $node.val();
+ }
+
+ // Now process the form action as the URL
+ url = $form.attr( 'action' );
+ } else if ( $node.is( 'a' ) ) {
+ // It's an anchor, process the href as the URL
+ url = $node.prop( 'href' );
+ } else {
+ // Somebody set up us the bomb
+ url = '';
+ }
+ }
+
+ // Parse the URL query params
+ uri = new mw.Uri( url );
+
+ for ( queryKey in uri.query ) {
+ queryValue = uri.query[queryKey];
+ if ( queryKey === 'action' ) {
+ // Submodule is the action
+ queryKey = 'submodule';
+ }
+ if ( queryKey === 'title' ) {
+ // Server is using page
+ queryKey = 'page';
+ }
+
+ // Only add this to the query map if it didn't already exist, eg. in a form input
+ if ( !queryMap[ queryKey ] ) {
+ queryMap[ queryKey ] = queryValue;
+ }
+ }
+
+ // Use the default submodule if no action in URL
+ queryMap.submodule = queryMap.submodule || this.defaultSubmodule;
+ // Default action is flow
+ queryMap.action = queryMap.action || 'flow';
+
+ // Override the automatically generated queryMap
+ for ( i = 0; i < overrides.length; i++ ) {
+ queryMap = flowOverrideQueryMap( queryMap, overrides[i] );
+ }
+
+ // Use the API map to transform this data if necessary, eg.
+ queryMap = flowApiTransformMap( queryMap );
+
+ return queryMap;
+ }
+
+ FlowApi.prototype.getQueryMap = flowApiGetQueryMap;
+
+ /**
+ * Using a given form, parses its action, serializes the data, and sends it as GET or POST depending on form method.
+ * With button, its name=value is serialized in. If button is an Event, it will attempt to find the clicked button.
+ * Additional params can be set with data-flow-api-params on both the clicked button or the form.
+ * @param {Event|Element} [button]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromForm( button, overrides ) {
+ var $deferred = $.Deferred(),
+ $button = $( button ),
+ method = $button.closest( 'form' ).attr( 'method' ) || 'GET',
+ queryMap;
+
+ // Parse the form action to get the rest of the queryMap
+ if ( !( queryMap = this.getQueryMap( button, null, overrides ) ) ) {
+ return $deferred.rejectWith( { error: 'Invalid form action' } );
+ }
+
+ if ( !( queryMap.action ) ) {
+ return $deferred.rejectWith( { error: 'Unknown action for form' } );
+ }
+
+ // Cancel any old form request, and also trigger a new one
+ return this.abortOldRequestFromNode( $button, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromForm = flowApiRequestFromForm;
+
+ /**
+ * Using a given anchor, parses its URL and sends it as a GET (default) or POST depending on data-flow-api-method.
+ * Additional params can be set with data-flow-api-params.
+ * @param {Element} anchor
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromAnchor( anchor, overrides ) {
+ var $anchor = $( anchor ),
+ $deferred = $.Deferred(),
+ queryMap,
+ method = $anchor.data( 'flow-api-method' ) || 'GET';
+
+ // Build the query map from this anchor's HREF
+ if ( !( queryMap = this.getQueryMap( anchor.href, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] requestFromAnchor error: invalid href', arguments );
+ return $deferred.rejectWith( { error: 'Invalid href' } );
+ }
+
+ // Abort any old requests, and have it issue a new one via GET or POST
+ return this.abortOldRequestFromNode( $anchor, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromAnchor = flowApiRequestFromAnchor;
+
+ /**
+ * Automatically calls requestFromAnchor or requestFromForm depending on the type of node given.
+ * @param {Element} node
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred|bool}
+ */
+ function flowApiRequestFromNode( node, overrides ) {
+ var $node = $( node );
+
+ if ( $node.is( 'a' ) ) {
+ return this.requestFromAnchor.apply( this, arguments );
+ } else if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ return this.requestFromForm.apply( this, arguments );
+ } else {
+ return false;
+ }
+ }
+
+ FlowApi.prototype.requestFromNode = flowApiRequestFromNode;
+
+ /**
+ * Handles aborting an old in-flight API request.
+ * If startNewMethod is given, this method also STARTS a new API call and stores it for later abortion if needed.
+ * @param {jQuery|Element} $node
+ * @param {Object} [queryMap]
+ * @param {String} [startNewMethod] If given: starts, stores, and returns a new API call
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {undefined|$.Deferred}
+ */
+ function flowApiAbortOldRequestFromNode( $node, queryMap, startNewMethod, overrides ) {
+ $node = $( $node );
+
+ if ( !queryMap ) {
+ // Get the queryMap automatically if one wasn't given
+ if ( !( queryMap = this.getQueryMap( $node, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] abortOldRequestFromNode failed to find a queryMap', arguments );
+ return;
+ }
+ }
+
+ // If this anchor already has a request in flight, abort it
+ var str = 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ prevApiCall = $node.data( str ),
+ newApiCall;
+
+ // If a previous API call was found, let's abort it
+ if ( prevApiCall ) {
+ $node.removeData( str );
+
+ if ( prevApiCall.abort ) {
+ prevApiCall.abort();
+ }
+
+ mw.flow.debug( '[FlowApi] apiCall abort request in flight: ' + str, arguments );
+ }
+
+ // If a method was given, we want to also issue a new API request now
+ if ( startNewMethod ) {
+ // Make a new request with this info
+ newApiCall = this.apiCall( queryMap, startNewMethod );
+
+ // Store this request on the node if it needs to be aborted
+ $node.data(
+ 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ newApiCall
+ );
+
+ // Remove the request on success
+ newApiCall.always( function () {
+ $node.removeData( 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule );
+ } );
+
+ return newApiCall;
+ }
+ }
+
+ FlowApi.prototype.abortOldRequestFromNode = flowApiAbortOldRequestFromNode;
+
+ // Export
+ mw.flow.FlowApi = FlowApi;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-baseconvert.js b/Flow/modules/engine/misc/flow-baseconvert.js
new file mode 100644
index 00000000..ae6e755f
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-baseconvert.js
@@ -0,0 +1,70 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ // Direct translation of wfBaseConvert. Can't be done with parseInt and
+ // Integer.toString because javascript uses doubles for math, giving only
+ // 53 bits of precision.
+ mw.flow.baseConvert = function ( input, sourceBase, destBase ) {
+ var regex = new RegExp( "^[" + '0123456789abcdefghijklmnopqrstuvwxyz'.substr( 0, sourceBase ) + "]+$" ),
+ baseChars = {
+ '10': 'a', '11': 'b', '12': 'c', '13': 'd', '14': 'e', '15': 'f',
+ '16': 'g', '17': 'h', '18': 'i', '19': 'j', '20': 'k', '21': 'l',
+ '22': 'm', '23': 'n', '24': 'o', '25': 'p', '26': 'q', '27': 'r',
+ '28': 's', '29': 't', '30': 'u', '31': 'v', '32': 'w', '33': 'x',
+ '34': 'y', '35': 'z',
+
+ '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5,
+ '6': 6, '7': 7, '8': 8, '9': 9, 'a': 10, 'b': 11,
+ 'c': 12, 'd': 13, 'e': 14, 'f': 15, 'g': 16, 'h': 17,
+ 'i': 18, 'j': 19, 'k': 20, 'l': 21, 'm': 22, 'n': 23,
+ 'o': 24, 'p': 25, 'q': 26, 'r': 27, 's': 28, 't': 29,
+ 'u': 30, 'v': 31, 'w': 32, 'x': 33, 'y': 34, 'z': 35
+ },
+ inDigits = [],
+ result = [],
+ i, work, workDigits;
+
+ input = String( input );
+ if ( sourceBase < 2 ||
+ sourceBase > 36 ||
+ destBase < 2 ||
+ destBase > 36 ||
+ sourceBase !== parseInt( sourceBase, 10 ) ||
+ destBase !== parseInt( destBase, 10 ) ||
+ !regex.test( input )
+ ) {
+ return false;
+ }
+
+ for ( i in input ) {
+ inDigits.push( baseChars[input[i]] );
+ }
+
+ // Iterate over the input, modulo-ing out an output digit
+ // at a time until input is gone.
+ while( inDigits.length ) {
+ work = 0;
+ workDigits = [];
+
+ // Long division...
+ for ( i in inDigits ) {
+ work *= sourceBase;
+ work += inDigits[i];
+
+ if ( workDigits.length || work >= destBase ) {
+ workDigits.push( parseInt( work / destBase, 10 ) );
+ }
+ work %= destBase;
+ }
+
+ // All that division leaves us with a remainder,
+ // which is conveniently our next output digit
+ result.push( baseChars[work] );
+
+ // And we continue
+ inDigits = workDigits;
+ }
+
+ return result.reverse().join("");
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-eventlog.js b/Flow/modules/engine/misc/flow-eventlog.js
new file mode 100644
index 00000000..aafa7515
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-eventlog.js
@@ -0,0 +1,45 @@
+( function ( mw, $ ) {
+ /**
+ * @param {String} schemaName Canonical schema name.
+ * @param {Object} [eventInstance] Shared event instance data.
+ * @returns {FlowEventLog}
+ * @constructor
+ */
+ function FlowEventLog( schemaName, eventInstance ) {
+ this.schemaName = schemaName;
+ this.eventInstance = eventInstance || {};
+
+ /**
+ * @param {object} eventInstance Additional event instance data for this
+ * particular event.
+ * @returns {$.Deferred}
+ */
+ function logEvent( eventInstance ) {
+ // Ensure eventLog & this schema exist, or return a stub deferred
+ if ( !mw.eventLog || !mw.eventLog.schemas[this.schemaName] ) {
+ return $.Deferred().promise();
+ }
+
+ return mw.eventLog.logEvent(
+ this.schemaName,
+ $.extend( this.eventInstance, eventInstance )
+ );
+ }
+ this.logEvent = logEvent;
+ }
+
+ var FlowEventLogRegistry = {
+ funnels: {},
+
+ /**
+ * Generates a unique id.
+ *
+ * @returns {string}
+ */
+ generateFunnelId: mw.user.generateRandomSessionId
+ };
+
+ // Export
+ mw.flow.FlowEventLog = FlowEventLog;
+ mw.flow.FlowEventLogRegistry = FlowEventLogRegistry;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-handlebars.js b/Flow/modules/engine/misc/flow-handlebars.js
new file mode 100644
index 00000000..b3f21925
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-handlebars.js
@@ -0,0 +1,581 @@
+/*!
+ * Implements a Handlebars layer for FlowBoard.TemplateEngine
+ */
+
+( function ( mw, $, moment, Handlebars ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _tplcache = {},
+ _timestamp = {
+ list: [],
+ currentIndex: 0
+ };
+
+
+ /**
+ * Instantiates a FlowHandlebars instance for TemplateEngine.
+ * @param {Object} FlowStorageEngine
+ * @returns {FlowHandlebars}
+ * @constructor
+ */
+ function FlowHandlebars( FlowStorageEngine ) {
+ return this;
+ }
+
+ mw.flow.FlowHandlebars = FlowHandlebars;
+
+ /**
+ * Returns a given template function. If template is missing, the template function is noop with mw.flow.debug.
+ * @param {String|Function} templateName
+ * @returns {Function}
+ */
+ FlowHandlebars.prototype.getTemplate = function ( templateName ) {
+ // If a template is already being passed, use it
+ if ( typeof templateName === 'function' ) {
+ return templateName;
+ }
+
+ if ( _tplcache[ templateName ] ) {
+ // Return cached compiled template
+ return _tplcache[ templateName ];
+ }
+
+ _tplcache[ templateName ] = mw.template.get( 'ext.flow.templating', 'handlebars/' + templateName + '.handlebars' );
+ if ( _tplcache[ templateName ] ) {
+ // Try to get this template
+ _tplcache[ templateName ] = _tplcache[ templateName ].render;
+ }
+
+ return _tplcache[ templateName ] || function () { mw.flow.debug( '[Handlebars] Missing template', arguments ); };
+ };
+
+ /**
+ * Processes a given template and returns the HTML generated by it.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.processTemplate = function ( templateName, args ) {
+ return FlowHandlebars.prototype.getTemplate( templateName )( args );
+ };
+
+ /**
+ * Runs processTemplate inside, but returns a DocumentFragment instead of an HTML string.
+ * This should be used for runtime parsing of a template, as it triggers processProgressiveEnhancement on the
+ * fragment, which allows progressiveEnhancement blocks to be instantiated.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {DocumentFragment}
+ */
+ FlowHandlebars.prototype.processTemplateGetFragment = function ( templateName, args ) {
+ var fragment = document.createDocumentFragment(),
+ div = document.createElement( 'div' );
+
+ div.innerHTML = FlowHandlebars.prototype.processTemplate( templateName, args );
+
+ FlowHandlebars.prototype.processProgressiveEnhancement( div );
+
+ while ( div.firstChild ) {
+ fragment.appendChild( div.firstChild );
+ }
+
+ div = null;
+
+ return fragment;
+ };
+
+ /**
+ * A method to call helper functions from outside templates. This removes Handlebars.SafeString wrappers.
+ * @param {String} helperName
+ * @param {...*} [args]
+ * @return mixed
+ */
+ FlowHandlebars.prototype.callHelper = function ( helperName, args ) {
+ var result = this[ helperName ].apply( this, Array.prototype.slice.call( arguments, 1 ) );
+ if ( result && result.string ) {
+ return result.string;
+ }
+ return result;
+ };
+
+ /**
+ * Finds scripts of x-handlebars-template-progressive-enhancement type, compiles its innerHTML as a Handlebars
+ * template, and then replaces the whole script tag with it. This is used to "progressively enhance" a page with
+ * elements that are only necessary with JavaScript. On a non-JS page, these elements are never rendered at all.
+ * @param {Element|jQuery} target
+ * @todo Lacks args, lacks functionality, full support. (see also FlowHandlebars.prototype.progressiveEnhancement)
+ */
+ FlowHandlebars.prototype.processProgressiveEnhancement = function ( target ) {
+ $( target ).find( 'script' ).addBack( 'script' ).filter( '[type="text/x-handlebars-template-progressive-enhancement"]' ).each( function () {
+ var $this = $( this ),
+ data = $this.data(),
+ target = $.trim( data.target ),
+ $target = $this,
+ content, $prevTarg, $nextTarg;
+
+ // Find new target, if not the script tag itself
+ if ( target ) {
+ $target = $this.findWithParent( target );
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[processProgressiveEnhancement] Failed to find target', target, arguments );
+ return;
+ }
+ }
+
+ // Replace the nested flowprogressivescript tag with a real script tag for recursive progressiveEnhancement
+ content = this.innerHTML.replace( /<\/flowprogressivescript>/g, '</script>' );
+
+ // Inject the content
+ switch ( data.type ) {
+ case 'content':
+ // Insert
+ $target.empty().append( content );
+ // Get all new nodes
+ $target = $target.children();
+ break;
+
+ case 'insert':
+ // Store sibling before adding new content
+ $prevTarg = $target.prev();
+ // Insert
+ $target.before( content );
+ // Get all new nodes
+ $target = $target.prevUntil( $prevTarg );
+ break;
+
+ case 'replace':
+ /* falls through */
+ default:
+ // Store siblings before adding new content
+ $prevTarg = $target.prev();
+ $nextTarg = $target.next();
+ // Insert
+ $target.replaceWith( content );
+ // Get all new nodes
+ $target = $prevTarg.nextUntil( $nextTarg );
+ }
+
+ // $target now contains all the new elements inserted; let's recursively do progressiveEnhancement if needed
+ FlowHandlebars.prototype.processProgressiveEnhancement( $target );
+
+ // Remove script tag
+ $this.remove();
+ } );
+ };
+
+ /**
+ * Parameters could be Message::rawParam (in PHP) object, which will
+ * translate into a { raw: "string" } object in JS.
+ * @todo: this does not exactly match the behavior in PHP yet (no parse,
+ * no escape), but at least it won't print an [Object object] param.
+ *
+ * @param {Array} parameters
+ * @return {Array}
+ */
+ function flowNormalizeL10nParameters( parameters ) {
+ return $.map( parameters, function ( arg ) {
+ return arg ? ( arg.raw || arg.plaintext || arg ) : '';
+ } );
+ }
+
+ /**
+ * Calls flowMessages to get localized message strings.
+ * @todo use mw.message
+ * @example {{l10n "reply_count" 12}}
+ * @param {String} str
+ * @param {...*} [args]
+ * @param {Object} [options]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.l10n = function ( str /*, args..., options */ ) {
+ // chop off str and options leaving just args
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return mw.message( str ).params( args ).text();
+ };
+
+ /**
+ * HTML-safe version of l10n.
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.l10nParse = function ( str /*, args..., options */ ) {
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return FlowHandlebars.prototype.html(
+ mw.message( str ).params( args ).parse()
+ );
+ };
+
+ /**
+ * Parses the timestamp out of a base-36 UUID, and calls timestamp with it.
+ * @example {{uuidTimestamp id "flow-message-x-"}}
+ * @param {String} uuid id
+ * @param {bool} [timeAgoOnly]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.uuidTimestamp = function ( uuid, timeAgoOnly ) {
+ var timestamp = mw.flow.uuidToTime( uuid );
+
+ return FlowHandlebars.prototype.timestamp( timestamp, timeAgoOnly );
+ };
+
+ /**
+ * Generates markup for an "nnn sssss ago" and date/time string.
+ * @example {{timestamp start_time}}
+ * @param {int} timestamp milliseconds
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.timestamp = function ( timestamp ) {
+ if ( isNaN( timestamp ) ) {
+ mw.flow.debug( '[timestamp] Invalid arguments', arguments );
+ return;
+ }
+
+ var guid,
+ formatter = moment( timestamp );
+
+ // Generate a GUID for this element to find it later
+ guid = ( Math.random() + 1 ).toString( 36 ).substring( 2 );
+
+ // Store this in the timestamps auto-updater array
+ _timestamp.list.push( { guid: guid, timestamp: timestamp, failcount: 0 } );
+
+ // Render the timestamp template
+ return FlowHandlebars.prototype.html(
+ FlowHandlebars.prototype.processTemplate(
+ 'timestamp',
+ {
+ time_iso: timestamp,
+ time_ago: formatter.fromNow(),
+ time_readable: formatter.format( 'LLL' ),
+ guid: guid
+ }
+ )
+ );
+ };
+
+ /**
+ * Updates one flow-timestamp node at a time every 100ms, until finishing, and then sleeps 5s.
+ * Nodes do not get updated again until they have changed.
+ * @todo Perhaps only update elements within the viewport?
+ * @todo Maybe updating elements every few seconds is distracting? Think about this.
+ */
+ function timestampAutoUpdate() {
+ var arrayItem, $ago, failed, secondsAgo, text, formatter,
+ currentTime = +new Date() / 1000;
+
+ // Only update elements that need updating (eg. only update minutes every 60s)
+ do {
+ arrayItem = _timestamp.list[ _timestamp.list._currentIndex ];
+
+ if ( !arrayItem || !arrayItem.nextUpdate || currentTime >= arrayItem.nextUpdate ) {
+ break;
+ }
+
+ // Find the next array item
+ _timestamp.list._currentIndex++;
+ } while ( arrayItem );
+
+ if ( !arrayItem ) {
+ // Finished array; reset loop
+ _timestamp.list._currentIndex = 0;
+
+ // Run again in 5s
+ setTimeout( timestampAutoUpdate, 5000 );
+ return;
+ }
+
+ $ago = $( document.getElementById( arrayItem.guid ) );
+ failed = true;
+ secondsAgo = currentTime - ( arrayItem.timestamp / 1000 );
+
+ if ( $ago && $ago.length ) {
+ formatter = moment( arrayItem.timestamp );
+ text = formatter.fromNow();
+
+ // Returned a valid "n ago" string?
+ if ( text ) {
+ // Reset the failcount
+ failed = arrayItem.failcount = 0;
+
+ // Set the next update time
+ arrayItem.nextUpdate = currentTime + ( secondsAgo > 604800 ? 604800 - currentTime % 604800 : ( secondsAgo > 86400 ? 86400 - currentTime % 86400 : ( secondsAgo > 3600 ? 3600 - currentTime % 3600 : ( secondsAgo > 60 ? 60 - currentTime % 60 : 1 ) ) ) );
+
+ // Only touch the DOM if the text has actually changed
+ if ( $ago.text() !== text ) {
+ $ago.text( text );
+ }
+ }
+ }
+
+ if ( failed && ++arrayItem.failcount > 9 ) {
+ // Remove this array item if we failed this 10 times in a row
+ _timestamp.list.splice( _timestamp.list._currentIndex, 1 );
+ } else {
+ // Go to next item
+ _timestamp.list._currentIndex++;
+ }
+
+ // Run every 100ms until we update all nodes
+ setTimeout( timestampAutoUpdate, 100 );
+ }
+
+ $( document ).ready( timestampAutoUpdate );
+
+ /**
+ * Do not escape HTML string. Used as a Handlebars helper.
+ * @example {{html "<div/>"}}
+ * @param {String} string
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.html = function ( string ) {
+ return new Handlebars.SafeString( string );
+ };
+
+ /**
+ *
+ * @example {{block this}}
+ * @param {Object} context
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.workflowBlock = function ( context, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_block_' + context.type + ( context['block-action-template'] || '' ),
+ context
+ ) );
+ };
+
+ /**
+ * @example {{post ../../../../rootBlock this}}
+ * @param {Object} context
+ * @param {Object} revision
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.postBlock = function ( context, revision, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_post',
+ {
+ revision: revision,
+ rootBlock: context
+ }
+ ) );
+ };
+
+ /**
+ * @example {{#each topics}}{{#eachPost this}}{{content}}{{/eachPost}}{{/each}}
+ * @param {String} context
+ * @param {String} postId
+ * @param {Object} options
+ * @returns {String}
+ * @todo support multiple postIds in an array
+ */
+ FlowHandlebars.prototype.eachPost = function ( context, postId, options ) {
+ var revId = ( context.posts && context.posts[postId] && context.posts[postId][0] ),
+ revision = ( context.revisions && context.revisions[revId] ) || { content: null };
+
+ if ( revision.content === null ) {
+ mw.flow.debug( '[eachPost] Failed to find revision object', arguments );
+ }
+
+ return options.fn ? options.fn( revision ) : revision;
+ };
+
+ /**
+ * The progressiveEnhancement helper essentially does one of replace things:
+ * 1. type="replace": (target="selector") Replaces target entirely with rendered template.
+ * 2. type="content": (target="selector") Replaces target's content with rendered template.
+ * 3. type="insert": Inserts rendered template at the helper's location.
+ *
+ * This template is used to simplify server-side and client-side rendering. Client-side renders a
+ * progressiveEnhancement helper instantly, in the post-process stage. The server-side renders only a script tag
+ * with a template inside. This script tag is found ondomready, and then the post-processing occurs at that time.
+ *
+ * Option keys:
+ * * type=String (replace, content, insert)
+ * * target=String (jQuery selector; needed for replace and content -- defaults to self)
+ * * id=String
+ * @example {{#progressiveEnhancement type="content"}}{{> ok}}{{/progressiveEnhancement}}
+ * @param {Object} options
+ * @return {String}
+ * @todo Implement support for full functionality, perhaps revisit the implementation.
+ */
+ FlowHandlebars.prototype.progressiveEnhancement = function ( options ) {
+ var hash = options.hash,
+ // Replace nested script tag with placeholder tag for
+ // recursive progresiveEnhancement
+ inner = options.fn( this ).replace( /<\/script>/g, '</flowprogressivescript>' );
+
+ if ( !hash.type ) {
+ hash.type = 'insert';
+ }
+
+ return FlowHandlebars.prototype.html(
+ '<scr' + 'ipt' +
+ ' type="text/x-handlebars-template-progressive-enhancement"' +
+ ' data-type="' + hash.type + '"' +
+ ( hash.target ? ' data-target="' + hash.target + '"' : '' ) +
+ ( hash.id ? ' id="' + hash.id + '"' : '' ) +
+ '>' +
+ inner +
+ '</scr' + 'ipt>'
+ );
+ };
+
+ /**
+ * Runs a callback when user is anonymous
+ * @param array $options which must contain fn and inverse key mapping to functions.
+ *
+ * @return mixed result of callback
+ */
+ FlowHandlebars.prototype.ifAnonymous = function( options ) {
+ if ( mw.user.isAnon() ) {
+ return options.fn( this );
+ }
+ return options.inverse( this );
+ };
+
+ /**
+ * Adds returnto parameter pointing to given Title to an existing URL
+ * @param string $title
+ *
+ * @return string modified url
+ */
+ FlowHandlebars.prototype.linkWithReturnTo = function( title ) {
+ return mw.util.getUrl( title, {
+ returntoquery: encodeURIComponent( window.location.search ),
+ returnto: mw.config.get( 'wgPageName' )
+ } );
+ };
+
+ /**
+ * Accepts the contentType and content properties returned from the api
+ * for individual revisions and ensures that content is included in the
+ * final html page in an XSS safe maner.
+ *
+ * It is expected that all content with contentType of html has been
+ * processed by parsoid and is safe for direct output into the document.
+ *
+ * Usage:
+ * {{escapeContent revision.contentType revision.content}}
+ *
+ * @param {string} contentType
+ * @param {string} content
+ * @return {string}
+ */
+ FlowHandlebars.prototype.escapeContent = function ( contentType, content ) {
+ if ( contentType === 'html' ) {
+ return FlowHandlebars.prototype.html( content );
+ }
+ return content;
+ };
+
+ /**
+ * Renders a tooltip node.
+ * @example {{#tooltip positionClass="up" contextClass="progressive" extraClass="flow-my-tooltip"}}what{{/tooltip}}
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.tooltip = function ( options ) {
+ var params = options.hash;
+
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_tooltip',
+ {
+ positionClass: params.positionClass ? 'flow-ui-tooltip-' + params.positionClass : null,
+ contextClass: params.contextClass ? 'mw-ui-' + params.contextClass : null,
+ extraClass: params.extraClass,
+ blockClass: params.isBlock ? 'flow-ui-tooltip-block' : null,
+ content: options.fn( this )
+ }
+ ) );
+ };
+
+ /**
+ * Return url for putting post into the specified moderation state. If the user
+ * cannot put the post into the specified state a blank string is returned.
+ *
+ * @param {Object}
+ * @param {string}
+ * @return {string}
+ */
+ FlowHandlebars.prototype.moderationAction = function ( actions, moderationState ) {
+ return actions[moderationState] ? actions[moderationState].url : '';
+ };
+
+ /**
+ * Concatenate all unnamed handlebars arguments
+ *
+ * @return {string}
+ */
+ FlowHandlebars.prototype.concat = function () {
+ // handlebars puts an options argument at the end of
+ // user supplied parameters, pop that off
+ return Array.prototype.slice.call( arguments, 0, -1 ).join( '' );
+ };
+
+ /**
+ * Renders block if condition is true
+ *
+ * @param {string} value
+ * @param {string} operator supported values: 'or'
+ * @param {string} value
+ */
+ FlowHandlebars.prototype.ifCond = function ( value, operator, value2, options ) {
+ if ( operator === 'or' ) {
+ return value || value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '===' ) {
+ return value === value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '!==' ) {
+ return value !== value2 ? options.fn( this ) : options.inverse( this );
+ }
+ return '';
+ };
+
+ /**
+ * Outputs debugging information
+ *
+ * For development use only
+ */
+ FlowHandlebars.prototype.debug = function () {
+ mw.flow.debug( '[Handlebars] debug', arguments );
+ };
+
+ // Load partials
+ $.each( mw.templates.values, function( moduleName ) {
+ $.each( this, function( name ) {
+ // remove extension
+ var partialMatch, partialName;
+
+ partialMatch = name.match( /handlebars\/(.*)\.partial\.handlebars$/ );
+ if ( partialMatch ) {
+ partialName = partialMatch[1];
+ Handlebars.partials[ partialName ] = mw.template.get( moduleName, name ).render;
+ }
+ } );
+ } );
+
+ // Register helpers
+ Handlebars.registerHelper( 'l10n', FlowHandlebars.prototype.l10n );
+ Handlebars.registerHelper( 'l10nParse', FlowHandlebars.prototype.l10nParse );
+ Handlebars.registerHelper( 'uuidTimestamp', FlowHandlebars.prototype.uuidTimestamp );
+ Handlebars.registerHelper( 'timestamp', FlowHandlebars.prototype.timestamp );
+ Handlebars.registerHelper( 'html', FlowHandlebars.prototype.html );
+ Handlebars.registerHelper( 'block', FlowHandlebars.prototype.workflowBlock );
+ Handlebars.registerHelper( 'post', FlowHandlebars.prototype.postBlock );
+ Handlebars.registerHelper( 'eachPost', FlowHandlebars.prototype.eachPost );
+ Handlebars.registerHelper( 'progressiveEnhancement', FlowHandlebars.prototype.progressiveEnhancement );
+ Handlebars.registerHelper( 'ifAnonymous', FlowHandlebars.prototype.ifAnonymous );
+ Handlebars.registerHelper( 'linkWithReturnTo', FlowHandlebars.prototype.linkWithReturnTo );
+ Handlebars.registerHelper( 'escapeContent', FlowHandlebars.prototype.escapeContent );
+ Handlebars.registerHelper( 'tooltip', FlowHandlebars.prototype.tooltip );
+ Handlebars.registerHelper( 'moderationAction', FlowHandlebars.prototype.moderationAction );
+ Handlebars.registerHelper( 'concat', FlowHandlebars.prototype.concat );
+ Handlebars.registerHelper( 'ifCond', FlowHandlebars.prototype.ifCond );
+ Handlebars.registerHelper( 'debug', FlowHandlebars.prototype.debug );
+
+}( mediaWiki, jQuery, moment, Handlebars ) );
diff --git a/Flow/modules/engine/misc/jquery.conditionalScroll.js b/Flow/modules/engine/misc/jquery.conditionalScroll.js
new file mode 100644
index 00000000..27d4791f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.conditionalScroll.js
@@ -0,0 +1,51 @@
+( function ( $ ) {
+ /**
+ * Scrolls the viewport to fit $el into view only if necessary. Scenarios:
+ * 1. If el starts above viewport, scrolls to put top of el at top of viewport.
+ * 2. If el ends below viewport and fits into viewport, scrolls to put bottom of el at bottom of viewport.
+ * 3. If el ends below viewport but is taller than the viewport, scrolls to put top of el at top of viewport.
+ * @param {string|int} [speed='fast']
+ */
+ $.fn.conditionalScrollIntoView = function ( speed ) {
+ speed = speed !== undefined ? speed : 'fast';
+
+ // We queue this to happen on the element, because we need to wait for it to finish performing its own
+ // animations (eg. it might be doing a slideDown), even though THIS actual animation occurs on body.
+ this.queue( function () {
+ var $this = $( this ),
+ viewportY = $( window ).scrollTop(),
+ viewportHeight = $( window ).height(),
+ elOffset = $this.offset(),
+ elHeight = $this.outerHeight(),
+ scrollTo = -1;
+
+ if ( elOffset.top < viewportY ) {
+ // Element starts above viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else if ( elOffset.top + elHeight > viewportY + viewportHeight ) {
+ // Element ends below viewport
+ if ( elHeight > viewportHeight ) {
+ // Too tall to fit into viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else {
+ // Fits into viewport; put el bottom at bottom
+ scrollTo = elOffset.top + elHeight - viewportHeight;
+ }
+ } // else: element is already in viewport.
+
+ if ( scrollTo > -1 ) {
+ // Scroll the viewport to display this element
+ $( 'html, body' ).animate( { scrollTop: scrollTo }, speed, function () {
+ // Fire off the next fx queue on the main element when we finish scrolling the window
+ $this.dequeue();
+ } );
+ } else {
+ // If we don't have to scroll, continue to the next fx queue item immediately
+ $this.dequeue();
+ }
+ } );
+
+ // Do nothing
+ return this;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/jquery.findWithParent.js b/Flow/modules/engine/misc/jquery.findWithParent.js
new file mode 100644
index 00000000..c2c60a5f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.findWithParent.js
@@ -0,0 +1,45 @@
+( function ( $ ) {
+ /**
+ * Gives support to find parent elements using .closest with less-than selector syntax.
+ * @example $.findWithParent( $div, "< html div < body" ); // find closest parent of $div "html", find child "div" of it, find closest parent "body" of that, return "body"
+ * @example $( '#foo' ).findWithParent( '.bar < .baz' ); // find child ".bar" of "#foo", return closest parent ".baz" from there
+ * @param {jQuery|Element|String} $context
+ * @param {String} selector
+ * @returns {jQuery}
+ */
+ function jQueryFindWithParent( $context, selector ) {
+ var matches;
+
+ $context = $( $context );
+ selector = $.trim( selector );
+
+ while ( selector && ( matches = selector.match(/(.*?(?:^|[>\s+~]))(<\s*[^>\s+~]+)(.*?)$/) ) ) {
+ if ( $.trim( matches[ 1 ] ) ) {
+ $context = $context.find( matches[ 1 ] );
+ }
+ if ( $.trim( matches[ 2 ] ) ) {
+ $context = $context.closest( matches[ 2 ].substr( 1 ) );
+ }
+ selector = $.trim( matches[ 3 ] );
+ }
+
+ if ( selector ) {
+ $context = $context.find( selector );
+ }
+
+ return $context;
+ }
+
+ $.findWithParent = jQueryFindWithParent;
+ $.fn.findWithParent = function ( selector ) {
+ var selectors = selector.split( ',' ),
+ $elements = $(),
+ self = this;
+
+ $.each( selectors, function( i, selector ) {
+ $elements = $elements.add( jQueryFindWithParent( self, selector ) );
+ } );
+
+ return $elements;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.enhance.js b/Flow/modules/engine/misc/mw-ui.enhance.js
new file mode 100644
index 00000000..3da4e70d
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.enhance.js
@@ -0,0 +1,451 @@
+/*!
+ * Enhances mediawiki-ui style elements with JavaScript.
+ */
+
+( function ( mw, $ ) {
+ /*
+ * Reduce eye-wandering due to adjacent colorful buttons
+ * This will make unhovered and unfocused sibling buttons become faded and blurred
+ * Usage: Buttons must be in a form, or in a parent with mw-ui-button-container, or they must be siblings
+ */
+ $( document ).ready( function () {
+ function onMwUiButtonFocus( event ) {
+ var $el, $form, $siblings;
+
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ $el = $( event.target );
+
+ if ( event.type !== 'keyup' || $el.is( ':focus' ) ) {
+ // Reset style
+ $el.removeClass( 'mw-ui-button-althover' );
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $siblings.not( $el ).filter( ':not(:focus)' )
+ .addClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ function onMwUiButtonBlur( event ) {
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ var $el = $( event.target ),
+ $form, $siblings, $focused;
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $focused = $siblings.not( $el ).filter( ':focus' );
+
+ if ( event.type === 'mouseleave' && $el.is( ':focus' ) ) {
+ // If this button is still focused, but the mouse left it, keep siblings faded
+ return;
+ } else if ( $focused.length ) {
+ // A sibling has focus; have it trigger the restyling
+ $focused.trigger( 'mouseenter.mw-ui-enhance' );
+ } else {
+ // No other siblings are focused; removing button fading
+ $siblings.removeClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance', '.mw-ui-button', onMwUiButtonFocus )
+ .on( 'mouseleave.mw-ui-enhance', '.mw-ui-button', onMwUiButtonBlur );
+
+ // Attach these independently, because jQuery doesn't support useCapture mode (focus propagation)
+ if ( document.attachEvent ) {
+ document.attachEvent( 'focusin', onMwUiButtonFocus );
+ document.attachEvent( 'focusout', onMwUiButtonBlur );
+ } else {
+ document.body.addEventListener( 'focus', onMwUiButtonFocus, true );
+ document.body.addEventListener( 'blur', onMwUiButtonBlur, true );
+ }
+ } );
+
+ /**
+ * Disables action and submit buttons when a form has required fields
+ * @param {jQuery} $form jQuery object corresponding to a form element.
+ */
+ function enableFormWithRequiredFields( $form ) {
+ var
+ $fields = $form.find( 'input, textarea' ).filter( '[required]' ),
+ ready = true;
+
+ $fields.each( function () {
+ var $this = $( this );
+ if ( mw.flow.editor.exists( $this ) ) {
+ if ( mw.flow.editor.getEditor( $this ).isEmpty() ) {
+ ready = false;
+ }
+ } else if ( this.value === '' ) {
+ ready = false;
+ }
+ } );
+
+ // @todo scrap data-role? use submit types? or a single role=action?
+ $form.find( '.mw-ui-button' ).filter( '[data-role=action], [data-role=submit]' )
+ .prop( 'disabled', !ready );
+ }
+ /*
+ * Disable / enable preview and submit buttons without/with text in field.
+ * Usage: field needs required attribute
+ */
+ $( document ).ready( function () {
+ // We should probably not use this change detection method for VE
+ $( document ).on( 'keyup.flow-actions-disabler', '.mw-ui-input', function () {
+ enableFormWithRequiredFields( $( this ).closest( 'form' ) );
+ } );
+ } );
+
+
+ /*
+ * mw-ui-tooltip
+ * Renders tooltips on over, and also via mw.tooltip.
+ */
+ $( document ).ready( function () {
+ var _$tooltip = $(
+ '<span class="flow-ui-tooltip flow-ui-tooltip-left">' +
+ '<span class="flow-ui-tooltip-content"></span>' +
+ '<span class="flow-ui-tooltip-triangle"></span>' +
+ '<span class="flow-ui-tooltip-close"></span>' +
+ '</span>'
+ ),
+ $activeTooltips = $(),
+ _mwUiTooltipExpireTimer;
+
+ /**
+ * Renders a tooltip at target.
+ * Options (either given as param, or fetched from target as data-tooltip-x params):
+ * tooltipSize=String (small,large,block)
+ * tooltipContext=String (constructive,destructive,progressive,regressive)
+ * tooltipPointing=String (up,down,left,right)
+ * tooltipClosable=Boolean
+ * tooltipContentCallback=Function
+ *
+ * @param {jQuery|Element} target
+ * @param {jQuery|Element|String} [content] A jQuery set, an element, or a string of
+ * HTML. If omitted, first tries tooltipContentCallback, then target.title
+ * @param {Object} [options]
+ */
+ function mwUiTooltipShow( target, content, options ) {
+ var $target = $( target ),
+ // Find previous tooltip for this el
+ $tooltip = $target.data( '$tooltip' ),
+
+ // Get window size and scroll details
+ windowWidth = $( window ).width(),
+ windowHeight = $( window ).height(),
+ scrollX = Math.max( window.pageXOffset, document.documentElement.scrollLeft, document.body.scrollLeft ),
+ scrollY = Math.max( window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop ),
+
+ // Store target and tooltip details
+ tooltipWidth, tooltipHeight,
+ targetPosition,
+ locationOrder, tooltipLocation = {},
+ insertFn = 'append',
+
+ // Options, no longer by objet reference
+ optionsUnreferenced = {},
+
+ i = 0;
+
+ options = options || {};
+ // Do this so that we don't alter the data object by reference
+ optionsUnreferenced.tooltipSize = options.tooltipSize || $target.data( 'tooltipSize' );
+ optionsUnreferenced.tooltipContext = options.tooltipContext || $target.data( 'tooltipContext' );
+ optionsUnreferenced.tooltipPointing = options.tooltipPointing || $target.data( 'tooltipPointing' );
+ optionsUnreferenced.tooltipContentCallback = options.tooltipContentCallback || $target.data( 'tooltipContentCallback' );
+ // @todo closable
+ optionsUnreferenced.tooltipClosable = options.tooltipClosable || $target.data( 'tooltipClosable' );
+
+ // Support passing jQuery as argument
+ target = $target[0];
+
+ if ( !content ) {
+ if ( optionsUnreferenced.tooltipContentCallback ) {
+ // Use content callback to get the content for this element
+ content = optionsUnreferenced.tooltipContentCallback( target, optionsUnreferenced );
+
+ if ( !content ) {
+ return false;
+ }
+ } else {
+ // Check to see if we're simply using target.title as the content
+ if ( !target.title ) {
+ return false;
+ }
+
+ content = target.title;
+ $target.data( 'tooltipTitle', content ); // store title
+ target.title = ''; // and hide it so it doesn't appear
+ insertFn = 'text';
+
+ if ( !optionsUnreferenced.tooltipSize ) {
+ // Default size for title tooltip is small
+ optionsUnreferenced.tooltipSize = 'small';
+ }
+ }
+ }
+
+ // No previous tooltip
+ if ( !$tooltip ) {
+ // See if content itself is a tooltip
+ try {
+ if ( $.type( content ) === 'string' ) {
+ $tooltip = $( $.parseHTML( content ) );
+ } else {
+ $tooltip = $( content );
+ }
+ } catch ( e ) {}
+ if ( !$tooltip || !$tooltip.is( '.flow-ui-tooltip' ) && !$tooltip.find( '.flow-ui-tooltip' ).length ) {
+ // Content is not and does not contain a tooltip, so instead, put content inside a new tooltip wrapper
+ $tooltip = _$tooltip.clone();
+ }
+ }
+
+ // Try to inherit tooltipContext from the target's classes
+ if ( !optionsUnreferenced.tooltipContext ) {
+ if ( $target.hasClass( 'mw-ui-progressive' ) ) {
+ optionsUnreferenced.tooltipContext = 'progressive';
+ } else if ( $target.hasClass( 'mw-ui-constructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'constructive';
+ } else if ( $target.hasClass( 'mw-ui-destructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'destructive';
+ }
+ }
+
+ $tooltip
+ // Add the content to it
+ .find( '.flow-ui-tooltip-content' )
+ .empty()
+ [ insertFn ]( content )
+ .end()
+ // Move this off-page before rendering it, so that we can calculate its real dimensions
+ // @todo use .parent() loop to check for z-index and + that to this if needed
+ .css( { position: 'absolute', zIndex: 1000, top: 0, left: '-999em' } )
+ // Render
+ // @todo inject at #bodyContent to inherit (font-)styling
+ .appendTo( 'body' );
+
+ // Tooltip style context
+ if ( optionsUnreferenced.tooltipContext ) {
+ $tooltip.removeClass( 'mw-ui-progressive mw-ui-constructive mw-ui-destructive' );
+ $tooltip.addClass( 'mw-ui-' + optionsUnreferenced.tooltipContext );
+ }
+
+ // Tooltip size (small, large)
+ if ( optionsUnreferenced.tooltipSize ) {
+ $tooltip.removeClass( 'flow-ui-tooltip-sm flow-ui-tooltip-lg' );
+ $tooltip.addClass( 'flow-ui-tooltip-' + optionsUnreferenced.tooltipSize );
+ }
+
+ // Remove the old pointing direction
+ $tooltip.removeClass( 'flow-ui-tooltip-up flow-ui-tooltip-down flow-ui-tooltip-left flow-ui-tooltip-right' );
+
+ // tooltip width and height with the new content
+ tooltipWidth = $tooltip.outerWidth( true );
+ tooltipHeight = $tooltip.outerHeight( true );
+
+ // target positioning info
+ targetPosition = $target.offset();
+ targetPosition.width = $target.outerWidth( true );
+ targetPosition.height = $target.outerHeight( true );
+ targetPosition.leftEnd = targetPosition.left + targetPosition.width;
+ targetPosition.topEnd = targetPosition.top + targetPosition.height;
+ targetPosition.leftMiddle = targetPosition.left + targetPosition.width / 2;
+ targetPosition.topMiddle = targetPosition.top + targetPosition.height / 2;
+
+ // Use the preferred pointing direction first
+ switch ( optionsUnreferenced.tooltipPointing ) {
+ case 'left': locationOrder = [ 'left', 'right', 'left' ]; break;
+ case 'right': locationOrder = [ 'right', 'left', 'right' ]; break;
+ case 'down': locationOrder = [ 'down', 'up', 'down' ]; break;
+ default: locationOrder = [ 'up', 'down', 'up' ];
+ }
+
+ do {
+ // Position of the POINTER, not the tooltip itself
+ switch ( locationOrder[ i ] ) {
+ case 'left':
+ tooltipLocation.left = targetPosition.leftEnd;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'right':
+ tooltipLocation.left = targetPosition.left - tooltipWidth;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'down':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.top - tooltipHeight;
+ break;
+ case 'up':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.topEnd;
+ break;
+ }
+
+ // Verify tooltip will be mostly visible in viewport
+ if (
+ tooltipLocation.left > scrollX - 5 &&
+ tooltipLocation.top > scrollY - 5 &&
+ tooltipLocation.left + tooltipWidth < windowWidth + scrollX + 5 &&
+ tooltipLocation.top + tooltipHeight < windowHeight + scrollY + 5
+ ) {
+ break;
+ }
+ if ( i + 1 === locationOrder.length ) {
+ break;
+ }
+ } while ( ++i <= locationOrder.length );
+
+ // Add the pointing direction class from the loop
+ $tooltip.addClass( 'flow-ui-tooltip-' + locationOrder[ i ] );
+
+ // Apply the new location CSS
+ $tooltip.css( tooltipLocation );
+
+ // Store this tooltip onto target
+ $target.data( '$tooltip', $tooltip );
+ // Store this target onto tooltip
+ $tooltip.data( '$target', $target );
+ // Add this tooltip to our set of active tooltips
+ $activeTooltips = $activeTooltips.add( $tooltip );
+
+ // Start the expiry timer
+ _mwUiTooltipExpire();
+
+ return $tooltip;
+ }
+
+ /**
+ * Hides the tooltip associated with target instantly.
+ * @param {Element|jQuery} target
+ */
+ function mwUiTooltipHide( target ) {
+ var $target = $( target ),
+ $tooltip = $target.data( '$tooltip' ),
+ tooltipTitle = $target.data( 'tooltipTitle' );
+
+ // Remove tooltip from DOM
+ if ( $tooltip ) {
+ $target.removeData( '$tooltip' );
+ $activeTooltips = $activeTooltips.not( $tooltip );
+ $tooltip.remove();
+ }
+
+ // Restore old title; was used for tooltip
+ if ( tooltipTitle ) {
+ $target[0].title = tooltipTitle;
+ $target.removeData( 'tooltipTitle' );
+ }
+ }
+
+ /**
+ * Runs on a timer to expire tooltips. This is useful in scenarios where a tooltip's target
+ * node has disappeared (removed from page), and didn't trigger a mouseout event. We detect
+ * the target disappearing, and as such remove the tooltip node.
+ */
+ function _mwUiTooltipExpire() {
+ clearTimeout( _mwUiTooltipExpireTimer );
+
+ $activeTooltips.each( function () {
+ var $this = $( this ),
+ $target = $this.data( '$target' );
+
+ // Remove the tooltip if this tooltip has been removed,
+ // or if target is not visible (hidden or removed from DOM)
+ if ( !this.parentNode || !$target.is( ':visible' ) ) {
+ // Remove the tooltip from the DOM
+ $this.remove();
+ // Unset tooltip from target
+ $target.removeData( '$tooltip' );
+ // Remove the tooltip from our active tooltips list
+ $activeTooltips = $activeTooltips.not( $this );
+ }
+ } );
+
+ if ( $activeTooltips.length ) {
+ // Check again in 500ms if we still have active tooltips
+ _mwUiTooltipExpireTimer = setTimeout( _mwUiTooltipExpire, 500 );
+ }
+ }
+
+ /**
+ * MW UI Tooltip access through JS API.
+ */
+ mw.tooltip = {
+ show: mwUiTooltipShow,
+ hide: mwUiTooltipHide
+ };
+
+ /**
+ * Event handler for mouse entering on a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipFocus( event ) {
+ mw.tooltip.show( this );
+ }
+
+ /**
+ * Event handler for mouse leaving a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipBlur( event ) {
+ mw.tooltip.hide( this );
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance focus.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipFocus )
+ .on( 'mouseleave.mw-ui-enhance blur.mw-ui-enhance click.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipBlur );
+ } );
+
+ /**
+ * Ask a user to confirm navigating away from a page when they have entered unsubmitted changes to a form.
+ */
+ var _oldOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = function () {
+ var uncommitted;
+
+ $( 'input, textarea' ).filter( '.mw-ui-input:visible' ).each( function () {
+ if ( $.trim( this.value ) && this.value !== this.defaultValue ) {
+ uncommitted = true;
+ return false;
+ }
+ } );
+
+ // Ask the user if they want to navigate away
+ if ( uncommitted ) {
+ return mw.msg( 'mw-ui-unsubmitted-confirm' );
+ }
+
+ // Run the old on beforeunload fn if it exists
+ if ( _oldOnBeforeUnload ) {
+ return _oldOnBeforeUnload();
+ }
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.modal.js b/Flow/modules/engine/misc/mw-ui.modal.js
new file mode 100644
index 00000000..c549db03
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.modal.js
@@ -0,0 +1,410 @@
+/*!
+ * mw-ui-modal
+ * Implements mw.Modal functionality.
+ */
+
+( function ( mw, $ ) {
+ // Make it easier to remove this later on, should it be implemented in Core
+ if ( mw.Modal ) {
+ return;
+ }
+
+ /**
+ * Accepts an element or HTML string as contents. If none given,
+ * modal will start in hidden state.
+ * Settings keys:
+ * - open (same arguments as open method)
+ * - title String
+ * - disableCloseOnOutsideClick Boolean (if true, ESC and background clicks do not close it)
+ *
+ * @todo Implement multi-step
+ * @todo Implement data-mwui handlers
+ * @todo Implement OOJS & events
+ *
+ * @example modal = mw.Modal();
+ * @example modal = mw.Modal( { open: 'Contents!!', title: 'Title!!' } );
+ * @example modal = mw.Modal( 'special_modal' );
+ *
+ * @param {String} [name] Name of modal (may be omitted)
+ * @param {Object} [settings]
+ * @return MwUiModal
+ */
+ function MwUiModal( name, settings ) {
+ // allow calling this method with or without "new" keyword
+ if ( this.constructor !== MwUiModal ) {
+ return new MwUiModal( name, settings );
+ }
+
+ // Defaults and ordering
+ if ( !settings && typeof name === 'object' ) {
+ settings = name;
+ name = null;
+ }
+ settings = settings || {};
+
+ // Set name
+ this.name = name;
+
+ // Set title
+ this.setTitle( settings.title );
+
+ // Set disableCloseOnOutsideClick
+ this.disableCloseOnOutsideClick = !!settings.disableCloseOnOutsideClick;
+
+ // Auto-open
+ if ( settings.open ) {
+ this.open( settings.open );
+ }
+
+ return this;
+ }
+
+ /** Stores template
+ * @todo use data-mwui attributes instead of data-flow **/
+ MwUiModal.prototype.template = '' +
+ '<div class="flow-ui-modal">' +
+ '<div class="flow-ui-modal-layout">' +
+ '<div class="flow-ui-modal-heading">' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-destructive flow-ui-modal-heading-prev" data-flow-interactive-handler="modalPrevOrClose"><span class="wikiglyph wikiglyph-x"></span></a>' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-constructive flow-ui-modal-heading-next" data-flow-interactive-handler="modalNextOrSubmit"><span class="wikiglyph wikiglyph-tick"></span></a>' +
+ // title
+ '</div>' +
+
+ '<div class="flow-ui-modal-content">' +
+ // content
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ /** Stores modal wrapper selector **/
+ MwUiModal.prototype.wrapperSelector = '.flow-ui-modal';
+ /** Stores content wrapper selector **/
+ MwUiModal.prototype.contentSelector = '.flow-ui-modal-content';
+ /** Stores heading wrapper selector, which contains prev/next links **/
+ MwUiModal.prototype.headingSelector = '.flow-ui-modal-heading';
+ /** Stores prev link selector **/
+ MwUiModal.prototype.prevSelector = '.flow-ui-modal-heading-prev';
+ /** Stores next link selector **/
+ MwUiModal.prototype.nextSelector = '.flow-ui-modal-heading-next';
+
+ // Primary functions
+
+ /**
+ * Closes and destroys the given instance of mw.Modal.
+ *
+ * @return {Boolean} false on failure, true on success
+ */
+ MwUiModal.prototype.close = function () {
+ // Remove references
+ this._contents = this._title = null;
+
+ if ( this.$node ) {
+ // Remove whole thing from page
+ this.getNode().remove();
+
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * You can visually render the modal using this method. Opens up by displaying it on the page.
+ *
+ * - Multi-step modals with an Array. You can pass [ Element, Element ] to have two steps.
+ * - Multi-step modals with an Object to have named step keys. Pass this for three steps:
+ * { steps: [ 'first', 'second', 'foobar' ], first: Element, second: Element, foobar: Element }
+ *
+ * @todo Currently only supports String|jQuery|Element. Implement multi-step modals.
+ *
+ * @param {Array|Object|Element|jQuery|String} [contents]
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.open = function ( contents ) {
+ var $node = this.getNode(),
+ $contentNode = this.getContentNode(),
+ $fields;
+
+ // Only update content if it's new
+ if ( contents && contents !== this._contents ) {
+ this._contents = contents;
+
+ $contentNode
+ // Remove children (this way we can unbind events)
+ .children()
+ .remove()
+ .end()
+ // Remove any plain text left over
+ .empty()
+ // Add the new content
+ .append( contents );
+ }
+
+ // Drop it into the page
+ $node.appendTo( 'body' );
+
+ // Hide the tick box @todo implement multi-step and event handling / form binding
+ $node.find( this.nextSelector ).hide();
+
+ // If something in here did not auto-focus, let's focus something
+ $fields = $node.find( 'textarea, input, select' ).filter( ':visible' );
+ if ( !$fields.filter( ':focus' ).length ) {
+ // Try to focus on an autofocus field
+ $fields = $fields.filter( '[autofocus]' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Try to focus on ANY input
+ $fields = $fields.end().filter( ':first' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Give focus to the wrapper itself
+ $node.focus();
+ }
+ }
+ }
+
+ return this;
+ };
+
+ /**
+ * Changes the title of the modal.
+ *
+ * @param {String|null} title
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setTitle = function ( title ) {
+ var $heading = this.getNode().find( this.headingSelector ),
+ $children;
+
+ title = title || '';
+
+ // Only update title if it's new
+ if ( title !== this._title ) {
+ this._title = title;
+
+ // Remove any element children temporarily, so we can set the title here
+ $children = $heading.children().detach();
+
+ $heading
+ // Set the new title
+ .text( title )
+ // Add the child nodes back
+ .prepend( $children );
+ }
+
+ // Show the heading if there's a title; hide otherwise
+ $heading[ title ? 'show' : 'hide' ]();
+
+ return this;
+ };
+
+ /**
+ * @todo Implement data-mwui handlers, currently using data-flow
+ */
+ MwUiModal.prototype.setInteractiveHandler = function () {
+ return false;
+ };
+
+ /**
+ * Returns modal name.
+ */
+ MwUiModal.prototype.getName = function () {
+ return this.name;
+ };
+
+ // Nodes
+
+ /**
+ * Returns the modal's wrapper Element, which contains the header node and content node.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getNode = function () {
+ var self = this,
+ $node = this.$node;
+
+ // Create our template instance
+ if ( !$node ) {
+ $node = this.$node = $( this.template );
+
+ // Store a self-reference
+ $node.data( 'MwUiModal', this );
+
+ // Bind close handlers
+ $node.on( 'click', function ( event ) {
+ // If we are clicking on the modal itself, it's the outside area, so close it;
+ // make sure we aren't clicking INSIDE the modal content!
+ if ( !self.disableCloseOnOutsideClick && this === $node[ 0 ] && event.target === $node[ 0 ] ) {
+ self.close();
+ }
+ } );
+ }
+
+ return $node;
+ };
+
+ /**
+ * Returns the wrapping Element on which you can bind bubbling events for your content.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getContentNode = function () {
+ return this.getNode().find( this.contentSelector );
+ };
+
+ // Step creation
+
+ /**
+ * Adds one or more steps, using the same arguments as modal.open.
+ * May overwrite steps if any exist with the same key in Object mode.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {Array|Object|Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.addSteps = function ( contents ) {
+ return false;
+ };
+
+ /**
+ * Changes a given step. If String to does not exist in the list of steps, throws an exception;
+ * int to always succeeds. If the given step is the currently-active one, rerenders the modal contents.
+ * Theoretically, you could use setStep to keep changing step 1 to create a pseudo-multi-step modal.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @param {Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setStep = function ( to, contents ) {
+ return false;
+ };
+
+ /**
+ * Returns an Object with steps, and their contents.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return Object
+ */
+ MwUiModal.prototype.getSteps = function ( to, contents ) {
+ return {};
+ };
+
+ // Step interaction
+
+ /**
+ * For a multi-step modal, goes to the previous step, otherwise, closes the modal.
+ *
+ * @return {MwUiModal|Boolean} false if none, MwUiModal on prev, true on close
+ */
+ MwUiModal.prototype.prevOrClose = function () {
+ if ( this.prev() === false ) {
+ return this.close();
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step (if any), otherwise, submits the form.
+ *
+ * @return {MwUiModal|Boolean} false if no next step and no button to click, MwUiModal on success
+ */
+ MwUiModal.prototype.nextOrSubmit = function () {
+ var $button;
+
+ if ( this.next() === false && this.$node ) {
+ // Find an anchor or button with role=primary
+ $button = this.$node.find( this.contentSelector ).find( 'a, input, button' ).filter( ':visible' ).filter( '[type=submit], [data-role=submit]' );
+
+ if ( !$button.length ) {
+ return false;
+ }
+
+ $button.trigger( 'click' );
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the previous step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.prev = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.next = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to a specific step by number or name.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.go = function ( to ) {
+ return false;
+ };
+
+ /**
+ * MW UI Modal access through JS API.
+ * @example mw.Modal( "<p>lorem</p>" );
+ */
+ mw.Modal = MwUiModal;
+
+ /**
+ * Returns an instance of mw.Modal if one is currently being displayed on the page.
+ * If node is given, tries to find which modal (if any) that node is within.
+ * Returns false if none found.
+ *
+ * @param {Element|jQuery} [node]
+ * @return {Boolean|MwUiModal}
+ */
+ mw.Modal.getModal = function ( node ) {
+ if ( node ) {
+ // Node was given; try to find a parent modal
+ return $( node ).closest( MwUiModal.prototype.wrapperSelector ).data( 'MwUiModal') || false;
+ }
+
+ // No node given; return the last-opened modal on the page
+ return $( 'body' ).children( MwUiModal.prototype.wrapperSelector ).filter( ':last' ).data( 'MwUiModal' ) || false;
+ };
+
+ // Transforms: automatically map these functions to call their mw.Modal methods globally, on any active instance
+ $.each( [ 'close', 'getName', 'prev', 'next', 'prevOrClose', 'nextOrSubmit', 'go' ], function ( i, fn ) {
+ mw.Modal[ fn ] = function () {
+ var args = Array.prototype.splice.call( arguments, 0, arguments.length - 1 ),
+ node = arguments[ arguments.length - 1 ],
+ modal;
+
+ // Find the node, if any was given
+ if ( !node || ( typeof node.is === 'function' && !node.is( '*' ) ) || node.nodeType !== 1 ) {
+ // The last argument to this function was not a node, assume none was intended to be given
+ node = null;
+ args = arguments;
+ }
+
+ // Try to find that modal
+ modal = mw.Modal.getModal( node );
+
+ // Call the intended function locally
+ if ( modal ) {
+ modal[ fn ].apply( modal, args );
+ }
+ };
+ } );
+}( mw, jQuery ) );
diff --git a/Flow/modules/flow-initialize.js b/Flow/modules/flow-initialize.js
new file mode 100644
index 00000000..279a1e3c
--- /dev/null
+++ b/Flow/modules/flow-initialize.js
@@ -0,0 +1,14 @@
+/*!
+ * Runs Flow code, using methods in FlowUI.
+ */
+
+( function ( $ ) {
+ // Pretend we got some data and run with it
+ /*
+ * Now do stuff
+ * @todo not like this
+ */
+ $( document ).ready( function () {
+ mw.flow.initComponent( $( '.flow-component' ) );
+ } );
+}( jQuery ) );
diff --git a/Flow/modules/handlebars.js b/Flow/modules/handlebars.js
new file mode 100644
index 00000000..a0acc7b7
--- /dev/null
+++ b/Flow/modules/handlebars.js
@@ -0,0 +1,25 @@
+// Register the Handlebars compiler with MediaWiki.
+( function() {
+ /*
+ * @class HandlebarsTemplateCompiler
+ * @singleton
+ */
+ var handlebars = {
+ /*
+ * Compiler source code into a template object
+ *
+ * @method
+ * @param {String} src the source of a template
+ * @return {HandleBars.Template} template object
+ */
+ compile: function( src ) {
+ return {
+ /* @param {*} data */
+ render: Handlebars.compile( src, { preventIndent: true } )
+ };
+ }
+ };
+
+ // register Handlebars with core.
+ mw.template.registerCompiler( 'handlebars', handlebars );
+}() );
diff --git a/Flow/modules/messagePoster/ext.flow.messagePoster.js b/Flow/modules/messagePoster/ext.flow.messagePoster.js
new file mode 100644
index 00000000..1937f1c4
--- /dev/null
+++ b/Flow/modules/messagePoster/ext.flow.messagePoster.js
@@ -0,0 +1,57 @@
+( function ( $, mw, OO ) {
+ mw.flow = mw.flow || {};
+
+ /**
+ * This is an implementation of MessagePoster for Flow boards
+ *
+ * The title can be a non-existent board, but it will only work if Flow is allowed in that
+ * namespace or the user has flow-create-board
+ *
+ * @class
+ * @constructor
+ *
+ * @extends mw.messagePoster.MessagePoster
+ *
+ * @param {mw.Title} title Title of Flow board
+ */
+ mw.flow.MessagePoster = function MwFlowMessagePoster( title ) {
+ // I considered using FlowApi, but most of that functionality is about mapping <form>
+ // or <a> tags to AJAX, which is not applicable. This allows us to keep
+ // mediawiki.messagePoster.flow-board light-weight.
+
+ this.api = new mw.Api();
+ this.title = title;
+ };
+
+ OO.inheritClass(
+ mw.flow.MessagePoster,
+ mw.messagePoster.MessagePoster
+ );
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.MessagePoster.prototype.post = function ( subject, body ) {
+ mw.flow.MessagePoster.parent.prototype.post.call( this, subject, body );
+
+ return this.api.postWithToken( 'edit', {
+ action: 'flow',
+ submodule: 'new-topic',
+ page: this.title.getPrefixedDb(),
+ nttopic: subject,
+ ntcontent: body,
+ ntformat: 'wikitext',
+ ntmetadataonly: 1
+ }, {
+ // IE 8 seems to have cached some POST requests without this
+ cache: false
+ } ).then(
+ null, // Preserve parameters from postWithToken promise
+ function ( code, details ) {
+ return $.Deferred().reject( 'api-fail', code, details );
+ }
+ ).promise();
+ };
+
+ mw.messagePoster.factory.register( 'flow-board', mw.flow.MessagePoster );
+} ( jQuery, mediaWiki, OO ) );
diff --git a/Flow/modules/notification/icon/Talk-ltr.png b/Flow/modules/notification/icon/Talk-ltr.png
new file mode 100644
index 00000000..124822f5
--- /dev/null
+++ b/Flow/modules/notification/icon/Talk-ltr.png
Binary files differ
diff --git a/Flow/modules/notification/icon/Talk-rtl.png b/Flow/modules/notification/icon/Talk-rtl.png
new file mode 100644
index 00000000..cdc652bf
--- /dev/null
+++ b/Flow/modules/notification/icon/Talk-rtl.png
Binary files differ
diff --git a/Flow/modules/styles/board/content-preview.less b/Flow/modules/styles/board/content-preview.less
new file mode 100644
index 00000000..30a518d3
--- /dev/null
+++ b/Flow/modules/styles/board/content-preview.less
@@ -0,0 +1,24 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Preview
+.flow-content-preview {
+ word-wrap: break-word;
+ word-break: break-word;
+
+ background-color: #FDFFE7;
+ border: 1px solid #FCEB92;
+ padding: 5px;
+ margin-top: 5px;
+ margin-bottom: 15px;
+ white-space: normal;
+ overflow: auto;
+
+ display: none; // Hide initially
+
+ .flow-preview-sub-container {
+ margin-top: 5px;
+ }
+}
diff --git a/Flow/modules/styles/board/editor-switcher.less b/Flow/modules/styles/board/editor-switcher.less
new file mode 100644
index 00000000..d1cdd185
--- /dev/null
+++ b/Flow/modules/styles/board/editor-switcher.less
@@ -0,0 +1,54 @@
+@import 'mediawiki.mixins';
+@import 'mediawiki.ui/variables';
+@import 'flow.colors';
+
+// extra specificity is needed to override .mw-ui-button.mw-ui-*
+.mw-ui-button.flow-editor-color {
+ .flow-editor-none & {
+ color: white;
+ background-color: @colorConstructive;
+ }
+
+ .flow-editor-visualeditor & {
+ color: @colorText;
+ background-color: white;
+ }
+}
+
+.flow-editor {
+ // because we're attaching switcher controls below the textarea & we
+ // want them to look unified with the textarea, we'll have to take away
+ // it's border and re-apply on the parent node that contains both
+ &.flow-editor-none {
+ border: 1px solid @colorFieldBorder;
+
+ textarea {
+ border: 0;
+ }
+
+ .flow-switcher-controls {
+ background-color: white;
+ padding: .25em;
+ }
+ }
+
+ // would prefer textarea:not(.flow-input-compressed) above, but ie8 wont do it
+ // so here we re-apply the border from .mw-ui-input that was removed above.
+ textarea.flow-input-compressed {
+ border-bottom: 1px solid @colorFieldBorder;
+ }
+
+ // @todo this is basically the terms of use, come up with a shared
+ // name for all but the float
+ .flow-wikitext-editor-help {
+ float: left;
+ vertical-align: middle;
+ color: @colorTextLight;
+ font-size: .75em;
+ line-height: 1.4;
+ }
+
+ a.flow-editor-color {
+ float: right;
+ }
+}
diff --git a/Flow/modules/styles/board/form-actions.less b/Flow/modules/styles/board/form-actions.less
new file mode 100644
index 00000000..2901e96e
--- /dev/null
+++ b/Flow/modules/styles/board/form-actions.less
@@ -0,0 +1,54 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Form button actions should be right-affixed
+.flow-form-actions {
+ position: relative;
+ margin-top: .25em;
+
+ button.mw-ui-button, a.mw-ui-button {
+ float: right;
+ margin-left: .25em;
+ }
+}
+
+textarea.mw-ui-input.flow-input-compressed {
+ height: 2.25em;
+ min-height: 2.25em;
+ resize: none;
+}
+
+.flow-anon-warning {
+ position: relative;
+}
+.flow-anon-warning-desktop {
+ display: none;
+ position: absolute;
+ right: -15em;
+ width: 15em;
+}
+
+.client-js {
+ // We determine in JS whether their editor has a preview mode.
+ // We assume all no-JS compatible editors do.
+ // The extra specificity is to override the normal .flow-js behavior
+
+ .flow-js.flow-form-action-preview {
+ display: none;
+ }
+
+ .flow-editor-supports-preview .flow-js.flow-form-action-preview {
+ display: block;
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-anon-warning-mobile {
+ display: none;
+ }
+ .flow-anon-warning-desktop {
+ display: block;
+ }
+}
diff --git a/Flow/modules/styles/board/header.less b/Flow/modules/styles/board/header.less
new file mode 100644
index 00000000..aa953623
--- /dev/null
+++ b/Flow/modules/styles/board/header.less
@@ -0,0 +1,27 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-board-header {
+ word-break: break-word;
+}
+
+// Top board header
+.flow-board-header-nav {
+ position: relative;
+ text-align: right;
+}
+
+// Top board header edit icon
+.flow-board-header-icon {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ opacity: .33;
+
+ &:hover,
+ &:focus {
+ opacity: 1;
+ }
+}
diff --git a/Flow/modules/styles/board/menu.less b/Flow/modules/styles/board/menu.less
new file mode 100644
index 00000000..5aa8ce26
--- /dev/null
+++ b/Flow/modules/styles/board/menu.less
@@ -0,0 +1,154 @@
+@import 'mediawiki.mixins';
+@import 'flow.variables';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// @todo document flow-menu
+.flow-menu {
+ top: 0;
+ clear: both;
+ position: static;
+ right: 0;
+ bottom: 0;
+
+ ul {
+ font-size: 0.75em;
+ }
+ li {
+ display: inline;
+ text-align: left;
+
+ a {
+ font-weight: inherit;
+ }
+ }
+
+ a:focus {
+ outline: none;
+ }
+
+ // Hide the menu trigger completely in no-js mode
+ .flow-menu-js-drop {
+ display: none;
+ }
+}
+
+div.flow-menu-inverted {
+ right: auto;
+ left: 0;
+}
+
+// Use child selector to block IE6; it doesn't support :hover
+div > .flow-menu {
+ bottom: auto;
+ display: block;
+ border: none;
+
+ &.flow-menu-inverted {
+ right: auto;
+ left: 0;
+
+ .flow-menu-js-drop {
+ text-align: left;
+ }
+ }
+
+
+ // the toc needs to retain display:block for purposes
+ // of triggering autoload eagerly behind the scenes.
+ // flow-menu-scrollable and flow-menu-hoverable cannot
+ // be combined, as this hides the hoverable control.
+ &.flow-menu-scrollable {
+ visibility: hidden;
+ ul {
+ display: block;
+ }
+ }
+
+
+
+ &.flow-menu-hoverable:hover,
+ &.focus {
+ z-index: 2;
+
+ ul {
+ display: block;
+ }
+
+ &.flow-menu-scrollable {
+ visibility: visible;
+ }
+
+ .flow-menu-js-drop a {
+ outline: none;
+ border-color: transparent;
+ background: transparent;
+ background: rgba(0,0,0,0.05);
+
+ .caret {
+ border-top-color: #000;
+ }
+ }
+ }
+
+ ul {
+ // By default the menu control is shown and the menu
+ // itself is hidden
+ display: none;
+ font-size: 1em;
+ box-shadow: 0 1px 2px @colorGrayLight;
+ background: #fff;
+ border-radius: 2px;
+
+ > section:not(:first-of-type) > li:first-of-type,
+ li.flow-menu-section:not(:first-of-type) {
+ border-top: 1px solid @colorGrayLighter;
+ }
+
+ li {
+ display: block;
+ cursor: default;
+ }
+ }
+
+ // This is the menu opener handler; it contains an anchor which triggers the menu in touch devices, without JS
+ .flow-menu-js-drop {
+ display: block;
+ text-align: right;
+ text-indent: 0;
+ cursor: pointer;
+
+ a {
+ display: inline-block;
+ padding: 0 .5em;
+ border: 1px solid @colorGrayLight;
+ border-radius: 3px;
+ border-width: 0;
+ color: @colorTextLight;
+ }
+ }
+
+ // This is a hidden menu trigger; used when the menu is opened from a secondary handler via menuToggle
+ .flow-menu-js-drop-hidden {
+ position: absolute;
+ left: -999em;
+ height: 0;
+ }
+}
+
+// @todo move this
+div.flow-post > .flow-menu {
+ .flow-menu-js-drop {
+ a {
+ border-color: @colorGrayLightest;
+ border-width: 0;
+ }
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ // On desktop, the flow-menu is no longer inline
+ .flow-menu {
+ position: absolute;
+ }
+}
diff --git a/Flow/modules/styles/board/moderated.less b/Flow/modules/styles/board/moderated.less
new file mode 100644
index 00000000..1f120547
--- /dev/null
+++ b/Flow/modules/styles/board/moderated.less
@@ -0,0 +1,35 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// Visually mark moderated comments and posts
+// Locked and deleted are inverted (white bg)
+.flow-topic-moderatestate-lock,
+.flow-topic-moderatestate-delete {
+ color: @colorTextLight;
+
+ .flow-topic-titlebar {
+ background-color: @colorWhite;
+ border: solid 1px @colorGrayLight;
+ }
+}
+
+// Entire moderated post element
+.flow-post-moderated .flow-author a,
+.flow-moderated-post-content {
+ color: @colorTextLight;
+}
+
+.flow-post {
+ // Hide the message about the moderation action when the post is expanded
+ .flow-element-expanded .flow-moderated-post-content {
+ display: none;
+ }
+
+ .flow-element-collapsed {
+ .flow-post-content,
+ .flow-post-meta {
+ display: none;
+ }
+ }
+}
diff --git a/Flow/modules/styles/board/navigation.less b/Flow/modules/styles/board/navigation.less
new file mode 100644
index 00000000..ed290290
--- /dev/null
+++ b/Flow/modules/styles/board/navigation.less
@@ -0,0 +1,139 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Top board navigation bar
+.flow-board-navigation {
+ left: 0;
+ position: static;
+ padding: 0;
+ white-space: nowrap;
+ min-width: 14em;
+ clear: both;
+
+ a:link, a:visited {
+ padding: 0.2em 0.3em;
+ }
+
+ a.flow-board-navigator-last {
+ float: right;
+ position: static;
+ }
+
+ .flow-board-navigation-inner {
+ overflow: hidden;
+ border-bottom: 1px solid @colorGrayLight;
+ white-space: nowrap;
+ }
+
+ .flow-board-navigator-filter {
+ display: inline-block;
+ position: relative;
+ }
+
+ a {
+ display: inline-block;
+
+ &:link, &:visited {
+ padding: 0.25em .75em;
+ color: @colorTextLight;
+ }
+ &:hover, &:focus, &.flow-board-navigator-link-highlight {
+ color: #000;
+ text-decoration: none;
+ }
+
+ &.flow-board-navigator-right {
+ float: right;
+ }
+ // The active menu item
+ &.flow-board-navigator-active {
+ font-weight: bold;
+ }
+ &.flow-board-navigator-first {
+ padding-left: 0;
+ }
+ }
+
+ // Added by JS when the window has been scrolled beyond the navigation, so it sticks to the viewport
+ &.flow-board-navigation-affixed {
+ position: fixed;
+ z-index: 2;
+ top: 0;
+ width: 100%;
+ background: @colorWhite;
+
+ .flow-board-toc-menu {
+ .flow-list {
+ // em version can probably be dropped when we drop IE 8
+ max-height: 30.6em;
+ max-height: 85vh;
+ }
+ }
+
+ .flow-board-navigation-inner {
+ & > a {
+ display: none; // hide everything but the current topic title
+ }
+
+ & > a.flow-board-navigator-active {
+ // Clip the topic title
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ }
+}
+
+// Filter & TOC menu below navigation bar
+.flow-board-header-menu {
+ position: relative;
+ float: right;
+
+ // Make TOC wide
+ .flow-board-toc-menu {
+ width: 100%;
+ position: absolute;
+
+ .flow-list {
+ overflow-y: auto;
+ // em version can probably be dropped when we drop IE 8
+ max-height: 18em;
+ max-height: 50vh;
+ }
+
+ a {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-align: left;
+ text-overflow: ellipsis;
+ padding-left: 0;
+ padding-right: 0;
+
+ &.active {
+ font-weight: bold;
+ }
+ }
+
+ // This makes the list items align with the TOC bar text
+ .wikiglyph {
+ visibility: hidden;
+ font-weight: bold;
+ }
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ html .flow-board-navigation {
+ left: 0;
+ font-size: 1.15em;
+ position: relative;
+ padding-top: .5em;
+ }
+}
diff --git a/Flow/modules/styles/board/replycount.less b/Flow/modules/styles/board/replycount.less
new file mode 100644
index 00000000..8b538532
--- /dev/null
+++ b/Flow/modules/styles/board/replycount.less
@@ -0,0 +1,30 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Reply count (only visible in compact mode)
+.flow-reply-count {
+ display: none;
+ position: absolute;
+ top: 50%;
+ right: 1.5em;
+ margin-top: -.55em;
+ color: @colorGrayLighter;
+ font-size: 2em;
+ opacity: .5;
+
+ .flow-reply-count-number {
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ padding-left: .25em;
+ width: 100%;
+ color: @colorTextLight;
+ font-size: .5em;
+ line-height: 2.25;
+ font-weight: bold;
+ text-align: center;
+ }
+}
diff --git a/Flow/modules/styles/board/terms-of-use.less b/Flow/modules/styles/board/terms-of-use.less
new file mode 100644
index 00000000..7aa0ef6c
--- /dev/null
+++ b/Flow/modules/styles/board/terms-of-use.less
@@ -0,0 +1,22 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// @todo: Give more generic name
+// TOU should be tiny and grey
+.flow-terms-of-use {
+ display: block;
+ clear: both;
+ height: 3.6em;
+ vertical-align: middle;
+ color: @colorTextLight;
+ font-size: .75em;
+ line-height: 1.4;
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-terms-of-use {
+ clear: none;
+ }
+}
diff --git a/Flow/modules/styles/board/timestamps.less b/Flow/modules/styles/board/timestamps.less
new file mode 100644
index 00000000..3219df20
--- /dev/null
+++ b/Flow/modules/styles/board/timestamps.less
@@ -0,0 +1,59 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-timestamp {
+ text-align: left;
+
+ span {
+ unicode-bidi: embed;
+ }
+
+ .flow-timestamp-user-formatted {
+ display: none;
+ }
+ .flow-timestamp-ago {
+ display: inline;
+ }
+
+ &:hover {
+ .flow-timestamp-user-formatted {
+ display: inline;
+ }
+ .flow-timestamp-ago {
+ display: none;
+ }
+ }
+}
+
+.flow-timestamp-ago,
+.flow-timestamp-user-formatted {
+ display: block;
+ position: relative;
+}
+
+.flow-timestamp-ago {
+ margin-top: -1em;
+}
+
+a.flow-timestamp-anchor {
+ &, &:visited {
+ color: inherit;
+ }
+}
+
+// Colors are from mediawiki.skinning/elements.css
+// Could use a.flow-timestamp-anchor:not(:hover) to set color to gray
+// only when *not* hovering (and avoid the copied colors), but we can't
+// use :not for now due to old IE.
+a.flow-timestamp-anchor:hover {
+ color: #0645ad;
+
+ &:visited {
+ color: #0b0080;
+ }
+
+ &:active {
+ color: #faa700;
+ }
+}
diff --git a/Flow/modules/styles/board/topic/meta.less b/Flow/modules/styles/board/topic/meta.less
new file mode 100644
index 00000000..d3cd82b4
--- /dev/null
+++ b/Flow/modules/styles/board/topic/meta.less
@@ -0,0 +1,9 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Topic metadata
+.flow-topic-meta {
+ color: @colorTextLight;
+}
diff --git a/Flow/modules/styles/board/topic/post.less b/Flow/modules/styles/board/topic/post.less
new file mode 100644
index 00000000..5080b3b6
--- /dev/null
+++ b/Flow/modules/styles/board/topic/post.less
@@ -0,0 +1,190 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+@highlightedIndent: 0.2em;
+
+// Helpers
+.minimalPostHighlight( @negativeMargin ) {
+ margin-left: 0 - @negativeMargin - @topicIndent;
+ padding-left: @topicIndent + @negativeMargin - @highlightedIndent;
+}
+
+// Comments
+form.flow-post {
+ margin-left: @topicIndent - (@textareaPadding * 2);
+}
+
+.flow-post {
+ position: relative;
+ margin: .5em 0 0 .75em;
+ padding: 0 .5em 0 0;
+ color: @colorText;
+ word-wrap: break-word;
+
+ // Nested comments (replies & reply forms)
+ .flow-replies {
+ margin-left: @topicIndent;
+ padding-left: 0.5em;
+ border-left: 1px dotted @colorGrayLighter;
+
+ // Remove tangent preview nesting (no IE6 support, but acceptable degradation)
+ &.flow-preview {
+ margin-left: 0;
+ padding-left: 0;
+ border-left-width: 0;
+
+ .flow-post-main {
+ padding-left: 0;
+ border-left-width: 0;
+ }
+ }
+ }
+
+ &.flow-post-max-depth .flow-replies {
+ margin-left: 0;
+ padding-left: 0;
+ border-left-width: 0;
+
+ .flow-post-max-depth {
+ margin-left: 0;
+ }
+ }
+
+ .flow-post-main {
+ margin-left: 0.1em;
+ }
+
+ // Highlights a post (no IE6 support, but acceptable degradation)
+ &.flow-post-highlighted {
+ > .flow-post-main {
+ @highlightedIndent: @topicIndent - 0.75em;
+ padding-left: @highlightedIndent;
+ border-left: solid @highlightedIndent @colorHighlight;
+ }
+ }
+
+ // Highlights all posts newer than a specific post
+ &.flow-post-highlight-newer {
+ .flow-post-content {
+ border-left: solid @highlightedIndent @colorHighlightNewer;
+ }
+ }
+
+ &.flow-post-highlight-newer {
+ .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ }
+
+ .flow-post {
+ &.flow-post-highlight-newer {
+ > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( -0.1em );
+ }
+ }
+
+ .flow-post {
+ &.flow-post-highlight-newer {
+ > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( -0.1em );
+ }
+ }
+ }
+ }
+
+ // Content of comments
+ .flow-post-content {
+ // protect from content breaking out of its box
+ word-break: break-word;
+ overflow: auto;
+ max-height: 2000px;
+ }
+
+ // Author link in post
+ .flow-author {
+ font-size: .875em;
+ line-height: 1.2;
+ display: inline-block;
+ color: @colorText;
+ word-wrap: break-word;
+
+ .mw-userlink {
+ font-weight: bold;
+ }
+
+ .mw-usertoollinks {
+ opacity: 0;
+ .transition( opacity .25s linear );
+ }
+ &:hover .mw-usertoollinks {
+ opacity: 1;
+ }
+ }
+}
+
+// Comment metadata
+.flow-post-meta {
+ // @todo needs overflow: hidden but crops button border at bottom
+ color: @colorGrayDark;
+ font-size: .875em;
+ text-align: right;
+}
+
+.flow-post-meta-actions {
+ float: left;
+ a {
+ &::after {
+ content: "\2022";
+ padding: 0 8px;
+ text-decoration: none;
+ display: inline-block;
+ color: @colorGrayDark;
+ }
+ &:last-child {
+ &::after {
+ content: "";
+ }
+ }
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-post {
+ /* left margin provided by highlighting zone */
+ margin: 1em 0 0 @topicIndent;
+ padding: 0;
+
+ .flow-author {
+ line-height: inherit;
+ font-size: inherit;
+ }
+
+ &.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 1.3em );
+ }
+ .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ .flow-post .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ }
+}
+
+// What to do? vector changes this width on us from screen-hd.less with:
+//
+// @media screen and (min-width: 982px)
+// div#content {
+// margin-left: 11em;
+// padding: 1.25em 1.5em 1.5em 1.5em;
+// }
+//
+// The standard padding for narrower screens is 1em all around.
+@media all and (min-width: 982px) {
+ .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 1.8em );
+ }
+}
diff --git a/Flow/modules/styles/board/topic/summary.less b/Flow/modules/styles/board/topic/summary.less
new file mode 100644
index 00000000..87ea6765
--- /dev/null
+++ b/Flow/modules/styles/board/topic/summary.less
@@ -0,0 +1,18 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-topic-summary {
+ border-top: 1px dotted @colorGrayLight;
+ margin-top: .33em;
+
+ // Needs increased specificity to override `div#content p`
+ div#content & > p {
+ font-style: italic;
+ &:last-of-type {
+ // Remove margin-bottom from last p in summary, to remove excess bottom whitespace
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/Flow/modules/styles/board/topic/titlebar.less b/Flow/modules/styles/board/topic/titlebar.less
new file mode 100644
index 00000000..018a41c5
--- /dev/null
+++ b/Flow/modules/styles/board/topic/titlebar.less
@@ -0,0 +1,71 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Show that the topic titlebar is clickable
+.flow-topic-titlebar {
+ position: relative;
+ padding: .5em .75em;
+ background: @colorGrayLightest;
+ border-radius: 3px;
+ outline: none;
+
+ // use child selector to block ie6
+ .flow-menu {
+ top: 1.5em;
+ }
+}
+
+// needs extra specificity to override `div#content h2` from vector
+div#content .flow-topic-title {
+ padding: 0;
+ border-bottom: none;
+ margin: 0 2.5em .15em 0;
+ font-family: sans-serif;
+ font-weight: bold;
+ word-break: break-word;
+ word-wrap: break-word;
+ overflow: visible;
+}
+
+.flow-moderated-topic-title {
+ margin-bottom: .33em;
+ color: @colorTextLight;
+ font-weight: bold;
+}
+
+// Override default Vector heading styles
+div#content h2.flow-topic-title {
+ font-size: 1.75em;
+}
+
+// Notification about subscribing to a topic
+.flow-notification-tooltip-topicsub {
+ width: 15em;
+}
+.flow-notification-tooltip-icon {
+ font-size: 2.5em;
+ text-align: center;
+}
+.flow-notification-tooltip-title {
+ font-size: 1em;
+ font-weight: bold;
+}
+
+.flow-undo {
+ float: right;
+}
+
+.flow-topic-title-activate-edit {
+ .flow-topic-title {
+ display: none;
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-titlebar {
+ padding: 1em (@topicIndent + 1) 1em @topicIndent;
+ }
+}
diff --git a/Flow/modules/styles/board/topic/watchlist.less b/Flow/modules/styles/board/topic/watchlist.less
new file mode 100644
index 00000000..95081b0c
--- /dev/null
+++ b/Flow/modules/styles/board/topic/watchlist.less
@@ -0,0 +1,69 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-watch-link {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ &.flow-board-watch-link {
+ font-size: 1.5em;
+
+ // 1em for the size of the watch star,
+ // 0.25em for the margin-bottom on the h1
+ // 0.5em for the #contentSub
+ top: -1.75em;
+
+ // Override default right value, this div is getting a specific width
+ // (in line with other flow elements) and the icon will be floated to
+ // the right.
+ right: auto;
+ a {
+ float: right;
+ }
+ }
+
+ a {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ padding: .25em .5em;
+
+ &.mw-ui-quiet {
+ // Quiet mode shows the outline star
+ .wikiglyph-star {
+ display: none;
+ }
+ .wikiglyph-unstar {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ }
+ }
+
+ // Regular mode shows the full star
+ .wikiglyph-unstar {
+ display: none;
+ }
+ }
+}
+
+.flow-topic-watchlist {
+ a {
+ font-size: 1.8em;
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-watchlist {
+ a {
+ font-size: inherit;
+ }
+ }
+}
+
+.content {
+ position: relative;
+}
diff --git a/Flow/modules/styles/common.less b/Flow/modules/styles/common.less
new file mode 100644
index 00000000..b7022afa
--- /dev/null
+++ b/Flow/modules/styles/common.less
@@ -0,0 +1,124 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-component {
+
+ .flow-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ // Box-sizing: border-box is default in the Flow world
+ * {
+ .box-sizing(border-box);
+ }
+}
+
+// Keep a fixed spacing between each individual flow section
+.flow-newtopic-form,
+.flow-topics-bar {
+ padding-top: 1.5em;
+}
+
+// Top board header
+.flow-board-header,
+// board navigation
+.flow-board-navigation,
+// The sort navigation
+.flow-board-header-menu,
+// The whole board content wrapper
+.flow-board {
+ font-size: .875em;
+ width: 100%;
+ max-width: 850px;
+}
+
+// Individual topic containers
+.flow-topic {
+ padding: 1.6em 0 1.4em;
+}
+
+// Revision view
+.flow-revision-content {
+ background: none repeat scroll 0 0 #EDEDED;
+ color: #777777;
+ margin-top: 20px;
+ padding: 10px;
+}
+
+// "No more" link
+.flow-no-more,
+// "Undo" moderation link
+.flow-undo {
+ font-size: .875em;
+ color: #777777;
+}
+
+.flow-topic-meta {
+ font-size: .875em;
+}
+
+// Decorate as a pipelist, ex: (foo | bar | baz)
+.flow-pipelist {
+ span ~ span:before {
+ content: ' | ';
+ }
+}
+
+.flow-ui-clear {
+ clear: both;
+ line-height: 0;
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-meta {
+ font-size: 1em;
+ }
+
+ // Top board header
+ .flow-board-header,
+ // The sort navigation
+ .flow-board-header-menu,
+ // board navigation
+ .flow-board-navigation,
+ // The whole board content wrapper
+ .flow-board {
+ // Set a fixed font-size from which everything else can use a relative amount
+ font-size: 1em;
+ line-height: 1.4;
+ }
+}
+
+.flow-history-moderation-menu {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+
+ li {
+ display: inline;
+ }
+
+ > section {
+ display: inline;
+
+ &:not(:first-of-type) > li {
+ &:first-of-type {
+ border-top: 0px;
+
+ &:before {
+ content: '(';
+ }
+ }
+ & + li:before {
+ content: ' | ';
+ }
+ &:last-of-type:after {
+ content: ')';
+ }
+ }
+ }
+}
diff --git a/Flow/modules/styles/errors.less b/Flow/modules/styles/errors.less
new file mode 100644
index 00000000..7419e618
--- /dev/null
+++ b/Flow/modules/styles/errors.less
@@ -0,0 +1,11 @@
+// Error messages
+.flow-errors.errorbox {
+ display: block; // overwrites core .errorbox's display: inline-block
+ margin: 1em 0 0;
+
+ .mw-warning-with-logexcerpt {
+ border: none;
+ margin: 0;
+ padding: 0;
+ }
+}
diff --git a/Flow/modules/styles/flow.less/flow.colors.less b/Flow/modules/styles/flow.less/flow.colors.less
new file mode 100644
index 00000000..d324391b
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.colors.less
@@ -0,0 +1,4 @@
+@import 'mediawiki.ui/variables';
+
+@colorHighlight: #00AF89;
+@colorHighlightNewer: #0645AD;
diff --git a/Flow/modules/styles/flow.less/flow.helpers.less b/Flow/modules/styles/flow.less/flow.helpers.less
new file mode 100644
index 00000000..dbd12629
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.helpers.less
@@ -0,0 +1,16 @@
+// Cross-browser Helpers
+
+// @todo: Revisit this. See https://gerrit.wikimedia.org/r/72212
+.box-shadow( @value ) {
+ -webkit-box-shadow: @value; // Android 2.3+, iOS 4.0.2-4.2, Safari 3-4
+ box-shadow: @value; // Chrome 6+, Firefox 4+, IE 9+, iOS 5+, Opera 10.50+
+}
+// @todo: Consolidate with mobile and move this mixin to core. Both MobileFrontend and Flow are using it.
+.transition ( @value ) {
+ -webkit-backface-visibility: hidden; // fixes Chrome 1px movement bug
+ -webkit-transition: @value;
+ -moz-transition: @value;
+ -ms-transition: @value;
+ -o-transition: @value;
+ transition: @value;
+}
diff --git a/Flow/modules/styles/flow.less/flow.variables.less b/Flow/modules/styles/flow.less/flow.variables.less
new file mode 100644
index 00000000..55ffe267
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.variables.less
@@ -0,0 +1,5 @@
+@topicIndent: 1.5em;
+@textareaPadding: .3em;
+
+// @todo: Use same variable as MobileFrontend
+@wgFlowDeviceWidthTablet: 768px;
diff --git a/Flow/modules/styles/history/history-line.less b/Flow/modules/styles/history/history-line.less
new file mode 100644
index 00000000..3f6bb8df
--- /dev/null
+++ b/Flow/modules/styles/history/history-line.less
@@ -0,0 +1,3 @@
+.flow-history-moderation-action {
+ text-transform: lowercase;
+}
diff --git a/Flow/modules/styles/js.less b/Flow/modules/styles/js.less
new file mode 100644
index 00000000..9da41676
--- /dev/null
+++ b/Flow/modules/styles/js.less
@@ -0,0 +1,104 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// @todo: Find better home for this css
+.client-js {
+ /*
+ Fallback elements
+
+ Fallback elements are invisible when JavaScript is enabled. They only exist when JavaScript does not run.
+
+ Markup:
+ <div class="flow-ui-fallback-element"></div>
+
+ Styleguide X.
+ */
+ .flow-ui-fallback-element {
+ visibility: hidden;
+ height: 0;
+ }
+
+ // A preview version of a given block
+ .flow-preview {
+ cursor: help;
+ margin-top: .5em;
+ color: @colorRegressive;
+ }
+
+ div#content div#bodyContent .flow-preview-target-hidden {
+ display: none;
+ }
+
+ // With JS, hide .flow-nojs & display .flow-js elements
+ .flow-nojs {
+ display: none;
+ }
+ .flow-js {
+ display: block;
+ }
+}
+
+.client-nojs {
+ // Without JS, hide .flow-js & display .flow-nojs elements
+ .flow-nojs {
+ display: block;
+ }
+ .flow-js {
+ display: none;
+ }
+}
+
+// When the load more wrapper is being processed, show the spinning loading icon
+.flow-load-more.flow-api-inprogress {
+ .flow-topics > &,
+ .flow-board-header-menu .flow-list & {
+ .flow-loading;
+ }
+}
+
+// Basic API interaction indicator
+div#content div#bodyContent .flow-api-inprogress {
+ opacity: 0.5;
+ cursor: wait;
+}
+
+// Spinning loading icon
+.flow-loading {
+ overflow: visible;
+
+ display: block;
+ width: 100%;
+ text-align: center;
+
+ &:before {
+ display: inline-block;
+ content: "\e018";
+ font-family: 'WikiFont-Glyphs';
+ font-size: 3em;
+ line-height: 1em;
+ -webkit-font-smoothing: antialiased;
+
+ -webkit-animation: spin infinite 2s linear;
+ -moz-animation: spin infinite 2s linear;
+ -ms-animation: spin infinite 2s linear;
+ -o-animation: spin infinite 2s linear;
+ animation: spin infinite 2s linear;
+ }
+}
+
+@-webkit-keyframes spin {
+ 0% { -webkit-transform: rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { -webkit-transform: rotate(360deg); opacity: .5; }
+}
+@-moz-keyframes spin {
+ 0% { -moz-transform: rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { -moz-transform: rotate(360deg); opacity: .5; }
+}
+@keyframes spin {
+ 0% { transform:rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { transform:rotate(360deg); opacity: .5; }
+}
diff --git a/Flow/modules/styles/mediawiki.ui/forms.less b/Flow/modules/styles/mediawiki.ui/forms.less
new file mode 100644
index 00000000..cb371b8d
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/forms.less
@@ -0,0 +1,243 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Form elements [Draft]
+//
+// Styleguide 4.
+
+.flow-ui-input-replacement-anchor {
+ display: block;
+ margin: 1em 0 0 .9em;
+
+ // FIXME: It's a shame we have to duplicate css in mw-ui-input. Need saner way going forward. mw-ui-textarea ?
+ &.mw-ui-input-large {
+ margin: 0;
+ font-size: 1.75em;
+ font-style: italic;
+ line-height: 1.25;
+ color: @colorTextLight;
+ }
+}
+
+.client-nojs {
+ .flow-ui-form {
+ // Hide destructive actions in no-JavaScript mode.
+ .flow-ui-destructive {
+ display: none;
+ }
+ }
+}
+
+// Make all text fields 100% wide by default
+.mw-ui-fieldtype-text,
+.mw-ui-fieldtag-textarea {
+ width: 100%;
+}
+
+// Wrapper element for stylized form elements
+.mw-ui-field {
+ position: relative;
+ display: inline-block;
+ white-space: nowrap;
+ min-height: 0;
+
+ .mw-ui-input {
+ margin: 0;
+ }
+}
+
+.mw-ui-field-icon {
+ display: none;
+}
+
+.mw-ui-uls-icon:before {
+ //.glyphicon-globe
+ content: "\e135";
+ opacity: 0.66;
+}
+
+
+/*
+== MediaWiki UI Text Field Validation ==
+
+=== Guidelines ===
+
+
+=== Notes ===
+Does not support IE7 nor IE8.
+ */
+/*.flow-ui-validated */.mw-ui-field:not(.ie8WillIgnoreThis) {
+ .mw-ui-field-icon {
+ white-space: nowrap;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 2em;
+ height: 100%;
+ text-align: center;
+ pointer-events: none;
+ }
+
+ .mw-ui-field-icon:before {
+ display: inline-block;
+ position: absolute;
+ top: 50%;
+ left: 0;
+ margin-top: -.6em;
+ width: 2em;
+ color: @colorGrayLight;
+ font-size: 1em;
+ line-height: 1;
+ text-align: center;
+ pointer-events: none;
+ //.glyphicon
+ font-family: 'Glyphicons Halflings';
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .mw-ui-validation-icon {
+ border-radius: 0 2px 2px 0;
+ border: 1px solid @colorGrayDark;
+ border-width: 1px 1px 1px 0;
+ }
+ .mw-ui-validation-icon:before {
+ color: #fff;
+ }
+
+ .mw-ui-input {
+ // Hide the ULS icon because these elements have HTML5 controls
+ &[type='date'], &[type='number'], &[type='search'], &[type='time'] {
+ ~ .mw-ui-uls-icon {
+ visibility: hidden;
+ }
+ }
+
+ &:valid {
+ &[required], &[min], &[max], &[pattern],
+ &[type='color'], &[type='date'], &[type='email'], &[type='number'],
+ &[type='url'], &[type='range'], &[type='time'] {
+ border-right-width: 2em;
+
+ ~ .mw-ui-validation-icon {
+ display: block;
+ }
+ }
+ ~ .mw-ui-validation-icon {
+ background: #00B08A;
+ background: rgba(0, 176, 138, .85);
+ }
+ ~ .mw-ui-validation-icon:before {
+ //.glyphicon-ok
+ content: "\e013";
+ }
+
+ // Support up to two icons side by side
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 2em;
+ }
+ &[type='date'] {
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 4em;
+ }
+ }
+ &[type='number'], &[type='time'] {
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 3em;
+ }
+ }
+ }
+
+ &:invalid {
+ &[required], &[min], &[max], &[pattern],
+ &[type='color'], &[type='date'], &[type='email'], &[type='number'],
+ &[type='url'], &[type='range'], &[type='time'] {
+ border-right-width: 2em;
+
+ ~ .mw-ui-validation-icon {
+ display: block;
+ }
+ }
+ ~ .mw-ui-validation-icon {
+ background: #D31300;
+ background: rgba(211, 19, 0, .85);
+ }
+ ~ .mw-ui-validation-icon:before {
+ //.glyphicon-remove
+ content: "\e014";
+ }
+
+ // Support up to two icons side by side
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 2em;
+ }
+ }
+
+ &:focus {
+ ~ .mw-ui-uls-icon {
+ display: block;
+ }
+ }
+ }
+}
+
+
+/*
+== MediaWiki UI Radio and Checkbox ==
+
+=== Guidelines ===
+
+
+=== Notes ===
+Does not support IE7 nor IE8.
+ */
+.mw-ui-fieldtag-input:not(.ie8WillIgnoreThis) {
+ cursor: pointer;
+
+ .mw-ui-radio:before,
+ .mw-ui-checkbox:before {
+ display: inline-block;
+ vertical-align: text-bottom;
+ font-family: 'Glyphicons Halflings';
+ -webkit-font-smoothing: antialiased;
+ color: @colorTextLight;
+ font-size: inherit;
+ line-height: inherit;
+ }
+
+ input[type='radio'],
+ input[type='checkbox'] {
+ display: none;
+ }
+
+ input[type='radio'] {
+ + .mw-ui-radio:before {
+ //.glyphicon-dashboard
+ content: "\e141";
+ }
+ &:checked + .mw-ui-radio:before {
+ //.glyphicon-record
+ text-shadow: none;
+ content: "\e165";
+ }
+ }
+ input[type='checkbox'] {
+ + .mw-ui-checkbox:before {
+ //.glyphicon-unchecked
+ content: "\e157";
+ }
+ &:checked + .mw-ui-checkbox:before {
+ //.glyphicon-check
+ content: "\e067";
+ }
+ }
+}
+
+input.mw-ui-input-large {
+ padding-left: .75em;
+}
+
+textarea.mw-ui-input-large {
+ padding-left: @topicIndent;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/modal.less b/Flow/modules/styles/mediawiki.ui/modal.less
new file mode 100644
index 00000000..463010ca
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/modal.less
@@ -0,0 +1,84 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-ui-modal {
+ .box-sizing(border-box);
+ // make content centered
+ display: block; // nonflex
+ display: flex; // flexbox
+ align-items: center; // flexbox
+ justify-content: center; // flexbox
+ text-align: center;
+ // affix
+ position: fixed;
+ z-index: 100;
+ top: 0;
+ left: 0;
+ // background styling
+ width: 100%;
+ height: 100%;
+ background: fade( @colorWhite, 75% );
+
+ // fix content centering for nonflex
+ &:before {
+ .box-sizing(border-box);
+ content: '';
+ display: inline-block;
+ height: 100%;
+ vertical-align: middle;
+ }
+}
+
+.flow-ui-modal-layout {
+ .box-sizing(border-box);
+ // center in viewport
+ vertical-align: middle;
+ display: inline-block; // nonflex center fix
+ min-width: 320px;
+ // scroll content if too big
+ overflow: auto;
+ max-width: 97%;
+ max-height: 97%;
+ // box styling
+ position: relative;
+ background: @colorWhite;
+ box-shadow: 0 4px 0 0 @colorGrayLighter, 0 0 0 1px @colorGrayLighter;
+ border-radius: 3px;
+ color: @colorText;
+ text-align: left;
+}
+
+.flow-ui-modal-heading {
+ .box-sizing(border-box);
+ margin: .3em .3em 0;
+ padding: 0 .3em;
+ font-weight: bold;
+ color: @colorText;
+ line-height: 2.2;
+ border-bottom: 1px solid @colorGrayLight;
+}
+
+.flow-ui-modal-heading-prev {
+ float: left;
+ display: inline-block;
+ padding: 0 .3em;
+ margin: 0 .6em 0 -.3em;
+ height: 100%;
+ border-right: 1px solid @colorGrayLight;
+ color: @colorTextLight;
+}
+.flow-ui-modal-heading-next {
+ float: right;
+ display: inline-block;
+ padding: 0 .3em;
+ margin: 0 -.3em 0 .6em;
+ height: 100%;
+ border-left: 1px solid @colorGrayLight;
+ color: @colorTextLight;
+}
+
+.flow-ui-modal-content {
+ overflow: hidden;
+ margin: 1.3em;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/text.less b/Flow/modules/styles/mediawiki.ui/text.less
new file mode 100644
index 00000000..84a32281
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/text.less
@@ -0,0 +1,10 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-ui-text-truncated {
+ white-space: nowrap;
+ overflow: hidden;
+ -webkit-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/tooltips.less b/Flow/modules/styles/mediawiki.ui/tooltips.less
new file mode 100644
index 00000000..fb85a3d8
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/tooltips.less
@@ -0,0 +1,212 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+/*
+Tooltips
+
+<h3>Guidelines</h3>
+
+Requires the following markup at minimum: <span class="mw-ui-tooltip">CONTENT<span class="mw-ui-tooltip-triangle"></span></span>
+An additional class should be added relating the triangle to the direction of the content: mw-ui-tooltip-DIRECTION, where direction is one of up, down, left, or right.
+Adding to this could be an extra class: mw-ui-tooltip-inverted, which moves the tooltip and triangle to the opposing side. This used when the tooltip would go off the right of the viewport, and instead aligns to the right of the viewport.
+Finally, a context class can be given to assign it a color (eg. mw-ui-progressive).
+
+This is intended to be used with JavaScript, but does not have to be. With JS, you can directly bind the element to given X-Y coords for an element.
+
+Styleguide 4.0.
+ */
+.flow-ui-tooltip {
+ position: relative;
+ top: 1px;
+ display: inline-block;
+ padding: .5em;
+ background: @colorWhite;
+ *background: @colorOffWhite; // ie6
+ color: @colorText;
+ word-wrap: break-word;
+ border-radius: 3px;
+ .box-shadow( ~"0 2px 0 0 @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ opacity: .9;
+
+ a {
+ // FIXME: Due to the lack of a fix for bug 66746 this link is treated as an external link.
+ // Yes Shahyar !important is bad
+ // but the alternative css hacks that would be needed here are even more horrible.
+ color: #fff !important;
+ font-weight: bold;
+ }
+
+ font-size: .875em; // not inherited from div#bodyContent, as we insert at body
+
+ #bodyContent & {
+ font-size: 1em;
+ }
+
+ .flow-ui-tooltip-triangle {
+ position: absolute;
+ overflow: hidden;
+ pointer-events: none;
+
+ // Fix offset-by-1px bug
+ z-index: 1;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+
+ &:after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ width: 1em;
+ height: 1em;
+ background: @colorWhite;
+ *background: @colorOffWhite; //ie6
+ transform: rotate(45deg);
+ -webkit-transform: rotate(45deg);
+ }
+ }
+
+ // mw-ui-tooltip helpers to cleanly set triangle location
+ // The first four are because less.php doesn't support "@{var}: n" syntax
+ .flow-ui-tooltip-triangle-location-horizontal( top ) { top: -1em; }
+ .flow-ui-tooltip-triangle-location-horizontal( bottom ) { bottom: -1em; }
+ .flow-ui-tooltip-triangle-location-vertical( left ) { left: -1em; }
+ .flow-ui-tooltip-triangle-location-vertical( right ) { right: -1em; }
+ // up-down
+ .flow-ui-tooltip-triangle-location( horizontal, @location ) {
+ width: 2em;
+ height: 1em;
+ left: 50%;
+ .flow-ui-tooltip-triangle-location-horizontal( @location );
+ margin-left: -1em;
+ }
+ // left-right
+ .flow-ui-tooltip-triangle-location( vertical, @location ) {
+ width: 1em;
+ height: 2em;
+ .flow-ui-tooltip-triangle-location-vertical( @location );
+ top: 50%;
+ margin-top: -1em;
+ }
+
+ // triangle on top
+ &.flow-ui-tooltip-up {
+ margin-top: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( horizontal, top );
+
+ &:after {
+ top: .5em;
+ left: .5em;
+ .box-shadow( ~"0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle on bottom
+ &.flow-ui-tooltip-down {
+ margin-bottom: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( horizontal, bottom );
+
+ &:after {
+ top: -.5em;
+ left: .5em;
+ .box-shadow( ~"0 -1.5px 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle at left
+ &.flow-ui-tooltip-left {
+ margin-left: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( vertical, left );
+
+ &:after {
+ margin-top: -1px;
+ top: .5em;
+ right: -.5em;
+ .box-shadow( ~"1.5px 0 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle at right
+ &.flow-ui-tooltip-right {
+ margin-left: -.75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( vertical, right );
+
+ &:after {
+ margin-top: -1px;
+ top: .5em;
+ left: -.5em;
+ .box-shadow( ~"0 1.5px 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ .flow-ui-tooltip-color( @backgroundColor ) {
+ @backgroundColorDarkened: darken( @backgroundColor, @colorDarkenPercentage );
+
+ background: @backgroundColor;
+ .box-shadow( ~"0 2px 0 0 @{backgroundColorDarkened}" );
+ color: @colorWhite;
+
+ .flow-ui-tooltip-triangle:after {
+ background: @backgroundColor;
+ }
+ &.flow-ui-tooltip-down .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"0 -2px 0 2px @{backgroundColorDarkened}" );
+ }
+ &.flow-ui-tooltip-left .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"2px 0 0 2px @{backgroundColorDarkened}" );
+ }
+ &.flow-ui-tooltip-right .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"0 2px 0 2px @{backgroundColorDarkened}" );
+ }
+ }
+
+ // Content for tooltips generated by JS
+ .flow-ui-tooltip-content {
+ display: block;
+ max-width: 360px;
+ }
+
+ // Don't apply these classes on IE6
+ &[class] {
+ &.mw-ui-progressive {
+ .flow-ui-tooltip-color( @colorProgressive );
+ }
+ &.mw-ui-constructive {
+ .flow-ui-tooltip-color( @colorConstructive );
+ }
+ &.mw-ui-destructive {
+ .flow-ui-tooltip-color( @colorDestructive );
+ }
+ &.flow-ui-tooltip-small {
+ font-size: .75em;
+
+ .flow-ui-tooltip-content {
+ max-width: 240px;
+ }
+ }
+ &.flow-ui-tooltip-large {
+ max-width: 100%;
+
+ .flow-ui-tooltip-content {
+ max-width: 100%;
+ }
+ }
+ }
+}
+
+// Block-level tooltip
+.flow-ui-tooltip-block {
+ width: 100%;
+}
diff --git a/Flow/modules/styles/minerva/common.less b/Flow/modules/styles/minerva/common.less
new file mode 100644
index 00000000..782cd0dc
--- /dev/null
+++ b/Flow/modules/styles/minerva/common.less
@@ -0,0 +1,4 @@
+// On mobile enable the talk page button when Flow is enabled
+.mw-mobile-mode.stable #ca-talk {
+ display: block;
+}
diff --git a/Flow/modules/vendor/Storer.js b/Flow/modules/vendor/Storer.js
new file mode 100644
index 00000000..2708b223
--- /dev/null
+++ b/Flow/modules/vendor/Storer.js
@@ -0,0 +1,1355 @@
+/*!
+ * Storer.js is a fallback-reliant, HTML5 Storage-based storage system.<br/>
+ * <br/>
+ * All of its storage subsystems implement getItem, setItem, removeItem, clear, key, and length, as the HTML5 Web
+ * Storage specification is written, with some enhancements on them, and slight deviations on memory/cookieStorage.<br/>
+ * <br/>
+ * It piggybacks on the real HTML5 storage when available, and creates the additional functionality of being able to
+ * prepend a 'prefix' to all key names automatically (see initStorer params). This is useful for projects where you
+ * would like to use Storage without worrying about name collisions.<br/>
+ * <br/>
+ * It _always_ returns every type of storage, and falls back to others, as listed below. In the worst-case scenario,
+ * all the storage subsystems are instances of memoryStorage, which means no persistance is available, but that no code
+ * will break while performing actions on the current page.<br/>
+ * <br/>
+ * The fallbacks are as follows:<br/>
+ * localStorage = localStorage || userData || cookieStorage || memoryStorage<br/>
+ * sessionStorage = sessionStorage || window.name || memoryStorage<br/>
+ * cookieStorage = cookieStorage || memoryStorage<br/>
+ * memoryStorage = memoryStorage<br/>
+ * <br/>
+ * cookieStorage also supports an additional 'global' Boolean argument on all of its methods, allowing you to escape
+ * out of the 'prefix' defined, so that you may use it to fetch general cookies as well.<br/>
+ * <br/>
+ * initStorer is called, takes a callback function, which will return the storage subsystems.<br/>
+ * This is necessary because the Internet Explorer fallback for localStorage is userData, which needs to be able to
+ * insert an element into the document before proceeding. On any modern or non-IE browser, the callback function is
+ * triggered synchronously and immediately.<br/>
+ * <br/>
+ * Note: for IE6-7 compatibility, initStorer requires a function called domReady, or uses jQuery(document).ready if available.<br/>
+ * <br/>
+ * Here is a cat. =^.^= His name is Frisbee.
+ * <br/>
+ *
+ * @copyright Viafoura, Inc. <viafoura.com>
+ * @author Shahyar G <github.com/shahyar>, originally for <github.com/viafoura>
+ * @license CC-BY 3.0 <creativecommons.org/licenses/by/3.0>: Keep @copyright, @author intact.
+ *
+ * @example
+ * initStorer(function (Storer) {
+ * cookieStorage = Storer.cookieStorage;
+ * memoryStorage = Storer.memoryStorage;
+ * sessionStorage = Storer.sessionStorage;
+ * localStorage = Storer.localStorage;
+ * }, { 'prefix': '_MyStorage_' });
+ */
+
+/**
+ * This will return an object with each of the storage types.
+ * The callback will fire when all of the necessary types have been created, although it's really only necessary
+ * for Internet Explorer's userData storage, which requires domReady to begin.
+ *
+ * @author Shahyar G <github.com/shahyar>, originally for Viafoura, Inc. <viafoura.com>
+ * @param {Function} [callback]
+ * @param {Object} [params]
+ * {String} [prefix=''] automatic key prefix for sessionStorage and localStorage
+ * {String} [default_domain=''] default domain for cookies
+ * {String} [default_path=''] default path for cookies
+ * {Boolean} [no_cookie_fallback=false] If true, do not use cookies as fallback for localStorage
+ * @return {Object} {cookieStorage, localStorage, memoryStorage, sessionStorage}
+ * @version 0.1.3
+ */
+function initStorer(callback, params) {
+ "use strict";
+
+ var _TESTID = '__SG__',
+ top = window,
+ PREFIX = (params = Object.prototype.toString.call(callback) === "[object Object]" ? callback : (params || {})).prefix || '',
+ NO_COOKIE_FALLBACK = params.no_cookie_fallback || false,
+ _callbackNow = true,
+ cookieStorage, localStorage, memoryStorage, sessionStorage;
+
+ if (params === callback) {
+ // Allow passing params without callback
+ callback = null;
+ }
+
+ // get top within cross-domain limit if we're in an iframe
+ try { while (top !== top.top) { top = top.top; } } catch (e) {}
+
+ /**
+ * Returns result.value if result has ._end key, or returns result entirely otherwise.
+ * Returns null when: result is null or undefined, or end && end > current timestamp.
+ * @param {String|Number|Date|null|undefined} end
+ * @param {*} result
+ * @param {Function} remove_callback
+ * @param {String} remove_callback_key
+ * @returns {*}
+ * @private
+ */
+ function _checkEnd(end, result, remove_callback, remove_callback_key) {
+ if (result === null || result === undefined || (end && parseInt(+new Date() / 1000, 10) > parseInt(end, 10))) {
+ // Remove this key from the data set
+ remove_callback(remove_callback_key);
+ // Return nothing
+ return null;
+ }
+ // Return the actual data
+ return result._end !== undefined ? result.value : result;
+ }
+
+ /**
+ * Parses str into JSON object, but also handles backwards compatibility with 0.0.4 when data was not automatically
+ * JSONified. If data._end exists, also runs _checkEnd. When not a valid JSON object, returns str back.
+ * @param {String|*} str
+ * @param {Function} [remove_callback]
+ * @param {String} [callback_key]
+ * @returns {*}
+ * @private
+ */
+ function _getJSON(str, remove_callback, callback_key) {
+ try {
+ var obj = str && JSON.parse(str);
+ if (obj) {
+ // Backwards compatibility for 0.0.4, when _end did not exist
+ if (obj._end !== undefined) {
+ // Check for expiry
+ return _checkEnd(obj._end, obj.value, remove_callback, callback_key);
+ }
+ return obj;
+ }
+ } catch (e) {}
+
+ // Non-JSON data (0.0.4)
+ return str;
+ }
+
+ /**
+ * Puts data and end (standardized to seconds) in an object, and returns it for use.
+ * If end is valid and end > now, data = null, and remove_callback is called,
+ * otherwise, set_callback is called.
+ * @param {Object|*} data
+ * @param {String|Number|Date} [end]
+ * @param {Function} [set_callback]
+ * @param {Function} [remove_callback]
+ * @param {String} [callback_key]
+ * @param {Boolean} [json]
+ * @returns {*}
+ * @private
+ */
+ function _storeEnd(data, end, set_callback, remove_callback, callback_key, json) {
+ var now = parseInt(+new Date() / 1000, 10);
+
+ switch (typeof end) {
+ case "number":
+ // Max-age, although we allow end=0 to mimic 0 for cookies
+ end = end && parseInt(now + end, 10);
+ break;
+ case "string":
+ // timestamp or Date string
+ end = end.length > 4 && "" + parseInt(end, 10) === end ? parseInt(end, 10) : parseInt(+new Date(end) / 1000, 10);
+ break;
+ case "object":
+ if (end.toGMTString) {
+ // Date object
+ end = parseInt(+end / 1000, 10);
+ }
+ break;
+ default:
+ end = null;
+ }
+
+ data = { value: end && now > end ? null : data, _end: end || null };
+
+ if (data.value === null || data.value === undefined) {
+ // Automatically expire this item
+ remove_callback && remove_callback(callback_key);
+ } else if (json) {
+ // Set the data with JSON
+ set_callback && set_callback(callback_key, JSON.stringify(data._end ? data : data.value));
+ } else {
+ // Set the data
+ set_callback && set_callback(callback_key, data._end ? data : data.value);
+ }
+
+ return data;
+ }
+
+ /**
+ * Clears expired data from each storage subsystem.
+ * @private
+ */
+ function _clearExpired() {
+ var i, j, key;
+ // Iterate over every storage subsystem
+ for (i in _returnable) {
+ // Ignore memoryStorage, as it doesn't have anything to expire
+ if (_returnable.hasOwnProperty(i) && i.charAt(0) !== '_' && _returnable[i].STORE_TYPE !== 'memoryStorage') {
+ j = 0;
+ // Iterate over every key in this subsystem
+ while ((key = _returnable[i].key(j++))) {
+ // getItem automatically handles removing expired items
+ _returnable[i].getItem(key);
+ }
+ }
+ }
+ }
+
+ /**
+ * A hack for Safari's inability to extend a class with Storage.
+ * @param {String} name
+ * @param {Storage} StoreRef
+ * @return {Object}
+ */
+ function _createReferencedStorage(name, StoreRef) {
+ var store = {
+ STORE_TYPE: 'ref' + name,
+ key: function (key) {
+ return StoreRef.key(key);
+ },
+ getItem: function (key) {
+ return StoreRef.getItem(key);
+ },
+ setItem: function (key, value, end) {
+ return StoreRef.setItem(key, value, end);
+ },
+ removeItem: function (key) {
+ return StoreRef.removeItem(key);
+ },
+ clear: function () {
+ return StoreRef.clear();
+ }
+ };
+ Object.defineProperty(store, "length", { get: function () { return StoreRef.length; } });
+ return store;
+ }
+
+ /**
+ * A hack for IE8's inability to extend a class with Storage. We use a DOM property getter to apply length.
+ * @param {String} name
+ * @param {Storage} StoreRef
+ * @return {Object}
+ */
+ function _createDOMStorage(name, StoreRef) {
+ var store = document.createElement('div');
+ store.STORE_TYPE = 'DOM' + name;
+ store.key = function (key) {
+ try {
+ return StoreRef.key(key);
+ } catch (e) { return null; } // IE8 throws an exception on nonexistent keys
+ };
+ store.getItem = function (key) {
+ return StoreRef.getItem(key);
+ };
+ store.setItem = function (key, value, end) {
+ return StoreRef.setItem(key, value, end);
+ };
+ store.removeItem = function (key) {
+ return StoreRef.removeItem(key);
+ };
+ store.clear = function () {
+ return StoreRef.clear();
+ };
+ Object.defineProperty(store, "length", { get: function () { return StoreRef.length; } });
+ return store;
+ }
+
+ /**
+ * Amends getItem and setItem to support expiry times for HTML5 Storage.
+ * @param {Object|Storage} StoreRef
+ * @return {Object}
+ * @private
+ */
+ function _adjustHTML5Storage(StoreRef) {
+ var _getItem = StoreRef.getItem,
+ _setItem = StoreRef.setItem,
+ _removeItem = StoreRef._removeItem || StoreRef.removeItem,
+ _removeItemCallback = function (key) {
+ _removeItem(key);
+ };
+
+ StoreRef.getItem = function (key) {
+ return _getJSON(_getItem(key), _removeItemCallback, key);
+ };
+ StoreRef.setItem = function (key, data, end) {
+ return _storeEnd(
+ data,
+ end,
+ function (key, value) {
+ _setItem(key, value);
+ },
+ _removeItemCallback,
+ key,
+ true
+ );
+ };
+
+ return StoreRef;
+ }
+
+ /**
+ *
+ * @param {Object|Storage} StoreRef
+ * @returns {*}
+ * @private
+ */
+ function _assignPrefix(StoreRef) {
+ // Use the rest of the object natively without a prefix
+ // memoryStorage doesn't need prefixes
+ if (!PREFIX || StoreRef.STORE_TYPE === 'memoryStorage') {
+ return StoreRef;
+ }
+
+ // Rewire functions to use a prefix and avoid collisions
+ // @todo Rewire length for prefixes as well
+ StoreRef._getItem = StoreRef.getItem;
+ StoreRef._setItem = StoreRef.setItem;
+ StoreRef._removeItem = StoreRef.removeItem;
+ StoreRef._key = StoreRef.key;
+
+ /** Variable # of items in Storage.
+ * @const int length
+ * @memberof sessionStorage
+ * @memberof localStorage */
+
+ /**
+ * Returns an item from the current type of Storage.
+ * @param {String} key
+ * @returns {*}
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.getItem = function (key) {
+ return StoreRef._getItem(PREFIX + key);
+ };
+
+ /**
+ * Sets an item in the current type of Storage.
+ * end is expiry: Number = seconds from now, String = date string for Date(), or Date object.
+ * @param {String} key
+ * @param {*} data
+ * @param {int|String|Date} [end]
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.setItem = function (key, data, end) {
+ return StoreRef._setItem(PREFIX + key, data, end);
+ };
+
+ /**
+ * Removes key from the current Storage instance, if it has been set.
+ * @param {String} key
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.removeItem = function (key) {
+ return StoreRef._removeItem(PREFIX + key);
+ };
+
+ StoreRef._key = StoreRef.key;
+ /**
+ * Gets the key (if any) at index, from the current Storage instance.
+ * @param {int} index
+ * @returns {String|null}
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.key = function (index) {
+ if ((index = StoreRef._key(index)) !== undefined && index !== null) {
+ // Chop off the index
+ return index.indexOf(PREFIX) === 0 ? index.substr(PREFIX.length) : index;
+ }
+ return null;
+ };
+
+ if (StoreRef.STORE_TYPE !== 'cookieStorage') {
+ // cookieStorage has its own clear which supports prefixes
+ /**
+ * Removes all the current keys from this Storage instance.
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.clear = function () {
+ for (var i = StoreRef.length, key; i--;) {
+ if ((key = StoreRef._key(i)).indexOf(PREFIX) === 0) {
+ StoreRef._removeItem(key);
+ }
+ }
+ };
+ } else {
+ // cookieStorage is the only one which implements hasItem
+ if (StoreRef.hasItem) {
+ StoreRef._hasItem = StoreRef.hasItem;
+ StoreRef.hasItem = function (key) {
+ return StoreRef._hasItem(PREFIX + key);
+ };
+ }
+ }
+
+ return StoreRef;
+ }
+
+ /**
+ * Returns memoryStorage on failure
+ * @param {String} [cookie_prefix] An additional prefix, useful for isolating fallbacks for local/sessionStorage.
+ * @return {cookieStorage|memoryStorage}
+ */
+ function _createCookieStorage(cookie_prefix) {
+ cookie_prefix = (cookie_prefix || '');
+ var _cookiergx = new RegExp("(?:^|;)\\s*" + cookie_prefix + PREFIX + "[^=;]+\\s*(?:=[^;]*)?", "g"),
+ _nameclean = new RegExp("^;?\\s*" + cookie_prefix + PREFIX),
+ _cookiergxGlobal = new RegExp("(?:^|;)\\s*[^=;]+\\s*(?:=[^;]*)?", "g"),
+ _namecleanGlobal = new RegExp("^;?\\s*"),
+ _expire = (new Date(1979)).toGMTString(),
+ /**
+ * @namespace cookieStorage
+ * @memberof Storer
+ * @public
+ * @global
+ */
+ _cookieStorage = {
+ /** @const String STORE_TYPE
+ * @default "cookieStorage"
+ * @memberof cookieStorage */
+ STORE_TYPE: 'cookieStorage',
+ /** Default domain to use in cookieStorage.setItem (set by initStorer)
+ * @const String DEFAULT_DOMAIN
+ * @memberof cookieStorage */
+ DEFAULT_DOMAIN: escape(params.default_domain || ''),
+ /** Default path to use in cookieStorage.setItem (set by initStorer)
+ * @const String DEFAULT_PATH
+ * @memberof cookieStorage */
+ DEFAULT_PATH: escape(params.default_path || ''),
+
+ /** Variable # of items in storage
+ * @const int length
+ * @memberof cookieStorage */
+ length: 0,
+
+ /**
+ * Returns the cookie key at idx.
+ * @param {int} idx
+ * @param {Boolean} [global=false] Omits prefix.
+ * @return {*}
+ * @memberof cookieStorage
+ */
+ key: function (idx, global) {
+ var cookies = _cookieStorage.getAll(false, global);
+ return cookies[idx] ? cookies[idx].key : undefined;
+ },
+
+ /**
+ * Clears all cookies for this prefix.
+ * @param {Boolean} [global=false] true omits the prefix, and erases all cookies
+ * @memberof cookieStorage
+ */
+ clear: function (global) {
+ var cookies = _cookieStorage.getAll(false, global),
+ i = cookies.length;
+
+ while (i--) {
+ // Don't use static _removeItemFn reference, because cookieStorage.clear is not handled by _assignPrefix
+ _cookieStorage.removeItem(cookies[i].key);
+ }
+ },
+
+ /**
+ * Returns an Array of Objects of key-value pairs, or an Object with properties-values plus length (as_object).
+ * @param {Boolean} [as_object=false] true returns a single object of key-value pairs
+ * @param {Boolean} [global=false] true gets all cookies, omitting the default prefix
+ * @return {Object[]|Object}
+ * @memberof cookieStorage
+ */
+ getAll: function (as_object, global) {
+ var cleaner = global ? _namecleanGlobal : _nameclean,
+ matches = document.cookie.match(global ? _cookiergxGlobal : _cookiergx) || [],
+ i = matches.length, _cache;
+
+ if (as_object === true) { // object of properties/values
+ for (_cache = {length: i}; i--;) {
+ _cache[unescape((matches[i] = matches[i].split('='))[0].replace(cleaner, ''))] = matches[i][1];
+ }
+ } else { // array of key/value objects
+ for (_cache = []; i--;) {
+ _cache.push({ key: unescape((matches[i] = matches[i].split('='))[0].replace(cleaner, '')), value: matches[i][1] });
+ }
+ }
+
+ return _cache;
+ },
+
+ /**
+ * Get a cookie by name.
+ * @param {String} key
+ * @param {Boolean} [global=false] true omits the prefix, and searches for a match "globally"
+ * @return {String}
+ * @memberof cookieStorage
+ */
+ getItem: function (key, global) {
+ if (!key || !_hasItemFn(key, global)) {
+ return null;
+ }
+
+ return ((global = document.cookie.match(new RegExp('(?:^|;) *' + escape((global ? '' : cookie_prefix) + key) + '=([^;]*)(?:;|$)'))), global && global[0] ? unescape(global[1]) : null);
+ },
+
+ /**
+ * cookieStorage.setItem(key, value, end, path, domain, is_secure);
+ * @param {String} key name of the cookie
+ * @param {String} value value of the cookie;
+ * @param {Number|String|Date} [end] max-age in seconds (e.g., 31536e3 for a year) or the
+ * expires date in GMTString format or in Date Object format; if not specified it will expire at the end of session;
+ * @param {String} [path] e.g., "/", "/mydir"; if not specified, defaults to the current path of the current document location;
+ * @param {String} [domain] e.g., "example.com", ".example.com" (includes all subdomains) or "subdomain.example.com"; if not
+ * specified, defaults to the host portion of the current document location;
+ * @param {Boolean} [is_secure=false] cookie will be transmitted only over secure protocol as https;
+ * @param {Boolean} [global=false] true omits prefix, defines the cookie "globally"
+ * @return {Boolean}
+ * @memberof cookieStorage
+ **/
+ setItem: function (key, value, end, path, domain, is_secure, global) {
+ if (!key || key === 'expires' || key === 'max-age' || key === 'path' || key === 'domain' || key === 'secure') {
+ return false;
+ }
+
+ var sExpires = "",
+ store_end = _storeEnd(value, end);
+ if (store_end._end !== null) {
+ sExpires = "; expires=" + (new Date(store_end._end * 1000)).toGMTString();
+ }
+
+ if (store_end.value !== null && value !== undefined && value !== null) {
+ domain = (domain = typeof domain === 'string' ? escape(domain) : _cookieStorage.DEFAULT_DOMAIN) ? '; domain=' + domain : '';
+ path = (path = typeof path === 'string' ? escape(path) : _cookieStorage.DEFAULT_PATH) ? '; path=' + path : '';
+ document.cookie = escape((global ? '' : cookie_prefix) + key) + '=' + escape(value) + sExpires + domain + path + (is_secure ? '; secure' : '');
+
+ _updateLength();
+ return true;
+ }
+
+ return _removeItemFn(key, domain, path, is_secure, global);
+ },
+
+ /**
+ * Get a cookie by name
+ * @param {String} key
+ * @param {String} [path]
+ * @param {String} [domain]
+ * @param {Boolean} [is_secure]
+ * @param {Boolean} [global=false] Omits prefix.
+ * @memberof cookieStorage
+ */
+ removeItem: function (key, domain, path, is_secure, global) {
+ if (!key || !_hasItemFn(key, global)) {
+ return;
+ }
+
+ domain = (domain = typeof domain === 'string' ? escape(domain) : _cookieStorage.DEFAULT_DOMAIN) ? '; domain=' + domain : '';
+ path = (path = typeof path === 'string' ? escape(path) : _cookieStorage.DEFAULT_PATH) ? '; path=' + path : '';
+ document.cookie = escape((global ? '' : cookie_prefix) + key) + '=; expires=' + _expire + domain + path + (is_secure ? '; secure' : '');
+
+ _updateLength();
+ },
+
+ /**
+ * Returns true if a cookie with that name was found, false otherwise
+ * @param {String} key
+ * @param {Boolean} [global=false] Omits prefix.
+ * @param {Boolean}
+ * @memberof cookieStorage
+ */
+ hasItem: function (key, global) {
+ return (new RegExp('(?:^|;) *' + escape((global ? '' : cookie_prefix) + key) + '=')).test(document.cookie);
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = _cookieStorage.removeItem,
+ _hasItemFn = _cookieStorage.hasItem;
+
+ /**
+ * Updates cookieStorage.length on update
+ * @private
+ */
+ function _updateLength() {
+ _cookieStorage.length = _cookieStorage.getAll().length;
+ }
+
+ _cookieStorage.setItem(_TESTID, 4);
+ if (_cookieStorage.getItem(_TESTID) == 4) {
+ _cookieStorage.removeItem(_TESTID);
+ return _assignPrefix(_cookieStorage);
+ }
+ return _createMemoryStorage();
+ }
+
+ /**
+ * Returns a memoryStorage object. This is a constructor to be reused as a fallback on sessionStorage & localStorage
+ * @return {memoryStorage}
+ */
+ function _createMemoryStorage() {
+ var _data = {}, // key : data
+ _keys = [], // _keys key : _ikey key
+ _ikey = {}; // _ikey key : _keys key
+ /**
+ * @namespace memoryStorage
+ */
+ var _memoryStorage = {
+ /** @const String STORE_TYPE
+ * @default "memoryStorage"
+ * @memberof memoryStorage */
+ STORE_TYPE: 'memoryStorage',
+
+ /** Variable # of items in storage
+ * @const int length
+ * @memberof memoryStorage */
+ length: 0,
+
+ /**
+ * Get key name by id
+ * @param {int} i
+ * @return {String|null}
+ * @memberof memoryStorage
+ */
+ key: function (i) {
+ return _keys[i];
+ },
+
+ /**
+ * Get an item
+ * @param {String} key
+ * @return {*}
+ * @memberof memoryStorage
+ */
+ getItem: function (key) {
+ return _checkEnd(_data[key] && _data[key]._end, _data[key], _memoryStorage.removeItem, key);
+ },
+
+ /**
+ * Set an item
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ * @memberof memoryStorage
+ */
+ setItem: function (key, data, end) {
+ if (data !== null && data !== undefined) {
+ _ikey[key] === undefined && (_ikey[key] = (_memoryStorage.length = _keys.push(key)) - 1);
+ return (_data[key] = _storeEnd(data, end)).value;
+ }
+ return _memoryStorage.removeItem(key);
+ },
+
+ /**
+ * Removes an item
+ * @param {String} key
+ * @return {Boolean}
+ * @memberof memoryStorage
+ */
+ removeItem: function (key) {
+ var was = _data[key] !== undefined;
+ if (_ikey[key] !== undefined) {
+ // re-reference all the keys because we've removed an item in between
+ for (var i = _keys.length; --i > _ikey[key];) {
+ _ikey[_keys[i]]--;
+ }
+ _keys.splice(_ikey[key], 1);
+ delete _ikey[key];
+ }
+ delete _data[key];
+ _memoryStorage.length = _keys.length;
+ return was;
+ },
+
+ /**
+ * Clears memoryStorage
+ * @memberof memoryStorage
+ */
+ clear: function () {
+ for (var i in _data) {
+ if (_data.hasOwnProperty(i)) {
+ delete _data[i];
+ }
+ }
+ _memoryStorage.length = _keys.length = 0;
+ _ikey = {};
+ }
+ };
+ return _memoryStorage;
+ }
+
+ /**
+ * Returns a nameStorage object. This constructor is designed to be a fallback for sessionStorage in IE7 and under.
+ * It uses window.name and RC4 encryption on a per-domain basis. Inspired by LSS by Andrea Giammarchi.
+ * @param {DOMWindow} [win=top]
+ * @return {nameStorage}
+ */
+ function _createNameStorage(win) {
+ if (!win) {
+ win = top;
+ }
+
+ /** RC4 Stream Cipher
+ * http://www.wisdom.weizmann.ac.il/~itsik/RC4/rc4.html
+ * -----------------------------------------------
+ * @description A quick stream cipher to encode & decode any string, using a random key of up to 256 bytes.
+ *
+ * @author Ported to JavaScript by Andrea Giammarchi
+ * @license MIT-style license
+ * @blog http://webreflection.blogspot.com/
+ * @version 1.2.1
+ */
+ var RC4 = (function (String, fromCharCode, random) {
+ return {
+ /** RC4.decode(key:String, data:String):String
+ * @description given a data string encoded with the same key
+ * generates original data string.
+ * @param {String} key key precedently used to encode data
+ * @param {String} data data encoded using same key
+ * @return {String} decoded data
+ * @private
+ */
+ decode: function (key, data) {
+ return this.encode(key, data);
+ },
+
+ /** RC4.encode(key:String, data:String):String
+ * @description encode a data string using provided key
+ * @param {String} key key to use for this encoding
+ * @param {String} data data to encode
+ * @return {String} encoded data. Will require same key to be decoded
+ * @private
+ */
+ encode: function (key, data) {
+ for (var length = key.length, len = data.length, decode = [], a = [],
+ i = 0, j = 0, k = 0, l = 0, $;
+ i < 256;
+ i++
+ ) {
+ a[i] = i;
+ }
+ for (i = 0; i < 256; i++) {
+ j = (j + ($ = a[i]) + key.charCodeAt(i % length)) % 256;
+ a[i] = a[j];
+ a[j] = $;
+ }
+ for (j = 0; k < len; k++) {
+ i = k % 256;
+ j = (j + ($ = a[i])) % 256;
+ length = a[i] = a[j];
+ a[j] = $;
+ decode[l++] = data.charCodeAt(k) ^ a[(length + $) % 256];
+ }
+ return fromCharCode.apply(String, decode);
+ },
+
+ /** RC4.key(length:Number):String
+ * @description generate a random key with arbitrary length
+ * @param {Number} length The length of the generated key
+ * @return {String} a randomly generated key
+ * @private
+ */
+ key: function (length) {
+ for (var i = 0, key = []; i < length; i++) {
+ key[i] = 1 + ((random() * 255) << 0);
+ }
+ return fromCharCode.apply(String, key);
+ }
+ };
+ // I like to freeze stuff in interpretation time
+ // it makes things a bit safer when obtrusive libraries
+ // are around
+ }(String, String.fromCharCode, Math.random)),
+ // Opera will store on every set, because it has no onbeforeunload
+ is_opera = Object.prototype.toString.call(window.opera) === "[object Opera]",
+ // Key used for this domain
+ KEY;
+ // Try to fetch an old key
+ try {
+ KEY = decodeURI(cookieStorage.getItem('.sessionStorageKey'));
+ } catch (e) {}
+ // Generate an encryption key if we don't have a valid one.
+ if (!KEY || KEY.length !== STRENGTH) {
+ KEY = RC4.key(STRENGTH);
+ cookieStorage.setItem('.sessionStorageKey', encodeURI(KEY));
+ }
+
+ // Domain used for prefixing keys
+ var DOMAIN = win.document.domain,
+ // Encrypted domain
+ EDOMAIN = RC4.encode(KEY, DOMAIN),
+ // Start of Header
+ SOH = '#' + String.fromCharCode(1) + 'STOR/' + EDOMAIN,
+ // End of Transmission
+ EOT = EDOMAIN + '/STOR' + String.fromCharCode(4) + '#',
+ // Start of Text
+ STX = String.fromCharCode(2) + ';',
+ // End of Transmission Block
+ ETB = String.fromCharCode(23) + ';',
+ // End of Text
+ ETX = ';' + String.fromCharCode(3),
+ // Key strength in bytes (32 = 256-bit)
+ STRENGTH = 32,
+ // Lengths
+ SOHl = SOH.length,
+ EOTl = EOT.length,
+ STXl = STX.length,
+ ETBl = ETB.length,
+ ETXl = ETX.length,
+ // Data storage by key name
+ _dataObject = {},
+ // Key storage by index
+ _dataArray = [],
+
+ /**
+ * Cannot be accessed directly, and in fact appears as Storer.sessionStorage when in use.
+ * You can, however, know that it is in use when sessionStorage.STORE_TYPE === 'name'.
+ * @namespace nameStorage
+ */
+ _nameStorage = {
+ /** @const String STORE_TYPE
+ * @default "name"
+ * @memberof nameStorage */
+ STORE_TYPE: 'name',
+
+ /** Number of items in storage */
+ length: 0,
+
+ /**
+ * Get an item key by its index
+ * @param {int} index
+ * @return {String|null} key
+ */
+ key: function (index) {
+ return _dataArray[index];
+ },
+
+ /**
+ * Get an item by its key
+ * @param {String} key
+ * @return {String|null} data
+ */
+ getItem: function (key) {
+ return _checkEnd(_dataObject[key] && _dataObject[key]._end, _dataObject[key], _removeItemFn, key);
+ },
+
+ /**
+ * Set an item by key
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ */
+ setItem: function (key, data, end) {
+ var store_end = _storeEnd(data, end)._end;
+
+ if (store_end.value === null) {
+ return _removeItemFn(key);
+ }
+
+ if (_dataObject[key]) {
+ // Update an existing key's value
+ _dataObject[key].value = data;
+ _dataObject[key]._end = store_end._end;
+ } else {
+ // Store this item by its key
+ _dataObject[key] = {
+ value: data,
+ // For new items, increment the length property
+ index: (_nameStorage.length = _dataArray.push(key)) - 1,
+ _end: store_end._end
+ };
+ }
+
+ if (!store_end._end) {
+ // Save some space
+ delete _dataObject[key]._end;
+ }
+
+ is_opera && _write();
+
+ return data;
+ },
+
+ /**
+ * Remove an item by key
+ * @param {String} key
+ */
+ removeItem: function (key) {
+ if (_dataObject[key]) {
+ // Validity check on _dataArray just in case, to prevent corruption
+ if (_dataArray[_dataObject[key].index] === key) {
+ // Remove the stored index
+ _dataArray.splice(_dataObject[key].index, 1);
+
+ // Update length property
+ _nameStorage.length = _dataArray.length;
+
+ // Update all other indices to point to their new locations
+ for (var i = _dataObject[key].index, len = _dataArray.length; i < len; i++) {
+ _dataObject[_dataArray[i]].index--;
+ }
+ }
+
+ // Delete the stored data
+ delete _dataObject[key];
+
+ is_opera && _write();
+
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Completely empies storage
+ */
+ clear: function () {
+ _dataArray.length = 0;
+ _dataObject = {};
+ is_opera && _write();
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = _nameStorage.removeItem;
+
+ // Format: FULLCONTENT: [SOH]CONTENTPIECECONTENTPIECE...[EOT]
+ // Format: CONTENTPIECE: [STX]keylength[:]contentlength[ETB]key[ETB]content[ETX]
+ /*
+ win.name = SOH
+ + STX + 5 + ':' + 5 + ETB + 'hello' + ETB + 'world' + ETX
+ + STX + 2 + ':' + 10 + ETB + 'my' + ETB + 'abcd?fhi~k' + ETX
+ + EOT;
+ */
+
+ /**
+ * Writes _dataObject's keys and values to window.name.
+ */
+ function _write() {
+ var str = win.name,
+ start = str.indexOf(SOH),
+ end = str.indexOf(EOT),
+ i = _dataArray.length;
+
+ // Remove any previous storage on window.name
+ if (start > -1 && end > start) {
+ win.name = str.slice(0, start) + str.slice(end + EOTl);
+ }
+
+ for (str = ''; i--;) {
+ if (_dataObject[_dataArray[i]] && _dataObject[_dataArray[i]].value) {
+ // STX --------KEY LENGTH--------- : ----------------CONTENT LENGTH---------------- ETB -----KEY----- ETB -------------CONTENT------------ ETX
+ str += STX + ('' + _dataArray[i]).length + ':' + ('' + _dataObject[_dataArray[i]].value).length + ETB + _dataArray[i] + ETB + _dataObject[_dataArray[i]].value + ETX;
+ }
+ }
+
+ // Encrypt the contents and write it to window.name
+ win.name += SOH + encodeURI(RC4.encode(KEY, str)) + EOT;
+ }
+
+ /**
+ * This function processes window.name, tries to find matching keys, and stores them.
+ */
+ function _initialize() {
+ var str = win.name,
+ start = str.indexOf(SOH),
+ end = str.indexOf(EOT),
+ last_index = 0,
+ item_key = '',
+ item_klen = 0,
+ item_clen = 0;
+
+ if (start > -1 && end > start) {
+ // Remove it from the window to append it later. This helps with invalid data.
+ // eg. ABC;def;HIJ -> ABCHIJ
+ win.name = str.slice(0, start) + str.slice(end + EOTl);
+
+ // Use the rest of the string for storage parsing
+ str = RC4.decode(KEY, decodeURI(str.slice(start + SOHl, end)));
+
+ // Find the start of an item
+ while ((start = str.indexOf(STX, last_index)) !== -1) {
+ last_index = start + STXl; // move index to start of item, past STX
+
+ // Find out how long this item is, and its key name
+ if ((end = str.indexOf(ETB, last_index)) !== -1) {
+ // [1] content length, [0] key length
+ item_klen = str.slice(last_index, end).split(':');
+ item_clen = parseInt(item_klen[1], 10);
+ item_klen = parseInt(item_klen[0], 10);
+
+ last_index = end + ETBl; // move index to start of item key, past length-ETB
+
+ // Validate: Make sure ETB is immediately after the key
+ if ((end = str.indexOf(ETB, last_index)) === last_index + item_klen) {
+ // Parse out this item's key
+ item_key = str.substr(last_index, item_klen);
+
+ last_index = end + ETBl; // move index to start of item content, past key-ETB
+
+ // Validate: Make sure ETX is immediately after the content
+ if ((end = str.indexOf(ETX, last_index)) === last_index + item_clen) {
+ // Store this item
+ _nameStorage.setItem(item_key, str.substr(last_index, item_clen));
+ }
+ }
+ }
+ }
+ }
+
+ // _write data onbeforeunload
+ if (win.addEventListener) {
+ win.addEventListener('beforeunload', _write, true);
+ } else if (win.attachEvent) {
+ win.attachEvent('onbeforeunload', _write);
+ }
+ }
+
+ try {
+ _initialize();
+ } catch (e) {
+ }
+
+ return _nameStorage;
+ }
+ if (callback) {
+ // Create a callback wrapper to empty expired data preemptively
+ callback = (function (callback) {
+ return function () {
+ callback(_returnable);
+ setTimeout(_clearExpired, 100); // delay expiration
+ };
+ }(callback));
+ }
+
+ // Return this stuff
+ var _returnable = {
+ 'cookieStorage': null,
+ 'localStorage': null,
+ 'memoryStorage': null,
+ 'sessionStorage': null,
+ '_createCookieStorage': _createCookieStorage,
+ '_createMemoryStorage': _createMemoryStorage
+ };
+
+ /**
+ * @instanceof cookieStorage
+ */
+ _returnable.cookieStorage = cookieStorage = _createCookieStorage();
+
+ /**
+ * @instanceof memoryStorage
+ */
+ _returnable.memoryStorage = memoryStorage = _createMemoryStorage();
+
+ /**
+ * @namespace sessionStorage
+ * @mixes localStorage
+ */
+ _returnable.sessionStorage = sessionStorage = (function () {
+ // Grab sessionStorage from top window
+ var _sessionStorage = top.sessionStorage;
+
+ // Try to use original sessionStorage
+ if (_sessionStorage) {
+ try {
+ // Test to make sure it works and isn't full
+ _sessionStorage.setItem(_TESTID, 1);
+ _sessionStorage.removeItem(_TESTID);
+
+ // Now clone sessionStorage so that we may extend it with our own methods
+ var _tmp = function () {
+ };
+ _tmp.prototype = _sessionStorage;
+ // jshint -W055
+ _tmp = new _tmp();
+ try {
+ if (_tmp.getItem) {
+ _tmp.setItem(_TESTID, 2);
+ _tmp.removeItem(_TESTID);
+ }
+ } catch (e) {
+ // Firefox 14+ throws a security exception when wrapping a native class
+ _tmp = null;
+ }
+
+ if (_tmp && !_tmp.getItem) {
+ // Internet Explorer 8 does not inherit the prototype here. We can hack around it using a DOM object
+ _sessionStorage = _adjustHTML5Storage(_createDOMStorage('sessionStorage', _sessionStorage));
+ } else if (!_tmp || Object.prototype.toString.apply(Storage.prototype) === '[object StoragePrototype]') {
+ // Safari throws a type error when extending with Storage
+ _sessionStorage = _adjustHTML5Storage(_createReferencedStorage('sessionStorage', _sessionStorage));
+ } else {
+ _sessionStorage = _adjustHTML5Storage(_tmp);
+ }
+ } catch (e) {
+ _sessionStorage = null;
+ }
+ }
+
+ // Build one
+ if (!_sessionStorage) {
+ try {
+ // instantiate nameStorage
+ _sessionStorage = _createNameStorage();
+
+ // Test it
+ _sessionStorage.setItem(_TESTID, 2);
+ if (_sessionStorage.getItem(_TESTID) == 2) {
+ _sessionStorage.removeItem(_TESTID);
+ } else {
+ _sessionStorage = null;
+ }
+ } catch (e) {
+ _sessionStorage = null;
+ }
+ // Last ditch effort: use memory storage
+ if (!_sessionStorage) {
+ _sessionStorage = _createMemoryStorage();
+ }
+ }
+
+ // cookieStorage already calls _assignPrefix
+ return _sessionStorage.STORE_TYPE === 'cookieStorage' ? _sessionStorage : _assignPrefix(_sessionStorage);
+ }());
+
+ /**
+ * @namespace localStorage
+ */
+ _returnable.localStorage = localStorage = (function () {
+ var _localStorage;
+
+ if (top.localStorage || top.globalStorage) {
+ try {
+ _localStorage = top.localStorage || top.globalStorage[location.hostname];
+ _localStorage.setItem(_TESTID, 1);
+ _localStorage.removeItem(_TESTID);
+
+ // Now clone sessionStorage so that we may extend it with our own methods
+ var _tmp = function () {};
+ _tmp.prototype = _localStorage;
+ // jshint -W055
+ _tmp = new _tmp();
+ try {
+ if (_tmp.getItem) {
+ _tmp.setItem(_TESTID, 2);
+ _tmp.removeItem(_TESTID);
+ }
+ } catch (e) {
+ // Firefox 14+ throws a security exception when wrapping a native class
+ _tmp = null;
+ }
+
+ if (_tmp && !_tmp.getItem) {
+ // Internet Explorer 8 does not inherit the prototype here. We can hack around it using a DOM object
+ _localStorage = _adjustHTML5Storage(_createDOMStorage('localStorage', _localStorage));
+ } else if (!_tmp || Object.prototype.toString.apply(Storage.prototype) === '[object StoragePrototype]') {
+ // Safari throws a type error when extending with Storage
+ _localStorage = _adjustHTML5Storage(_createReferencedStorage('localStorage', _localStorage));
+ } else {
+ // Spec
+ _localStorage = _adjustHTML5Storage(_tmp);
+ }
+ } catch (e) {
+ _localStorage = null;
+ }
+ }
+
+ // Did not work, try alternatives...
+ // Try userData first
+ if (!_localStorage) {
+ _localStorage = (function () {
+ /**
+ * @param {String} str
+ * @return {String}
+ */
+
+ var _esc = function (str) {
+ return 'PS' + str.replace(_e, '__').replace(_s, '_s');
+ },
+ _e = /_/g,
+ _s = / /g,
+ _PREFIX = _esc(PREFIX + 'uData'),
+ _NAME = _esc('Storer');
+
+ if (window.ActiveXObject) {
+ // Try userData
+ try {
+ // Data cache
+ var _data = {}, // key : data
+ _keys = [], // _keys key : _ikey key
+ _ikey = {}, // _ikey key : _keys key
+ /**
+ * Cannot be accessed directly, and in fact appears as Storer.localStorage when in use.
+ * You can, however, know that it is in use when localStorage.STORE_TYPE === 'userData'.
+ * @namespace userDataStorage */
+ userData = {
+ /** @const String STORE_TYPE
+ * @default "userData"
+ * @memberof userDataStorage */
+ STORE_TYPE: 'userData',
+
+ /** # of items */
+ length: 0,
+
+ /**
+ * Returns key of i
+ * @param {int} i
+ * @return {String}
+ */
+ key: function (i) {
+ return _keys[i];
+ },
+
+ /**
+ * Gets data of key
+ * @param {String} key
+ * @return {*}
+ */
+ getItem: function (key) {
+ var esckey = _esc(key);
+ return _checkEnd(el.getAttribute('_end_' + esckey), el.getAttribute(esckey), _removeItemFn, key);
+ },
+
+ /**
+ * Sets key to data
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ */
+ setItem: function (key, data, end) {
+ if (data !== null && data !== undefined) {
+ var esckey = _esc(key),
+ store_end = _storeEnd(data, end);
+ if (store_end.value !== null) {
+ el.setAttribute(esckey, data);
+ if (!store_end._end) {
+ // Save some space
+ el.removeAttribute('_end_' + esckey);
+ } else {
+ el.setAttribute('_end_' + esckey, "" + store_end._end);
+ }
+ _ikey[key] === undefined && (_ikey[key] = (userData.length = _keys.push(key)) - 1);
+ el.save(_PREFIX + _NAME);
+ return (_data[key] = store_end.value);
+ }
+ }
+ return _removeItemFn(key);
+ },
+
+ /**
+ * Removes item at key
+ * @param {String} key
+ */
+ removeItem: function (key) {
+ var esckey = _esc(key);
+ el.removeAttribute(esckey);
+ el.removeAttribute('_end_' + esckey);
+ if (_ikey[key] !== undefined) {
+ // re-reference all the keys because we've removed an item in between
+ for (var i = _keys.length; --i > _ikey[key];) {
+ _ikey[_keys[i]]--;
+ }
+ _keys.splice(_ikey[key], 1);
+ delete _ikey[key];
+ }
+ el.save(_PREFIX + _NAME);
+ userData.length = _keys.length;
+ },
+
+ /**
+ * Clears all data
+ */
+ clear: function () {
+ for (var doc = el.xmlDocument,
+ attributes = doc.firstChild.attributes,
+ attr,
+ i = attributes.length;
+ 0 <= --i;) {
+ attr = attributes[i];
+ delete _data[attr.nodeName]; // remove from cache
+ el.removeAttribute(attr.nodeName); // use the standard DOM properties to remove the item
+ userData.length--;
+ }
+ el.save(_PREFIX + _NAME);
+ userData.length = _keys.length = 0;
+ _data = {};
+ _ikey = {};
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = userData.removeItem,
+ _hasItemFn = userData.hasItem;
+
+ // Init userData element
+ var el = document.createElement('input');
+ el.style.display = 'none';
+ el.addBehavior('#default#userData');
+
+ var fn = (typeof domReady === 'function' ? domReady : (typeof jQuery !== 'undefined' ? jQuery(document).ready : false));
+ _callbackNow = !fn;
+
+ fn && fn(function () {
+ try {
+ var bod = document.body || document.getElementsByTagName('head')[0];
+ bod.appendChild(el);
+ el.load(_PREFIX + _NAME);
+
+ // Test
+ userData.setItem(_TESTID, 3);
+ if (userData.getItem(_TESTID) == 3) {
+ userData.removeItem(_TESTID);
+
+ // Good. Parse.
+ var attr,
+ // the reference to the XMLDocument
+ doc = el.xmlDocument,
+ // the root element will always be the firstChild of the XMLDocument
+ attributes = doc.firstChild.attributes,
+ i = -1,
+ len = attributes.length;
+ while (++i < len) {
+ attr = attributes[i];
+ if (attr.nodeValue !== undefined && attr.nodeValue !== null) {
+ _ikey[attr.nodeName] = _keys.push(attr.nodeName) - 1;
+ _data[attr.nodeName] = attr.nodeValue; // use the standard DOM properties to retrieve the key and value
+ }
+ }
+
+ _returnable.localStorage = localStorage = userData;
+ callback && callback(_returnable);
+ } else {
+ userData = null;
+ }
+ } catch (e) {
+ userData = null;
+ }
+
+ if (!userData) {
+ _returnable.localStorage = localStorage = _localStorage = NO_COOKIE_FALLBACK ? _createMemoryStorage() : _createCookieStorage('localStorage');
+ callback && callback(_returnable);
+ }
+ });
+
+ return userData;
+ } catch (e) {}
+ }
+ }());
+ }
+ if (!_localStorage) {
+ // Try cookie or memory
+ _localStorage = NO_COOKIE_FALLBACK ? _createMemoryStorage() : _createCookieStorage('localStorage');
+ }
+
+ // cookieStorage already calls _assignPrefix
+ return _localStorage.STORE_TYPE === 'cookieStorage' ? _localStorage : _assignPrefix(_localStorage);
+ }());
+
+ _callbackNow && callback && callback(_returnable);
+
+ return _returnable;
+}
+
+window.mediaWiki = window.mediaWiki || {};
+mediaWiki.flow = mediaWiki.flow || {};
+mediaWiki.flow.vendor = mediaWiki.flow.vendor || {};
+mediaWiki.flow.vendor.initStorer = initStorer; \ No newline at end of file
diff --git a/Flow/modules/vendor/handlebars.js b/Flow/modules/vendor/handlebars.js
new file mode 100644
index 00000000..f826bbfd
--- /dev/null
+++ b/Flow/modules/vendor/handlebars.js
@@ -0,0 +1,3079 @@
+/*!
+
+ handlebars v2.0.0
+
+Copyright (C) 2011-2014 by Yehuda Katz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+@license
+*/
+/* exported Handlebars */
+(function (root, factory) {
+ if (typeof define === 'function' && define.amd) {
+ define([], factory);
+ } else if (typeof exports === 'object') {
+ module.exports = factory();
+ } else {
+ root.Handlebars = root.Handlebars || factory();
+ }
+}(this, function () {
+// handlebars/safe-string.js
+var __module4__ = (function() {
+ "use strict";
+ var __exports__;
+ // Build out our basic SafeString type
+ function SafeString(string) {
+ this.string = string;
+ }
+
+ SafeString.prototype.toString = function() {
+ return "" + this.string;
+ };
+
+ __exports__ = SafeString;
+ return __exports__;
+})();
+
+// handlebars/utils.js
+var __module3__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__ = {};
+ /*jshint -W004 */
+ var SafeString = __dependency1__;
+
+ var escape = {
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ '"': "&quot;",
+ "'": "&#x27;",
+ "`": "&#x60;"
+ };
+
+ var badChars = /[&<>"'`]/g;
+ var possible = /[&<>"'`]/;
+
+ function escapeChar(chr) {
+ return escape[chr];
+ }
+
+ function extend(obj /* , ...source */) {
+ for (var i = 1; i < arguments.length; i++) {
+ for (var key in arguments[i]) {
+ if (Object.prototype.hasOwnProperty.call(arguments[i], key)) {
+ obj[key] = arguments[i][key];
+ }
+ }
+ }
+
+ return obj;
+ }
+
+ __exports__.extend = extend;var toString = Object.prototype.toString;
+ __exports__.toString = toString;
+ // Sourced from lodash
+ // https://github.com/bestiejs/lodash/blob/master/LICENSE.txt
+ var isFunction = function(value) {
+ return typeof value === 'function';
+ };
+ // fallback for older versions of Chrome and Safari
+ /* istanbul ignore next */
+ if (isFunction(/x/)) {
+ isFunction = function(value) {
+ return typeof value === 'function' && toString.call(value) === '[object Function]';
+ };
+ }
+ var isFunction;
+ __exports__.isFunction = isFunction;
+ /* istanbul ignore next */
+ var isArray = Array.isArray || function(value) {
+ return (value && typeof value === 'object') ? toString.call(value) === '[object Array]' : false;
+ };
+ __exports__.isArray = isArray;
+
+ function escapeExpression(string) {
+ // don't escape SafeStrings, since they're already safe
+ if (string instanceof SafeString) {
+ return string.toString();
+ } else if (string == null) {
+ return "";
+ } else if (!string) {
+ return string + '';
+ }
+
+ // Force a string conversion as this will be done by the append regardless and
+ // the regex test will do this transparently behind the scenes, causing issues if
+ // an object's to string has escaped characters in it.
+ string = "" + string;
+
+ if(!possible.test(string)) { return string; }
+ return string.replace(badChars, escapeChar);
+ }
+
+ __exports__.escapeExpression = escapeExpression;function isEmpty(value) {
+ if (!value && value !== 0) {
+ return true;
+ } else if (isArray(value) && value.length === 0) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ __exports__.isEmpty = isEmpty;function appendContextPath(contextPath, id) {
+ return (contextPath ? contextPath + '.' : '') + id;
+ }
+
+ __exports__.appendContextPath = appendContextPath;
+ return __exports__;
+})(__module4__);
+
+// handlebars/exception.js
+var __module5__ = (function() {
+ "use strict";
+ var __exports__;
+
+ var errorProps = ['description', 'fileName', 'lineNumber', 'message', 'name', 'number', 'stack'];
+
+ function Exception(message, node) {
+ var line;
+ if (node && node.firstLine) {
+ line = node.firstLine;
+
+ message += ' - ' + line + ':' + node.firstColumn;
+ }
+
+ var tmp = Error.prototype.constructor.call(this, message);
+
+ // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work.
+ for (var idx = 0; idx < errorProps.length; idx++) {
+ this[errorProps[idx]] = tmp[errorProps[idx]];
+ }
+
+ if (line) {
+ this.lineNumber = line;
+ this.column = node.firstColumn;
+ }
+ }
+
+ Exception.prototype = new Error();
+
+ __exports__ = Exception;
+ return __exports__;
+})();
+
+// handlebars/base.js
+var __module2__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__ = {};
+ var Utils = __dependency1__;
+ var Exception = __dependency2__;
+
+ var VERSION = "2.0.0";
+ __exports__.VERSION = VERSION;var COMPILER_REVISION = 6;
+ __exports__.COMPILER_REVISION = COMPILER_REVISION;
+ var REVISION_CHANGES = {
+ 1: '<= 1.0.rc.2', // 1.0.rc.2 is actually rev2 but doesn't report it
+ 2: '== 1.0.0-rc.3',
+ 3: '== 1.0.0-rc.4',
+ 4: '== 1.x.x',
+ 5: '== 2.0.0-alpha.x',
+ 6: '>= 2.0.0-beta.1'
+ };
+ __exports__.REVISION_CHANGES = REVISION_CHANGES;
+ var isArray = Utils.isArray,
+ isFunction = Utils.isFunction,
+ toString = Utils.toString,
+ objectType = '[object Object]';
+
+ function HandlebarsEnvironment(helpers, partials) {
+ this.helpers = helpers || {};
+ this.partials = partials || {};
+
+ registerDefaultHelpers(this);
+ }
+
+ __exports__.HandlebarsEnvironment = HandlebarsEnvironment;HandlebarsEnvironment.prototype = {
+ constructor: HandlebarsEnvironment,
+
+ logger: logger,
+ log: log,
+
+ registerHelper: function(name, fn) {
+ if (toString.call(name) === objectType) {
+ if (fn) { throw new Exception('Arg not supported with multiple helpers'); }
+ Utils.extend(this.helpers, name);
+ } else {
+ this.helpers[name] = fn;
+ }
+ },
+ unregisterHelper: function(name) {
+ delete this.helpers[name];
+ },
+
+ registerPartial: function(name, partial) {
+ if (toString.call(name) === objectType) {
+ Utils.extend(this.partials, name);
+ } else {
+ this.partials[name] = partial;
+ }
+ },
+ unregisterPartial: function(name) {
+ delete this.partials[name];
+ }
+ };
+
+ function registerDefaultHelpers(instance) {
+ instance.registerHelper('helperMissing', function(/* [args, ]options */) {
+ if(arguments.length === 1) {
+ // A missing field in a {{foo}} constuct.
+ return undefined;
+ } else {
+ // Someone is actually trying to call something, blow up.
+ throw new Exception("Missing helper: '" + arguments[arguments.length-1].name + "'");
+ }
+ });
+
+ instance.registerHelper('blockHelperMissing', function(context, options) {
+ var inverse = options.inverse,
+ fn = options.fn;
+
+ if(context === true) {
+ return fn(this);
+ } else if(context === false || context == null) {
+ return inverse(this);
+ } else if (isArray(context)) {
+ if(context.length > 0) {
+ if (options.ids) {
+ options.ids = [options.name];
+ }
+
+ return instance.helpers.each(context, options);
+ } else {
+ return inverse(this);
+ }
+ } else {
+ if (options.data && options.ids) {
+ var data = createFrame(options.data);
+ data.contextPath = Utils.appendContextPath(options.data.contextPath, options.name);
+ options = {data: data};
+ }
+
+ return fn(context, options);
+ }
+ });
+
+ instance.registerHelper('each', function(context, options) {
+ if (!options) {
+ throw new Exception('Must pass iterator to #each');
+ }
+
+ var fn = options.fn, inverse = options.inverse;
+ var i = 0, ret = "", data;
+
+ var contextPath;
+ if (options.data && options.ids) {
+ contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]) + '.';
+ }
+
+ if (isFunction(context)) { context = context.call(this); }
+
+ if (options.data) {
+ data = createFrame(options.data);
+ }
+
+ if(context && typeof context === 'object') {
+ if (isArray(context)) {
+ for(var j = context.length; i<j; i++) {
+ if (data) {
+ data.index = i;
+ data.first = (i === 0);
+ data.last = (i === (context.length-1));
+
+ if (contextPath) {
+ data.contextPath = contextPath + i;
+ }
+ }
+ ret = ret + fn(context[i], { data: data });
+ }
+ } else {
+ for(var key in context) {
+ if(context.hasOwnProperty(key)) {
+ if(data) {
+ data.key = key;
+ data.index = i;
+ data.first = (i === 0);
+
+ if (contextPath) {
+ data.contextPath = contextPath + key;
+ }
+ }
+ ret = ret + fn(context[key], {data: data});
+ i++;
+ }
+ }
+ }
+ }
+
+ if(i === 0){
+ ret = inverse(this);
+ }
+
+ return ret;
+ });
+
+ instance.registerHelper('if', function(conditional, options) {
+ if (isFunction(conditional)) { conditional = conditional.call(this); }
+
+ // Default behavior is to render the positive path if the value is truthy and not empty.
+ // The `includeZero` option may be set to treat the condtional as purely not empty based on the
+ // behavior of isEmpty. Effectively this determines if 0 is handled by the positive path or negative.
+ if ((!options.hash.includeZero && !conditional) || Utils.isEmpty(conditional)) {
+ return options.inverse(this);
+ } else {
+ return options.fn(this);
+ }
+ });
+
+ instance.registerHelper('unless', function(conditional, options) {
+ return instance.helpers['if'].call(this, conditional, {fn: options.inverse, inverse: options.fn, hash: options.hash});
+ });
+
+ instance.registerHelper('with', function(context, options) {
+ if (isFunction(context)) { context = context.call(this); }
+
+ var fn = options.fn;
+
+ if (!Utils.isEmpty(context)) {
+ if (options.data && options.ids) {
+ var data = createFrame(options.data);
+ data.contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]);
+ options = {data:data};
+ }
+
+ return fn(context, options);
+ } else {
+ return options.inverse(this);
+ }
+ });
+
+ instance.registerHelper('log', function(message, options) {
+ var level = options.data && options.data.level != null ? parseInt(options.data.level, 10) : 1;
+ instance.log(level, message);
+ });
+
+ instance.registerHelper('lookup', function(obj, field) {
+ return obj && obj[field];
+ });
+ }
+
+ var logger = {
+ methodMap: { 0: 'debug', 1: 'info', 2: 'warn', 3: 'error' },
+
+ // State enum
+ DEBUG: 0,
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3,
+ level: 3,
+
+ // can be overridden in the host environment
+ log: function(level, message) {
+ if (logger.level <= level) {
+ var method = logger.methodMap[level];
+ if (typeof console !== 'undefined' && console[method]) {
+ console[method].call(console, message);
+ }
+ }
+ }
+ };
+ __exports__.logger = logger;
+ var log = logger.log;
+ __exports__.log = log;
+ var createFrame = function(object) {
+ var frame = Utils.extend({}, object);
+ frame._parent = object;
+ return frame;
+ };
+ __exports__.createFrame = createFrame;
+ return __exports__;
+})(__module3__, __module5__);
+
+// handlebars/runtime.js
+var __module6__ = (function(__dependency1__, __dependency2__, __dependency3__) {
+ "use strict";
+ var __exports__ = {};
+ var Utils = __dependency1__;
+ var Exception = __dependency2__;
+ var COMPILER_REVISION = __dependency3__.COMPILER_REVISION;
+ var REVISION_CHANGES = __dependency3__.REVISION_CHANGES;
+ var createFrame = __dependency3__.createFrame;
+
+ function checkRevision(compilerInfo) {
+ var compilerRevision = compilerInfo && compilerInfo[0] || 1,
+ currentRevision = COMPILER_REVISION;
+
+ if (compilerRevision !== currentRevision) {
+ if (compilerRevision < currentRevision) {
+ var runtimeVersions = REVISION_CHANGES[currentRevision],
+ compilerVersions = REVISION_CHANGES[compilerRevision];
+ throw new Exception("Template was precompiled with an older version of Handlebars than the current runtime. "+
+ "Please update your precompiler to a newer version ("+runtimeVersions+") or downgrade your runtime to an older version ("+compilerVersions+").");
+ } else {
+ // Use the embedded version info since the runtime doesn't know about this revision yet
+ throw new Exception("Template was precompiled with a newer version of Handlebars than the current runtime. "+
+ "Please update your runtime to a newer version ("+compilerInfo[1]+").");
+ }
+ }
+ }
+
+ __exports__.checkRevision = checkRevision;// TODO: Remove this line and break up compilePartial
+
+ function template(templateSpec, env) {
+ /* istanbul ignore next */
+ if (!env) {
+ throw new Exception("No environment passed to template");
+ }
+ if (!templateSpec || !templateSpec.main) {
+ throw new Exception('Unknown template object: ' + typeof templateSpec);
+ }
+
+ // Note: Using env.VM references rather than local var references throughout this section to allow
+ // for external users to override these as psuedo-supported APIs.
+ env.VM.checkRevision(templateSpec.compiler);
+
+ var invokePartialWrapper = function(partial, indent, name, context, hash, helpers, partials, data, depths) {
+ if (hash) {
+ context = Utils.extend({}, context, hash);
+ }
+
+ var result = env.VM.invokePartial.call(this, partial, name, context, helpers, partials, data, depths);
+
+ if (result == null && env.compile) {
+ var options = { helpers: helpers, partials: partials, data: data, depths: depths };
+ partials[name] = env.compile(partial, { data: data !== undefined, compat: templateSpec.compat }, env);
+ result = partials[name](context, options);
+ }
+ if (result != null) {
+ if (indent) {
+ var lines = result.split('\n');
+ for (var i = 0, l = lines.length; i < l; i++) {
+ if (!lines[i] && i + 1 === l) {
+ break;
+ }
+
+ lines[i] = indent + lines[i];
+ }
+ result = lines.join('\n');
+ }
+ return result;
+ } else {
+ throw new Exception("The partial " + name + " could not be compiled when running in runtime-only mode");
+ }
+ };
+
+ // Just add water
+ var container = {
+ lookup: function(depths, name) {
+ var len = depths.length;
+ for (var i = 0; i < len; i++) {
+ if (depths[i] && depths[i][name] != null) {
+ return depths[i][name];
+ }
+ }
+ },
+ lambda: function(current, context) {
+ return typeof current === 'function' ? current.call(context) : current;
+ },
+
+ escapeExpression: Utils.escapeExpression,
+ invokePartial: invokePartialWrapper,
+
+ fn: function(i) {
+ return templateSpec[i];
+ },
+
+ programs: [],
+ program: function(i, data, depths) {
+ var programWrapper = this.programs[i],
+ fn = this.fn(i);
+ if (data || depths) {
+ programWrapper = program(this, i, fn, data, depths);
+ } else if (!programWrapper) {
+ programWrapper = this.programs[i] = program(this, i, fn);
+ }
+ return programWrapper;
+ },
+
+ data: function(data, depth) {
+ while (data && depth--) {
+ data = data._parent;
+ }
+ return data;
+ },
+ merge: function(param, common) {
+ var ret = param || common;
+
+ if (param && common && (param !== common)) {
+ ret = Utils.extend({}, common, param);
+ }
+
+ return ret;
+ },
+
+ noop: env.VM.noop,
+ compilerInfo: templateSpec.compiler
+ };
+
+ var ret = function(context, options) {
+ options = options || {};
+ var data = options.data;
+
+ ret._setup(options);
+ if (!options.partial && templateSpec.useData) {
+ data = initData(context, data);
+ }
+ var depths;
+ if (templateSpec.useDepths) {
+ depths = options.depths ? [context].concat(options.depths) : [context];
+ }
+
+ return templateSpec.main.call(container, context, container.helpers, container.partials, data, depths);
+ };
+ ret.isTop = true;
+
+ ret._setup = function(options) {
+ if (!options.partial) {
+ container.helpers = container.merge(options.helpers, env.helpers);
+
+ if (templateSpec.usePartial) {
+ container.partials = container.merge(options.partials, env.partials);
+ }
+ } else {
+ container.helpers = options.helpers;
+ container.partials = options.partials;
+ }
+ };
+
+ ret._child = function(i, data, depths) {
+ if (templateSpec.useDepths && !depths) {
+ throw new Exception('must pass parent depths');
+ }
+
+ return program(container, i, templateSpec[i], data, depths);
+ };
+ return ret;
+ }
+
+ __exports__.template = template;function program(container, i, fn, data, depths) {
+ var prog = function(context, options) {
+ options = options || {};
+
+ return fn.call(container, context, container.helpers, container.partials, options.data || data, depths && [context].concat(depths));
+ };
+ prog.program = i;
+ prog.depth = depths ? depths.length : 0;
+ return prog;
+ }
+
+ __exports__.program = program;function invokePartial(partial, name, context, helpers, partials, data, depths) {
+ var options = { partial: true, helpers: helpers, partials: partials, data: data, depths: depths };
+
+ if(partial === undefined) {
+ throw new Exception("The partial " + name + " could not be found");
+ } else if(partial instanceof Function) {
+ return partial(context, options);
+ }
+ }
+
+ __exports__.invokePartial = invokePartial;function noop() { return ""; }
+
+ __exports__.noop = noop;function initData(context, data) {
+ if (!data || !('root' in data)) {
+ data = data ? createFrame(data) : {};
+ data.root = context;
+ }
+ return data;
+ }
+ return __exports__;
+})(__module3__, __module5__, __module2__);
+
+// handlebars.runtime.js
+var __module1__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__) {
+ "use strict";
+ var __exports__;
+ /*globals Handlebars: true */
+ var base = __dependency1__;
+
+ // Each of these augment the Handlebars object. No need to setup here.
+ // (This is done to easily share code between commonjs and browse envs)
+ var SafeString = __dependency2__;
+ var Exception = __dependency3__;
+ var Utils = __dependency4__;
+ var runtime = __dependency5__;
+
+ // For compatibility and usage outside of module systems, make the Handlebars object a namespace
+ var create = function() {
+ var hb = new base.HandlebarsEnvironment();
+
+ Utils.extend(hb, base);
+ hb.SafeString = SafeString;
+ hb.Exception = Exception;
+ hb.Utils = Utils;
+ hb.escapeExpression = Utils.escapeExpression;
+
+ hb.VM = runtime;
+ hb.template = function(spec) {
+ return runtime.template(spec, hb);
+ };
+
+ return hb;
+ };
+
+ var Handlebars = create();
+ Handlebars.create = create;
+
+ Handlebars['default'] = Handlebars;
+
+ __exports__ = Handlebars;
+ return __exports__;
+})(__module2__, __module4__, __module5__, __module3__, __module6__);
+
+// handlebars/compiler/ast.js
+var __module7__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__;
+ var Exception = __dependency1__;
+
+ function LocationInfo(locInfo) {
+ locInfo = locInfo || {};
+ this.firstLine = locInfo.first_line;
+ this.firstColumn = locInfo.first_column;
+ this.lastColumn = locInfo.last_column;
+ this.lastLine = locInfo.last_line;
+ }
+
+ var AST = {
+ ProgramNode: function(statements, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "program";
+ this.statements = statements;
+ this.strip = strip;
+ },
+
+ MustacheNode: function(rawParams, hash, open, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "mustache";
+ this.strip = strip;
+
+ // Open may be a string parsed from the parser or a passed boolean flag
+ if (open != null && open.charAt) {
+ // Must use charAt to support IE pre-10
+ var escapeFlag = open.charAt(3) || open.charAt(2);
+ this.escaped = escapeFlag !== '{' && escapeFlag !== '&';
+ } else {
+ this.escaped = !!open;
+ }
+
+ if (rawParams instanceof AST.SexprNode) {
+ this.sexpr = rawParams;
+ } else {
+ // Support old AST API
+ this.sexpr = new AST.SexprNode(rawParams, hash);
+ }
+
+ // Support old AST API that stored this info in MustacheNode
+ this.id = this.sexpr.id;
+ this.params = this.sexpr.params;
+ this.hash = this.sexpr.hash;
+ this.eligibleHelper = this.sexpr.eligibleHelper;
+ this.isHelper = this.sexpr.isHelper;
+ },
+
+ SexprNode: function(rawParams, hash, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ this.type = "sexpr";
+ this.hash = hash;
+
+ var id = this.id = rawParams[0];
+ var params = this.params = rawParams.slice(1);
+
+ // a mustache is definitely a helper if:
+ // * it is an eligible helper, and
+ // * it has at least one parameter or hash segment
+ this.isHelper = !!(params.length || hash);
+
+ // a mustache is an eligible helper if:
+ // * its id is simple (a single part, not `this` or `..`)
+ this.eligibleHelper = this.isHelper || id.isSimple;
+
+ // if a mustache is an eligible helper but not a definite
+ // helper, it is ambiguous, and will be resolved in a later
+ // pass or at runtime.
+ },
+
+ PartialNode: function(partialName, context, hash, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "partial";
+ this.partialName = partialName;
+ this.context = context;
+ this.hash = hash;
+ this.strip = strip;
+
+ this.strip.inlineStandalone = true;
+ },
+
+ BlockNode: function(mustache, program, inverse, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ this.type = 'block';
+ this.mustache = mustache;
+ this.program = program;
+ this.inverse = inverse;
+ this.strip = strip;
+
+ if (inverse && !program) {
+ this.isInverse = true;
+ }
+ },
+
+ RawBlockNode: function(mustache, content, close, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ if (mustache.sexpr.id.original !== close) {
+ throw new Exception(mustache.sexpr.id.original + " doesn't match " + close, this);
+ }
+
+ content = new AST.ContentNode(content, locInfo);
+
+ this.type = 'block';
+ this.mustache = mustache;
+ this.program = new AST.ProgramNode([content], {}, locInfo);
+ },
+
+ ContentNode: function(string, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "content";
+ this.original = this.string = string;
+ },
+
+ HashNode: function(pairs, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "hash";
+ this.pairs = pairs;
+ },
+
+ IdNode: function(parts, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "ID";
+
+ var original = "",
+ dig = [],
+ depth = 0,
+ depthString = '';
+
+ for(var i=0,l=parts.length; i<l; i++) {
+ var part = parts[i].part;
+ original += (parts[i].separator || '') + part;
+
+ if (part === ".." || part === "." || part === "this") {
+ if (dig.length > 0) {
+ throw new Exception("Invalid path: " + original, this);
+ } else if (part === "..") {
+ depth++;
+ depthString += '../';
+ } else {
+ this.isScoped = true;
+ }
+ } else {
+ dig.push(part);
+ }
+ }
+
+ this.original = original;
+ this.parts = dig;
+ this.string = dig.join('.');
+ this.depth = depth;
+ this.idName = depthString + this.string;
+
+ // an ID is simple if it only has one part, and that part is not
+ // `..` or `this`.
+ this.isSimple = parts.length === 1 && !this.isScoped && depth === 0;
+
+ this.stringModeValue = this.string;
+ },
+
+ PartialNameNode: function(name, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "PARTIAL_NAME";
+ this.name = name.original;
+ },
+
+ DataNode: function(id, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "DATA";
+ this.id = id;
+ this.stringModeValue = id.stringModeValue;
+ this.idName = '@' + id.stringModeValue;
+ },
+
+ StringNode: function(string, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "STRING";
+ this.original =
+ this.string =
+ this.stringModeValue = string;
+ },
+
+ NumberNode: function(number, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "NUMBER";
+ this.original =
+ this.number = number;
+ this.stringModeValue = Number(number);
+ },
+
+ BooleanNode: function(bool, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "BOOLEAN";
+ this.bool = bool;
+ this.stringModeValue = bool === "true";
+ },
+
+ CommentNode: function(comment, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "comment";
+ this.comment = comment;
+
+ this.strip = {
+ inlineStandalone: true
+ };
+ }
+ };
+
+
+ // Must be exported as an object rather than the root of the module as the jison lexer
+ // most modify the object to operate properly.
+ __exports__ = AST;
+ return __exports__;
+})(__module5__);
+
+// handlebars/compiler/parser.js
+var __module9__ = (function() {
+ "use strict";
+ var __exports__;
+ /* jshint ignore:start */
+ /* istanbul ignore next */
+ /* Jison generated parser */
+ var handlebars = (function(){
+ var parser = {trace: function trace() { },
+ yy: {},
+ symbols_: {"error":2,"root":3,"program":4,"EOF":5,"program_repetition0":6,"statement":7,"mustache":8,"block":9,"rawBlock":10,"partial":11,"CONTENT":12,"COMMENT":13,"openRawBlock":14,"END_RAW_BLOCK":15,"OPEN_RAW_BLOCK":16,"sexpr":17,"CLOSE_RAW_BLOCK":18,"openBlock":19,"block_option0":20,"closeBlock":21,"openInverse":22,"block_option1":23,"OPEN_BLOCK":24,"CLOSE":25,"OPEN_INVERSE":26,"inverseAndProgram":27,"INVERSE":28,"OPEN_ENDBLOCK":29,"path":30,"OPEN":31,"OPEN_UNESCAPED":32,"CLOSE_UNESCAPED":33,"OPEN_PARTIAL":34,"partialName":35,"param":36,"partial_option0":37,"partial_option1":38,"sexpr_repetition0":39,"sexpr_option0":40,"dataName":41,"STRING":42,"NUMBER":43,"BOOLEAN":44,"OPEN_SEXPR":45,"CLOSE_SEXPR":46,"hash":47,"hash_repetition_plus0":48,"hashSegment":49,"ID":50,"EQUALS":51,"DATA":52,"pathSegments":53,"SEP":54,"$accept":0,"$end":1},
+ terminals_: {2:"error",5:"EOF",12:"CONTENT",13:"COMMENT",15:"END_RAW_BLOCK",16:"OPEN_RAW_BLOCK",18:"CLOSE_RAW_BLOCK",24:"OPEN_BLOCK",25:"CLOSE",26:"OPEN_INVERSE",28:"INVERSE",29:"OPEN_ENDBLOCK",31:"OPEN",32:"OPEN_UNESCAPED",33:"CLOSE_UNESCAPED",34:"OPEN_PARTIAL",42:"STRING",43:"NUMBER",44:"BOOLEAN",45:"OPEN_SEXPR",46:"CLOSE_SEXPR",50:"ID",51:"EQUALS",52:"DATA",54:"SEP"},
+ productions_: [0,[3,2],[4,1],[7,1],[7,1],[7,1],[7,1],[7,1],[7,1],[10,3],[14,3],[9,4],[9,4],[19,3],[22,3],[27,2],[21,3],[8,3],[8,3],[11,5],[11,4],[17,3],[17,1],[36,1],[36,1],[36,1],[36,1],[36,1],[36,3],[47,1],[49,3],[35,1],[35,1],[35,1],[41,2],[30,1],[53,3],[53,1],[6,0],[6,2],[20,0],[20,1],[23,0],[23,1],[37,0],[37,1],[38,0],[38,1],[39,0],[39,2],[40,0],[40,1],[48,1],[48,2]],
+ performAction: function anonymous(yytext,yyleng,yylineno,yy,yystate,$$,_$) {
+
+ var $0 = $$.length - 1;
+ switch (yystate) {
+ case 1: yy.prepareProgram($$[$0-1].statements, true); return $$[$0-1];
+ break;
+ case 2:this.$ = new yy.ProgramNode(yy.prepareProgram($$[$0]), {}, this._$);
+ break;
+ case 3:this.$ = $$[$0];
+ break;
+ case 4:this.$ = $$[$0];
+ break;
+ case 5:this.$ = $$[$0];
+ break;
+ case 6:this.$ = $$[$0];
+ break;
+ case 7:this.$ = new yy.ContentNode($$[$0], this._$);
+ break;
+ case 8:this.$ = new yy.CommentNode($$[$0], this._$);
+ break;
+ case 9:this.$ = new yy.RawBlockNode($$[$0-2], $$[$0-1], $$[$0], this._$);
+ break;
+ case 10:this.$ = new yy.MustacheNode($$[$0-1], null, '', '', this._$);
+ break;
+ case 11:this.$ = yy.prepareBlock($$[$0-3], $$[$0-2], $$[$0-1], $$[$0], false, this._$);
+ break;
+ case 12:this.$ = yy.prepareBlock($$[$0-3], $$[$0-2], $$[$0-1], $$[$0], true, this._$);
+ break;
+ case 13:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 14:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 15:this.$ = { strip: yy.stripFlags($$[$0-1], $$[$0-1]), program: $$[$0] };
+ break;
+ case 16:this.$ = {path: $$[$0-1], strip: yy.stripFlags($$[$0-2], $$[$0])};
+ break;
+ case 17:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 18:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 19:this.$ = new yy.PartialNode($$[$0-3], $$[$0-2], $$[$0-1], yy.stripFlags($$[$0-4], $$[$0]), this._$);
+ break;
+ case 20:this.$ = new yy.PartialNode($$[$0-2], undefined, $$[$0-1], yy.stripFlags($$[$0-3], $$[$0]), this._$);
+ break;
+ case 21:this.$ = new yy.SexprNode([$$[$0-2]].concat($$[$0-1]), $$[$0], this._$);
+ break;
+ case 22:this.$ = new yy.SexprNode([$$[$0]], null, this._$);
+ break;
+ case 23:this.$ = $$[$0];
+ break;
+ case 24:this.$ = new yy.StringNode($$[$0], this._$);
+ break;
+ case 25:this.$ = new yy.NumberNode($$[$0], this._$);
+ break;
+ case 26:this.$ = new yy.BooleanNode($$[$0], this._$);
+ break;
+ case 27:this.$ = $$[$0];
+ break;
+ case 28:$$[$0-1].isHelper = true; this.$ = $$[$0-1];
+ break;
+ case 29:this.$ = new yy.HashNode($$[$0], this._$);
+ break;
+ case 30:this.$ = [$$[$0-2], $$[$0]];
+ break;
+ case 31:this.$ = new yy.PartialNameNode($$[$0], this._$);
+ break;
+ case 32:this.$ = new yy.PartialNameNode(new yy.StringNode($$[$0], this._$), this._$);
+ break;
+ case 33:this.$ = new yy.PartialNameNode(new yy.NumberNode($$[$0], this._$));
+ break;
+ case 34:this.$ = new yy.DataNode($$[$0], this._$);
+ break;
+ case 35:this.$ = new yy.IdNode($$[$0], this._$);
+ break;
+ case 36: $$[$0-2].push({part: $$[$0], separator: $$[$0-1]}); this.$ = $$[$0-2];
+ break;
+ case 37:this.$ = [{part: $$[$0]}];
+ break;
+ case 38:this.$ = [];
+ break;
+ case 39:$$[$0-1].push($$[$0]);
+ break;
+ case 48:this.$ = [];
+ break;
+ case 49:$$[$0-1].push($$[$0]);
+ break;
+ case 52:this.$ = [$$[$0]];
+ break;
+ case 53:$$[$0-1].push($$[$0]);
+ break;
+ }
+ },
+ table: [{3:1,4:2,5:[2,38],6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],31:[2,38],32:[2,38],34:[2,38]},{1:[3]},{5:[1,4]},{5:[2,2],7:5,8:6,9:7,10:8,11:9,12:[1,10],13:[1,11],14:16,16:[1,20],19:14,22:15,24:[1,18],26:[1,19],28:[2,2],29:[2,2],31:[1,12],32:[1,13],34:[1,17]},{1:[2,1]},{5:[2,39],12:[2,39],13:[2,39],16:[2,39],24:[2,39],26:[2,39],28:[2,39],29:[2,39],31:[2,39],32:[2,39],34:[2,39]},{5:[2,3],12:[2,3],13:[2,3],16:[2,3],24:[2,3],26:[2,3],28:[2,3],29:[2,3],31:[2,3],32:[2,3],34:[2,3]},{5:[2,4],12:[2,4],13:[2,4],16:[2,4],24:[2,4],26:[2,4],28:[2,4],29:[2,4],31:[2,4],32:[2,4],34:[2,4]},{5:[2,5],12:[2,5],13:[2,5],16:[2,5],24:[2,5],26:[2,5],28:[2,5],29:[2,5],31:[2,5],32:[2,5],34:[2,5]},{5:[2,6],12:[2,6],13:[2,6],16:[2,6],24:[2,6],26:[2,6],28:[2,6],29:[2,6],31:[2,6],32:[2,6],34:[2,6]},{5:[2,7],12:[2,7],13:[2,7],16:[2,7],24:[2,7],26:[2,7],28:[2,7],29:[2,7],31:[2,7],32:[2,7],34:[2,7]},{5:[2,8],12:[2,8],13:[2,8],16:[2,8],24:[2,8],26:[2,8],28:[2,8],29:[2,8],31:[2,8],32:[2,8],34:[2,8]},{17:21,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:27,30:22,41:23,50:[1,26],52:[1,25],53:24},{4:28,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],28:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{4:29,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],28:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{12:[1,30]},{30:32,35:31,42:[1,33],43:[1,34],50:[1,26],53:24},{17:35,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:36,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:37,30:22,41:23,50:[1,26],52:[1,25],53:24},{25:[1,38]},{18:[2,48],25:[2,48],33:[2,48],39:39,42:[2,48],43:[2,48],44:[2,48],45:[2,48],46:[2,48],50:[2,48],52:[2,48]},{18:[2,22],25:[2,22],33:[2,22],46:[2,22]},{18:[2,35],25:[2,35],33:[2,35],42:[2,35],43:[2,35],44:[2,35],45:[2,35],46:[2,35],50:[2,35],52:[2,35],54:[1,40]},{30:41,50:[1,26],53:24},{18:[2,37],25:[2,37],33:[2,37],42:[2,37],43:[2,37],44:[2,37],45:[2,37],46:[2,37],50:[2,37],52:[2,37],54:[2,37]},{33:[1,42]},{20:43,27:44,28:[1,45],29:[2,40]},{23:46,27:47,28:[1,45],29:[2,42]},{15:[1,48]},{25:[2,46],30:51,36:49,38:50,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],47:57,48:58,49:60,50:[1,59],52:[1,25],53:24},{25:[2,31],42:[2,31],43:[2,31],44:[2,31],45:[2,31],50:[2,31],52:[2,31]},{25:[2,32],42:[2,32],43:[2,32],44:[2,32],45:[2,32],50:[2,32],52:[2,32]},{25:[2,33],42:[2,33],43:[2,33],44:[2,33],45:[2,33],50:[2,33],52:[2,33]},{25:[1,61]},{25:[1,62]},{18:[1,63]},{5:[2,17],12:[2,17],13:[2,17],16:[2,17],24:[2,17],26:[2,17],28:[2,17],29:[2,17],31:[2,17],32:[2,17],34:[2,17]},{18:[2,50],25:[2,50],30:51,33:[2,50],36:65,40:64,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],46:[2,50],47:66,48:58,49:60,50:[1,59],52:[1,25],53:24},{50:[1,67]},{18:[2,34],25:[2,34],33:[2,34],42:[2,34],43:[2,34],44:[2,34],45:[2,34],46:[2,34],50:[2,34],52:[2,34]},{5:[2,18],12:[2,18],13:[2,18],16:[2,18],24:[2,18],26:[2,18],28:[2,18],29:[2,18],31:[2,18],32:[2,18],34:[2,18]},{21:68,29:[1,69]},{29:[2,41]},{4:70,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{21:71,29:[1,69]},{29:[2,43]},{5:[2,9],12:[2,9],13:[2,9],16:[2,9],24:[2,9],26:[2,9],28:[2,9],29:[2,9],31:[2,9],32:[2,9],34:[2,9]},{25:[2,44],37:72,47:73,48:58,49:60,50:[1,74]},{25:[1,75]},{18:[2,23],25:[2,23],33:[2,23],42:[2,23],43:[2,23],44:[2,23],45:[2,23],46:[2,23],50:[2,23],52:[2,23]},{18:[2,24],25:[2,24],33:[2,24],42:[2,24],43:[2,24],44:[2,24],45:[2,24],46:[2,24],50:[2,24],52:[2,24]},{18:[2,25],25:[2,25],33:[2,25],42:[2,25],43:[2,25],44:[2,25],45:[2,25],46:[2,25],50:[2,25],52:[2,25]},{18:[2,26],25:[2,26],33:[2,26],42:[2,26],43:[2,26],44:[2,26],45:[2,26],46:[2,26],50:[2,26],52:[2,26]},{18:[2,27],25:[2,27],33:[2,27],42:[2,27],43:[2,27],44:[2,27],45:[2,27],46:[2,27],50:[2,27],52:[2,27]},{17:76,30:22,41:23,50:[1,26],52:[1,25],53:24},{25:[2,47]},{18:[2,29],25:[2,29],33:[2,29],46:[2,29],49:77,50:[1,74]},{18:[2,37],25:[2,37],33:[2,37],42:[2,37],43:[2,37],44:[2,37],45:[2,37],46:[2,37],50:[2,37],51:[1,78],52:[2,37],54:[2,37]},{18:[2,52],25:[2,52],33:[2,52],46:[2,52],50:[2,52]},{12:[2,13],13:[2,13],16:[2,13],24:[2,13],26:[2,13],28:[2,13],29:[2,13],31:[2,13],32:[2,13],34:[2,13]},{12:[2,14],13:[2,14],16:[2,14],24:[2,14],26:[2,14],28:[2,14],29:[2,14],31:[2,14],32:[2,14],34:[2,14]},{12:[2,10]},{18:[2,21],25:[2,21],33:[2,21],46:[2,21]},{18:[2,49],25:[2,49],33:[2,49],42:[2,49],43:[2,49],44:[2,49],45:[2,49],46:[2,49],50:[2,49],52:[2,49]},{18:[2,51],25:[2,51],33:[2,51],46:[2,51]},{18:[2,36],25:[2,36],33:[2,36],42:[2,36],43:[2,36],44:[2,36],45:[2,36],46:[2,36],50:[2,36],52:[2,36],54:[2,36]},{5:[2,11],12:[2,11],13:[2,11],16:[2,11],24:[2,11],26:[2,11],28:[2,11],29:[2,11],31:[2,11],32:[2,11],34:[2,11]},{30:79,50:[1,26],53:24},{29:[2,15]},{5:[2,12],12:[2,12],13:[2,12],16:[2,12],24:[2,12],26:[2,12],28:[2,12],29:[2,12],31:[2,12],32:[2,12],34:[2,12]},{25:[1,80]},{25:[2,45]},{51:[1,78]},{5:[2,20],12:[2,20],13:[2,20],16:[2,20],24:[2,20],26:[2,20],28:[2,20],29:[2,20],31:[2,20],32:[2,20],34:[2,20]},{46:[1,81]},{18:[2,53],25:[2,53],33:[2,53],46:[2,53],50:[2,53]},{30:51,36:82,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],50:[1,26],52:[1,25],53:24},{25:[1,83]},{5:[2,19],12:[2,19],13:[2,19],16:[2,19],24:[2,19],26:[2,19],28:[2,19],29:[2,19],31:[2,19],32:[2,19],34:[2,19]},{18:[2,28],25:[2,28],33:[2,28],42:[2,28],43:[2,28],44:[2,28],45:[2,28],46:[2,28],50:[2,28],52:[2,28]},{18:[2,30],25:[2,30],33:[2,30],46:[2,30],50:[2,30]},{5:[2,16],12:[2,16],13:[2,16],16:[2,16],24:[2,16],26:[2,16],28:[2,16],29:[2,16],31:[2,16],32:[2,16],34:[2,16]}],
+ defaultActions: {4:[2,1],44:[2,41],47:[2,43],57:[2,47],63:[2,10],70:[2,15],73:[2,45]},
+ parseError: function parseError(str, hash) {
+ throw new Error(str);
+ },
+ parse: function parse(input) {
+ var self = this, stack = [0], vstack = [null], lstack = [], table = this.table, yytext = "", yylineno = 0, yyleng = 0, recovering = 0, TERROR = 2, EOF = 1;
+ this.lexer.setInput(input);
+ this.lexer.yy = this.yy;
+ this.yy.lexer = this.lexer;
+ this.yy.parser = this;
+ if (typeof this.lexer.yylloc == "undefined")
+ this.lexer.yylloc = {};
+ var yyloc = this.lexer.yylloc;
+ lstack.push(yyloc);
+ var ranges = this.lexer.options && this.lexer.options.ranges;
+ if (typeof this.yy.parseError === "function")
+ this.parseError = this.yy.parseError;
+ function popStack(n) {
+ stack.length = stack.length - 2 * n;
+ vstack.length = vstack.length - n;
+ lstack.length = lstack.length - n;
+ }
+ function lex() {
+ var token;
+ token = self.lexer.lex() || 1;
+ if (typeof token !== "number") {
+ token = self.symbols_[token] || token;
+ }
+ return token;
+ }
+ var symbol, preErrorSymbol, state, action, a, r, yyval = {}, p, len, newState, expected;
+ while (true) {
+ state = stack[stack.length - 1];
+ if (this.defaultActions[state]) {
+ action = this.defaultActions[state];
+ } else {
+ if (symbol === null || typeof symbol == "undefined") {
+ symbol = lex();
+ }
+ action = table[state] && table[state][symbol];
+ }
+ if (typeof action === "undefined" || !action.length || !action[0]) {
+ var errStr = "";
+ if (!recovering) {
+ expected = [];
+ for (p in table[state])
+ if (this.terminals_[p] && p > 2) {
+ expected.push("'" + this.terminals_[p] + "'");
+ }
+ if (this.lexer.showPosition) {
+ errStr = "Parse error on line " + (yylineno + 1) + ":\n" + this.lexer.showPosition() + "\nExpecting " + expected.join(", ") + ", got '" + (this.terminals_[symbol] || symbol) + "'";
+ } else {
+ errStr = "Parse error on line " + (yylineno + 1) + ": Unexpected " + (symbol == 1?"end of input":"'" + (this.terminals_[symbol] || symbol) + "'");
+ }
+ this.parseError(errStr, {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected});
+ }
+ }
+ if (action[0] instanceof Array && action.length > 1) {
+ throw new Error("Parse Error: multiple actions possible at state: " + state + ", token: " + symbol);
+ }
+ switch (action[0]) {
+ case 1:
+ stack.push(symbol);
+ vstack.push(this.lexer.yytext);
+ lstack.push(this.lexer.yylloc);
+ stack.push(action[1]);
+ symbol = null;
+ if (!preErrorSymbol) {
+ yyleng = this.lexer.yyleng;
+ yytext = this.lexer.yytext;
+ yylineno = this.lexer.yylineno;
+ yyloc = this.lexer.yylloc;
+ if (recovering > 0)
+ recovering--;
+ } else {
+ symbol = preErrorSymbol;
+ preErrorSymbol = null;
+ }
+ break;
+ case 2:
+ len = this.productions_[action[1]][1];
+ yyval.$ = vstack[vstack.length - len];
+ yyval._$ = {first_line: lstack[lstack.length - (len || 1)].first_line, last_line: lstack[lstack.length - 1].last_line, first_column: lstack[lstack.length - (len || 1)].first_column, last_column: lstack[lstack.length - 1].last_column};
+ if (ranges) {
+ yyval._$.range = [lstack[lstack.length - (len || 1)].range[0], lstack[lstack.length - 1].range[1]];
+ }
+ r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack);
+ if (typeof r !== "undefined") {
+ return r;
+ }
+ if (len) {
+ stack = stack.slice(0, -1 * len * 2);
+ vstack = vstack.slice(0, -1 * len);
+ lstack = lstack.slice(0, -1 * len);
+ }
+ stack.push(this.productions_[action[1]][0]);
+ vstack.push(yyval.$);
+ lstack.push(yyval._$);
+ newState = table[stack[stack.length - 2]][stack[stack.length - 1]];
+ stack.push(newState);
+ break;
+ case 3:
+ return true;
+ }
+ }
+ return true;
+ }
+ };
+ /* Jison generated lexer */
+ var lexer = (function(){
+ var lexer = ({EOF:1,
+ parseError:function parseError(str, hash) {
+ if (this.yy.parser) {
+ this.yy.parser.parseError(str, hash);
+ } else {
+ throw new Error(str);
+ }
+ },
+ setInput:function (input) {
+ this._input = input;
+ this._more = this._less = this.done = false;
+ this.yylineno = this.yyleng = 0;
+ this.yytext = this.matched = this.match = '';
+ this.conditionStack = ['INITIAL'];
+ this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0};
+ if (this.options.ranges) this.yylloc.range = [0,0];
+ this.offset = 0;
+ return this;
+ },
+ input:function () {
+ var ch = this._input[0];
+ this.yytext += ch;
+ this.yyleng++;
+ this.offset++;
+ this.match += ch;
+ this.matched += ch;
+ var lines = ch.match(/(?:\r\n?|\n).*/g);
+ if (lines) {
+ this.yylineno++;
+ this.yylloc.last_line++;
+ } else {
+ this.yylloc.last_column++;
+ }
+ if (this.options.ranges) this.yylloc.range[1]++;
+
+ this._input = this._input.slice(1);
+ return ch;
+ },
+ unput:function (ch) {
+ var len = ch.length;
+ var lines = ch.split(/(?:\r\n?|\n)/g);
+
+ this._input = ch + this._input;
+ this.yytext = this.yytext.substr(0, this.yytext.length-len-1);
+ //this.yyleng -= len;
+ this.offset -= len;
+ var oldLines = this.match.split(/(?:\r\n?|\n)/g);
+ this.match = this.match.substr(0, this.match.length-1);
+ this.matched = this.matched.substr(0, this.matched.length-1);
+
+ if (lines.length-1) this.yylineno -= lines.length-1;
+ var r = this.yylloc.range;
+
+ this.yylloc = {first_line: this.yylloc.first_line,
+ last_line: this.yylineno+1,
+ first_column: this.yylloc.first_column,
+ last_column: lines ?
+ (lines.length === oldLines.length ? this.yylloc.first_column : 0) + oldLines[oldLines.length - lines.length].length - lines[0].length:
+ this.yylloc.first_column - len
+ };
+
+ if (this.options.ranges) {
+ this.yylloc.range = [r[0], r[0] + this.yyleng - len];
+ }
+ return this;
+ },
+ more:function () {
+ this._more = true;
+ return this;
+ },
+ less:function (n) {
+ this.unput(this.match.slice(n));
+ },
+ pastInput:function () {
+ var past = this.matched.substr(0, this.matched.length - this.match.length);
+ return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, "");
+ },
+ upcomingInput:function () {
+ var next = this.match;
+ if (next.length < 20) {
+ next += this._input.substr(0, 20-next.length);
+ }
+ return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, "");
+ },
+ showPosition:function () {
+ var pre = this.pastInput();
+ var c = new Array(pre.length + 1).join("-");
+ return pre + this.upcomingInput() + "\n" + c+"^";
+ },
+ next:function () {
+ if (this.done) {
+ return this.EOF;
+ }
+ if (!this._input) this.done = true;
+
+ var token,
+ match,
+ tempMatch,
+ index,
+ col,
+ lines;
+ if (!this._more) {
+ this.yytext = '';
+ this.match = '';
+ }
+ var rules = this._currentRules();
+ for (var i=0;i < rules.length; i++) {
+ tempMatch = this._input.match(this.rules[rules[i]]);
+ if (tempMatch && (!match || tempMatch[0].length > match[0].length)) {
+ match = tempMatch;
+ index = i;
+ if (!this.options.flex) break;
+ }
+ }
+ if (match) {
+ lines = match[0].match(/(?:\r\n?|\n).*/g);
+ if (lines) this.yylineno += lines.length;
+ this.yylloc = {first_line: this.yylloc.last_line,
+ last_line: this.yylineno+1,
+ first_column: this.yylloc.last_column,
+ last_column: lines ? lines[lines.length-1].length-lines[lines.length-1].match(/\r?\n?/)[0].length : this.yylloc.last_column + match[0].length};
+ this.yytext += match[0];
+ this.match += match[0];
+ this.matches = match;
+ this.yyleng = this.yytext.length;
+ if (this.options.ranges) {
+ this.yylloc.range = [this.offset, this.offset += this.yyleng];
+ }
+ this._more = false;
+ this._input = this._input.slice(match[0].length);
+ this.matched += match[0];
+ token = this.performAction.call(this, this.yy, this, rules[index],this.conditionStack[this.conditionStack.length-1]);
+ if (this.done && this._input) this.done = false;
+ if (token) return token;
+ else return;
+ }
+ if (this._input === "") {
+ return this.EOF;
+ } else {
+ return this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(),
+ {text: "", token: null, line: this.yylineno});
+ }
+ },
+ lex:function lex() {
+ var r = this.next();
+ if (typeof r !== 'undefined') {
+ return r;
+ } else {
+ return this.lex();
+ }
+ },
+ begin:function begin(condition) {
+ this.conditionStack.push(condition);
+ },
+ popState:function popState() {
+ return this.conditionStack.pop();
+ },
+ _currentRules:function _currentRules() {
+ return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules;
+ },
+ topState:function () {
+ return this.conditionStack[this.conditionStack.length-2];
+ },
+ pushState:function begin(condition) {
+ this.begin(condition);
+ }});
+ lexer.options = {};
+ lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) {
+
+
+ function strip(start, end) {
+ return yy_.yytext = yy_.yytext.substr(start, yy_.yyleng-end);
+ }
+
+
+ var YYSTATE=YY_START
+ switch($avoiding_name_collisions) {
+ case 0:
+ if(yy_.yytext.slice(-2) === "\\\\") {
+ strip(0,1);
+ this.begin("mu");
+ } else if(yy_.yytext.slice(-1) === "\\") {
+ strip(0,1);
+ this.begin("emu");
+ } else {
+ this.begin("mu");
+ }
+ if(yy_.yytext) return 12;
+
+ break;
+ case 1:return 12;
+ break;
+ case 2:
+ this.popState();
+ return 12;
+
+ break;
+ case 3:
+ yy_.yytext = yy_.yytext.substr(5, yy_.yyleng-9);
+ this.popState();
+ return 15;
+
+ break;
+ case 4: return 12;
+ break;
+ case 5:strip(0,4); this.popState(); return 13;
+ break;
+ case 6:return 45;
+ break;
+ case 7:return 46;
+ break;
+ case 8: return 16;
+ break;
+ case 9:
+ this.popState();
+ this.begin('raw');
+ return 18;
+
+ break;
+ case 10:return 34;
+ break;
+ case 11:return 24;
+ break;
+ case 12:return 29;
+ break;
+ case 13:this.popState(); return 28;
+ break;
+ case 14:this.popState(); return 28;
+ break;
+ case 15:return 26;
+ break;
+ case 16:return 26;
+ break;
+ case 17:return 32;
+ break;
+ case 18:return 31;
+ break;
+ case 19:this.popState(); this.begin('com');
+ break;
+ case 20:strip(3,5); this.popState(); return 13;
+ break;
+ case 21:return 31;
+ break;
+ case 22:return 51;
+ break;
+ case 23:return 50;
+ break;
+ case 24:return 50;
+ break;
+ case 25:return 54;
+ break;
+ case 26:// ignore whitespace
+ break;
+ case 27:this.popState(); return 33;
+ break;
+ case 28:this.popState(); return 25;
+ break;
+ case 29:yy_.yytext = strip(1,2).replace(/\\"/g,'"'); return 42;
+ break;
+ case 30:yy_.yytext = strip(1,2).replace(/\\'/g,"'"); return 42;
+ break;
+ case 31:return 52;
+ break;
+ case 32:return 44;
+ break;
+ case 33:return 44;
+ break;
+ case 34:return 43;
+ break;
+ case 35:return 50;
+ break;
+ case 36:yy_.yytext = strip(1,2); return 50;
+ break;
+ case 37:return 'INVALID';
+ break;
+ case 38:return 5;
+ break;
+ }
+ };
+ lexer.rules = [/^(?:[^\x00]*?(?=(\{\{)))/,/^(?:[^\x00]+)/,/^(?:[^\x00]{2,}?(?=(\{\{|\\\{\{|\\\\\{\{|$)))/,/^(?:\{\{\{\{\/[^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=[=}\s\/.])\}\}\}\})/,/^(?:[^\x00]*?(?=(\{\{\{\{\/)))/,/^(?:[\s\S]*?--\}\})/,/^(?:\()/,/^(?:\))/,/^(?:\{\{\{\{)/,/^(?:\}\}\}\})/,/^(?:\{\{(~)?>)/,/^(?:\{\{(~)?#)/,/^(?:\{\{(~)?\/)/,/^(?:\{\{(~)?\^\s*(~)?\}\})/,/^(?:\{\{(~)?\s*else\s*(~)?\}\})/,/^(?:\{\{(~)?\^)/,/^(?:\{\{(~)?\s*else\b)/,/^(?:\{\{(~)?\{)/,/^(?:\{\{(~)?&)/,/^(?:\{\{!--)/,/^(?:\{\{![\s\S]*?\}\})/,/^(?:\{\{(~)?)/,/^(?:=)/,/^(?:\.\.)/,/^(?:\.(?=([=~}\s\/.)])))/,/^(?:[\/.])/,/^(?:\s+)/,/^(?:\}(~)?\}\})/,/^(?:(~)?\}\})/,/^(?:"(\\["]|[^"])*")/,/^(?:'(\\[']|[^'])*')/,/^(?:@)/,/^(?:true(?=([~}\s)])))/,/^(?:false(?=([~}\s)])))/,/^(?:-?[0-9]+(?:\.[0-9]+)?(?=([~}\s)])))/,/^(?:([^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=([=~}\s\/.)]))))/,/^(?:\[[^\]]*\])/,/^(?:.)/,/^(?:$)/];
+ lexer.conditions = {"mu":{"rules":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"inclusive":false},"emu":{"rules":[2],"inclusive":false},"com":{"rules":[5],"inclusive":false},"raw":{"rules":[3,4],"inclusive":false},"INITIAL":{"rules":[0,1,38],"inclusive":true}};
+ return lexer;})()
+ parser.lexer = lexer;
+ function Parser () { this.yy = {}; }Parser.prototype = parser;parser.Parser = Parser;
+ return new Parser;
+ })();__exports__ = handlebars;
+ /* jshint ignore:end */
+ return __exports__;
+})();
+
+// handlebars/compiler/helpers.js
+var __module10__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__ = {};
+ var Exception = __dependency1__;
+
+ function stripFlags(open, close) {
+ return {
+ left: open.charAt(2) === '~',
+ right: close.charAt(close.length-3) === '~'
+ };
+ }
+
+ __exports__.stripFlags = stripFlags;
+ function prepareBlock(mustache, program, inverseAndProgram, close, inverted, locInfo) {
+ /*jshint -W040 */
+ if (mustache.sexpr.id.original !== close.path.original) {
+ throw new Exception(mustache.sexpr.id.original + ' doesn\'t match ' + close.path.original, mustache);
+ }
+
+ var inverse = inverseAndProgram && inverseAndProgram.program;
+
+ var strip = {
+ left: mustache.strip.left,
+ right: close.strip.right,
+
+ // Determine the standalone candiacy. Basically flag our content as being possibly standalone
+ // so our parent can determine if we actually are standalone
+ openStandalone: isNextWhitespace(program.statements),
+ closeStandalone: isPrevWhitespace((inverse || program).statements)
+ };
+
+ if (mustache.strip.right) {
+ omitRight(program.statements, null, true);
+ }
+
+ if (inverse) {
+ var inverseStrip = inverseAndProgram.strip;
+
+ if (inverseStrip.left) {
+ omitLeft(program.statements, null, true);
+ }
+ if (inverseStrip.right) {
+ omitRight(inverse.statements, null, true);
+ }
+ if (close.strip.left) {
+ omitLeft(inverse.statements, null, true);
+ }
+
+ // Find standalone else statments
+ if (isPrevWhitespace(program.statements)
+ && isNextWhitespace(inverse.statements)) {
+
+ omitLeft(program.statements);
+ omitRight(inverse.statements);
+ }
+ } else {
+ if (close.strip.left) {
+ omitLeft(program.statements, null, true);
+ }
+ }
+
+ if (inverted) {
+ return new this.BlockNode(mustache, inverse, program, strip, locInfo);
+ } else {
+ return new this.BlockNode(mustache, program, inverse, strip, locInfo);
+ }
+ }
+
+ __exports__.prepareBlock = prepareBlock;
+ function prepareProgram(statements, isRoot) {
+ for (var i = 0, l = statements.length; i < l; i++) {
+ var current = statements[i],
+ strip = current.strip;
+
+ if (!strip) {
+ continue;
+ }
+
+ var _isPrevWhitespace = isPrevWhitespace(statements, i, isRoot, current.type === 'partial'),
+ _isNextWhitespace = isNextWhitespace(statements, i, isRoot),
+
+ openStandalone = strip.openStandalone && _isPrevWhitespace,
+ closeStandalone = strip.closeStandalone && _isNextWhitespace,
+ inlineStandalone = strip.inlineStandalone && _isPrevWhitespace && _isNextWhitespace;
+
+ if (strip.right) {
+ omitRight(statements, i, true);
+ }
+ if (strip.left) {
+ omitLeft(statements, i, true);
+ }
+
+ if (inlineStandalone) {
+ omitRight(statements, i);
+
+ if (omitLeft(statements, i)) {
+ // If we are on a standalone node, save the indent info for partials
+ if (current.type === 'partial') {
+ current.indent = (/([ \t]+$)/).exec(statements[i-1].original) ? RegExp.$1 : '';
+ }
+ }
+ }
+ if (openStandalone) {
+ omitRight((current.program || current.inverse).statements);
+
+ // Strip out the previous content node if it's whitespace only
+ omitLeft(statements, i);
+ }
+ if (closeStandalone) {
+ // Always strip the next node
+ omitRight(statements, i);
+
+ omitLeft((current.inverse || current.program).statements);
+ }
+ }
+
+ return statements;
+ }
+
+ __exports__.prepareProgram = prepareProgram;function isPrevWhitespace(statements, i, isRoot) {
+ if (i === undefined) {
+ i = statements.length;
+ }
+
+ // Nodes that end with newlines are considered whitespace (but are special
+ // cased for strip operations)
+ var prev = statements[i-1],
+ sibling = statements[i-2];
+ if (!prev) {
+ return isRoot;
+ }
+
+ if (prev.type === 'content') {
+ return (sibling || !isRoot ? (/\r?\n\s*?$/) : (/(^|\r?\n)\s*?$/)).test(prev.original);
+ }
+ }
+ function isNextWhitespace(statements, i, isRoot) {
+ if (i === undefined) {
+ i = -1;
+ }
+
+ var next = statements[i+1],
+ sibling = statements[i+2];
+ if (!next) {
+ return isRoot;
+ }
+
+ if (next.type === 'content') {
+ return (sibling || !isRoot ? (/^\s*?\r?\n/) : (/^\s*?(\r?\n|$)/)).test(next.original);
+ }
+ }
+
+ // Marks the node to the right of the position as omitted.
+ // I.e. {{foo}}' ' will mark the ' ' node as omitted.
+ //
+ // If i is undefined, then the first child will be marked as such.
+ //
+ // If mulitple is truthy then all whitespace will be stripped out until non-whitespace
+ // content is met.
+ function omitRight(statements, i, multiple) {
+ var current = statements[i == null ? 0 : i + 1];
+ if (!current || current.type !== 'content' || (!multiple && current.rightStripped)) {
+ return;
+ }
+
+ var original = current.string;
+ current.string = current.string.replace(multiple ? (/^\s+/) : (/^[ \t]*\r?\n?/), '');
+ current.rightStripped = current.string !== original;
+ }
+
+ // Marks the node to the left of the position as omitted.
+ // I.e. ' '{{foo}} will mark the ' ' node as omitted.
+ //
+ // If i is undefined then the last child will be marked as such.
+ //
+ // If mulitple is truthy then all whitespace will be stripped out until non-whitespace
+ // content is met.
+ function omitLeft(statements, i, multiple) {
+ var current = statements[i == null ? statements.length - 1 : i - 1];
+ if (!current || current.type !== 'content' || (!multiple && current.leftStripped)) {
+ return;
+ }
+
+ // We omit the last node if it's whitespace only and not preceeded by a non-content node.
+ var original = current.string;
+ current.string = current.string.replace(multiple ? (/\s+$/) : (/[ \t]+$/), '');
+ current.leftStripped = current.string !== original;
+ return current.leftStripped;
+ }
+ return __exports__;
+})(__module5__);
+
+// handlebars/compiler/base.js
+var __module8__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__) {
+ "use strict";
+ var __exports__ = {};
+ var parser = __dependency1__;
+ var AST = __dependency2__;
+ var Helpers = __dependency3__;
+ var extend = __dependency4__.extend;
+
+ __exports__.parser = parser;
+
+ var yy = {};
+ extend(yy, Helpers, AST);
+
+ function parse(input) {
+ // Just return if an already-compile AST was passed in.
+ if (input.constructor === AST.ProgramNode) { return input; }
+
+ parser.yy = yy;
+
+ return parser.parse(input);
+ }
+
+ __exports__.parse = parse;
+ return __exports__;
+})(__module9__, __module7__, __module10__, __module3__);
+
+// handlebars/compiler/compiler.js
+var __module11__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__ = {};
+ var Exception = __dependency1__;
+ var isArray = __dependency2__.isArray;
+
+ var slice = [].slice;
+
+ function Compiler() {}
+
+ __exports__.Compiler = Compiler;// the foundHelper register will disambiguate helper lookup from finding a
+ // function in a context. This is necessary for mustache compatibility, which
+ // requires that context functions in blocks are evaluated by blockHelperMissing,
+ // and then proceed as if the resulting value was provided to blockHelperMissing.
+
+ Compiler.prototype = {
+ compiler: Compiler,
+
+ equals: function(other) {
+ var len = this.opcodes.length;
+ if (other.opcodes.length !== len) {
+ return false;
+ }
+
+ for (var i = 0; i < len; i++) {
+ var opcode = this.opcodes[i],
+ otherOpcode = other.opcodes[i];
+ if (opcode.opcode !== otherOpcode.opcode || !argEquals(opcode.args, otherOpcode.args)) {
+ return false;
+ }
+ }
+
+ // We know that length is the same between the two arrays because they are directly tied
+ // to the opcode behavior above.
+ len = this.children.length;
+ for (i = 0; i < len; i++) {
+ if (!this.children[i].equals(other.children[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ guid: 0,
+
+ compile: function(program, options) {
+ this.opcodes = [];
+ this.children = [];
+ this.depths = {list: []};
+ this.options = options;
+ this.stringParams = options.stringParams;
+ this.trackIds = options.trackIds;
+
+ // These changes will propagate to the other compiler components
+ var knownHelpers = this.options.knownHelpers;
+ this.options.knownHelpers = {
+ 'helperMissing': true,
+ 'blockHelperMissing': true,
+ 'each': true,
+ 'if': true,
+ 'unless': true,
+ 'with': true,
+ 'log': true,
+ 'lookup': true
+ };
+ if (knownHelpers) {
+ for (var name in knownHelpers) {
+ this.options.knownHelpers[name] = knownHelpers[name];
+ }
+ }
+
+ return this.accept(program);
+ },
+
+ accept: function(node) {
+ return this[node.type](node);
+ },
+
+ program: function(program) {
+ var statements = program.statements;
+
+ for(var i=0, l=statements.length; i<l; i++) {
+ this.accept(statements[i]);
+ }
+ this.isSimple = l === 1;
+
+ this.depths.list = this.depths.list.sort(function(a, b) {
+ return a - b;
+ });
+
+ return this;
+ },
+
+ compileProgram: function(program) {
+ var result = new this.compiler().compile(program, this.options);
+ var guid = this.guid++, depth;
+
+ this.usePartial = this.usePartial || result.usePartial;
+
+ this.children[guid] = result;
+
+ for(var i=0, l=result.depths.list.length; i<l; i++) {
+ depth = result.depths.list[i];
+
+ if(depth < 2) { continue; }
+ else { this.addDepth(depth - 1); }
+ }
+
+ return guid;
+ },
+
+ block: function(block) {
+ var mustache = block.mustache,
+ program = block.program,
+ inverse = block.inverse;
+
+ if (program) {
+ program = this.compileProgram(program);
+ }
+
+ if (inverse) {
+ inverse = this.compileProgram(inverse);
+ }
+
+ var sexpr = mustache.sexpr;
+ var type = this.classifySexpr(sexpr);
+
+ if (type === "helper") {
+ this.helperSexpr(sexpr, program, inverse);
+ } else if (type === "simple") {
+ this.simpleSexpr(sexpr);
+
+ // now that the simple mustache is resolved, we need to
+ // evaluate it by executing `blockHelperMissing`
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+ this.opcode('emptyHash');
+ this.opcode('blockValue', sexpr.id.original);
+ } else {
+ this.ambiguousSexpr(sexpr, program, inverse);
+
+ // now that the simple mustache is resolved, we need to
+ // evaluate it by executing `blockHelperMissing`
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+ this.opcode('emptyHash');
+ this.opcode('ambiguousBlockValue');
+ }
+
+ this.opcode('append');
+ },
+
+ hash: function(hash) {
+ var pairs = hash.pairs, i, l;
+
+ this.opcode('pushHash');
+
+ for(i=0, l=pairs.length; i<l; i++) {
+ this.pushParam(pairs[i][1]);
+ }
+ while(i--) {
+ this.opcode('assignToHash', pairs[i][0]);
+ }
+ this.opcode('popHash');
+ },
+
+ partial: function(partial) {
+ var partialName = partial.partialName;
+ this.usePartial = true;
+
+ if (partial.hash) {
+ this.accept(partial.hash);
+ } else {
+ this.opcode('push', 'undefined');
+ }
+
+ if (partial.context) {
+ this.accept(partial.context);
+ } else {
+ this.opcode('getContext', 0);
+ this.opcode('pushContext');
+ }
+
+ this.opcode('invokePartial', partialName.name, partial.indent || '');
+ this.opcode('append');
+ },
+
+ content: function(content) {
+ if (content.string) {
+ this.opcode('appendContent', content.string);
+ }
+ },
+
+ mustache: function(mustache) {
+ this.sexpr(mustache.sexpr);
+
+ if(mustache.escaped && !this.options.noEscape) {
+ this.opcode('appendEscaped');
+ } else {
+ this.opcode('append');
+ }
+ },
+
+ ambiguousSexpr: function(sexpr, program, inverse) {
+ var id = sexpr.id,
+ name = id.parts[0],
+ isBlock = program != null || inverse != null;
+
+ this.opcode('getContext', id.depth);
+
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+
+ this.ID(id);
+
+ this.opcode('invokeAmbiguous', name, isBlock);
+ },
+
+ simpleSexpr: function(sexpr) {
+ var id = sexpr.id;
+
+ if (id.type === 'DATA') {
+ this.DATA(id);
+ } else if (id.parts.length) {
+ this.ID(id);
+ } else {
+ // Simplified ID for `this`
+ this.addDepth(id.depth);
+ this.opcode('getContext', id.depth);
+ this.opcode('pushContext');
+ }
+
+ this.opcode('resolvePossibleLambda');
+ },
+
+ helperSexpr: function(sexpr, program, inverse) {
+ var params = this.setupFullMustacheParams(sexpr, program, inverse),
+ id = sexpr.id,
+ name = id.parts[0];
+
+ if (this.options.knownHelpers[name]) {
+ this.opcode('invokeKnownHelper', params.length, name);
+ } else if (this.options.knownHelpersOnly) {
+ throw new Exception("You specified knownHelpersOnly, but used the unknown helper " + name, sexpr);
+ } else {
+ id.falsy = true;
+
+ this.ID(id);
+ this.opcode('invokeHelper', params.length, id.original, id.isSimple);
+ }
+ },
+
+ sexpr: function(sexpr) {
+ var type = this.classifySexpr(sexpr);
+
+ if (type === "simple") {
+ this.simpleSexpr(sexpr);
+ } else if (type === "helper") {
+ this.helperSexpr(sexpr);
+ } else {
+ this.ambiguousSexpr(sexpr);
+ }
+ },
+
+ ID: function(id) {
+ this.addDepth(id.depth);
+ this.opcode('getContext', id.depth);
+
+ var name = id.parts[0];
+ if (!name) {
+ // Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
+ this.opcode('pushContext');
+ } else {
+ this.opcode('lookupOnContext', id.parts, id.falsy, id.isScoped);
+ }
+ },
+
+ DATA: function(data) {
+ this.options.data = true;
+ this.opcode('lookupData', data.id.depth, data.id.parts);
+ },
+
+ STRING: function(string) {
+ this.opcode('pushString', string.string);
+ },
+
+ NUMBER: function(number) {
+ this.opcode('pushLiteral', number.number);
+ },
+
+ BOOLEAN: function(bool) {
+ this.opcode('pushLiteral', bool.bool);
+ },
+
+ comment: function() {},
+
+ // HELPERS
+ opcode: function(name) {
+ this.opcodes.push({ opcode: name, args: slice.call(arguments, 1) });
+ },
+
+ addDepth: function(depth) {
+ if(depth === 0) { return; }
+
+ if(!this.depths[depth]) {
+ this.depths[depth] = true;
+ this.depths.list.push(depth);
+ }
+ },
+
+ classifySexpr: function(sexpr) {
+ var isHelper = sexpr.isHelper;
+ var isEligible = sexpr.eligibleHelper;
+ var options = this.options;
+
+ // if ambiguous, we can possibly resolve the ambiguity now
+ // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc.
+ if (isEligible && !isHelper) {
+ var name = sexpr.id.parts[0];
+
+ if (options.knownHelpers[name]) {
+ isHelper = true;
+ } else if (options.knownHelpersOnly) {
+ isEligible = false;
+ }
+ }
+
+ if (isHelper) { return "helper"; }
+ else if (isEligible) { return "ambiguous"; }
+ else { return "simple"; }
+ },
+
+ pushParams: function(params) {
+ for(var i=0, l=params.length; i<l; i++) {
+ this.pushParam(params[i]);
+ }
+ },
+
+ pushParam: function(val) {
+ if (this.stringParams) {
+ if(val.depth) {
+ this.addDepth(val.depth);
+ }
+ this.opcode('getContext', val.depth || 0);
+ this.opcode('pushStringParam', val.stringModeValue, val.type);
+
+ if (val.type === 'sexpr') {
+ // Subexpressions get evaluated and passed in
+ // in string params mode.
+ this.sexpr(val);
+ }
+ } else {
+ if (this.trackIds) {
+ this.opcode('pushId', val.type, val.idName || val.stringModeValue);
+ }
+ this.accept(val);
+ }
+ },
+
+ setupFullMustacheParams: function(sexpr, program, inverse) {
+ var params = sexpr.params;
+ this.pushParams(params);
+
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+
+ if (sexpr.hash) {
+ this.hash(sexpr.hash);
+ } else {
+ this.opcode('emptyHash');
+ }
+
+ return params;
+ }
+ };
+
+ function precompile(input, options, env) {
+ if (input == null || (typeof input !== 'string' && input.constructor !== env.AST.ProgramNode)) {
+ throw new Exception("You must pass a string or Handlebars AST to Handlebars.precompile. You passed " + input);
+ }
+
+ options = options || {};
+ if (!('data' in options)) {
+ options.data = true;
+ }
+ if (options.compat) {
+ options.useDepths = true;
+ }
+
+ var ast = env.parse(input);
+ var environment = new env.Compiler().compile(ast, options);
+ return new env.JavaScriptCompiler().compile(environment, options);
+ }
+
+ __exports__.precompile = precompile;function compile(input, options, env) {
+ if (input == null || (typeof input !== 'string' && input.constructor !== env.AST.ProgramNode)) {
+ throw new Exception("You must pass a string or Handlebars AST to Handlebars.compile. You passed " + input);
+ }
+
+ options = options || {};
+
+ if (!('data' in options)) {
+ options.data = true;
+ }
+ if (options.compat) {
+ options.useDepths = true;
+ }
+
+ var compiled;
+
+ function compileInput() {
+ var ast = env.parse(input);
+ var environment = new env.Compiler().compile(ast, options);
+ var templateSpec = new env.JavaScriptCompiler().compile(environment, options, undefined, true);
+ return env.template(templateSpec);
+ }
+
+ // Template is only compiled on first use and cached after that point.
+ var ret = function(context, options) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled.call(this, context, options);
+ };
+ ret._setup = function(options) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled._setup(options);
+ };
+ ret._child = function(i, data, depths) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled._child(i, data, depths);
+ };
+ return ret;
+ }
+
+ __exports__.compile = compile;function argEquals(a, b) {
+ if (a === b) {
+ return true;
+ }
+
+ if (isArray(a) && isArray(b) && a.length === b.length) {
+ for (var i = 0; i < a.length; i++) {
+ if (!argEquals(a[i], b[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+ return __exports__;
+})(__module5__, __module3__);
+
+// handlebars/compiler/javascript-compiler.js
+var __module12__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__;
+ var COMPILER_REVISION = __dependency1__.COMPILER_REVISION;
+ var REVISION_CHANGES = __dependency1__.REVISION_CHANGES;
+ var Exception = __dependency2__;
+
+ function Literal(value) {
+ this.value = value;
+ }
+
+ function JavaScriptCompiler() {}
+
+ JavaScriptCompiler.prototype = {
+ // PUBLIC API: You can override these methods in a subclass to provide
+ // alternative compiled forms for name lookup and buffering semantics
+ nameLookup: function(parent, name /* , type*/) {
+ if (JavaScriptCompiler.isValidJavaScriptVariableName(name)) {
+ return parent + "." + name;
+ } else {
+ return parent + "['" + name + "']";
+ }
+ },
+ depthedLookup: function(name) {
+ this.aliases.lookup = 'this.lookup';
+
+ return 'lookup(depths, "' + name + '")';
+ },
+
+ compilerInfo: function() {
+ var revision = COMPILER_REVISION,
+ versions = REVISION_CHANGES[revision];
+ return [revision, versions];
+ },
+
+ appendToBuffer: function(string) {
+ if (this.environment.isSimple) {
+ return "return " + string + ";";
+ } else {
+ return {
+ appendToBuffer: true,
+ content: string,
+ toString: function() { return "buffer += " + string + ";"; }
+ };
+ }
+ },
+
+ initializeBuffer: function() {
+ return this.quotedString("");
+ },
+
+ namespace: "Handlebars",
+ // END PUBLIC API
+
+ compile: function(environment, options, context, asObject) {
+ this.environment = environment;
+ this.options = options;
+ this.stringParams = this.options.stringParams;
+ this.trackIds = this.options.trackIds;
+ this.precompile = !asObject;
+
+ this.name = this.environment.name;
+ this.isChild = !!context;
+ this.context = context || {
+ programs: [],
+ environments: []
+ };
+
+ this.preamble();
+
+ this.stackSlot = 0;
+ this.stackVars = [];
+ this.aliases = {};
+ this.registers = { list: [] };
+ this.hashes = [];
+ this.compileStack = [];
+ this.inlineStack = [];
+
+ this.compileChildren(environment, options);
+
+ this.useDepths = this.useDepths || environment.depths.list.length || this.options.compat;
+
+ var opcodes = environment.opcodes,
+ opcode,
+ i,
+ l;
+
+ for (i = 0, l = opcodes.length; i < l; i++) {
+ opcode = opcodes[i];
+
+ this[opcode.opcode].apply(this, opcode.args);
+ }
+
+ // Flush any trailing content that might be pending.
+ this.pushSource('');
+
+ /* istanbul ignore next */
+ if (this.stackSlot || this.inlineStack.length || this.compileStack.length) {
+ throw new Exception('Compile completed with content left on stack');
+ }
+
+ var fn = this.createFunctionContext(asObject);
+ if (!this.isChild) {
+ var ret = {
+ compiler: this.compilerInfo(),
+ main: fn
+ };
+ var programs = this.context.programs;
+ for (i = 0, l = programs.length; i < l; i++) {
+ if (programs[i]) {
+ ret[i] = programs[i];
+ }
+ }
+
+ if (this.environment.usePartial) {
+ ret.usePartial = true;
+ }
+ if (this.options.data) {
+ ret.useData = true;
+ }
+ if (this.useDepths) {
+ ret.useDepths = true;
+ }
+ if (this.options.compat) {
+ ret.compat = true;
+ }
+
+ if (!asObject) {
+ ret.compiler = JSON.stringify(ret.compiler);
+ ret = this.objectLiteral(ret);
+ }
+
+ return ret;
+ } else {
+ return fn;
+ }
+ },
+
+ preamble: function() {
+ // track the last context pushed into place to allow skipping the
+ // getContext opcode when it would be a noop
+ this.lastContext = 0;
+ this.source = [];
+ },
+
+ createFunctionContext: function(asObject) {
+ var varDeclarations = '';
+
+ var locals = this.stackVars.concat(this.registers.list);
+ if(locals.length > 0) {
+ varDeclarations += ", " + locals.join(", ");
+ }
+
+ // Generate minimizer alias mappings
+ for (var alias in this.aliases) {
+ if (this.aliases.hasOwnProperty(alias)) {
+ varDeclarations += ', ' + alias + '=' + this.aliases[alias];
+ }
+ }
+
+ var params = ["depth0", "helpers", "partials", "data"];
+
+ if (this.useDepths) {
+ params.push('depths');
+ }
+
+ // Perform a second pass over the output to merge content when possible
+ var source = this.mergeSource(varDeclarations);
+
+ if (asObject) {
+ params.push(source);
+
+ return Function.apply(this, params);
+ } else {
+ return 'function(' + params.join(',') + ') {\n ' + source + '}';
+ }
+ },
+ mergeSource: function(varDeclarations) {
+ var source = '',
+ buffer,
+ appendOnly = !this.forceBuffer,
+ appendFirst;
+
+ for (var i = 0, len = this.source.length; i < len; i++) {
+ var line = this.source[i];
+ if (line.appendToBuffer) {
+ if (buffer) {
+ buffer = buffer + '\n + ' + line.content;
+ } else {
+ buffer = line.content;
+ }
+ } else {
+ if (buffer) {
+ if (!source) {
+ appendFirst = true;
+ source = buffer + ';\n ';
+ } else {
+ source += 'buffer += ' + buffer + ';\n ';
+ }
+ buffer = undefined;
+ }
+ source += line + '\n ';
+
+ if (!this.environment.isSimple) {
+ appendOnly = false;
+ }
+ }
+ }
+
+ if (appendOnly) {
+ if (buffer || !source) {
+ source += 'return ' + (buffer || '""') + ';\n';
+ }
+ } else {
+ varDeclarations += ", buffer = " + (appendFirst ? '' : this.initializeBuffer());
+ if (buffer) {
+ source += 'return buffer + ' + buffer + ';\n';
+ } else {
+ source += 'return buffer;\n';
+ }
+ }
+
+ if (varDeclarations) {
+ source = 'var ' + varDeclarations.substring(2) + (appendFirst ? '' : ';\n ') + source;
+ }
+
+ return source;
+ },
+
+ // [blockValue]
+ //
+ // On stack, before: hash, inverse, program, value
+ // On stack, after: return value of blockHelperMissing
+ //
+ // The purpose of this opcode is to take a block of the form
+ // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and
+ // replace it on the stack with the result of properly
+ // invoking blockHelperMissing.
+ blockValue: function(name) {
+ this.aliases.blockHelperMissing = 'helpers.blockHelperMissing';
+
+ var params = [this.contextName(0)];
+ this.setupParams(name, 0, params);
+
+ var blockName = this.popStack();
+ params.splice(1, 0, blockName);
+
+ this.push('blockHelperMissing.call(' + params.join(', ') + ')');
+ },
+
+ // [ambiguousBlockValue]
+ //
+ // On stack, before: hash, inverse, program, value
+ // Compiler value, before: lastHelper=value of last found helper, if any
+ // On stack, after, if no lastHelper: same as [blockValue]
+ // On stack, after, if lastHelper: value
+ ambiguousBlockValue: function() {
+ this.aliases.blockHelperMissing = 'helpers.blockHelperMissing';
+
+ // We're being a bit cheeky and reusing the options value from the prior exec
+ var params = [this.contextName(0)];
+ this.setupParams('', 0, params, true);
+
+ this.flushInline();
+
+ var current = this.topStack();
+ params.splice(1, 0, current);
+
+ this.pushSource("if (!" + this.lastHelper + ") { " + current + " = blockHelperMissing.call(" + params.join(", ") + "); }");
+ },
+
+ // [appendContent]
+ //
+ // On stack, before: ...
+ // On stack, after: ...
+ //
+ // Appends the string value of `content` to the current buffer
+ appendContent: function(content) {
+ if (this.pendingContent) {
+ content = this.pendingContent + content;
+ }
+
+ this.pendingContent = content;
+ },
+
+ // [append]
+ //
+ // On stack, before: value, ...
+ // On stack, after: ...
+ //
+ // Coerces `value` to a String and appends it to the current buffer.
+ //
+ // If `value` is truthy, or 0, it is coerced into a string and appended
+ // Otherwise, the empty string is appended
+ append: function() {
+ // Force anything that is inlined onto the stack so we don't have duplication
+ // when we examine local
+ this.flushInline();
+ var local = this.popStack();
+ this.pushSource('if (' + local + ' != null) { ' + this.appendToBuffer(local) + ' }');
+ if (this.environment.isSimple) {
+ this.pushSource("else { " + this.appendToBuffer("''") + " }");
+ }
+ },
+
+ // [appendEscaped]
+ //
+ // On stack, before: value, ...
+ // On stack, after: ...
+ //
+ // Escape `value` and append it to the buffer
+ appendEscaped: function() {
+ this.aliases.escapeExpression = 'this.escapeExpression';
+
+ this.pushSource(this.appendToBuffer("escapeExpression(" + this.popStack() + ")"));
+ },
+
+ // [getContext]
+ //
+ // On stack, before: ...
+ // On stack, after: ...
+ // Compiler value, after: lastContext=depth
+ //
+ // Set the value of the `lastContext` compiler value to the depth
+ getContext: function(depth) {
+ this.lastContext = depth;
+ },
+
+ // [pushContext]
+ //
+ // On stack, before: ...
+ // On stack, after: currentContext, ...
+ //
+ // Pushes the value of the current context onto the stack.
+ pushContext: function() {
+ this.pushStackLiteral(this.contextName(this.lastContext));
+ },
+
+ // [lookupOnContext]
+ //
+ // On stack, before: ...
+ // On stack, after: currentContext[name], ...
+ //
+ // Looks up the value of `name` on the current context and pushes
+ // it onto the stack.
+ lookupOnContext: function(parts, falsy, scoped) {
+ /*jshint -W083 */
+ var i = 0,
+ len = parts.length;
+
+ if (!scoped && this.options.compat && !this.lastContext) {
+ // The depthed query is expected to handle the undefined logic for the root level that
+ // is implemented below, so we evaluate that directly in compat mode
+ this.push(this.depthedLookup(parts[i++]));
+ } else {
+ this.pushContext();
+ }
+
+ for (; i < len; i++) {
+ this.replaceStack(function(current) {
+ var lookup = this.nameLookup(current, parts[i], 'context');
+ // We want to ensure that zero and false are handled properly if the context (falsy flag)
+ // needs to have the special handling for these values.
+ if (!falsy) {
+ return ' != null ? ' + lookup + ' : ' + current;
+ } else {
+ // Otherwise we can use generic falsy handling
+ return ' && ' + lookup;
+ }
+ });
+ }
+ },
+
+ // [lookupData]
+ //
+ // On stack, before: ...
+ // On stack, after: data, ...
+ //
+ // Push the data lookup operator
+ lookupData: function(depth, parts) {
+ /*jshint -W083 */
+ if (!depth) {
+ this.pushStackLiteral('data');
+ } else {
+ this.pushStackLiteral('this.data(data, ' + depth + ')');
+ }
+
+ var len = parts.length;
+ for (var i = 0; i < len; i++) {
+ this.replaceStack(function(current) {
+ return ' && ' + this.nameLookup(current, parts[i], 'data');
+ });
+ }
+ },
+
+ // [resolvePossibleLambda]
+ //
+ // On stack, before: value, ...
+ // On stack, after: resolved value, ...
+ //
+ // If the `value` is a lambda, replace it on the stack by
+ // the return value of the lambda
+ resolvePossibleLambda: function() {
+ this.aliases.lambda = 'this.lambda';
+
+ this.push('lambda(' + this.popStack() + ', ' + this.contextName(0) + ')');
+ },
+
+ // [pushStringParam]
+ //
+ // On stack, before: ...
+ // On stack, after: string, currentContext, ...
+ //
+ // This opcode is designed for use in string mode, which
+ // provides the string value of a parameter along with its
+ // depth rather than resolving it immediately.
+ pushStringParam: function(string, type) {
+ this.pushContext();
+ this.pushString(type);
+
+ // If it's a subexpression, the string result
+ // will be pushed after this opcode.
+ if (type !== 'sexpr') {
+ if (typeof string === 'string') {
+ this.pushString(string);
+ } else {
+ this.pushStackLiteral(string);
+ }
+ }
+ },
+
+ emptyHash: function() {
+ this.pushStackLiteral('{}');
+
+ if (this.trackIds) {
+ this.push('{}'); // hashIds
+ }
+ if (this.stringParams) {
+ this.push('{}'); // hashContexts
+ this.push('{}'); // hashTypes
+ }
+ },
+ pushHash: function() {
+ if (this.hash) {
+ this.hashes.push(this.hash);
+ }
+ this.hash = {values: [], types: [], contexts: [], ids: []};
+ },
+ popHash: function() {
+ var hash = this.hash;
+ this.hash = this.hashes.pop();
+
+ if (this.trackIds) {
+ this.push('{' + hash.ids.join(',') + '}');
+ }
+ if (this.stringParams) {
+ this.push('{' + hash.contexts.join(',') + '}');
+ this.push('{' + hash.types.join(',') + '}');
+ }
+
+ this.push('{\n ' + hash.values.join(',\n ') + '\n }');
+ },
+
+ // [pushString]
+ //
+ // On stack, before: ...
+ // On stack, after: quotedString(string), ...
+ //
+ // Push a quoted version of `string` onto the stack
+ pushString: function(string) {
+ this.pushStackLiteral(this.quotedString(string));
+ },
+
+ // [push]
+ //
+ // On stack, before: ...
+ // On stack, after: expr, ...
+ //
+ // Push an expression onto the stack
+ push: function(expr) {
+ this.inlineStack.push(expr);
+ return expr;
+ },
+
+ // [pushLiteral]
+ //
+ // On stack, before: ...
+ // On stack, after: value, ...
+ //
+ // Pushes a value onto the stack. This operation prevents
+ // the compiler from creating a temporary variable to hold
+ // it.
+ pushLiteral: function(value) {
+ this.pushStackLiteral(value);
+ },
+
+ // [pushProgram]
+ //
+ // On stack, before: ...
+ // On stack, after: program(guid), ...
+ //
+ // Push a program expression onto the stack. This takes
+ // a compile-time guid and converts it into a runtime-accessible
+ // expression.
+ pushProgram: function(guid) {
+ if (guid != null) {
+ this.pushStackLiteral(this.programExpression(guid));
+ } else {
+ this.pushStackLiteral(null);
+ }
+ },
+
+ // [invokeHelper]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of helper invocation
+ //
+ // Pops off the helper's parameters, invokes the helper,
+ // and pushes the helper's return value onto the stack.
+ //
+ // If the helper is not found, `helperMissing` is called.
+ invokeHelper: function(paramSize, name, isSimple) {
+ this.aliases.helperMissing = 'helpers.helperMissing';
+
+ var nonHelper = this.popStack();
+ var helper = this.setupHelper(paramSize, name);
+
+ var lookup = (isSimple ? helper.name + ' || ' : '') + nonHelper + ' || helperMissing';
+ this.push('((' + lookup + ').call(' + helper.callParams + '))');
+ },
+
+ // [invokeKnownHelper]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of helper invocation
+ //
+ // This operation is used when the helper is known to exist,
+ // so a `helperMissing` fallback is not required.
+ invokeKnownHelper: function(paramSize, name) {
+ var helper = this.setupHelper(paramSize, name);
+ this.push(helper.name + ".call(" + helper.callParams + ")");
+ },
+
+ // [invokeAmbiguous]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of disambiguation
+ //
+ // This operation is used when an expression like `{{foo}}`
+ // is provided, but we don't know at compile-time whether it
+ // is a helper or a path.
+ //
+ // This operation emits more code than the other options,
+ // and can be avoided by passing the `knownHelpers` and
+ // `knownHelpersOnly` flags at compile-time.
+ invokeAmbiguous: function(name, helperCall) {
+ this.aliases.functionType = '"function"';
+ this.aliases.helperMissing = 'helpers.helperMissing';
+ this.useRegister('helper');
+
+ var nonHelper = this.popStack();
+
+ this.emptyHash();
+ var helper = this.setupHelper(0, name, helperCall);
+
+ var helperName = this.lastHelper = this.nameLookup('helpers', name, 'helper');
+
+ this.push(
+ '((helper = (helper = ' + helperName + ' || ' + nonHelper + ') != null ? helper : helperMissing'
+ + (helper.paramsInit ? '),(' + helper.paramsInit : '') + '),'
+ + '(typeof helper === functionType ? helper.call(' + helper.callParams + ') : helper))');
+ },
+
+ // [invokePartial]
+ //
+ // On stack, before: context, ...
+ // On stack after: result of partial invocation
+ //
+ // This operation pops off a context, invokes a partial with that context,
+ // and pushes the result of the invocation back.
+ invokePartial: function(name, indent) {
+ var params = [this.nameLookup('partials', name, 'partial'), "'" + indent + "'", "'" + name + "'", this.popStack(), this.popStack(), "helpers", "partials"];
+
+ if (this.options.data) {
+ params.push("data");
+ } else if (this.options.compat) {
+ params.push('undefined');
+ }
+ if (this.options.compat) {
+ params.push('depths');
+ }
+
+ this.push("this.invokePartial(" + params.join(", ") + ")");
+ },
+
+ // [assignToHash]
+ //
+ // On stack, before: value, ..., hash, ...
+ // On stack, after: ..., hash, ...
+ //
+ // Pops a value off the stack and assigns it to the current hash
+ assignToHash: function(key) {
+ var value = this.popStack(),
+ context,
+ type,
+ id;
+
+ if (this.trackIds) {
+ id = this.popStack();
+ }
+ if (this.stringParams) {
+ type = this.popStack();
+ context = this.popStack();
+ }
+
+ var hash = this.hash;
+ if (context) {
+ hash.contexts.push("'" + key + "': " + context);
+ }
+ if (type) {
+ hash.types.push("'" + key + "': " + type);
+ }
+ if (id) {
+ hash.ids.push("'" + key + "': " + id);
+ }
+ hash.values.push("'" + key + "': (" + value + ")");
+ },
+
+ pushId: function(type, name) {
+ if (type === 'ID' || type === 'DATA') {
+ this.pushString(name);
+ } else if (type === 'sexpr') {
+ this.pushStackLiteral('true');
+ } else {
+ this.pushStackLiteral('null');
+ }
+ },
+
+ // HELPERS
+
+ compiler: JavaScriptCompiler,
+
+ compileChildren: function(environment, options) {
+ var children = environment.children, child, compiler;
+
+ for(var i=0, l=children.length; i<l; i++) {
+ child = children[i];
+ compiler = new this.compiler();
+
+ var index = this.matchExistingProgram(child);
+
+ if (index == null) {
+ this.context.programs.push(''); // Placeholder to prevent name conflicts for nested children
+ index = this.context.programs.length;
+ child.index = index;
+ child.name = 'program' + index;
+ this.context.programs[index] = compiler.compile(child, options, this.context, !this.precompile);
+ this.context.environments[index] = child;
+
+ this.useDepths = this.useDepths || compiler.useDepths;
+ } else {
+ child.index = index;
+ child.name = 'program' + index;
+ }
+ }
+ },
+ matchExistingProgram: function(child) {
+ for (var i = 0, len = this.context.environments.length; i < len; i++) {
+ var environment = this.context.environments[i];
+ if (environment && environment.equals(child)) {
+ return i;
+ }
+ }
+ },
+
+ programExpression: function(guid) {
+ var child = this.environment.children[guid],
+ depths = child.depths.list,
+ useDepths = this.useDepths,
+ depth;
+
+ var programParams = [child.index, 'data'];
+
+ if (useDepths) {
+ programParams.push('depths');
+ }
+
+ return 'this.program(' + programParams.join(', ') + ')';
+ },
+
+ useRegister: function(name) {
+ if(!this.registers[name]) {
+ this.registers[name] = true;
+ this.registers.list.push(name);
+ }
+ },
+
+ pushStackLiteral: function(item) {
+ return this.push(new Literal(item));
+ },
+
+ pushSource: function(source) {
+ if (this.pendingContent) {
+ this.source.push(this.appendToBuffer(this.quotedString(this.pendingContent)));
+ this.pendingContent = undefined;
+ }
+
+ if (source) {
+ this.source.push(source);
+ }
+ },
+
+ pushStack: function(item) {
+ this.flushInline();
+
+ var stack = this.incrStack();
+ this.pushSource(stack + " = " + item + ";");
+ this.compileStack.push(stack);
+ return stack;
+ },
+
+ replaceStack: function(callback) {
+ var prefix = '',
+ inline = this.isInline(),
+ stack,
+ createdStack,
+ usedLiteral;
+
+ /* istanbul ignore next */
+ if (!this.isInline()) {
+ throw new Exception('replaceStack on non-inline');
+ }
+
+ // We want to merge the inline statement into the replacement statement via ','
+ var top = this.popStack(true);
+
+ if (top instanceof Literal) {
+ // Literals do not need to be inlined
+ prefix = stack = top.value;
+ usedLiteral = true;
+ } else {
+ // Get or create the current stack name for use by the inline
+ createdStack = !this.stackSlot;
+ var name = !createdStack ? this.topStackName() : this.incrStack();
+
+ prefix = '(' + this.push(name) + ' = ' + top + ')';
+ stack = this.topStack();
+ }
+
+ var item = callback.call(this, stack);
+
+ if (!usedLiteral) {
+ this.popStack();
+ }
+ if (createdStack) {
+ this.stackSlot--;
+ }
+ this.push('(' + prefix + item + ')');
+ },
+
+ incrStack: function() {
+ this.stackSlot++;
+ if(this.stackSlot > this.stackVars.length) { this.stackVars.push("stack" + this.stackSlot); }
+ return this.topStackName();
+ },
+ topStackName: function() {
+ return "stack" + this.stackSlot;
+ },
+ flushInline: function() {
+ var inlineStack = this.inlineStack;
+ if (inlineStack.length) {
+ this.inlineStack = [];
+ for (var i = 0, len = inlineStack.length; i < len; i++) {
+ var entry = inlineStack[i];
+ if (entry instanceof Literal) {
+ this.compileStack.push(entry);
+ } else {
+ this.pushStack(entry);
+ }
+ }
+ }
+ },
+ isInline: function() {
+ return this.inlineStack.length;
+ },
+
+ popStack: function(wrapped) {
+ var inline = this.isInline(),
+ item = (inline ? this.inlineStack : this.compileStack).pop();
+
+ if (!wrapped && (item instanceof Literal)) {
+ return item.value;
+ } else {
+ if (!inline) {
+ /* istanbul ignore next */
+ if (!this.stackSlot) {
+ throw new Exception('Invalid stack pop');
+ }
+ this.stackSlot--;
+ }
+ return item;
+ }
+ },
+
+ topStack: function() {
+ var stack = (this.isInline() ? this.inlineStack : this.compileStack),
+ item = stack[stack.length - 1];
+
+ if (item instanceof Literal) {
+ return item.value;
+ } else {
+ return item;
+ }
+ },
+
+ contextName: function(context) {
+ if (this.useDepths && context) {
+ return 'depths[' + context + ']';
+ } else {
+ return 'depth' + context;
+ }
+ },
+
+ quotedString: function(str) {
+ return '"' + str
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\u2028/g, '\\u2028') // Per Ecma-262 7.3 + 7.8.4
+ .replace(/\u2029/g, '\\u2029') + '"';
+ },
+
+ objectLiteral: function(obj) {
+ var pairs = [];
+
+ for (var key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ pairs.push(this.quotedString(key) + ':' + obj[key]);
+ }
+ }
+
+ return '{' + pairs.join(',') + '}';
+ },
+
+ setupHelper: function(paramSize, name, blockHelper) {
+ var params = [],
+ paramsInit = this.setupParams(name, paramSize, params, blockHelper);
+ var foundHelper = this.nameLookup('helpers', name, 'helper');
+
+ return {
+ params: params,
+ paramsInit: paramsInit,
+ name: foundHelper,
+ callParams: [this.contextName(0)].concat(params).join(", ")
+ };
+ },
+
+ setupOptions: function(helper, paramSize, params) {
+ var options = {}, contexts = [], types = [], ids = [], param, inverse, program;
+
+ options.name = this.quotedString(helper);
+ options.hash = this.popStack();
+
+ if (this.trackIds) {
+ options.hashIds = this.popStack();
+ }
+ if (this.stringParams) {
+ options.hashTypes = this.popStack();
+ options.hashContexts = this.popStack();
+ }
+
+ inverse = this.popStack();
+ program = this.popStack();
+
+ // Avoid setting fn and inverse if neither are set. This allows
+ // helpers to do a check for `if (options.fn)`
+ if (program || inverse) {
+ if (!program) {
+ program = 'this.noop';
+ }
+
+ if (!inverse) {
+ inverse = 'this.noop';
+ }
+
+ options.fn = program;
+ options.inverse = inverse;
+ }
+
+ // The parameters go on to the stack in order (making sure that they are evaluated in order)
+ // so we need to pop them off the stack in reverse order
+ var i = paramSize;
+ while (i--) {
+ param = this.popStack();
+ params[i] = param;
+
+ if (this.trackIds) {
+ ids[i] = this.popStack();
+ }
+ if (this.stringParams) {
+ types[i] = this.popStack();
+ contexts[i] = this.popStack();
+ }
+ }
+
+ if (this.trackIds) {
+ options.ids = "[" + ids.join(",") + "]";
+ }
+ if (this.stringParams) {
+ options.types = "[" + types.join(",") + "]";
+ options.contexts = "[" + contexts.join(",") + "]";
+ }
+
+ if (this.options.data) {
+ options.data = "data";
+ }
+
+ return options;
+ },
+
+ // the params and contexts arguments are passed in arrays
+ // to fill in
+ setupParams: function(helperName, paramSize, params, useRegister) {
+ var options = this.objectLiteral(this.setupOptions(helperName, paramSize, params));
+
+ if (useRegister) {
+ this.useRegister('options');
+ params.push('options');
+ return 'options=' + options;
+ } else {
+ params.push(options);
+ return '';
+ }
+ }
+ };
+
+ var reservedWords = (
+ "break else new var" +
+ " case finally return void" +
+ " catch for switch while" +
+ " continue function this with" +
+ " default if throw" +
+ " delete in try" +
+ " do instanceof typeof" +
+ " abstract enum int short" +
+ " boolean export interface static" +
+ " byte extends long super" +
+ " char final native synchronized" +
+ " class float package throws" +
+ " const goto private transient" +
+ " debugger implements protected volatile" +
+ " double import public let yield"
+ ).split(" ");
+
+ var compilerWords = JavaScriptCompiler.RESERVED_WORDS = {};
+
+ for(var i=0, l=reservedWords.length; i<l; i++) {
+ compilerWords[reservedWords[i]] = true;
+ }
+
+ JavaScriptCompiler.isValidJavaScriptVariableName = function(name) {
+ return !JavaScriptCompiler.RESERVED_WORDS[name] && /^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(name);
+ };
+
+ __exports__ = JavaScriptCompiler;
+ return __exports__;
+})(__module2__, __module5__);
+
+// handlebars.js
+var __module0__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__) {
+ "use strict";
+ var __exports__;
+ /*globals Handlebars: true */
+ var Handlebars = __dependency1__;
+
+ // Compiler imports
+ var AST = __dependency2__;
+ var Parser = __dependency3__.parser;
+ var parse = __dependency3__.parse;
+ var Compiler = __dependency4__.Compiler;
+ var compile = __dependency4__.compile;
+ var precompile = __dependency4__.precompile;
+ var JavaScriptCompiler = __dependency5__;
+
+ var _create = Handlebars.create;
+ var create = function() {
+ var hb = _create();
+
+ hb.compile = function(input, options) {
+ return compile(input, options, hb);
+ };
+ hb.precompile = function (input, options) {
+ return precompile(input, options, hb);
+ };
+
+ hb.AST = AST;
+ hb.Compiler = Compiler;
+ hb.JavaScriptCompiler = JavaScriptCompiler;
+ hb.Parser = Parser;
+ hb.parse = parse;
+
+ return hb;
+ };
+
+ Handlebars = create();
+ Handlebars.create = create;
+
+ Handlebars['default'] = Handlebars;
+
+ __exports__ = Handlebars;
+ return __exports__;
+})(__module1__, __module7__, __module8__, __module11__, __module12__);
+
+ return __module0__;
+}));
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.eot b/Flow/modules/wikiglyph/WikiFont-Glyphs.eot
new file mode 100644
index 00000000..c8e54244
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.eot
Binary files differ
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.svg b/Flow/modules/wikiglyph/WikiFont-Glyphs.svg
new file mode 100644
index 00000000..41fd4acb
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.svg
@@ -0,0 +1,291 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
+<svg xmlns="http://www.w3.org/2000/svg">
+<metadata>
+Created by FontPrep 20130207 at Tue Sep 9 22:46:02 2014
+ By May Tee-Galloway
+Modified BSD License
+</metadata>
+<defs>
+<font id="1b21e434c004bb0ef5da181d3ec74d04" horiz-adv-x='1024' >
+ <font-face
+ font-family="WikiFont-Glyphs"
+ font-weight="400"
+ font-stretch="normal"
+ units-per-em="2048"
+ panose-1="0 0 5 0 0 0 0 0 0 0"
+ ascent="1638"
+ descent="-410"
+ bbox="10.8621 -308 2049 1792"
+ underline-thickness="50"
+ underline-position="-25"
+ unicode-range="U+0020-E903"
+ />
+<missing-glyph horiz-adv-x="600"
+ />
+ <glyph glyph-name=".notdef" horiz-adv-x="600"
+ />
+ <glyph glyph-name=".null" horiz-adv-x="0"
+ />
+ <glyph glyph-name="nonmarkingreturn" horiz-adv-x="682"
+ />
+ <glyph glyph-name="space" unicode=" " horiz-adv-x="600"
+ />
+ <glyph glyph-name="uniE000" unicode="&#xe000;"
+d="M877 1073q-88 -1 -164 -55t-110 -132q-35 -78 -23 -170t76 -163q96 -95 224 -99t226 89q56 55 78 128t6 142t-54 128t-104 95t-155 37zM888 1305q224 0 383 -160q137 -137 157 -328t-85 -349l362 -362l-61 -61q-43 -43 -103 -43t-102 43l-260 258q-159 -103 -351 -81
+t-327 156q-103 104 -140 244t0 280t142 244q161 159 385 159z" />
+ <glyph glyph-name="uniE001" unicode="&#xe001;"
+d="M790 755h802q60 -1 102 -43t42 -102v-84h-946v-309l-477 405l477 441v-308z" />
+ <glyph glyph-name="uniE002" unicode="&#xe002;"
+d="M1585 1143q60 0 102 -42l58 -60l-959 -959l-497 497l161 161l336 -335l696 696q43 42 103 42z" />
+ <glyph glyph-name="uniE003" unicode="&#xe003;"
+d="M1513 1032q0 -59 -42 -102l-291 -293l393 -393l-162 -162l-393 393l-395 -393l-60 60q-43 42 -43 102t43 102l292 292l-379 379l162 162l379 -379l394 394l60 -60q42 -43 42 -102z" />
+ <glyph glyph-name="uniE004" unicode="&#xe004;"
+d="M1024 568l299 -298l122 122l-298 299l298 299l-122 122l-299 -298l-287 288l-124 -124l288 -287l-298 -299l122 -122zM1024 1386q142 0 270 -55t222 -149t148 -222q55 -128 55 -269t-55 -269q-54 -128 -148 -222t-222 -149t-270 -55t-270 55t-222 149t-148 222
+q-55 128 -55 269t55 269q54 128 148 222t222 149t270 55z" />
+ <glyph glyph-name="uniE005" unicode="&#xe005;"
+d="M1142 803l-118 239l-118 -239l-265 -39l192 -186l-46 -264l237 124l237 -124l-46 264l191 186zM792 960l232 471l232 -471l520 -75l-376 -367l89 -518l-465 244l-465 -243l89 517l-376 367z" />
+ <glyph glyph-name="uniE006" unicode="&#xe006;" horiz-adv-x="2053"
+d="M794 965l233 474l232 -474l522 -76l-377 -368l90 -521l-467 245l-467 -243l89 519l-377 368z" />
+ <glyph glyph-name="uniE007" unicode="&#xe007;"
+d="M1094 624h438v-150h-438v150zM1094 387h550v-150h-550v150zM1094 150h340v-150h-340v150zM1006 711v-429l-409 -213l79 453l-331 320l457 66l204 412q204 -412 214 -414l448 -64l-136 -131h-526z" />
+ <glyph glyph-name="uniE008" unicode="&#xe008;"
+d="M1544 1202q0 -42 -26 -81l-118 -117l-73 69l191 192q26 -21 26 -63zM1503 751h272q0 -42 -30 -73t-71 -31h-171v104zM974 1174v272q42 0 73 -30t31 -71v-171h-104zM1518 178q-42 0 -71 29l-120 119l71 71l191 -190q-29 -29 -71 -29zM530 1223q41 0 70 -29l120 -120
+l-71 -72l-189 192q29 29 70 29zM973 219h104v-272q-42 0 -73 30t-31 71v171zM373 751h171v-104h-272q0 42 30 73t71 31zM530 276l120 120l71 -71l-190 -191q-29 29 -29 71t28 71zM1026 1062q151 0 257 -106t106 -258t-106 -258t-256 -106t-258 108t-108 257q0 151 107 257
+t258 106z" />
+ <glyph glyph-name="uniE009" unicode="&#xe009;"
+d="M1138 0h-86q-59 0 -101 42t-42 101v325l-619 648h1469l-621 -650v-466z" />
+ <glyph glyph-name="uniE010" unicode="&#xe010;"
+d="M1024 867q73 0 125 -52t52 -125q0 -74 -52 -126q-51 -52 -125 -52t-126 52t-52 126t52 125q52 52 126 52zM1024 363q134 0 230 96q96 97 96 231t-96 230t-230 96t-230 -96t-96 -230t96 -231q96 -96 230 -96zM1012 1212q92 0 186 -30t178 -72q83 -44 173 -104
+q139 -92 247 -187t143 -129l-30 -29q-66 -64 -171 -148q-179 -144 -367 -245t-347 -101q-285 1 -575 207q-138 98 -228 192t-113 124l24 29q49 62 143 147q93 84 175 140t168 102q88 46 195 75t199 29z" />
+ <glyph glyph-name="uniE011" unicode="&#xe011;"
+d="M1112 137h-172v253q-104 7 -207 35l-87 -230l-160 61l85 226q-101 46 -182 104l-153 -181l-131 110l153 182q-96 94 -167 213l90 52q45 28 97 19t87 -50q121 -149 294 -231t367 -82t368 82q172 82 293 231q35 41 87 50t98 -19l83 -54q-71 -118 -162 -209l165 -196
+l-131 -110l-164 194q-85 -61 -179 -104l89 -234l-161 -61l-90 237q-105 -28 -210 -35v-253z" />
+ <glyph glyph-name="uniE012" unicode="&#xe012;"
+d="M731 990v302h-66q-36 0 -61 -26t-25 -60v-216h152zM616 1397h721q48 0 82 -34t34 -82v-1326l-360 240l-349 -240v929h-271v370q0 59 42 101t101 42z" />
+ <glyph glyph-name="uniE013" unicode="&#xe013;"
+d="M1392 1039v222h-672q-23 0 -40 -17t-17 -40v-165h729zM1387 100v548h-738v-548h738zM703 1361h784v-322h189q60 0 102 -42t42 -101v-640h-333v-256h-939v256h-221q-41 0 -70 29t-29 70v684h332v179q0 59 42 101t101 42z" />
+ <glyph glyph-name="uniE014" unicode="&#xe014;"
+d="M732 1082l256 255l266 -266q4 -4 10 -2q7 2 10 40q6 82 54 122t107 41t111 -47l16 -16q62 -63 62 -125t-36 -104t-82 -52t-64 -10t-20 -6t2 -11l266 -266l-258 -258q-4 -5 -2 -11t8 -6q8 -1 36 -4t43 -6q50 -11 86 -54t37 -102t-47 -110l-16 -16q-64 -64 -125 -64
+t-109 42q-48 41 -54 123q-4 61 -21 44l-292 -292l-279 279q-12 12 5 17t52 8t49 6q50 9 86 53t37 104t-47 108l-16 17q-21 24 -61 44q-40 19 -82 19t-85 -36t-54 -87q-3 -13 -6 -48t-8 -52t-17 -6l-276 276l289 289q4 4 2 10q-1 6 -5 6t-33 4q-29 3 -43 6q-50 11 -86 54
+t-37 102t47 110l16 16q64 64 124 64t108 -40q46 -39 56 -134q4 -40 20 -28z" />
+ <glyph glyph-name="uniE015" unicode="&#xe015;" horiz-adv-x="1835"
+d="M986 1159h-168v-562l382 -220l12 21q30 51 14 108t-66 87l-174 102v464zM918 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92q-186 0 -346 92t-252 252q-92 159 -93 346q1 187 93 346q92 160 252 252t346 92z" />
+ <glyph glyph-name="uniE016" unicode="&#xe016;"
+d="M1316 1044q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM1316 462q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM734 1044q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33
+q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM1024 753q-48 1 -80 -33q-34 -34 -34 -82t34 -80q34 -34 80 -34q48 0 82 34t33 80q1 48 -33 82t-82 33zM748 462q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM386 1277h1134
+q59 0 101 -42t42 -101v-1134h-1134q-59 0 -101 42t-42 101v1134z" />
+ <glyph glyph-name="uniE017" unicode="&#xe017;"
+d="M1131 1137v-293h292v139l300 -277l-300 -255v140h-293v-292h139l-277 -299l-254 299h139v292h-292v-139l-301 254l300 278v-139h293v292h-140l255 300l278 -300h-139z" />
+ <glyph glyph-name="uniE018" unicode="&#xe018;" horiz-adv-x="2055"
+d="M1028 954q-108 0 -186 -77t-78 -187t77 -186q77 -77 187 -77q110 1 186 77t77 186q0 110 -77 187t-186 77zM902 1380h253l25 -153q53 -14 118 -49l128 92l178 -182l-91 -128q37 -66 50 -117l155 -27v-252l-155 -25q-13 -50 -50 -119l91 -128l-178 -178l-128 91
+q-57 -31 -118 -49l-25 -156h-255l-25 154q-62 17 -119 48l-128 -89l-178 178l90 128q-32 59 -49 119l-154 26v254l154 24q16 59 49 119l-90 128l179 178l129 -91q63 35 117 49z" />
+ <glyph glyph-name="uniE019" unicode="&#xe019;"
+d="M1583 790q69 0 118 -49t49 -118t-49 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49zM1190 623q0 -69 -48 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49q70 0 118 -49t48 -118zM631 623q0 -69 -49 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49
+t118 -49t49 -118z" />
+ <glyph glyph-name="uniE020" unicode="&#xe020;"
+d="M259 1197h1393q58 -1 98 -41t40 -97v-64l-762 -388l-769 390v200zM1790 0h-1393q-57 0 -97 40t-41 98v667l771 -387l760 387v-805z" />
+ <glyph glyph-name="uniE021" unicode="&#xe021;"
+d="M1225 1325q99 86 231 82t224 -87q92 -82 110 -200q29 -182 -125 -320q-5 -4 -92 -82q-87 -77 -749 -668q-96 -84 -226 -86t-226 81q-162 157 -104 359q21 72 54 110t56 58q22 20 52 46t44 39l459 408q151 134 286 52q63 -38 87 -103q24 -64 7 -132q-18 -68 -71 -115
+l-449 -399q-97 109 -16 180q200 181 374 335q33 28 12 63q-20 35 -44 34q-25 0 -57 -28t-278 -248t-260 -235t-42 -43q-37 -39 -55 -96q-18 -58 21 -112q38 -54 93 -72t101 -4q79 25 723 616l214 196q37 40 41 98t-24 102t-62 62q-36 18 -74 16t-90 -7q-52 -6 -89 35
+q-2 3 -34 30q-31 28 -22 35z" />
+ <glyph glyph-name="uniE022" unicode="&#xe022;"
+d="M1418 204v347h204v-551h-1075q-53 0 -89 36t-36 89v426h204v-257q0 -38 26 -64t64 -26h702zM1119 846v-509h-75q-52 1 -88 37t-37 88v384h-268l384 416l352 -416h-268z" />
+ <glyph glyph-name="uniE023" unicode="&#xe023;"
+d="M1049 1400h87v-536h306l-403 -477l-440 478h306v391q1 60 43 102t101 42zM334 634h235v-296q0 -44 30 -74t73 -30h807v399h235v-633h-1236q-60 0 -102 42t-42 101v491z" />
+ <glyph glyph-name="uniE024" unicode="&#xe024;"
+d="M433 1171h197l177 -180h-194v-706q1 -43 31 -73t73 -31h705v194l180 -184v-190l-1025 -1q-60 0 -102 42t-42 101v1028zM837 508q0 60 43 101l317 317l-248 248h651v-650l-241 240l-419 -419l-60 61q-43 42 -43 102z" />
+ <glyph glyph-name="uniE025" unicode="&#xe025;"
+d="M1022 -108q-70 0 -129 43q-58 43 -79 108h412q-22 -67 -78 -109t-126 -42zM466 848q0 150 75 278t203 204t276 78q112 -2 214 -48t176 -120t120 -177q44 -103 44 -215v-471l147 -235h-1402l147 235v471z" />
+ <glyph glyph-name="uniE026" unicode="&#xe026;"
+d="M917 62l403 -85q-34 -61 -98 -91t-134 -16t-116 69q-47 54 -55 123zM1545 268l95 -261l-1372 289l193 200l97 460q30 146 130 256t240 159q140 48 287 20q145 -33 255 -134t156 -242t16 -287z" />
+ <glyph glyph-name="uniE027" unicode="&#xe027;"
+d="M1279 1080l-439 -414l411 -439q35 -37 33 -89t-39 -89l-52 -49l-635 676l675 633l50 -52q36 -38 35 -90t-39 -87z" />
+ <glyph glyph-name="uniE028" unicode="&#xe028;"
+d="M1539 1009q52 0 89 -37l51 -51l-656 -656l-654 655l51 52q36 38 88 38t89 -37l427 -426l426 425q37 37 89 37z" />
+ <glyph glyph-name="uniE029" unicode="&#xe029;"
+d="M1024 1164q-99 0 -169 -70t-70 -169t70 -169t169 -70t169 70t70 169t-70 169t-169 70zM1024 394q28 0 88 -54t75 -62v-370l-164 162l-163 -161v369q16 8 76 62t88 54zM1521 925q0 -30 -63 -61q-63 -30 -71 -52t8 -58t29 -71q13 -34 2 -50q-11 -17 -50 -15t-72 8t-57 -8
+q-17 -14 -31 -86t-38 -80q-22 -8 -77 43q-55 50 -75 50t-51 -26q-30 -26 -58 -49t-49 -17q-22 5 -36 79t-31 86t-93 4q-76 -9 -86 11q-11 16 2 50q13 35 29 71q17 36 11 52t-28 32t-46 26q-24 12 -44 28t-19 33q0 30 63 61q63 30 72 54q8 22 -23 90t-19 86t52 16
+q39 -2 72 -8t57 8q17 12 31 86t38 80q24 7 78 -43t76 -50t76 50t78 43q24 -6 38 -80t31 -86t93 -4q76 9 86 -11q14 -19 -17 -87t-25 -84q6 -18 28 -34t46 -26q63 -30 63 -61z" />
+ <glyph glyph-name="uniE030" unicode="&#xe030;"
+d="M1240 1042q-60 0 -101 -42q-42 -42 -42 -102t42 -101q41 -42 101 -42t102 42q42 41 42 101t-42 102t-102 42zM1237 1365q126 0 233 -62t169 -169t62 -233q0 -192 -136 -328t-328 -136q-98 0 -188 39v-148h-164v-164h-164v-164h-375l23 224l458 458q-55 104 -55 224
+t62 228q62 107 170 169t233 62z" />
+ <glyph glyph-name="uniE031" unicode="&#xe031;"
+d="M143 1457h1495l-4 -172h-1342l1 -977h-150v1149zM1294 284l-332 347l-53 -22l-350 -457l1186 1l-271 238zM410 1149h1495v-1149h-1495v1149z" />
+ <glyph glyph-name="uniE032" unicode="&#xe032;"
+d="M1351 165l438 166v1096l-438 -167v-1095zM258 164l438 167v1096l-438 -167v-1096zM1911 1533v-1286l-622 -237l-533 213l-554 -211q-24 -8 -45 6t-21 40v1286l621 237l533 -213l555 211q24 9 45 -5t21 -41z" />
+ <glyph glyph-name="uniE033" unicode="&#xe033;"
+d="M1351 1204q143 0 257 -100q54 -48 88 -123q33 -76 33 -168q0 -228 -200 -401q-78 -68 -288 -240t-217 -172l-503 414q-93 77 -147 179q-54 103 -55 197t21 159q39 119 141 187q101 68 217 68q191 0 326 -163q134 163 327 163z" />
+ <glyph glyph-name="uniE040" unicode="&#xe040;"
+d="M1726 429h-1224q-60 0 -102 42t-42 101v29h1368v-172zM1726 774h-1368v172h1368v-172zM358 1119v172h1224q60 0 102 -42t42 -101v-29h-1368z" />
+ <glyph glyph-name="uniE041" unicode="&#xe041;"
+d="M1503 524h-1145v243h1145v-243zM1058 0h-556q-60 0 -102 42t-42 101v100h700v-243zM358 1048v243h1224q60 0 102 -42t42 -102v-99h-1368z" />
+ <glyph glyph-name="uniE042" unicode="&#xe042;"
+d="M340 877h1144v-173h-1144v173zM340 604h698v-172h-698v172zM340 244h1369v-244h-1225q-60 0 -102 42t-42 102v100zM340 1293h1225q60 0 102 -42t42 -102v-100h-1369v244z" />
+ <glyph glyph-name="uniE043" unicode="&#xe043;"
+d="M1710 701h-1368v172h1368v-172zM340 604h698v-172h-698v172z" />
+ <glyph glyph-name="article" unicode="&#xe100;"
+d="M1127 958h279v353h-279v-353zM640 706h766v100h-766v-100zM640 453h766v98h-766v-98zM640 201h766v101h-766v-101zM974 1058h-334v-100h334v100zM975 1311h-335v-100h335v100zM442 1508h1032q61 0 97 -33t36 -94v-1381h-1035q-60 0 -95 35t-35 94v1379z" />
+ <glyph glyph-name="articleCheck" unicode="&#xe101;"
+d="M1367 954v350h-279v-350h279zM937 1205v100h-332v-100h332zM937 954v99h-332v-99h332zM1369 702v100h-764v-100h764zM406 1501h1017q59 0 101 -42t42 -101v-812l-250 -250l-155 154h208v100h-764v-100h330l-82 -82l-77 -66h-171v-100h402l-96 96l129 129l268 -267
+l449 447q33 35 81 35t82 -35l47 -47l-401 -401v-159h-159l-99 -99l-99 99h-659q-60 0 -102 42t-42 101v1358z" />
+ <glyph glyph-name="articleSearch" unicode="&#xe102;"
+d="M1369 950v350h-279v-350h279zM939 1201v100h-332v-100h332zM939 950v99h-332v-99h332zM1381 698q152 0 252 -101t114 -229t-57 -235l-1 -1l245 -246l-41 -41q-29 -29 -70 -29t-69 29l-176 176q-86 -58 -199 -58q-152 0 -260 108q-107 108 -107 260t108 260
+q109 107 261 107zM1380 542q-85 0 -149 -62q-5 -3 -8 -8q-56 -63 -54 -147q1 -85 63 -145t148 -60t148 62t62 150t-62 148q-63 62 -148 62zM607 546v-100h320q-19 -76 -13 -148h-307v-100h324q33 -114 117 -198l5 -4h-501q-60 0 -102 42t-42 101v1358h1017q59 0 101 -42
+t42 -101v-596q-89 40 -188 40h-773v-100h484q-82 -64 -126 -152h-358z" />
+ <glyph glyph-name="uniE300" unicode="&#xe300;"
+d="M1115 906l-91 254l-90 -254l-244 116l116 -244l-254 -90l254 -91l-116 -244l244 116l90 -254l91 254l243 -116l-115 244l254 91l-254 90l115 244zM1048 1375q164 0 321 -92q158 -93 250 -251t92 -344t-92 -346q-92 -158 -250 -250q-157 -92 -321 -92t-291 54t-219 147
+q-92 91 -146 219t-55 268q1 140 55 267t146 219t219 146t291 55z" />
+ <glyph glyph-name="uniE301" unicode="&#xe301;"
+d="M1026 1103q-52 0 -89 -37t-37 -89t37 -89t89 -37t89 37t37 89t-37 89t-89 37zM1092 90q50 -12 74 -42t25 -64q2 -34 -28 -74t-70 -84q-40 -42 -52 -75q-11 33 -51 75t-70 84q-30 40 -28 74t26 62q24 30 72 42q-33 -18 -35 -48q-1 -30 39 -58t47 -51q7 21 30 37t41 32
+t15 44q-2 28 -35 46zM1027 1441q98 -47 166 -124q123 -139 144 -347t-31 -442l137 -348l-222 72q-27 -69 -49 -113h-296q-23 46 -48 111l-223 -68l135 365q-108 513 124 773q67 75 163 121z" />
+ <glyph glyph-name="uniE500" unicode="&#xe500;"
+d="M572 1081q-54 0 -53 19v99q0 19 28 33t67 14l189 17l69 78h296l72 -80l195 -15q39 0 67 -14t27 -33v-99q-1 -19 -53 -19h-904zM1324 -5h-599q-27 -1 -47 19t-20 47l-115 883h963l-114 -883q0 -26 -20 -46t-48 -20z" />
+ <glyph glyph-name="uniE501" unicode="&#xe501;"
+d="M1218 0h-490q-46 0 -60 46q-8 27 -11 51q-2 25 -3 30l821 820h34l-116 -882q-8 -49 -51 -59q-42 -10 -80 -8q-39 2 -44 2zM524 1101v99q0 18 28 32t67 14l188 16l68 78h298l70 -78l165 -12l217 218l105 -103l-1361 -1403l-125 125l367 367l-65 491h557l137 137h-621
+l-43 -1q-52 0 -52 20z" />
+ <glyph glyph-name="uniE502" unicode="&#xe502;"
+d="M1469 569v244h-892v-244h892zM1024 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92t-346 92q-160 92 -252 252q-92 159 -92 346t92 346q92 160 252 252q159 92 346 92z" />
+ <glyph glyph-name="uniE503" unicode="&#xe503;"
+d="M1469 566v245h-86l234 233q103 -172 95 -378t-115 -361q-108 -155 -266 -233q-159 -78 -335 -72t-327 96l470 470h330zM576 810v-244h179l244 244h-423zM1433 1242l275 275l121 -121l-1435 -1437l-120 124l192 195q-143 192 -133 438t168 427q115 131 279 191t338 35
+q174 -24 315 -127z" />
+ <glyph glyph-name="uniE504" unicode="&#xe504;"
+d="M633 52q-1 -31 -29 -43t-56 -9l-28 3v1340q142 13 366 -59q88 -28 170 -68t158 -49q188 -22 314 104v-714q-46 -77 -150 -113q-139 -49 -320 55q-268 176 -425 124v-571z" />
+ <glyph glyph-name="uniE505" unicode="&#xe505;"
+d="M1106 554l564 567v-569q-46 -77 -150 -112q-140 -47 -318 57q-47 31 -96 57zM778 50q0 -30 -28 -42t-56 -10l-28 3v115l112 111v-177zM1331 1166l311 309l119 -119l-1388 -1388l-119 120l412 415v837q190 11 442 -97q146 -62 223 -77z" />
+ <glyph glyph-name="uniE506" unicode="&#xe506;"
+d="M1345 685l-502 291v-581zM1025 1380q187 0 346 -92t251 -252q92 -159 93 -346q-1 -187 -93 -346q-92 -160 -251 -252t-346 -92t-347 92t-252 252q-92 159 -92 346t92 346q92 160 252 252t347 92z" />
+ <glyph glyph-name="uniE507" unicode="&#xe507;"
+d="M1281 432v517h-516v-517h516zM1024 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92t-346 92q-160 92 -252 252q-92 159 -92 346t92 346q92 160 252 252q159 92 346 92z" />
+ <glyph glyph-name="uniE508" unicode="&#xe508;"
+d="M1196 950q0 200 -172 200t-173 -199v-177h345v176zM1408 953v-179h175v-774h-975q-59 0 -101 42t-42 102v630h175v178q0 117 49 215t137 155t198 57q164 1 274 -123t110 -303z" />
+ <glyph glyph-name="uniE509" unicode="&#xe509;"
+d="M1408 1014h-212q0 94 -43 146t-131 52q-171 0 -171 -199v-239h732v-774h-975q-59 0 -101 42t-42 102v630h175v240q0 180 110 304t274 124t274 -124t110 -304z" />
+ <glyph glyph-name="uniE600" unicode="&#xe600;"
+d="M1024 1376q94 0 161 -50q131 -96 131 -280q0 -183 -86 -302t-206 -119t-206 119t-86 302q0 184 130 280q68 50 162 50zM1657 717v-717h-1122q-59 0 -101 42t-42 102v573h322q41 -85 130 -139t175 -54q85 0 177 52t139 141h322z" />
+ <glyph glyph-name="uniE601" unicode="&#xe601;"
+d="M1215 902q-36 0 -61 -25t-25 -61t25 -60q25 -26 61 -26t61 25t25 61t-25 61t-61 25zM833 902q-36 0 -61 -26t-25 -62t25 -60t61 -24t61 25t25 61t-25 60q-25 26 -61 26zM702 550q-22 0 -38 -16q-15 -16 -15 -38t16 -38q85 -86 219 -118t268 -4t226 107q20 17 26 41
+t-11 42t-39 20t-38 -13q-68 -65 -178 -89t-220 0t-178 91q-16 15 -38 15zM554 1282h1111l-1 -1120q0 -22 -12 -54q-38 -107 -157 -107l-1110 -1l-2 1119q0 57 42 110t129 53z" />
+ <glyph glyph-name="uniE602" unicode="&#xe602;"
+d="M1299 742q75 0 129 44t53 106q-65 -64 -181 -64t-183 64q-1 -62 53 -106t129 -44zM770 742q76 0 128 44t52 106q-66 -64 -180 -64t-182 64q0 -62 54 -106t128 -44zM789 500q-17 12 -39 7t-34 -23t-8 -40t24 -34q128 -81 292 -83t295 75q18 10 24 32t-3 41q-10 19 -32 25
+t-41 -6q-102 -62 -240 -60t-238 66zM554 1282h1111v-1119q0 -57 -42 -110t-129 -53h-1111v1119q0 57 42 110t129 53z" />
+ <glyph glyph-name="uniE700" unicode="&#xe700;"
+d="M1600 457l-181 540l-183 -540h364zM508 1413h182v-240h481l-59 -180h-422q53 -206 187 -354q30 -33 84 -87l-62 -180q-80 80 -95 96q-14 17 -50 55t-50 58t-48 64q-33 44 -59 113q-83 -266 -275 -422q-76 -62 -177 -117l-23 69q-16 48 1 94t60 73q78 62 125 108
+q46 46 82 105t56 111t55 154l7 60h-425v180h425v240zM1437 1173q47 0 84 -24t52 -67l391 -1161h-182l-122 358h-486l-121 -358h-182l425 1252h141z" />
+ <glyph glyph-name="uniE701" unicode="&#xe701;"
+d="M1666 676v150h-150v-150h150zM1441 676v150h-150v-150h150zM1666 414v150h-150v-150h150zM1216 676v150h-150v-150h150zM1441 414v150h-150v-150h150zM1666 152v150h-150v-150h150zM991 826h-150v-148l150 -2v150zM1216 414v150h-150v-150h150zM766 678v148h-150v-148
+h150zM991 414v150h-150v-150h150zM1441 152v150h-825v-150h825zM541 677v149h-149v-149h149zM766 414v150h-150v-150h150zM542 414v150h-150v-150h150zM542 152v150h-149v-150h149zM240 977h1433q59 -1 101 -43t43 -101v-833h-1433q-60 0 -102 42t-42 102v833z" />
+ <glyph glyph-name="uniE800" unicode="&#xe800;"
+d="M366 361q26 -18 78 -70l1004 908q-27 48 -81 66zM258 402l1113 998q125 -21 221 -124t103 -230l-1114 -1002l-412 -44z" />
+ <glyph glyph-name="uniE801" unicode="&#xe801;"
+d="M1161 1502q115 0 233 -50t166 -139q-353 -9 -564 -267l113 -171l-573 22l42 579l149 -168q218 194 434 194zM358 359q38 -25 77 -69l546 492l319 -10l-174 259q94 95 222 147t269 60q66 -137 71 -191l-1115 -1004l-412 -45l89 402l210 189t228 205l147 -6z" />
+ <glyph glyph-name="uniE802" unicode="&#xe802;"
+d="M358 359q38 -25 77 -69l546 492l319 -10l-174 259q94 95 222 147t269 60q66 -137 71 -191l-1115 -1004l-412 -45l89 402l210 189t228 205l147 -6z" />
+ <glyph glyph-name="uniE803" unicode="&#xe803;"
+d="M1161 1502q115 0 233 -50t166 -139q-353 -9 -564 -267l113 -171l-573 22l42 579l149 -168q218 194 434 194z" />
+ <glyph glyph-name="uniE804" unicode="&#xe804;"
+d="M1468 1302q-88 0 -88 -101l1 -89h175l-1 89q1 101 -87 101zM1468 1419q83 0 139 -63t56 -154v-90h88v-392h-566v392h89v91q0 91 56 153t138 63zM1088 622h138l-644 -580l-413 -45l88 403l831 743v-133l-722 -652q26 -18 78 -70l644 582v-248z" />
+ <glyph glyph-name="uniE805" unicode="&#xe805;"
+d="M1088 622h138l-644 -580l-413 -45l88 403l831 743v-133l-722 -652q26 -18 78 -70l644 582v-248z" />
+ <glyph glyph-name="uniE806" unicode="&#xe806;"
+d="M1468 1302q-88 0 -88 -101l1 -89h175l-1 89q1 101 -87 101zM1468 1419q83 0 139 -63t56 -154v-90h88v-392h-566v392h89v91q0 91 56 153t138 63z" />
+ <glyph glyph-name="uniE810" unicode="&#xe810;"
+d="M294 1277h1581v-1116q-2 -17 -4 -24q-2 -8 -5 -21q-3 -14 -9 -26q-7 -12 -15 -26q-41 -64 -130 -64h-1673l255 283v994z" />
+ <glyph glyph-name="uniE811" unicode="&#xe811;"
+d="M679 938h1066v-730l186 -208h-1134q-82 0 -109 74q-9 25 -9 44v820zM230 1337h1068v-305h-714v-633h-541l187 209v729z" />
+ <glyph glyph-name="uniE812" unicode="&#xe812;"
+d="M1171 727v267h-172v-267h-269v-165h269v-279h172v279h270v165h-270zM294 1277h1581v-1116q-2 -17 -4 -24q-2 -8 -5 -21q-3 -14 -9 -26q-7 -12 -15 -26q-41 -64 -130 -64h-1673l255 283v994z" />
+ <glyph glyph-name="uniE813" unicode="&#xe813;"
+d="M1289 1034q-38 0 -65 -27t-27 -65t27 -64t65 -27q38 1 65 27t27 64t-27 65t-65 27zM908 1034q-38 0 -64 -27t-27 -65q1 -38 27 -64t64 -27q38 1 65 27t27 64t-27 65t-65 27zM1109 625q-112 1 -236 27t-192 52l-68 26q0 -132 65 -244t177 -177q111 -65 227 -65t206 38
+t155 104q65 65 103 155t39 189q-236 -105 -476 -105zM294 1278h1581v-1117q-19 -152 -148 -161h-1688l255 285v993z" />
+ <glyph glyph-name="uniE820" unicode="&#xe820;"
+d="M678 858q161 134 339 100q122 -22 204 -111q27 -29 25 -71t-33 -70q-62 -56 -71 -62q-17 70 -75 111q-57 41 -129 39t-125 -52l-238 -221q-66 -65 -65 -151t59 -144q101 -103 247 -14q38 23 65 49q28 27 49 29t78 -15t132 -8q-16 -15 -64 -61t-70 -66q-86 -76 -148 -104
+q-167 -76 -321 4q-128 67 -175 209q-48 142 12 277q30 69 88 122q20 18 92 92t124 118zM1272 1320q47 12 110 7q63 -4 130 -39t110 -88q91 -112 85 -263q-5 -151 -119 -257q-26 -23 -94 -93q-140 -143 -240 -191q-100 -49 -222 -26t-204 112q-26 30 -24 71t32 69
+q63 57 72 62q17 -70 75 -111q57 -41 129 -38t124 52l238 220q66 66 66 151q0 86 -49 134q-85 85 -170 61t-161 -94q-33 -31 -83 -17q-51 14 -79 20t-88 -1q16 14 64 61q48 46 70 66t68 55t80 50t80 27z" />
+ <glyph glyph-name="uniE830" unicode="&#xe830;"
+d="M1760 1116q0 -45 -26 -82t-68 -53q-104 -39 -177 -125t-101 -195h372v-514q0 -61 -44 -105t-106 -45h-513v355q1 126 15 226t57 210t122 202t189 152t280 87v-113zM924 1116q0 -44 -27 -82t-70 -53q-104 -39 -176 -125t-100 -195h372v-514q0 -62 -44 -106t-105 -44h-514
+v355q2 126 16 226t56 210q43 110 122 201q79 92 189 152t281 88v-113z" />
+ <glyph glyph-name="uniE831" unicode="&#xe831;"
+d="M1522 1515h166v-269h279v-166h-279v-280h-166v280h-269v166h269v269zM1420 980v-122q-73 -82 -103 -196h372v-517q1 -62 -43 -106t-106 -44h-514v342q2 202 42 359t146 284h206zM851 1116q0 -45 -26 -82t-68 -53q-105 -41 -177 -125t-100 -194h371v-514q0 -62 -44 -108
+t-105 -45h-513v359q3 306 99 496q95 190 283 292q109 60 280 88v-114z" />
+ <glyph glyph-name="uniE840" unicode="&#xe840;"
+d="M1441 347v655h-872v-655h872zM660 438l218 291l200 -200l109 91l163 -182h-690zM160 1294h1727v-1294h-1727v1294z" />
+ <glyph glyph-name="uniE841" unicode="&#xe841;"
+d="M1854 1184q0 101 -87 101t-87 -101v-89h174v89zM1767 1401q63 1 113 -39q88 -70 82 -238v-29h87v-390h-564v390h84q-1 113 17 170t68 97t113 39zM1591 167v1l-301 264l-200 -118l-368 385l-60 -24l-389 -508h1318zM108 -2v1276h1379q-15 -37 -16 -77h-88v-593h386v-606
+h-1661z" />
+ <glyph glyph-name="uniE842" unicode="&#xe842;"
+d="M1591 167v1l-301 264l-200 -118l-368 385l-60 -24l-389 -508h1318zM108 -2v1276h1379q-15 -37 -16 -77h-88v-593h386v-606h-1661z" />
+ <glyph glyph-name="uniE843" unicode="&#xe843;"
+d="M1854 1184q0 101 -87 101t-87 -101v-89h174v89zM1767 1401q63 1 113 -39q88 -70 82 -238v-29h87v-390h-564v390h84q-1 113 17 170t68 97t113 39z" />
+ <glyph glyph-name="uniE844" unicode="&#xe844;"
+d="M1600 1546h171v-268h271v-164h-271v-279h-171v279h-269v164h269v268zM1085 315l-368 386l-59 -25l-389 -508l1317 1l-301 265zM104 0v1276h1121v-267h269v-269h270v-740h-1660z" />
+ <glyph glyph-name="uniE845" unicode="&#xe845;"
+d="M1085 315l-368 386l-59 -25l-389 -508l1317 1l-301 265zM104 0v1276h1121v-267h269v-269h270v-740h-1660z" />
+ <glyph glyph-name="uniE846" unicode="&#xe846;"
+d="M1600 1546h171v-268h271v-164h-271v-279h-171v279h-269v164h269v268z" />
+ <glyph glyph-name="uniE847" unicode="&#xe847;"
+d="M1336 1087v487h-650v-487h650zM753 1154l163 217l149 -149l81 68l122 -136h-515zM381 1792h1286v-2048h-1286v2048z" />
+ <glyph glyph-name="uniE848" unicode="&#xe848;"
+d="M1418 890h-460l-107 108h-271v-567l82 -82h756v541zM1887 1294v-1294h-1727v1294h1727z" />
+ <glyph glyph-name="uniE849" unicode="&#xe849;"
+d="M694 1571v-422l61 -61h563v403h-342l-81 80h-201zM381 1792h1286v-2048h-1286v2048z" />
+ <glyph glyph-name="uniE850" unicode="&#xe850;"
+d="M1507 1176v248h159v-248h193q25 0 42 -17t17 -42v-94h-252v-259h-160v259h-248v153h249zM131 193v983h1032v-155h-880v-724q-1 -49 35 -85t86 -36h1102v463h156v-639h-1338q-80 0 -136 57t-57 136z" />
+ <glyph glyph-name="uniE851" unicode="&#xe851;"
+d="M1023 1237q-143 -1 -243 -101t-100 -242t100 -243t244 -101q142 0 242 101t101 243q0 142 -101 242t-243 101zM1187 1507q163 -43 285 -165t164 -284q42 -164 0 -326q-44 -162 -166 -284l-448 -448l-447 445q-122 121 -165 285t1 326q44 163 166 285q123 121 285 165
+t325 1z" />
+ <glyph glyph-name="uniE852" unicode="&#xe852;"
+d="M1557 1689h127v-198h153q19 1 33 -13t14 -34v-75h-200v-206h-128v206h-197v122h198v198zM1026 1237q-142 -1 -242 -101t-101 -242q1 -142 101 -243t242 -101t243 101t101 243t-101 242t-243 101zM1269 1280h198v-206h167q49 -160 7 -330t-168 -296l-448 -448l-447 445
+q-122 121 -165 285t1 326q44 163 174 293t320 167t361 -36v-200z" />
+ <glyph glyph-name="uniE853" unicode="&#xe853;"
+d="M1026 1237q-142 -1 -242 -101t-101 -242q1 -142 101 -243t242 -101t243 101t101 243t-101 242t-243 101zM1269 1280h198v-206h167q49 -160 7 -330t-168 -296l-448 -448l-447 445q-122 121 -165 285t1 326q44 163 174 293t320 167t361 -36v-200z" />
+ <glyph glyph-name="uniE854" unicode="&#xe854;"
+d="M1557 1689h127v-198h153q19 1 33 -13t14 -34v-75h-200v-206h-128v206h-197v122h198v198z" />
+ <glyph glyph-name="uniE870" unicode="&#xe870;"
+d="M639 706h766v100h-766v-100zM639 453h766v98h-766v-98zM639 201h766v101h-766v-101zM974 1058h-335v-100h335v100zM974 1311h-335v-100h335v100zM1406 1508h67q61 0 97 -33t36 -94v-1381h-1035q-60 0 -95 35t-35 94v1379h687v-547l137 173l141 -173v547z" />
+ <glyph glyph-name="uniE871" unicode="&#xe871;"
+d="M1667 319v918h-358q-59 -1 -101 -43t-42 -101v-779q23 5 42 5h459zM1208 1438h661v-1321h-661q-73 -1 -126 -51t-58 -122q-4 72 -58 122t-127 51h-660v1321h660q73 0 127 -50t58 -122q5 72 58 122t126 50z" />
+ <glyph glyph-name="uniE872" unicode="&#xe872;"
+d="M1404 1205v100h-494v-100h494zM1252 952v100h-342v-100h342zM443 1506h151v-1506h-151v1506zM707 1506h899v-1329q0 -65 -61 -121t-129 -56h-709v1506z" />
+ <glyph glyph-name="uniE873" unicode="&#xe873;"
+d="M1002 1137q-32 1 -54 -21t-21 -54t23 -54t53 -22t53 22t22 53t-22 53t-54 23zM750 1137q-32 1 -54 -21t-21 -54t23 -54t53 -22t53 22t22 53t-22 53t-54 23zM497 1137q-31 1 -53 -21t-22 -54t22 -54t54 -22t54 22t21 53t-23 53t-53 23zM1628 148v736h-1206v-646
+q0 -38 26 -64t64 -26h1116zM272 1239h1505v-1239h-1361q-60 0 -102 42t-42 102v1095z" />
+ <glyph glyph-name="uniE874" unicode="&#xe874;"
+d="M1611 858v175h-902v-175h902zM1611 203v504h-309v-504h309zM1151 607v100h-441v-100h441zM1151 405v100h-441v-100h441zM1151 202v100h-441v-100h441zM307 311q0 -132 100 -132q49 -1 75 35t26 97v921h1158q60 0 102 -42t42 -102v-1088h-1403q-127 0 -189 74t-62 233v510
+h248v-152h-97v-354z" />
+ <glyph glyph-name="uniE875" unicode="&#xe875;"
+d="M1718 1237q-36 25 -82 25t-81 -27q-199 -149 -268 -379q-38 -123 -27 -213q176 51 322 163q147 113 191 285q9 41 -6 81t-49 65zM26 151h1995v-150h-1995v150zM293 715l171 171l102 -102l-171 -172l172 -172l-102 -102l-173 172l-165 -166l-101 102l164 166l-165 166
+l102 102zM2023 362v-56l-147 -1q7 83 -7 92q-54 35 -232 -29q-38 -14 -51 -18q-108 -35 -230 -7t-189 125q-163 -29 -447 -20v150q263 -6 393 14q-21 136 26 292t145 284q98 130 222 194q151 80 306 -36q84 -62 110 -178q12 -54 -8 -134t-79 -168q-58 -89 -137 -156
+q-155 -132 -361 -199q104 -61 235 -7q65 26 134 40h1q131 22 199 -1q87 -30 106 -100q11 -42 11 -81z" />
+ <glyph glyph-name="uniE876" unicode="&#xe876;"
+d="M202 1310h-80v45q93 3 93 60h55v-339h-68v234zM340 749q0 -43 -18 -67t-55 -51l-27 -19q-40 -28 -51 -45h151v-60h-236q0 35 15 69t65 70t68 54q18 20 19 44q-1 24 -13 38q-12 15 -34 15q-50 0 -50 -65h-66q3 123 112 123q54 0 88 -31q32 -32 32 -75zM114 176
+q7 106 106 106q47 0 77 -24q29 -26 29 -61t-14 -51t-26 -20q8 0 29 -20t21 -60t-28 -72t-93 -31q-102 0 -107 109h64q0 -57 49 -57q18 0 33 13q14 13 14 38q0 50 -73 50v46q33 0 49 8t16 37q0 42 -43 42t-43 -53h-60zM1944 226v-226h-1435v226h1435zM1944 794v-226h-1435
+v226h1435zM1827 1359q49 -1 83 -35t34 -83v-108h-1435v226h1318z" />
+ <glyph glyph-name="uniE877" unicode="&#xe877;"
+d="M520 787h1414v-223h-1414v223zM243 1369q54 0 92 -38q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM520 223h1414v-223h-1414v223zM243 805q54 0 92 -38q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM243 241q54 0 92 -38
+q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM520 1351h1298q48 0 82 -34t34 -82v-107h-1414v223z" />
+ <glyph glyph-name="uniE878" unicode="&#xe878;"
+d="M286 664l313 290v-555zM805 585h583v-185h-583v185zM805 954h957v-187h-957v187zM284 186h1480v-186h-1382q-42 0 -70 28t-28 69v89zM284 1353h1381q41 -1 69 -29t28 -68v-90h-1478v187z" />
+ <glyph glyph-name="uniE879" unicode="&#xe879;"
+d="M804 954h958v-186h-958v186zM804 586h584v-186h-584v186zM284 399v555l314 -265zM283 186h1481v-186h-1383q-40 0 -69 28t-29 69v89zM283 1354h1381q40 0 69 -28t29 -69v-90h-1479v187z" />
+ <glyph glyph-name="uniE900" unicode="&#xe900;"
+d="M1653 945l105 57l78 -145l142 77l58 -108l-144 -77l76 -141l-106 -58l-76 142l-143 -74l-58 106l144 77zM1279 867l63 155q121 -52 209 -77l-47 -147l-4 -14zM965 1004l66 156q149 -62 211 -94l-69 -156zM590 1358q64 52 148 45q84 -8 136 -72q54 -63 46 -147t-72 -137
+t-148 -45t-136 72t-45 148t71 136zM413 1119q-100 0 -170 -50l-96 141q116 76 266 79v-170zM44 1062l156 -69q-33 -84 -5 -157l-159 -60q-54 148 8 286zM398 596l-115 -125q-131 126 -167 168l125 113q102 -107 157 -156zM467 520q93 76 211 66t194 -104q76 -92 66 -210
+q-11 -118 -103 -194q-93 -75 -211 -66q-118 10 -194 102t-66 210q10 120 103 196z" />
+ <glyph glyph-name="uniE901" unicode="&#xe901;"
+d="M1155 351q-55 -55 -135 -57t-138 56t-57 139q1 80 57 135q56 54 136 56t138 -56t56 -138t-57 -135zM1573 491q0 -165 -81 -297t-219 -202q-138 -72 -298 -59q-203 16 -347 161t-159 338q-14 193 83 346q55 87 91 121q39 -35 70 -67t31 -35l38 -38l37 37l36 -140l-141 36
+l37 37l-33 33q-22 -24 -59 -94q-37 -71 -43 -156h45v53l126 -74l-126 -74v52h-46q4 -143 100 -253l36 37l-37 37l141 36l-36 -141l-37 37l-37 -37q107 -99 253 -108v53h-52l74 125l74 -125h-53v-53q146 7 255 107l-38 38l-37 -37l-36 141l141 -36l-37 -37l37 -38
+q97 109 101 254h-49v-52l-125 74l125 74v-53h48q-11 143 -107 247l-30 -30l37 -37l-141 -36l36 140l37 -37l30 30q-57 49 -124 74l-22 10q-21 10 -50 23t-41 20q-86 50 -128 177l-10 52q0 2 -58 -16t-58 -17q25 58 153 271l44 75l183 -346q-7 3 -19 7l-38 13q-58 20 -56 13
+q13 -54 47 -85q26 -28 115 -62q155 -62 251 -202t96 -305z" />
+ <glyph glyph-name="uniE902" unicode="&#xe902;"
+d="M1966 1184q0 -22 -9 -31q-95 -10 -150 -107q-13 -23 -32 -64l-434 -940q-13 -22 -21 -32t-22 -10q-30 0 -43 42l-241 516l-266 -518q-18 -39 -51 -39t-49 41l-402 943q-45 106 -71 135t-95 33q-9 9 -10 31t8 30q49 -2 195 -2t202 2q9 -8 9 -30t-9 -31q-85 -9 -99 -39
+t24 -109l337 -770h8l208 412l-159 337q-48 102 -74 130t-82 38q-9 8 -10 30t8 31q40 -1 166 -1t166 1q9 -9 9 -31t-9 -30q-56 -9 -58 -39t38 -109l94 -181l91 176q42 79 44 105q3 26 -11 34t-44 13q-6 9 -5 31t8 31q37 -1 141 -1t139 1q9 -9 9 -31t-9 -30q-65 -10 -103 -50
+t-79 -119l-124 -261l236 -484h8l349 758q37 75 22 113t-101 44q-9 9 -9 31t9 30q52 -2 176 -2t168 2q9 -8 9 -30z" />
+ <glyph glyph-name="uniE903" unicode="&#xe903;"
+d="M1132 599q0 -106 86 -106q46 0 69 52l75 -38q-56 -94 -148 -94t-140 50t-49 136q-1 87 49 137t127 50q111 0 159 -87l-80 -42q-21 48 -67 48q-81 0 -81 -106zM1021 1091q-131 -1 -245 -67t-180 -180q-68 -114 -68 -244q0 -131 68 -244q68 -114 181 -180q114 -66 241 -66
+q128 0 240 61t178 161t74 225q18 231 -133 382q-152 152 -356 152zM786 599q0 -106 86 -106q46 0 69 52l74 -38q-52 -94 -158 -94q-81 0 -130 49t-49 136q0 88 50 138t126 50q110 0 160 -87l-81 -42q-21 48 -66 48q-81 0 -81 -106zM1020 1200q121 0 232 -48t192 -127
+q80 -79 128 -191t48 -254t-81 -280t-219 -219t-300 -81q-161 0 -299 82q-137 82 -219 220t-82 278t48 251t129 192t191 129t232 48z" />
+ <glyph glyph-name="newGlyph"
+d="M1378 -5h-765v-101h765v101zM1378 243h-765v-97h765v97zM1378 499h-765v-100h765v100zM947 751h-334v-100h334v100zM1380 1004h-280v-353h280v353zM947 1004h-334v-100l334 -1v101zM415 1200h1031q62 0 98 -33t36 -94v-1381h-1035q-59 0 -95 35t-35 94v1379z" />
+ </font>
+</defs></svg>
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf b/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf
new file mode 100644
index 00000000..3d767428
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf
Binary files differ
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.woff b/Flow/modules/wikiglyph/WikiFont-Glyphs.woff
new file mode 100644
index 00000000..7186d17e
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.woff
Binary files differ
diff --git a/Flow/modules/wikiglyph/flow-override.less b/Flow/modules/wikiglyph/flow-override.less
new file mode 100644
index 00000000..9a1da7b8
--- /dev/null
+++ b/Flow/modules/wikiglyph/flow-override.less
@@ -0,0 +1,7 @@
+
+// Flow overrides for the distributed wikiglyph css
+.mediawiki .wikiglyph {
+ height: .7em;
+ font-size: 1.6em;
+ line-height: .7em;
+}
diff --git a/Flow/modules/wikiglyph/wikiglyphs.css b/Flow/modules/wikiglyph/wikiglyphs.css
new file mode 100644
index 00000000..6ccde1e2
--- /dev/null
+++ b/Flow/modules/wikiglyph/wikiglyphs.css
@@ -0,0 +1,359 @@
+@font-face {
+ font-family: 'WikiFont-Glyphs';
+ src: url('WikiFont-Glyphs.eot'); /* IE9 Compat Modes */
+ src: url('WikiFont-Glyphs.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
+ url('WikiFont-Glyphs.woff') format('woff'), /* Modern Browsers */
+ url('WikiFont-Glyphs.ttf') format('truetype'), /* Safari, Android, iOS */
+ url('WikiFont-Glyphs.svg#8088f7bbbdba5c9832b27edb3dfcdf09') format('svg'); /* Legacy iOS */
+}
+
+
+.wikiglyph {
+ display: inline-block;
+ height: 1.10em;
+ font-family: 'WikiFont-Glyphs';
+ -webkit-font-smoothing: antialiased;
+ font-size: inherit;
+ font-style: normal;
+ font-weight: normal;
+ line-height: 1em;
+ overflow: visible;
+ vertical-align: text-bottom;
+}
+
+.wikiglyph[dir='rtl'] {
+ filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+}
+/* UI ELEMENTS e000-023
+*/
+
+.wikiglyph-magnifying-glass:before {
+ content: "\e000";
+}
+.wikiglyph-arrow-left:before {
+ content: "\e001";
+}
+.wikiglyph-tick:before {
+ content: "\e002";
+}
+.wikiglyph-x:before {
+ content: "\e003";
+}
+.wikiglyph-x-circle:before {
+ content: "\e004";
+}
+.wikiglyph-unstar:before {
+ content: "\e005";
+}
+.wikiglyph-star:before {
+ content: "\e006";
+}
+.wikiglyph-star-list:before {
+ content: "\e007";
+}
+.wikiglyph-sun:before {
+ content: "\e008";
+}
+.wikiglyph-funnel:before {
+ content: "\e009";
+}
+.wikiglyph-eye:before {
+ content: "\e010";
+}
+.wikiglyph-eye-lid:before {
+ content: "\e011";
+}
+.wikiglyph-bookmark:before {
+ content: "\e012";
+}
+.wikiglyph-printer:before {
+ content: "\e013";
+}
+.wikiglyph-puzzle:before {
+ content: "\e014";
+}
+.wikiglyph-clock:before {
+ content: "\e015";
+}
+.wikiglyph-dice:before {
+ content: "\e016";
+}
+.wikiglyph-move:before {
+ content: "\e017";
+}
+.wikiglyph-gear:before {
+ content: "\e018";
+}
+.wikiglyph-ellipsis:before {
+ content: "\e019";
+}
+.wikiglyph-envelope:before {
+ content: "\e020";
+}
+.wikiglyph-pin:before {
+ content: "\e021";
+}
+.wikiglyph-share:before {
+ content: "\e022";
+}
+.wikiglyph-download:before {
+ content: "\e023";
+}
+.wikiglyph-bell:before {
+ content: "\e025";
+}
+.wikiglyph-bell-ring:before {
+ content: "\e026";
+}
+.wikiglyph-caret-left:before {
+ content: "\e027";
+}
+.wikiglyph-caret-down:before {
+ content: "\e028";
+}
+.wikiglyph-ribbon:before {
+ content: "\e029";
+}
+.wikiglyph-key:before {
+ content: "\e030";
+}
+.wikiglyph-gallery:before {
+ content: "\e031";
+}
+.wikiglyph-map:before {
+ content: "\e032";
+}
+.wikiglyph-heart:before {
+ content: "\e033";
+}
+
+
+/* EXPERIMENTS e300-301
+*/
+.wikiglyph-star-circle:before {
+ content: "\e300";
+}
+.wikiglyph-rocket:before {
+ content: "\e301";
+}
+
+
+/* STRIPES e040-043
+*/
+.wikiglyph-stripe-compact:before {
+ content: "\e040";
+}
+.wikiglyph-stripe-toc:before {
+ content: "\e041";
+}
+.wikiglyph-stripe-expanded:before {
+ content: "\e042";
+}
+
+
+/* UI MODERATION ELEMENTS e500-508
+*/
+
+.wikiglyph-article:before {
+ content: "\e100";
+}
+.wikiglyph-article-check:before {
+ content: "\e101";
+}
+.wikiglyph-article-search:before {
+ content: "\e102";
+}
+.wikiglyph-trash:before {
+ content: "\e500";
+}
+.wikiglyph-trash-slash:before {
+ content: "\e501";
+}
+.wikiglyph-block:before {
+ content: "\e502";
+}
+.wikiglyph-block-slash:before {
+ content: "\e503";
+}
+.wikiglyph-flag:before {
+ content: "\e504";
+}
+.wikiglyph-flag-slash:before {
+ content: "\e505";
+}
+.wikiglyph-play:before {
+ content: "\e506";
+}
+.wikiglyph-stop:before {
+ content: "\e507";
+}
+.wikiglyph-lock:before {
+ content: "\e508";
+}
+.wikiglyph-unlock:before {
+ content: "\e509";
+}
+
+/* USER e600-602
+*/
+.wikiglyph-user-bust:before {
+ content: "\e600";
+}
+.wikiglyph-user-smile:before {
+ content: "\e601";
+}
+.wikiglyph-user-sleep:before {
+ content: "\e602";
+}
+
+
+/* TRANSLATION e700
+*/
+.wikiglyph-translate:before {
+ content: "\e700";
+}
+.wikiglyph-keyboard:before {
+ content: "\e701";
+}
+
+
+/* CONTRIBUTION e800-845
+*/
+.wikiglyph-pencil:before {
+ content: "\e800";
+}
+.wikiglyph-pencil-revert-full:before {
+ content: "\e801";
+}
+.wikiglyph-pencil-revert-pt1:before {
+ content: "\e802";
+}
+.wikiglyph-pencil-revert-pt2:before {
+ content: "\e803";
+}
+.wikiglyph-pencil-lock-full:before {
+ content: "\e804";
+}
+.wikiglyph-pencil-lock-pt1:before {
+ content: "\e805";
+}
+.wikiglyph-pencil-lock-pt2:before {
+ content: "\e806";
+}
+.wikiglyph-speech-bubble:before {
+ content: "\e810";
+}
+.wikiglyph-speech-bubbles:before {
+ content: "\e811";
+}
+.wikiglyph-speech-bubble-add:before {
+ content: "\e812";
+}
+.wikiglyph-speech-bubble-smile:before {
+ content: "\e813";
+}
+.wikiglyph-link:before {
+ content: "\e820";
+}
+.wikiglyph-quotes:before {
+ content: "\e830";
+}
+.wikiglyph-quotes-add:before {
+ content: "\e831";
+}
+.wikiglyph-image:before {
+ content: "\e840";
+}
+.wikiglyph-image-lock-full:before {
+ content: "\e841";
+}
+.wikiglyph-image-lock-pt1:before {
+ content: "\e842";
+}
+.wikiglyph-image-lock-pt2:before {
+ content: "\e843";
+}
+.wikiglyph-image-add-full:before {
+ content: "\e844";
+}
+.wikiglyph-image-add-pt1:before {
+ content: "\e845";
+}
+.wikiglyph-image-add-pt2:before {
+ content: "\e846";
+}
+.wikiglyph-image-main-placeholder:before {
+ content: "\e847";
+}
+.wikiglyph-folder:before {
+ content: "\e848";
+}
+.wikiglyph-folder-main-placeholder:before {
+ content: "\e849";
+}
+.wikiglyph-template-add:before {
+ content: "\e850";
+}
+.wikiglyph-pin:before {
+ content: "\e851";
+}
+.wikiglyph-pin-add:before {
+ content: "\e852";
+}
+.wikiglyph-pin-add-pt1:before {
+ content: "\e853";
+}
+.wikiglyph-pin-add-pt2:before {
+ content: "\e854";
+}
+.wikiglyph-cite:before {
+ content: "\e870";
+}
+.wikiglyph-book:before {
+ content: "\e871";
+}
+.wikiglyph-journal:before {
+ content: "\e872";
+}
+.wikiglyph-web:before {
+ content: "\e873";
+}
+.wikiglyph-news:before {
+ content: "\e874";
+}
+.wikiglyph-signature:before {
+ content: "\e875";
+}
+.wikiglyph-list-sorted:before {
+ content: "\e876";
+}
+.wikiglyph-list-unsorted:before {
+ content: "\e877";
+}
+.wikiglyph-indent-left:before {
+ content: "\e878";
+}
+.wikiglyph-indent-right:before {
+ content: "\e879";
+}
+
+/* WIKI-X e900+
+*/
+.wikiglyph-wikitrail:before {
+ content: "\e900";
+}
+.wikiglyph-ccmark:before {
+ content: "\e903";
+}
+.wikiglyph-cmark:before {
+ content: "\e901";
+}
+.wikiglyph-wmark:before {
+ content: "\e902";
+}
+
diff --git a/Flow/package.json b/Flow/package.json
new file mode 100644
index 00000000..0d7f59a5
--- /dev/null
+++ b/Flow/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "flow",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Build tools for Flow.",
+ "scripts": {
+ "test": "grunt test"
+ },
+ "devDependencies": {
+ "grunt": "0.4.5",
+ "grunt-contrib-csslint": "0.2.0",
+ "grunt-contrib-jshint": "0.10.0",
+ "grunt-contrib-watch": "0.6.1",
+ "grunt-banana-checker": "0.2.0",
+ "grunt-jscs": "0.7.1",
+ "jshint": "~2.5.0"
+ }
+}
diff --git a/Flow/scripts/.htaccess b/Flow/scripts/.htaccess
new file mode 100644
index 00000000..3a428827
--- /dev/null
+++ b/Flow/scripts/.htaccess
@@ -0,0 +1 @@
+Deny from all
diff --git a/Flow/scripts/analyze-phpstorm.sh b/Flow/scripts/analyze-phpstorm.sh
new file mode 100755
index 00000000..4dc81a41
--- /dev/null
+++ b/Flow/scripts/analyze-phpstorm.sh
@@ -0,0 +1,61 @@
+#!/bin/sh
+
+# the realpath executable isn't guaranteed, so define one.
+# FIXME: path with ' in it will break
+realpath() {
+ php -r "echo realpath('$1'), \"\\n\";"
+}
+
+if [ ! -x "$(which xmllint)" ]; then
+ echo xmllint is required to filter results
+ exit 1
+fi
+
+# specifying directly kinda sucks
+if [ ! -d "$PHPSTORM_BIN_HOME" ]; then
+ PHPSTORM_BIN_HOME="$(realpath ~/PhpStorm-133.803/bin)"
+fi
+
+if [ ! -x "$PHPSTORM_BIN_HOME/inspect.sh" ]; then
+ echo Could not locate PhpStorm.
+ echo Set PHPSTORM_BIN_HOME to the bin dir inside the uncompressed phpstorm executable
+ echo If you need a license check https://office.wikimedia.org/wiki/JetBrains
+ exit 1
+fi
+
+# all paths must be absolute
+EXTENSION="$(dirname $(dirname $(realpath $0)))"
+
+# Path to the main project
+MEDIAWIKI="$(realpath $EXTENSION/../..)"
+
+# Output path
+OUTPUT="/tmp/phpstorm-inspect.$$"
+
+$PHPSTORM_BIN_HOME/inspect.sh $MEDIAWIKI $EXTENSION/scripts/analyze-phpstorm.xml $OUTPUT -d $EXTENSION/includes -v2
+
+EXIT=0
+if [ $(find $OUTPUT | wc -l) -gt 1 ]; then
+ # @todo format the xml
+ for i in $OUTPUT/*; do
+ # Filter errors in api, its currently just a stub
+ xmllint --xpath "//problem[not(file[contains(text(), '/Flow/includes/api/')])]" "$i" 2>/dev/null > "$OUTPUT/tmp.out"
+ if [ -s "$OUTPUT/tmp.out" ]; then
+ EXIT=1
+ echo $i
+ echo
+ cat "$OUTPUT/tmp.out"
+ echo
+ echo
+ fi
+ done
+ test -f "$OUTPUT/tmp.out" && rm "$OUTPUT/tmp.out"
+fi
+
+if [ $EXIT -eq 0 ]; then
+ rm -rf $OUTPUT
+else
+ echo XML output stored in $OUTPUT
+fi
+
+exit $EXIT
diff --git a/Flow/scripts/analyze-phpstorm.xml b/Flow/scripts/analyze-phpstorm.xml
new file mode 100644
index 00000000..f1295f03
--- /dev/null
+++ b/Flow/scripts/analyze-phpstorm.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<inspections version="1.0" is_locked="false">
+ <option name="myName" value="Project Default" />
+ <option name="myLocal" value="false" />
+ <inspection_tool class="InconsistentLineSeparators" enabled="true" level="WARNING" enabled_by_default="true" />
+ <inspection_tool class="PhpAssignmentInConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" />
+ <inspection_tool class="PhpDivisionByZeroInspection" enabled="true" level="WARNING" enabled_by_default="true" />
+ <inspection_tool class="PhpIllegalArrayKeyTypeInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PhpIncludeInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PhpUndefinedMethodInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PhpUnusedParameterInspection" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="DONT_REPORT_PARAMETERS_COUNT_ACCESS" value="true" />
+ </inspection_tool>
+ <inspection_tool class="PhpUsageOfSilenceOperatorInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
+ <inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
+ <option name="processCode" value="true" />
+ <option name="processLiterals" value="true" />
+ <option name="processComments" value="true" />
+ </inspection_tool>
+</inspections>
+
diff --git a/Flow/scripts/gen-autoload.php b/Flow/scripts/gen-autoload.php
new file mode 100644
index 00000000..9ea48abe
--- /dev/null
+++ b/Flow/scripts/gen-autoload.php
@@ -0,0 +1,22 @@
+<?php
+
+require_once __DIR__ . '/../../../includes/utils/AutoloadGenerator.php';
+
+function main() {
+ $base = dirname( __DIR__ );
+ $generator = new AutoloadGenerator( $base );
+ foreach ( array( 'includes', 'tests/phpunit', 'vendor' ) as $dir ) {
+ $generator->readDir( $base . '/' . $dir );
+ }
+ foreach ( glob( $base . '/*.php' ) as $file ) {
+ $generator->readFile( $file );
+ }
+ // read entire maint dir, move helper to includes? to core?
+ $generator->readFile( $base . '/maintenance/MaintenanceDebugLogger.php' );
+
+ $generator->generateAutoload( basename( __DIR__ ) . '/' . basename( __FILE__ ) );
+
+ echo "Done.\n\n";
+}
+
+main();
diff --git a/Flow/scripts/generatecss.php b/Flow/scripts/generatecss.php
new file mode 100755
index 00000000..22e2a28a
--- /dev/null
+++ b/Flow/scripts/generatecss.php
@@ -0,0 +1,29 @@
+<?php
+if ( sizeof( $argv ) < 3 ) {
+ print "Call with 2 arguments: the path to the load url and the file to output to";
+ exit();
+}
+$loadUrl = $argv[1];
+$outputFile = $argv[2];
+
+define( 'MEDIAWIKI', true );
+const NS_MAIN = 0;
+$wgVersion = 1.23;
+$wgSpecialPages = array();
+$wgResourceModules = array();
+
+include "Resources.php";
+
+$query = array();
+$blacklist = array(
+);
+foreach( $wgResourceModules as $moduleName => $def ) {
+ if ( !in_array( $moduleName, $blacklist ) ) {
+ $query[] = $moduleName;
+ }
+}
+
+$url = $loadUrl . '?only=styles&skin=vector&modules=' . implode( '|', $query );
+echo $url;
+$css = file_get_contents($url);
+file_put_contents( $outputFile, $css );
diff --git a/Flow/scripts/hooks-shared.sh b/Flow/scripts/hooks-shared.sh
new file mode 100644
index 00000000..617b4a7b
--- /dev/null
+++ b/Flow/scripts/hooks-shared.sh
@@ -0,0 +1,36 @@
+#
+# Shared functionality of the Flow git hooks
+#
+
+realpath() {
+ php -r "echo realpath('$1'), \"\\n\";"
+}
+
+is_vagrant() {
+ DEST='.'
+ while [ "$(realpath $DEST)" != "/" ]; do
+ if [ -f $DEST/Vagrantfile ]; then
+ return 0;
+ fi
+ DEST="$DEST/.."
+ done
+ return 1
+}
+
+make() {
+ if is_vagrant; then
+ echo 'git hooks: Attempting to ssh into vagrant'
+ vagrant ssh -- cd /vagrant/mediawiki/extensions/Flow '&&' /bin/echo 'git hooks: Running commands inside Vagrant' '&&' sudo -u www-data make $* || exit 1
+ else
+ /usr/bin/env make $* || exit 1
+ fi
+}
+
+file_changed_in_commit() {
+ git diff --name-only --cached | grep -P "$1" 2>&1 >/dev/null
+}
+
+file_changed_in_head() {
+ git diff-tree --no-commit-id --name-only -r HEAD | grep -P "$1" 2>&1 >/dev/null
+}
+
diff --git a/Flow/scripts/pre-commit b/Flow/scripts/pre-commit
new file mode 100755
index 00000000..b5b82307
--- /dev/null
+++ b/Flow/scripts/pre-commit
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Enable this pre-commit hook by executing the following from the project root directory
+# $ ln -s $PWD/scripts/pre-commit .git/hooks/pre-commit
+#
+# Code from https://gist.github.com/holysugar/1318698 , simpler than
+# http://stackoverflow.com/a/6262715/451712
+
+# Work out location of Flow/scripts/ directory
+dir=$(dirname $(php -r "echo realpath('$0'), \"\\n\";"))
+# Move to the project root
+cd $(dirname $dir)
+# Source the shared shell functions
+. $dir/hooks-shared.sh
+
+if [ "$IGNORE_WHITESPACE" != "1" ]; then
+ # FIXME this reads the version of the file on-disk, which may not be the version
+ # about to be committed if you made changes to it since `git add`.
+ git diff --cached --name-only | (while read f; do
+ ERROR=0
+ if grep -n '[[:space:]]$' "$f" ; then
+ echo "'$f' has trailing whitespace\n" >&2
+ ERROR=1
+ fi
+ done; exit $ERROR)
+
+ if [ $? -ne 0 ];then
+ echo "if you know what you are doing, use 'IGNORE_WHITESPACE=1 git commit'"
+ exit 1
+ fi
+fi
+
+COMMANDS=""
+
+if file_changed_in_commit '\.less$'; then
+ if [ "$IGNORE_CHECKLESS" != "1" ]; then
+ COMMANDS="checkless $COMMANDS"
+ fi
+fi
+
+if file_changed_in_commit '\.js$'; then
+ if [ "$IGNORE_JSHINT" != "1" ]; then
+ COMMANDS="grunt $COMMANDS"
+ fi
+fi
+
+if file_changed_in_commit '\.php$'; then
+ if [ "$IGNORE_PHPLINT" != "1" ]; then
+ COMMANDS="phplint $COMMANDS"
+ fi
+fi
+
+if file_changed_in_commit '^i18n/'; then
+ if [ "$IGNORE_I18N" != "1" ]; then
+ COMMANDS="messagecheck $COMMANDS"
+ fi
+fi
+
+if [ "$COMMANDS" != "" ]; then
+ make $COMMANDS || exit 1
+fi
+
diff --git a/Flow/scripts/pre-review b/Flow/scripts/pre-review
new file mode 100755
index 00000000..f805b9bb
--- /dev/null
+++ b/Flow/scripts/pre-review
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+# Work out location of Flow/scripts/ directory
+dir=$(php -r "echo dirname( realpath( '$0' ) ), \"\\n\";")
+# Move to the project root
+cd $(dirname $dir)
+# Source the shared shell functions
+. $dir/hooks-shared.sh
+
+# only checks top commit for changes. havn't figured out how to get
+# git-review to tell us which commits are being submitted
+if file_changed_in_head '\.php$'; then
+ if [ "$USE_PHPSTORM" = "1" ]; then
+ # bit of a hack ... other things run inside vagrant but phpstorm is probably
+ # installed external to vagrant
+ /usr/bin/env make analyze-phpstorm
+ fi
+fi
+
+if file_changed_in_head '\.i18n\.php$'; then
+ COMMANDS="messagecheck $COMMANDS"
+fi
+
+if [ "$COMMANDS" != "" ]; then
+ make $COMMANDS || exit 1
+fi
+
diff --git a/Flow/scripts/qunit.sh b/Flow/scripts/qunit.sh
new file mode 100755
index 00000000..c06a0b0b
--- /dev/null
+++ b/Flow/scripts/qunit.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+echo "Running QUnit tests..."
+if command -v phantomjs > /dev/null ; then
+ URL=${MEDIAWIKI_URL:-"http://127.0.0.1:80/w/index.php/"}
+ echo "Using $URL as a development environment host."
+ echo "Please ensure \$wgEnableJavaScriptTest = true; in your LocalSettings.php"
+ echo "To specify a different host set MEDIAWIKI_URL environment variable"
+ echo '(e.g. by running "export MEDIAWIKI_URL=http://127.0.0.1:80/w/index.php/")'
+ phantomjs tests/externals/phantomjs-qunit-runner.js "${URL}Special:JavaScriptTest/qunit?filter=ext.flow"
+else
+ echo "You need to install PhantomJS to run QUnit tests in terminal!"
+ echo "See http://phantomjs.org/"
+ exit 1
+fi
diff --git a/Flow/scripts/remotecheck.sh b/Flow/scripts/remotecheck.sh
new file mode 100755
index 00000000..51278d2f
--- /dev/null
+++ b/Flow/scripts/remotecheck.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+if [ ! -e "scripts/remotes/gerrit.py" ]
+then
+ mkdir -p scripts/remotes
+ echo 'Installing GerritCommandLine tool'
+ curl -o scripts/remotes/gerrit.py https://raw.githubusercontent.com/jdlrobson/GerritCommandLine/master/gerrit.py
+ chmod +x scripts/remotes/gerrit.py
+fi
+if [ ! -e "scripts/remotes/message.py" ]
+then
+ mkdir -p scripts/remotes
+ echo 'Installing Message tool'
+ curl -o scripts/remotes/message.py https://raw.githubusercontent.com/jdlrobson/WikimediaMessageDevScript/master/message.py
+ chmod +x scripts/remotes/message.py
+fi
diff --git a/Flow/tests/browser/README.md b/Flow/tests/browser/README.md
new file mode 100644
index 00000000..36319498
--- /dev/null
+++ b/Flow/tests/browser/README.md
@@ -0,0 +1 @@
+Please see https://github.com/wikimedia/mediawiki-selenium for instructions on how to run tests.
diff --git a/Flow/tests/browser/features/action_menu_permalink.feature b/Flow/tests/browser/features/action_menu_permalink.feature
new file mode 100644
index 00000000..d6197117
--- /dev/null
+++ b/Flow/tests/browser/features/action_menu_permalink.feature
@@ -0,0 +1,31 @@
+@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Actions menu Permalink
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+
+ Scenario: Topic Actions menu Permalink
+ Given I have created a Flow topic with title "Permalinktest"
+ When I click the Topic Actions link
+ And I click Permalink from the Actions menu
+ And I am viewing Topic page
+ Then I see only one topic on the page
+ And the top post should have a heading which contains "Permalinktest"
+
+ Scenario: Actions menu Permalink
+ Given I have created a Flow topic with title "PermalinkReplyTest"
+ And I add 3 comments to the Topic
+ When I click the Post Actions link on the 3rd comment on the topic
+ And I click the Post Actions link on the 3rd comment on the topic
+ And I click Permalink from the 3rd comment Post Actions menu
+ And I am viewing Topic page
+ Then I see only one topic on the page
+ And the highlighted comment should contain the text for the 3rd comment
+
+ Scenario: Old style topic permalink
+ Given I have created a Flow topic with title "Permalinktest"
+ When I go to an old style permalink to my topic
+ And I am viewing Topic page
+ Then I see only one topic on the page
+ And the top post should have a heading which contains "Permalinktest"
diff --git a/Flow/tests/browser/features/anon_interface.feature b/Flow/tests/browser/features/anon_interface.feature
new file mode 100644
index 00000000..37eb3647
--- /dev/null
+++ b/Flow/tests/browser/features/anon_interface.feature
@@ -0,0 +1,9 @@
+@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @phantomjs @test2.wikipedia.org
+Feature: Check the interface for anonymous users
+
+ Scenario: Anon does not see block or actions
+ Given I am on Flow page
+ And I have created a Flow topic
+ # which is not hidden (this is implicit from the above step)
+ When I see a flow creator element
+ Then the block author link should not be visible
diff --git a/Flow/tests/browser/features/edit_existing.feature b/Flow/tests/browser/features/edit_existing.feature
new file mode 100644
index 00000000..bbbb24c8
--- /dev/null
+++ b/Flow/tests/browser/features/edit_existing.feature
@@ -0,0 +1,22 @@
+@clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Edit existing title
+
+ Assumes that the test Flow page has at least two topics (with posts).
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+ And I have created a Flow topic
+
+ Scenario: Edit an existing title
+ When I click the Edit title action
+ And I edit the title field with Title edited
+ And I save the new title
+ Then the top post should have a heading which contains "Title edited"
+
+ @phantomjs
+ Scenario: Edit existing post
+ When I click Edit post
+ And I edit the post field with Post edited
+ And I save the new post
+ Then the saved post should contain Post edited
diff --git a/Flow/tests/browser/features/flow_in_recent_changes.feature b/Flow/tests/browser/features/flow_in_recent_changes.feature
new file mode 100644
index 00000000..d8ba83ff
--- /dev/null
+++ b/Flow/tests/browser/features/flow_in_recent_changes.feature
@@ -0,0 +1,18 @@
+@clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Flow updates are in Recent Changes
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+ And I have created a Flow topic with title "New topic should be in Recent Changes"
+
+ Scenario: New topic is in Recent Changes
+ When I navigate to the Recent Changes page
+ Then the new topic should be in the Recent Changes page
+
+ Scenario: Edited topic is in Recent Changes
+ When I click the Edit title action
+ And I edit the title field with Title should be in Recent Changes
+ And I save the new title
+ And I navigate to the Recent Changes page
+ Then the new title should be in the Recent Changes page
diff --git a/Flow/tests/browser/features/flow_logged_in.feature b/Flow/tests/browser/features/flow_logged_in.feature
new file mode 100644
index 00000000..596db0aa
--- /dev/null
+++ b/Flow/tests/browser/features/flow_logged_in.feature
@@ -0,0 +1,33 @@
+@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @login @test2.wikipedia.org
+Feature: Create new topic logged in
+
+ It requires the cldr extension, a "Flow QA" page, and a "Selenium user" who has
+ permission to flow-delete (usually 'sysop'/administrator user right), to
+ flow-suppress (usually the 'oversight' user right), and to block (usually 'sysop').
+ If the Selenium_user's Flow editor is VisualEditor, then the flow_page
+ definitions have to change.
+
+ Background:
+ Given I am logged in
+ And I have created a Flow topic
+
+ Scenario: Add new Flow topic and show author and block links
+ Given the author link is visible
+ And the talk to author link is not visible
+ And the block author link is not visible
+ # hover doesn't work in IE, bug 67723
+ When I hover over the author link
+ Then the talk to author link should be visible
+ And the block author link should be visible
+
+ Scenario: Post Actions
+ When I click the Post Actions link
+ Then I should see a Hide button
+ And I should see a Delete button
+ And I should see a Suppress button
+
+ Scenario: Topic Actions
+ When I click the Topic Actions link
+ Then I should see a Hide topic button
+ And I should see a Delete topic button
+ And I should see a Suppress topic button
diff --git a/Flow/tests/browser/features/flow_no_javascript.feature b/Flow/tests/browser/features/flow_no_javascript.feature
new file mode 100644
index 00000000..439bbd6c
--- /dev/null
+++ b/Flow/tests/browser/features/flow_no_javascript.feature
@@ -0,0 +1,21 @@
+@custom-browser @en.wikipedia.beta.wmflabs.org @firefox @login @test2.m.wikipedia.org
+Feature: Basic site for legacy devices
+
+ Background:
+ Given I am using user agent "Mozilla/4.0 (compatible; MSIE 5.5b1; Mac_PowerPC)"
+ And I am on a Flow page without JavaScript
+
+ Scenario: I post a new topic without JavaScript
+ When I see the form to post a new topic
+ And I click Add topic no javascript
+ And I enter a no javascript topic title of "Selenium no javascript title"
+ And I enter a no javascript topic body of "Selenium no javascript body"
+ And I save a no javascript new topic
+ Then the page contains my no javascript topic
+ And the page contains my no javascript body
+
+ Scenario: I reply to a topic without JavaScript
+ When I see the form to reply to a topic
+ And I enter a no javascript reply of "Selenium no javascript reply"
+ And I save a no javascript reply
+ Then the page contains my no javascript reply
diff --git a/Flow/tests/browser/features/lock_unlock_topics.feature b/Flow/tests/browser/features/lock_unlock_topics.feature
new file mode 100644
index 00000000..062c3049
--- /dev/null
+++ b/Flow/tests/browser/features/lock_unlock_topics.feature
@@ -0,0 +1,48 @@
+@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @login @test2.wikipedia.org
+Feature: Lock and unlock topics
+
+ Background:
+ Given I am logged in
+
+ @wip
+ Scenario: Locked topics have no reply links
+ Given I am on Flow page
+ And I have created a Flow topic
+ And the top post has been locked
+ When I expand the top post
+ Then the original message for the top post should have no reply link
+ And the original message for the top post should have no edit link
+
+ @internet_explorer_10
+ Scenario: Locking a topic and then changing your mind
+ Given I am on Flow page
+ And I have created a Flow topic
+ When I click the Topic Actions link
+ And I click the Lock topic button
+ And I cancel the lock/unlock topic form
+ Then the top post should be an open discussion
+ And I should not see the lock/unlock form
+
+ @internet_explorer_10
+ Scenario: Locking a topic
+ Given I am on Flow page
+ And I have created a Flow topic
+ When I click the Topic Actions link
+ And I click the Lock topic button
+ And I type "This is a bikeshed" as the reason
+ And I submit the lock/unlock topic form
+ Then the top post should be a locked discussion
+ And the reason of the first topic should be "This is a bikeshed"
+ And the content of the top post should be visible
+
+ # Close-then-unlock doesn't work in IE, it caches the API response (bug 69160).
+ Scenario: Opening a topic
+ Given I am on Flow page
+ And I have created a Flow topic
+ And the top post has been locked
+ And I click the Topic Actions link
+ And I click the Unlock topic button
+ When I type "Fun discussion" as the reason
+ And I submit the lock/unlock topic form
+ Then the top post should be an open discussion
+ And the content of the top post should be visible
diff --git a/Flow/tests/browser/features/moderation.feature b/Flow/tests/browser/features/moderation.feature
new file mode 100644
index 00000000..3fa14415
--- /dev/null
+++ b/Flow/tests/browser/features/moderation.feature
@@ -0,0 +1,44 @@
+@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Moderation
+
+ Assumes Flow is enabled for the User_talk namespace.
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+
+ Scenario: Deleting a topic
+ Given I have created a Flow topic with title "Deletemeifyoudare"
+ When I click the Topic Actions link
+ And I click the Delete topic button
+ And I see a dialog box
+ And I give reason for deletion as being "He's a naughty boy"
+ And I click Delete topic
+ Then the top post should be marked as deleted
+
+ Scenario: Suppressing a topic
+ Given I have created a Flow topic with title "Suppressmeifyoudare"
+ When I click the Topic Actions link
+ And I click the Suppress topic button
+ And I see a dialog box
+ And I give reason for suppression as being "Quelling the peasants"
+ And I click Suppress topic
+ Then the top post should be marked as suppressed
+
+ Scenario: Cancelling a dialog without text
+ Given I have created a Flow topic with title "Testing cancel deletion of topic"
+ When I click the Topic Actions link
+ And I click the Delete topic button
+ And I see a dialog box
+ And I cancel the dialog
+ Then I do not see the dialog box
+
+ Scenario: Cancelling a dialog with text
+ Given I have created a Flow topic with title "Testing cancel deletion of topic"
+ When I click the Topic Actions link
+ And I click the Delete topic button
+ And I see a dialog box
+ And I give reason for suppression as being "About to change my mind"
+ And I cancel the dialog
+ And I confirm
+ Then I do not see the dialog box
diff --git a/Flow/tests/browser/features/new_topic.feature b/Flow/tests/browser/features/new_topic.feature
new file mode 100644
index 00000000..78494bbd
--- /dev/null
+++ b/Flow/tests/browser/features/new_topic.feature
@@ -0,0 +1,15 @@
+@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @phantomjs @test2.wikipedia.org
+Feature: Creating a new topic
+
+ Background:
+ Given I am on Flow page
+
+ Scenario: Cannot create a new topic without content
+ When I type "Anonymous user topic creation test" into the new topic title field
+ Then the Save New Topic button should be disabled
+
+ Scenario: Add new Flow topic as anonymous user
+ When I have created a Flow topic with title "Anonymous user topic creation"
+ # TODO the terminology below is terrible, posts don't have headings. It's the top topic's title and first post.
+ Then the top post should have a heading which contains "Anonymous user topic creation"
+ And the top post should have content which contains "Anonymous user topic creation"
diff --git a/Flow/tests/browser/features/post_links.feature b/Flow/tests/browser/features/post_links.feature
new file mode 100644
index 00000000..1ad2dfcd
--- /dev/null
+++ b/Flow/tests/browser/features/post_links.feature
@@ -0,0 +1,12 @@
+@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Follow user links
+
+ Scenario: User links takes me to the user page
+ Given I am logged in
+ And I am on Flow page
+ And I have created a Flow topic
+ And I see a flow creator element
+ When I click the flow creator element
+ Then I am on my user page
+
+
diff --git a/Flow/tests/browser/features/reply.feature b/Flow/tests/browser/features/reply.feature
new file mode 100644
index 00000000..a9bc50ea
--- /dev/null
+++ b/Flow/tests/browser/features/reply.feature
@@ -0,0 +1,30 @@
+@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Replying
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+
+ @phantomjs
+ Scenario: I can reply
+ Given I have created a Flow topic with title "Reply test"
+ When I reply with comment "Boom boom shake shake the room"
+ Then the top post's first reply should contain the text "Boom boom shake shake the room"
+
+ @phantomjs
+ Scenario: Replying updates watched state
+ Given I have created a Flow topic with title "Reply watch test"
+ And I am not watching my new Flow topic
+ When I reply with comment "I want to watch this title"
+ Then I should see an unwatch link on the topic
+
+# TODO maybe should test simple Cancelling reply as well.
+
+ Scenario: Previewing reply, continue editing, then cancel leaves usable form
+ Given I have created a Flow topic with title "Reply preview test"
+ When I start a reply with comment "my form lies over the ocean"
+ And I click the Preview button
+ And I click the Keep editing button
+ And I click the Cancel button and confirm the dialog
+ And I start a reply with comment "bring back my form to me"
+ Then I should see the topic reply form
diff --git a/Flow/tests/browser/features/reply_moderation.feature b/Flow/tests/browser/features/reply_moderation.feature
new file mode 100644
index 00000000..c52a5954
--- /dev/null
+++ b/Flow/tests/browser/features/reply_moderation.feature
@@ -0,0 +1,17 @@
+@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Reply moderation
+
+ Background:
+ Given I am logged in
+ And I am on Flow page
+
+ Scenario: Hiding a comment
+ Given I have created a Flow topic with title "Hide comment test"
+ And I add 3 comments to the Topic
+ When I click the Post Actions link on the 3rd comment on the topic
+ And I click Hide comment button
+ And I see a dialog box
+ And I give reason for hiding as being "Shhhh!"
+ And I click the Hide button in the dialog
+ Then the 3rd comment should be marked as hidden
+ And the content of the 3rd comment should not be visible
diff --git a/Flow/tests/browser/features/sorting_topics.feature b/Flow/tests/browser/features/sorting_topics.feature
new file mode 100644
index 00000000..a1bcc8ea
--- /dev/null
+++ b/Flow/tests/browser/features/sorting_topics.feature
@@ -0,0 +1,19 @@
+@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @phantomjs @test2.wikipedia.org
+Feature: Sorting topics
+
+ Background:
+ Given I am on Flow page
+
+ Scenario: Switch topic sorting to Recently Active Topics
+ When I click Newest topics link
+ And I click Recently active topics choice
+ Then the Flow page should show Recently active topics link
+ And the Flow page should not show Newest topics link
+
+ Scenario: Switch topic sorting to Recently Active Topics and then back to Newest topics
+ When I click Newest topics link
+ And I click Recently active topics choice
+ And I click Recently active topics link
+ And I click Newest topics choice
+ Then the Flow page should show Newest topics link
+ And the Flow page should not show Recently active topics link
diff --git a/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb b/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb
new file mode 100644
index 00000000..614e47db
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb
@@ -0,0 +1,41 @@
+When(/^I add (\d+) comments to the Topic$/) do |number|
+ number.to_i.times do
+ @saved_random = Random.new.rand.to_s
+ step 'I reply with comment "' + 'Selenium comment ' + @saved_random + '"'
+ end
+end
+
+When(/^I click Permalink from the Actions menu$/) do
+ on(FlowPage).permalink_button_element.when_present.click
+end
+
+When(/^I click Permalink from the 3rd comment Post Actions menu$/) do
+ on(FlowPage).actions_link_permalink_3rd_comment_element.when_present.click
+end
+
+When(/^I click the Post Actions link on the 3rd comment on the topic$/) do
+ on(FlowPage) do |page|
+ page.third_post_actions_link_element.when_present.focus
+ page.third_post_actions_link_element.click
+ end
+end
+
+When(/^I go to an old style permalink to my topic$/) do
+ on(FlowPage) do |curPage|
+ work_flow_id = curPage.flow_first_topic_element.attribute('data-flow-id')
+ visit(FlowOldPermalinkPage, using_params: { workflow_id: work_flow_id })
+ end
+end
+
+Then(/^I see only one topic on the page$/) do
+ on(FlowPage) do |page|
+ # We should have the a post with a heading
+ expect(page.flow_first_topic_heading_element.when_present).to be_visible
+ # but this should match nothing - there is only one topic.
+ expect(page.flow_second_topic_heading_element).not_to be_visible
+ end
+end
+
+Then(/^the highlighted comment should contain the text for the 3rd comment$/) do
+ expect(on(FlowPage).highlighted_post).to match @saved_random
+end
diff --git a/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb b/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb
new file mode 100644
index 00000000..e743ea96
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb
@@ -0,0 +1,47 @@
+When(/^I click Edit post$/) do
+ on(FlowPage) do |page|
+ page.edit_post_element.when_present.click
+ end
+end
+
+When(/^I click the Edit title action$/) do
+ on(FlowPage) do |page|
+ page.topic_actions_link_element.when_present.click
+ page.edit_title_button_element.when_present.click
+ end
+end
+
+When(/^I edit the post field with (.+)$/) do |edited_post|
+ on(FlowPage) do |page|
+ # Take focus away from menu
+ page.post_edit_element.when_present.click
+ page.post_edit_element.when_present.send_keys(edited_post + @random_string)
+ end
+end
+
+When(/^I edit the title field with (.+)$/) do |edited_title|
+ on(FlowPage) do |page|
+ @edited_topic_string = edited_title + @random_string
+ # Take focus away from menu
+ page.title_edit_element.when_present.click
+ page.title_edit_element.when_present.send_keys(@edited_topic_string)
+ end
+end
+
+When(/^I save the new post/) do
+ on(FlowPage) do |page|
+ page.change_post_save_element.when_present.click
+ page.change_post_save_element.when_not_present
+ end
+end
+
+When(/^I save the new title$/) do
+ on(FlowPage) do |page|
+ page.change_title_save_element.when_present.click
+ page.flow_first_topic_heading_element.when_present
+ end
+end
+
+Then(/^the saved post should contain (.+)$/) do |edited_post|
+ expect(on(FlowPage).flow_first_topic_body).to match(edited_post + @random_string)
+end
diff --git a/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb b/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb
new file mode 100644
index 00000000..7828377b
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb
@@ -0,0 +1,11 @@
+When(/^I navigate to the Recent Changes page$/) do
+ visit(RecentChangesPage)
+end
+
+Then(/^the new topic should be in the Recent Changes page$/) do
+ expect(on(RecentChangesPage).recent_changes_element.when_present.text).to match @topic_string
+end
+
+Then(/^the new title should be in the Recent Changes page$/) do
+ expect(on(RecentChangesPage).recent_changes_element.when_present.text).to match @edited_topic_string
+end
diff --git a/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb b/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb
new file mode 100644
index 00000000..765c4589
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb
@@ -0,0 +1,65 @@
+# This test has no javascript
+# Therefore this test has no AJAX
+# Therefore it should run without any "when_present" clauses
+# If you need a "when_present" to make the test run, that is a bug
+
+Given(/^I am on a Flow page without JavaScript$/) do
+ visit(FlowPage)
+end
+
+Given(/^I am using user agent "(.+)"$/) do |user_agent|
+ @user_agent = user_agent
+ @browser = browser(test_name(@scenario), { user_agent: user_agent })
+ $session_id = @browser.driver.instance_variable_get(:@bridge).session_id
+end
+
+When(/^I click Add topic no javascript$/) do
+ on(FlowPage).no_javascript_topic_title_text_element.click
+end
+
+When(/^I enter a no javascript reply of "(.*?)"$/) do |no_javascript_reply|
+ @no_javascript_reply = no_javascript_reply
+ on(FlowPage).no_javascript_reply_form_element.send_keys "#{@no_javascript_reply} #{@random_string}"
+end
+
+When(/^I enter a no javascript topic body of "(.*?)"$/) do |no_javascript_topic_body|
+ @no_javascript_topic_body = no_javascript_topic_body
+ on(FlowPage).no_javascript_topic_body_text_element.send_keys "#{@no_javascript_topic_body} #{@random_string}"
+end
+
+When(/^I enter a no javascript topic title of "(.*?)"$/) do |no_javascript_topic_title|
+ @no_javascript_topic_title = no_javascript_topic_title
+ on(FlowPage).no_javascript_topic_title_text_element.send_keys "#{@no_javascript_topic_title} #{@random_string}"
+end
+
+When(/^I save a no javascript new topic$/) do
+ on(FlowPage).no_javascript_add_topic_element.click
+end
+
+When(/^I save a no javascript reply$/) do
+ on(FlowPage).no_javascript_reply_element.click
+end
+
+When(/^I see the form to post a new topic$/) do
+ on(FlowPage) do |page|
+ page.no_javascript_start_topic_element.click
+ end
+end
+
+When(/^I see the form to reply to a topic$/) do
+ on(FlowPage) do |page|
+ page.no_javascript_start_reply_element.click
+ end
+end
+
+Then(/^the page contains my no javascript body$/) do
+ expect(on(FlowPage).no_javascript_page_content_body).to match "#{@no_javascript_topic_body} #{@random_string}"
+end
+
+Then(/^the page contains my no javascript topic$/) do
+ expect(on(FlowPage).no_javascript_page_content_title).to match "#{@no_javascript_topic_title} #{@random_string}"
+end
+
+Then(/^the page contains my no javascript reply$/) do
+ expect(on(FlowPage).no_javascript_page_flow_topics).to match "#{@no_javascript_reply} #{@random_string}"
+end
diff --git a/Flow/tests/browser/features/step_definitions/flow_steps.rb b/Flow/tests/browser/features/step_definitions/flow_steps.rb
new file mode 100644
index 00000000..55509d2e
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/flow_steps.rb
@@ -0,0 +1,167 @@
+Given(/^I am on a new board$/) do
+ visit NewFlowPage
+end
+
+Given(/^I am on Flow page$/) do
+ visit FlowPage
+ step "The Flow page is fully loaded"
+ step "page has no ResourceLoader errors"
+end
+
+# @todo: Rewrite to use more generic step below
+Given(/^I have created a Flow topic$/) do
+ step "I have created a Flow topic with title \"Title of Flow topic\""
+end
+
+Given(/^I have created a Flow topic with title "(.+)"$/) do |title|
+ step "I am on Flow page"
+ step "I type \"" + title + "\" into the new topic title field"
+ step "I type \"" + title + "\" into the new topic content field"
+ step "I click New topic save"
+end
+
+Given(/^the author link is visible$/) do
+ on(FlowPage).author_link_element.when_present.when_present
+end
+
+Given(/^the block author link is not visible$/) do
+ on(FlowPage).usertools_block_user_link_element.when_not_visible
+end
+
+Given(/^The Flow page is fully loaded$/) do
+ on(FlowPage).new_topic_body_element.when_not_visible
+end
+
+Given(/^the talk to author link is not visible$/) do
+ on(FlowPage).usertools_talk_link_element.when_not_visible
+end
+
+When(/^I am viewing Topic page$/) do
+ on(FlowPage).wait_until { @browser.url =~ /Topic/ }
+end
+
+When(/^I click New topic save$/) do
+ on(FlowPage) do |page|
+ page.new_topic_save_element.when_present.click
+
+ # Wait for the save to finish, at which point the button will hide
+ page.new_topic_save_element.when_not_visible(10); # Bug 71476 - Saving a new topic can take >5s on beta labs
+ end
+end
+
+When(/^I click the Delete topic button$/) do
+ on(FlowPage).topic_delete_button_element.when_present.click
+end
+
+When(/^I click the flow creator element$/) do
+ on(FlowPage).author_link_element.click
+end
+
+When(/^I click the Hide topic button$/) do
+ on(FlowPage).topic_hide_button_element.when_present.click
+end
+
+When(/^I click the Post Actions link$/) do
+ on(FlowPage).post_actions_link_element.when_present.click
+end
+
+When(/^I click the Suppress topic button$/) do
+ on(FlowPage).topic_suppress_button_element.when_present.click
+end
+
+When(/^I click the Topic Actions link$/) do
+ on(FlowPage).topic_actions_link_element.when_present.click
+end
+
+When(/^I hover over the author link$/) do
+ on(FlowPage).author_link_element.hover
+end
+
+When(/^I see a flow creator element$/) do
+ on(FlowPage).author_link_element.should be_visible
+end
+
+When(/^I type "(.+)" into the new topic content field$/) do |flow_body|
+ body_string = flow_body + @random_string + @automated_test_marker
+ on(FlowPage).new_topic_body_element.when_present.send_keys(body_string)
+end
+
+When(/^I type "(.+)" into the new topic title field$/) do |flow_title|
+ @automated_test_marker = " browsertest edit"
+ on(FlowPage) do |page|
+ @topic_string = flow_title + @random_string + @automated_test_marker
+ page.new_topic_title_element.when_present.click
+ page.new_topic_title_element.when_present.focus
+ page.new_topic_title_element.when_present.send_keys(@topic_string)
+ end
+end
+
+Then(/^I am on my user page$/) do
+ # Get the title of the page without '_' characters
+ text = 'User:' + ENV["MEDIAWIKI_USER"].gsub(/_/, ' ')
+ expect(on(UserPage).first_heading_element.text).to match(text)
+end
+
+Then(/^I should see a Delete button$/) do
+ expect(on(FlowPage).delete_button_element).to be_visible
+end
+
+Then(/^I should see a Delete topic button$/) do
+ expect(on(FlowPage).topic_delete_button_element.when_present).to be_visible
+end
+
+Then(/^I should see a Hide button$/) do
+ expect(on(FlowPage).hide_button_element.when_present).to be_visible
+end
+
+Then(/^I should see a Hide topic button$/) do
+ expect(on(FlowPage).topic_hide_button_element.when_present).to be_visible
+end
+
+Then(/^I should see a Suppress button$/) do
+ expect(on(FlowPage).suppress_button_element).to be_visible
+end
+
+Then(/^I should see a Suppress topic button$/) do
+ expect(on(FlowPage).topic_suppress_button_element.when_present).to be_visible
+end
+
+Then(/^the block author link should not be visible$/) do
+ expect(on(FlowPage).usertools_block_user_link_element).not_to be_visible
+end
+
+Then(/^the block author link should be visible$/) do
+ expect(on(FlowPage).usertools_block_user_link_element.when_present).to be_visible
+end
+
+Then(/^the content of the top post should be visible$/) do
+ expect(on(FlowPage).flow_first_topic_body_element.when_present).to be_visible
+end
+
+Then(/^the content of the top post should not be visible$/) do
+ expect(on(FlowPage).flow_first_topic_body_element).not_to be_visible
+end
+
+Then(/^the Save New Topic button should be disabled$/) do
+ val = on(FlowPage).new_topic_save_element.attribute("disabled")
+ expect(val).to eq("true")
+end
+
+Then(/^the talk to author link should be visible$/) do
+ expect(on(FlowPage).usertools_talk_link_element.when_present).to be_visible
+end
+
+Then(/^the top post should have a heading which contains "(.+)"$/) do |text|
+ on(FlowPage) do |page|
+ page.flow_first_topic_heading_element.when_present
+ expect(page.flow_first_topic_heading).to match(text)
+ end
+end
+
+Then(/^the top post should have content which contains "(.+)"$/) do |text|
+ expect(on(FlowPage).flow_first_topic_body).to match(text)
+end
+
+Then(/^the top post should not have a heading which contains "(.+)"$/) do |text|
+ expect(on(FlowPage).flow_first_topic_heading).not_to match(text)
+end
diff --git a/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb b/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb
new file mode 100644
index 00000000..787170f2
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb
@@ -0,0 +1,75 @@
+Given(/^I click the Lock topic button$/) do
+ on(FlowPage) do |page|
+ page.topic_lock_button_element.when_present.focus
+ page.topic_lock_button_element.click
+ end
+end
+
+Given(/^I click the Unlock topic button$/) do
+ on(FlowPage) do |page|
+ page.topic_unlock_button_element.when_present.focus
+ page.topic_unlock_button_element.click
+ end
+end
+
+Given(/^the top post has been locked$/) do
+ step 'I click the Topic Actions link'
+ step 'I click the Lock topic button'
+ step 'I type "This is a bikeshed" as the reason'
+ step 'I submit the lock/unlock topic form'
+end
+
+When(/^I cancel the lock\/unlock topic form$/) do
+ on(FlowPage).topic_lock_form_cancel_button_element.when_present.click
+end
+
+When(/^I expand the top post$/) do
+ on(FlowPage).flow_first_topic_heading_element.when_present.click
+end
+
+When(/^I submit the lock\/unlock topic form$/) do
+ on(FlowPage) do |page|
+ page.topic_lock_form_lock_button_element.when_present.click
+ page.topic_lock_form_lock_button_element.when_not_present
+ end
+end
+
+When(/^I type "(.*?)" as the reason$/) do |reason|
+ on(FlowPage) do |page|
+ page.topic_lock_form_reason_element.when_present.clear
+ # Focus textarea so that any menus that have been clicked lose their focus. In Chrome these might disrupt the test as
+ # elements may be masked and not clickable.
+ page.topic_lock_form_reason_element.click
+ page.topic_lock_form_reason_element.send_keys(reason)
+ end
+end
+
+Then(/^I should not see the lock\/unlock form$/) do
+ on(FlowPage) do |page|
+ page.topic_lock_form_element.when_not_present
+ expect(page.topic_lock_form_element).not_to be_visible
+ end
+end
+
+Then(/the original message for the top post should have no edit link$/) do
+ expect(on(FlowPage).flow_first_topic_original_post_edit_element).not_to be_visible
+end
+
+Then(/^the original message for the top post should have no reply link$/) do
+ expect(on(FlowPage).flow_first_topic_original_post_reply_element).not_to be_visible
+end
+
+Then(/^the reason of the first topic should be "(.*?)"$/) do |text|
+ expect(on(FlowPage).flow_reason_element.text).to match text
+end
+
+Then(/^the top post should be a locked discussion$/) do
+ expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present).to be_visible
+end
+
+Then(/^the top post should be an open discussion$/) do
+ on(FlowPage) do |page|
+ page.flow_first_topic_moderation_msg_element.when_not_present
+ expect(page.flow_first_topic_moderation_msg_element).not_to be_visible
+ end
+end
diff --git a/Flow/tests/browser/features/step_definitions/moderation_steps.rb b/Flow/tests/browser/features/step_definitions/moderation_steps.rb
new file mode 100644
index 00000000..c6421164
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/moderation_steps.rb
@@ -0,0 +1,47 @@
+When(/^I cancel the dialog$/) do
+ on(FlowPage).dialog_cancel_element.when_present.click
+end
+
+When(/^I click Delete topic$/) do
+ on(FlowPage).dialog_submit_delete_element.when_present.click
+end
+
+When(/^I click Hide topic$/) do
+ on(FlowPage).dialog_submit_hide_element.when_present.click
+end
+
+When(/^I click Suppress topic$/) do
+ on(FlowPage).dialog_submit_suppress_element.when_present.click
+end
+
+When(/^I give reason for deletion as being "(.*?)"$/) do |delete_reason|
+ on(FlowPage).dialog_input_element.when_present.send_keys(delete_reason)
+end
+
+When(/^I give reason for hiding as being "(.*?)"$/) do |hide_reason|
+ on(FlowPage).dialog_input_element.when_present.send_keys(hide_reason)
+end
+
+When(/^I give reason for suppression as being "(.*?)"$/) do |suppress_reason|
+ on(FlowPage).dialog_input_element.when_present.send_keys(suppress_reason)
+end
+
+When(/^I see a dialog box$/) do
+ on(FlowPage).dialog_element.when_present
+end
+
+Then(/^I confirm$/) do
+ on(FlowPage).confirm(true) {}
+end
+
+Then(/^I do not see the dialog box$/) do
+ on(FlowPage).dialog_element.when_not_present
+end
+
+Then(/^the top post should be marked as deleted$/) do
+ expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present.text).to match("This topic has been deleted")
+end
+
+Then(/^the top post should be marked as suppressed$/) do
+ expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present.text).to match("This topic has been suppressed")
+end
diff --git a/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb b/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb
new file mode 100644
index 00000000..b9f607f6
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb
@@ -0,0 +1,24 @@
+When(/^I click Hide comment button$/) do
+ on(FlowPage) do |page|
+ page.actions_link_hide_3rd_comment_element.when_present.focus
+ page.actions_link_hide_3rd_comment_element.click
+ end
+end
+
+When(/^I click the Hide button in the dialog$/) do
+ on(FlowPage) do |page|
+ page.dialog_submit_hide_element.click
+ page.dialog_submit_hide_element.when_not_present
+ end
+end
+
+Then(/^the 3rd comment should be marked as hidden$/) do
+ on(FlowPage) do |page|
+ page.third_reply_element.when_present
+ expect(page.third_reply_moderation_msg).to match('This comment was hidden')
+ end
+end
+
+Then(/^the content of the 3rd comment should not be visible$/) do
+ expect(on(FlowPage).third_reply_content_element).not_to be_visible
+end
diff --git a/Flow/tests/browser/features/step_definitions/reply_steps.rb b/Flow/tests/browser/features/step_definitions/reply_steps.rb
new file mode 100644
index 00000000..857b6876
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/reply_steps.rb
@@ -0,0 +1,65 @@
+Given(/^I am not watching my new Flow topic$/) do
+ on(FlowPage) do |page|
+ page.first_topic_unwatch_link_element.when_present.click
+ page.first_topic_unwatch_link_element.when_not_visible
+ end
+end
+
+When(/^I click the Cancel button and confirm the dialog$/) do
+ on(FlowPage) do |page|
+ page.confirm(true) do
+ page.new_reply_cancel_element.when_present.click
+ end
+ end
+end
+
+When(/^I click the Keep editing button$/) do
+ on(FlowPage) do |page|
+ page.keep_editing_element.when_present.click
+ # Keep editing returns to the Preview button.
+ page.wait_until { page.new_reply_preview_element.visible? }
+ end
+end
+
+When(/^I click the Preview button$/) do
+ on(FlowPage) do |page|
+ page.new_reply_preview_element.when_present.click
+ page.wait_until { page.preview_warning_element.visible? }
+ end
+end
+
+When(/^I reply with comment "(.*?)"$/) do |content|
+ on(FlowPage) do |page|
+ page.new_reply_save_element.when_not_present
+ page.new_reply_input_element.when_present.click
+ page.new_reply_input_element.send_keys(content)
+ page.new_reply_save_element.when_present.click
+ page.new_reply_save_element.when_not_present
+ end
+end
+
+When(/^I start a reply with comment "(.*?)"$/) do |content|
+ on(FlowPage) do |page|
+ page.new_reply_save_element.when_not_present
+ page.new_reply_input_element.when_present.click
+ page.new_reply_input_element.send_keys(content)
+ end
+end
+
+Then(/^I should see an unwatch link on the topic$/) do
+ expect(on(FlowPage).first_topic_unwatch_link_element).to be_visible
+end
+
+Then(/^the top post's first reply should contain the text "(.+)"$/) do |text|
+ on(FlowPage) do |page|
+ page.new_reply_save_element.when_not_present
+ expect(page.first_reply_body).to match(text)
+ end
+end
+
+Then(/^I should see the topic reply form$/) do
+ on(FlowPage) do |page|
+ page.wait_until { page.new_reply_input_element.visible? }
+ expect(page.new_reply_input_element).to be_visible
+ end
+end
diff --git a/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb b/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb
new file mode 100644
index 00000000..eb8b18bf
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb
@@ -0,0 +1,34 @@
+When(/^I click Newest topics choice$/) do
+ on(FlowPage).newest_topics_choice_element.when_present.click
+end
+
+When(/^I click Newest topics link$/) do
+ on(FlowPage).newest_topics_link_element.when_present.click
+end
+
+When(/^I click Recently active topics choice$/) do
+ on(FlowPage).recently_active_topics_choice_element.when_present.click
+end
+
+When(/^I click Recently active topics link$/) do
+ on(FlowPage) do |page|
+ page.recently_active_topics_choice_element.when_not_visible
+ page.recently_active_topics_link_element.when_present.click
+ end
+end
+
+Then(/^the Flow page should not show Recently active topics link$/) do
+ expect(on(FlowPage).recently_active_topics_link_element.when_not_visible).not_to be_visible
+end
+
+Then(/^the Flow page should show Recently active topics link$/) do
+ expect(on(FlowPage).recently_active_topics_link_element.when_present).to be_visible
+end
+
+Then(/^the Flow page should not show Newest topics link$/) do
+ expect(on(FlowPage).newest_topics_link_element.when_not_visible).not_to be_visible
+end
+
+Then(/^the Flow page should show Newest topics link$/) do
+ expect(on(FlowPage).newest_topics_link_element.when_present).to be_visible
+end
diff --git a/Flow/tests/browser/features/step_definitions/thank_steps.rb b/Flow/tests/browser/features/step_definitions/thank_steps.rb
new file mode 100644
index 00000000..d448258e
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/thank_steps.rb
@@ -0,0 +1,37 @@
+Given(/^the "(.*?)" page has a new unmoderated topic created by me$/) do |title|
+ client = on(APIPage).client
+ client.log_in(ENV["MEDIAWIKI_USER"], ENV["MEDIAWIKI_PASSWORD"])
+ client.action('flow', token_type: 'edit', submodule: 'new-topic', page: title, nttopic: 'Thank me please!', ntcontent: 'Hello')
+end
+
+Given(/^the most recent topic on "(.*?)" is written by another user$/) do |title|
+ client = on(APIPage).client
+ username = 'Selenium Flow user 2'
+ begin
+ client.create_account(username, ENV["MEDIAWIKI_PASSWORD"])
+ rescue MediawikiApi::ApiError
+ puts 'Assuming user ' + username + ' already exists since was unable to create.'
+ end
+
+ client.log_in(username, ENV["MEDIAWIKI_PASSWORD"])
+ client.action('flow', token_type: 'edit', submodule: 'new-topic', page: title, nttopic: 'Thank me please!', ntcontent: 'Hello')
+end
+
+When(/^I click on the Thank button$/) do
+ on(FlowPage).thank_button_element.click
+end
+
+When(/^I see a Thank button$/) do
+ on(FlowPage).thank_button_element.when_present
+end
+
+Then(/^I should not see a Thank button$/) do
+ expect(on(FlowPage).thank_button_element).not_to be_visible
+end
+
+Then(/^I should see the Thank button be replaced with Thanked button$/) do
+ on(FlowPage) do |page|
+ expect(page.thanked_button_element.when_present).to be_visible
+ expect(page.thank_button_element).not_to be_visible
+ end
+end
diff --git a/Flow/tests/browser/features/step_definitions/watch_steps.rb b/Flow/tests/browser/features/step_definitions/watch_steps.rb
new file mode 100644
index 00000000..57ca38a4
--- /dev/null
+++ b/Flow/tests/browser/features/step_definitions/watch_steps.rb
@@ -0,0 +1,64 @@
+Given(/^I am not watching the Flow board$/) do
+ on(FlowPage) do |page|
+ page.board_unwatch_link_element.when_present.click unless page.board_watch_link_element.visible?
+ end
+end
+
+Given(/^I am not watching the Flow topic$/) do
+ on(FlowPage).first_topic_unwatch_link_element.when_present.click
+end
+
+Given(/^I am watching the Flow topic$/) do
+ on(FlowPage).first_topic_unwatch_link_element.when_present
+end
+
+Given(/^I am watching the Flow board$/) do
+ on(FlowPage) do |page|
+ page.board_watch_link_element.when_present.click unless page.board_unwatch_link_element.visible?
+ end
+end
+
+When(/^I click the Unwatch Board link$/) do
+ on(FlowPage).board_unwatch_link_element.when_present.click
+end
+
+When(/^I click the Unwatch Topic link$/) do
+ on(FlowPage).first_topic_unwatch_link_element.when_present.click
+end
+
+When(/^I click the Watch Board link$/) do
+ on(FlowPage).board_watch_link_element.when_present.click
+end
+
+When(/^I click the Watch Topic link$/) do
+ on(FlowPage).first_topic_watch_link_element.when_present.click
+end
+
+Then(/^I should see the Unwatch Topic link$/) do
+ expect(on(FlowPage).first_topic_unwatch_link_element.when_present).to be_visible
+end
+
+Then(/^I should not see any watch links$/) do
+ on(FlowPage) do |page|
+ expect(page.board_watch_link_element).not_to be_visible
+ expect(page.first_topic_watch_link_element).not_to be_visible
+ end
+end
+
+Then(/^I should see the Unwatch Board link$/) do
+ on(FlowPage) do |page|
+ page.board_watch_link_element.when_not_visible
+ expect(page.board_unwatch_link_element).to be_visible
+ end
+end
+
+Then(/^I should see the Watch Board link$/) do
+ expect(on(FlowPage).board_watch_link_element.when_present).to be_visible
+end
+
+Then(/^I should see the Watch Topic link$/) do
+ on(FlowPage) do |page|
+ page.first_topic_unwatch_link_element.when_not_visible
+ expect(page.first_topic_watch_link_element).to be_visible
+ end
+end
diff --git a/Flow/tests/browser/features/support/env.rb b/Flow/tests/browser/features/support/env.rb
new file mode 100644
index 00000000..55b30cef
--- /dev/null
+++ b/Flow/tests/browser/features/support/env.rb
@@ -0,0 +1,10 @@
+require "mediawiki_api"
+require "mediawiki_selenium"
+
+if ENV['PAGE_WAIT_TIMEOUT']
+ PageObject.default_page_wait = ENV['PAGE_WAIT_TIMEOUT'].to_i
+end
+
+if ENV['ELEMENT_WAIT_TIMEOUT']
+ PageObject.default_element_wait = ENV['ELEMENT_WAIT_TIMEOUT'].to_i
+end
diff --git a/Flow/tests/browser/features/support/hooks.rb b/Flow/tests/browser/features/support/hooks.rb
new file mode 100644
index 00000000..5ab6259d
--- /dev/null
+++ b/Flow/tests/browser/features/support/hooks.rb
@@ -0,0 +1,3 @@
+# Allow running of bundle exec cucumber --dry-run -f stepdefs
+require "mediawiki_selenium"
+require 'page-object'
diff --git a/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb b/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb
new file mode 100644
index 00000000..d550a27b
--- /dev/null
+++ b/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb
@@ -0,0 +1,7 @@
+class FlowOldPermalinkPage
+ include PageObject
+ include URL
+
+ @params = { page: 'Talk:Flow QA', workflow_id: 'no workflow' }
+ page_url URL.url(params[:page]) + "?workflow=<%=params[:workflow_id]%>"
+end
diff --git a/Flow/tests/browser/features/support/pages/flow_page.rb b/Flow/tests/browser/features/support/pages/flow_page.rb
new file mode 100644
index 00000000..4a052247
--- /dev/null
+++ b/Flow/tests/browser/features/support/pages/flow_page.rb
@@ -0,0 +1,239 @@
+class WikiPage
+ include PageObject
+ a(:logout, css: "#pt-logout a")
+end
+
+class FlowPage < WikiPage
+ include URL
+ # MEDIAWIKI_URL must have this in $wgFlowOccupyPages array or $wgFlowOccupyNamespaces.
+ page_url URL.url("Talk:Flow_QA")
+
+ # board header
+ a(:edit_header_link, title: "Edit header")
+ div(:header_content, css: ".flow-board-header-detail-view p", index: 0)
+ form(:edit_header_form, css: ".flow-board-header-edit-view form")
+ textarea(:edit_header_textbox, css: ".flow-board-header-edit-view textarea")
+
+ a(:author_link, css: ".flow-author a", index: 0)
+ a(:cancel_button, text: "Cancel")
+
+ # XXX (mattflaschen, 2014-06-24): This is broken; there is no
+ # flow-topic-reply-form anywhere in Flow outside this file.
+ # Also, this should be named to distinguish between top-level posts and regular replies.
+ textarea(:comment_field, css: 'form.flow-topic-reply-form > textarea[name="topic_content"]')
+ button(:comment_reply_save, css: "form.flow-topic-reply-form .flow-reply-submit")
+ div(:flow_topics, class: "flow-topics")
+
+ # Dialogs
+ div(:dialog, css: ".flow-ui-modal")
+ textarea(:dialog_input, name: "topic_reason")
+ button(:dialog_cancel, css: "a.mw-ui-destructive:nth-child(2)")
+ button(:dialog_submit_delete, text: "Delete")
+ button(:dialog_submit_hide, text: "Hide")
+ button(:dialog_submit_suppress, text: "Suppress")
+
+ # Posts
+ ## Highlighted post
+ div(:highlighted_post, css: ".flow-post-highlighted")
+
+ ## First topic
+ div(:flow_first_topic, css: ".flow-topic", index: 0)
+ h2(:flow_first_topic_heading, css: ".flow-topic h2", index: 0)
+ # todo this is poor naming, it's really the first_topic_first_post_content
+ div(:flow_first_topic_body, css: ".flow-topic .flow-post-content", index: 0)
+ div(:flow_first_topic_moderation_msg) do |page|
+ page.flow_first_topic_element.div_element(css: "div.flow-topic-titlebar div.flow-moderated-topic-title")
+ end
+
+ div(:flow_first_topic_summary) do |page|
+ page.flow_first_topic_element.div_element(css: ".flow-topic-summary")
+ end
+ div(:flow_first_topic_original_post, css: ".flow-post", index: 0)
+ a(:flow_first_topic_original_post_edit) do |page|
+ page.flow_first_topic_original_post_element.link_element(text: "Edit")
+ end
+ a(:flow_first_topic_original_post_reply) do |page|
+ page.flow_first_topic_original_post_element.link_element(text: "Reply")
+ end
+ div(:flow_second_topic_heading, css: ".flow-topic", index: 1)
+
+ ### Hover over username behaviour
+ span(:usertools, css: '.mw-usertoollinks')
+ a(:usertools_talk_link) do |page|
+ page.usertools_element.link_element(text: 'Talk')
+ end
+ a(:usertools_block_user_link) do |page|
+ page.usertools_element.link_element(text: 'block')
+ end
+
+ ### First Topic actions menu
+
+ # For topic collapsing testing
+ # Works around CSS descendant selector problem (https://github.com/cheezy/page-object/issues/222)
+ div(:first_moderated_topic, css: '.flow-topic.flow-topic-moderated', index: 0)
+
+ div(:first_moderated_topic_titlebar) do |page|
+ page.first_moderated_topic_element.div_element(css: '.flow-topic-titlebar')
+ end
+
+ div(:first_moderated_message) do |page|
+ page.first_moderated_topic_titlebar_element.div_element(css: '.flow-moderated-topic-title')
+ end
+
+ h2(:first_moderated_topic_title) do |page|
+ page.first_moderated_topic_titlebar_element.h2_element(class: 'flow-topic-title')
+ end
+
+ div(:first_moderated_topic_post_content) do |page|
+ page.first_moderated_topic_element.div_element(class: 'flow-post', index: 0).div_element(class: 'flow-post-main').div_element(class: 'flow-post-content')
+ end
+
+ # Topic actions menu (all belonging to the first post)
+ a(:topic_actions_link, css: ".flow-topic .flow-topic-titlebar .flow-menu-js-drop a", index: 0)
+ ul(:topic_actions_menu, css: ".flow-topic .flow-topic-titlebar .flow-menu ul", index: 0)
+ a(:topic_hide_button) do |page|
+ page.topic_actions_menu_element.link_element(text: "Hide topic")
+ end
+ a(:topic_delete_button) do |page|
+ page.topic_actions_menu_element.link_element(text: "Delete topic")
+ end
+ a(:topic_suppress_button) do |page|
+ page.topic_actions_menu_element.link_element(text: "Suppress topic")
+ end
+ a(:permalink_button) do |page|
+ page.topic_actions_menu_element.link_element(text: "Permalink")
+ end
+ a(:edit_title_button) do |page|
+ page.topic_actions_menu_element.link_element(text: "Edit title")
+ end
+ a(:topic_lock_button) do |page|
+ page.topic_actions_menu_element.link_element(title: "Lock topic")
+ end
+ a(:topic_unlock_button) do |page|
+ page.topic_actions_menu_element.link_element(title: "Unlock topic")
+ end
+
+ ## Lock topic workflow
+ form(:topic_lock_form, css: ".flow-edit-form")
+ textarea(:topic_lock_form_reason, css: ".flow-edit-form textarea")
+ button(:topic_lock_form_lock_button, css: ".flow-edit-form .mw-ui-constructive")
+ button(:topic_lock_form_cancel_button, css: ".flow-edit-form .mw-ui-destructive")
+ div(:flow_reason, class: "flow-moderated-topic-reason")
+
+ ### Editing title of first topic
+ text_field(:title_edit, css: ".flow-topic-titlebar form .mw-ui-input", index: 0)
+ button(:change_title_save, css: ".flow-topic-titlebar form .mw-ui-constructive")
+
+ ### Post meta actions
+ span(:post_meta_actions, css: ".flow-post .flow-post-meta-actions", index: 0)
+ a(:edit_post) do |page|
+ page.post_meta_actions_element.link_element(title: "Edit")
+ end
+ a(:thank_button) do |page|
+ page.post_meta_actions_element.link_element(css: ".mw-thanks-flow-thank-link", index: 0)
+ end
+ span(:thanked_button) do |page|
+ page.post_meta_actions_element.span_element(css: ".mw-thanks-flow-thanked", index: 0)
+ end
+
+ ### First post of first topic actions menu
+ a(:post_actions_link, css: ".flow-topic .flow-post .flow-menu-js-drop a", index: 0)
+ ul(:post_actions_menu, css: ".flow-topic .flow-post .flow-menu ul", index: 0)
+ a(:hide_button) do |page|
+ page.post_actions_menu_element.link_element(title: "Hide")
+ end
+ a(:delete_button) do |page|
+ page.post_actions_menu_element.link_element(title: "Delete")
+ end
+ a(:suppress_button) do |page|
+ page.post_actions_menu_element.link_element(title: "Suppress")
+ end
+
+ ### Replies to top post
+ #### 1st reply
+ # @todo: This is broken. It should be clearly possible to distinguish between the top reply and
+ # the top post. There is an element .flow-replies which appears to be empty.
+ div(:first_reply, css: '.flow-post', index: 1)
+ div(:first_reply_body) do |page|
+ page.first_reply_element.div_element(css: '.flow-post-content')
+ end
+
+ #### 3rd reply
+ # @todo: Should be index: 2, but sadly no way to distinguish replies from original post
+ div(:third_reply, css: '.flow-post', index: 3)
+ div(:third_reply_moderation_msg) do |page|
+ page.third_reply_element.span_element(css: '.flow-moderated-post-content', index: 0)
+ end
+ div(:third_reply_content) do |page|
+ page.third_reply_element.div_element(css: '.flow-post-content', index: 0)
+ end
+
+ a(:third_post_actions_link, css: ".flow-topic .flow-post .flow-menu-js-drop a", index: 3)
+ ul(:third_post_actions_menu, css: ".flow-topic .flow-post .flow-menu ul", index: 3)
+ a(:actions_link_permalink_3rd_comment) do |page|
+ page.third_post_actions_menu_element.link_element(text: "Permalink")
+ end
+ a(:actions_link_hide_3rd_comment) do |page|
+ page.third_post_actions_menu_element.link_element(text: "Hide")
+ end
+
+ # New topic creation
+ form(:new_topic_form, css: ".flow-newtopic-form")
+ text_field(:new_topic_title, name: "topiclist_topic")
+ textarea(:new_topic_body, name: "topiclist_content")
+ button(:new_topic_cancel, css: ".flow-newtopic-form .mw-ui-destructive")
+ button(:new_topic_preview, css: ".flow-newtopic-form .mw-ui-progressive")
+ # FIXME: Remove flow-ui-constructive reference when cache has cleared
+ button(:new_topic_save, css: ".flow-newtopic-form .mw-ui-constructive, .flow-newtopic-form .flow-ui-constructive")
+
+ # Replying
+ # TODO (mattflaschen, 2014-06-24): Should distinguish between
+ # top-level replies to the topic, and replies to regular posts
+ form(:new_reply_form, css: ".flow-reply-form")
+ # Is an input when not focused, textarea when focused
+ textarea(:new_reply_input, css: ".flow-reply-form .mw-ui-input")
+ button(:new_reply_cancel, css: ".flow-reply-form .mw-ui-destructive")
+ button(:new_reply_preview, css: ".flow-reply-form .mw-ui-progressive")
+ button(:new_reply_save, css: ".flow-reply-form .mw-ui-constructive")
+ button(:keep_editing, text: "Keep editing")
+ div(:preview_warning, css: ".flow-preview-warning")
+
+ # Editing post workflow
+ text_area(:post_edit, css: ".flow-edit-post-form textarea")
+ button(:change_post_save, css: ".flow-edit-post-form .mw-ui-constructive")
+
+ button(:preview_button, class: "mw-ui-button flow-preview-submit")
+ div(:small_spinner, class: "mw-spinner mw-spinner-small mw-spinner-inline")
+
+ button(:edit_header_save, text: "Save header")
+
+ # No javascript elements
+ button(:no_javascript_add_topic, text: "Add topic")
+ div(:no_javascript_page_content_body, class: "flow-post-content")
+ div(:no_javascript_page_content_title, class: "flow-topic-titlebar")
+ div(:no_javascript_page_flow_topics, class: "flow-topics")
+ button(:no_javascript_reply, text: "Reply")
+ textarea(:no_javascript_reply_form, name: "topic_content")
+ a(:no_javascript_start_reply, href: /action=reply/)
+ a(:no_javascript_start_topic, href: /action=new-topic/)
+ textarea(:no_javascript_topic_body_text, name: "topiclist_content")
+ text_field(:no_javascript_topic_title_text, name: "topiclist_topic")
+
+ # Sorting
+ a(:newest_topics_link, text: "Newest topics")
+ a(:recently_active_topics_choice, href: /topiclist_sortby=updated/)
+ a(:recently_active_topics_link, text: "Recently active topics")
+ a(:newest_topics_choice, href: /topiclist_sortby=newest/)
+
+ ## Watch and unwatch links
+ div(:first_topic_watchlist_container, css: ".flow-topic-watchlist", index: 0)
+ a(:first_topic_watch_link) do |page|
+ page.first_topic_watchlist_container_element.link_element(css: ".flow-watch-link-watch")
+ end
+ a(:first_topic_unwatch_link) do |page|
+ page.first_topic_watchlist_container_element.link_element(css: ".flow-watch-link-unwatch")
+ end
+
+ a(:board_unwatch_link, href: /Flow_QA&action=unwatch/)
+ a(:board_watch_link, href: /Flow_QA&action=watch/)
+end
diff --git a/Flow/tests/browser/features/support/pages/new_flow_page.rb b/Flow/tests/browser/features/support/pages/new_flow_page.rb
new file mode 100644
index 00000000..16ed9c6e
--- /dev/null
+++ b/Flow/tests/browser/features/support/pages/new_flow_page.rb
@@ -0,0 +1,7 @@
+require "page-object"
+
+class NewFlowPage < FlowPage
+ include URL
+ # MEDIAWIKI_URL must have User_talk in $wgFlowOccupyNamespaces.
+ page_url URL.url("User_talk:New page " + Array.new(8) { [*'0'..'9', *'a'..'z', *'A'..'Z'].sample }.join)
+end
diff --git a/Flow/tests/browser/features/support/pages/recent_changes_page.rb b/Flow/tests/browser/features/support/pages/recent_changes_page.rb
new file mode 100644
index 00000000..5941295b
--- /dev/null
+++ b/Flow/tests/browser/features/support/pages/recent_changes_page.rb
@@ -0,0 +1,8 @@
+class RecentChangesPage
+ include PageObject
+
+ include URL
+ page_url URL.url('Special:RecentChanges')
+
+ div(:recent_changes, class: 'mw-changeslist')
+end
diff --git a/Flow/tests/browser/features/support/pages/user_page.rb b/Flow/tests/browser/features/support/pages/user_page.rb
new file mode 100644
index 00000000..baf8b769
--- /dev/null
+++ b/Flow/tests/browser/features/support/pages/user_page.rb
@@ -0,0 +1,9 @@
+class UserPage
+ include PageObject
+
+ include URL
+ # MEDIAWIKI_URL must have this in $wgFlowOccupyPages array or $wgFlowOccupyNamespaces.
+ page_url URL.url("User talk:ENV['MEDIAWIKI_USER']")
+
+ h1(:first_heading, id: "firstHeading")
+end
diff --git a/Flow/tests/browser/features/thank.feature b/Flow/tests/browser/features/thank.feature
new file mode 100644
index 00000000..a3479223
--- /dev/null
+++ b/Flow/tests/browser/features/thank.feature
@@ -0,0 +1,23 @@
+@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org
+Feature: Thank author of a Flow post
+
+ Scenario: Anon does not see Thank button
+ Given the "Talk:Flow QA" page has a new unmoderated topic created by me
+ And I am on Flow page
+ Then I should not see a Thank button
+
+ @login
+ Scenario: Thank the user
+ Given I am logged in
+ And the most recent topic on "Talk:Flow QA" is written by another user
+ And I am on Flow page
+ And I see a Thank button
+ When I click on the Thank button
+ Then I should see the Thank button be replaced with Thanked button
+
+ @login
+ Scenario: I cannot thank my own post
+ Given I am logged in
+ And the "Talk:Flow QA" page has a new unmoderated topic created by me
+ And I am on Flow page
+ Then I should not see a Thank button
diff --git a/Flow/tests/browser/features/watch.feature b/Flow/tests/browser/features/watch.feature
new file mode 100644
index 00000000..b7460834
--- /dev/null
+++ b/Flow/tests/browser/features/watch.feature
@@ -0,0 +1,36 @@
+@test2.wikipedia.org @en.wikipedia.beta.wmflabs.org @phantomjs
+Feature: Watching/Unwatching Boards and Topics
+
+ Scenario: Watch topic
+ Given I am logged in
+ And I am on Flow page
+ And I have created a Flow topic
+ And I am not watching the Flow topic
+ When I click the Watch Topic link
+ Then I should see the Unwatch Topic link
+
+ Scenario: Unwatch topic
+ Given I am logged in
+ And I am on Flow page
+ And I have created a Flow topic
+ And I am watching the Flow topic
+ When I click the Unwatch Topic link
+ Then I should see the Watch Topic link
+
+ Scenario: Watch board
+ Given I am logged in
+ And I am on Flow page
+ And I am not watching the Flow board
+ When I click the Watch Board link
+ Then I should see the Unwatch Board link
+
+ Scenario: Unwatch board
+ Given I am logged in
+ And I am on Flow page
+ And I am watching the Flow board
+ When I click the Unwatch Board link
+ Then I should see the Watch Board link
+
+ Scenario: No watch links for anonymous users
+ When I am on Flow page
+ Then I should not see any watch links
diff --git a/Flow/tests/externals/phantomjs-qunit-runner.js b/Flow/tests/externals/phantomjs-qunit-runner.js
new file mode 100644
index 00000000..4b1d38b1
--- /dev/null
+++ b/Flow/tests/externals/phantomjs-qunit-runner.js
@@ -0,0 +1,127 @@
+/*
+ * QtWebKit-powered headless test runner using PhantomJS
+ *
+ * PhantomJS binaries: http://phantomjs.org/download.html
+ * Requires PhantomJS 1.6+ (1.7+ recommended)
+ *
+ * Run with:
+ * phantomjs runner.js [url-of-your-qunit-testsuite]
+ *
+ * e.g.
+ * phantomjs runner.js http://localhost/qunit/test/index.html
+ */
+
+/*jshint latedef:false */
+/*global phantom:false, require:false, console:false, window:false, QUnit:false */
+
+(function() {
+ 'use strict';
+
+ var args = require('system').args;
+
+ // arg[0]: scriptName, args[1...]: arguments
+ if (args.length !== 2) {
+ console.error('Usage:\n phantomjs runner.js [url-of-your-qunit-testsuite]');
+ phantom.exit(1);
+ }
+
+ var url = args[1],
+ page = require('webpage').create();
+
+ // Route `console.log()` calls from within the Page context to the main Phantom context (i.e. current `this`)
+ page.onConsoleMessage = function(msg) {
+ console.log(msg);
+ };
+
+ page.onInitialized = function() {
+ page.evaluate(addLogging);
+ };
+
+ page.onCallback = function(message) {
+ var result,
+ failed;
+
+ if (message) {
+ if (message.name === 'QUnit.done') {
+ result = message.data;
+ failed = !result || result.failed;
+
+ phantom.exit(failed ? 1 : 0);
+ }
+ }
+ };
+
+ page.open(url, function(status) {
+ if (status !== 'success') {
+ console.error('Unable to access network: ' + status);
+ phantom.exit(1);
+ } else {
+ // Cannot do this verification with the 'DOMContentLoaded' handler because it
+ // will be too late to attach it if a page does not have any script tags.
+ var qunitMissing = page.evaluate(function() { return (typeof QUnit === 'undefined' || !QUnit); });
+ if (qunitMissing) {
+ console.error('The `QUnit` object is not present on this page.');
+ phantom.exit(1);
+ }
+
+ // Do nothing... the callback mechanism will handle everything!
+ }
+ });
+
+ function addLogging() {
+ window.document.addEventListener('DOMContentLoaded', function() {
+ var current_test_assertions = [];
+
+ QUnit.log(function(details) {
+ var response;
+
+ // Ignore passing assertions
+ if (details.result) {
+ return;
+ }
+
+ response = details.message || '';
+
+ if (typeof details.expected !== 'undefined') {
+ if (response) {
+ response += ', ';
+ }
+
+ response += 'expected: ' + details.expected + ', but was: ' + details.actual;
+ if (details.source) {
+ response += "\n" + details.source;
+ }
+ }
+
+ current_test_assertions.push('Failed assertion: ' + response);
+ });
+
+ QUnit.testDone(function(result) {
+ var i,
+ len,
+ name = result.module + ': ' + result.name;
+
+ if (result.failed) {
+ console.log('Test failed: ' + name);
+
+ for (i = 0, len = current_test_assertions.length; i < len; i++) {
+ console.log(' ' + current_test_assertions[i]);
+ }
+ }
+
+ current_test_assertions.length = 0;
+ });
+
+ QUnit.done(function(result) {
+ console.log('Took ' + result.runtime + 'ms to run ' + result.total + ' tests. ' + result.passed + ' passed, ' + result.failed + ' failed.');
+
+ if (typeof window.callPhantom === 'function') {
+ window.callPhantom({
+ 'name': 'QUnit.done',
+ 'data': result
+ });
+ }
+ });
+ }, false);
+ }
+})();
diff --git a/Flow/tests/phpunit/Block/TopicListTest.php b/Flow/tests/phpunit/Block/TopicListTest.php
new file mode 100644
index 00000000..887f58f7
--- /dev/null
+++ b/Flow/tests/phpunit/Block/TopicListTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Flow\Tests\Block;
+
+use Flow\Block\TopicListBlock;
+use Flow\Container;
+use Flow\Model\Workflow;
+use Title;
+use User;
+
+class TopicListTest extends \MediaWikiTestCase {
+
+ public function testSortByOption() {
+ $user = User::newFromId( 1 );
+ $user->setOption( 'flow-topiclist-sortby', '' );
+
+ $ctx = $this->getMock( 'IContextSource' );
+ $ctx->expects( $this->any() )
+ ->method( 'getUser' )
+ ->will( $this->returnValue( $user ) );
+
+ $workflow = Workflow::create( 'discussion', Title::newFromText( 'Talk:Flow_QA' ) );
+ $block = new TopicListBlock( $workflow, Container::get( 'storage' ) );
+ $block->init( $ctx, 'view' );
+
+ $res = $block->renderApi( array(
+ ) );
+ $this->assertEquals( 'newest', $res['sortby'], 'With no sortby defaults to newest' );
+
+ $res = $block->renderApi( array(
+ 'sortby' => 'foo',
+ ) );
+ $this->assertEquals( 'newest', $res['sortby'], 'With invalid sortby defaults to newest' );
+
+ $res = $block->renderApi( array(
+ 'sortby' => 'updated',
+ ) );
+ $this->assertEquals( 'updated', $res['sortby'], 'With sortby updated output changes to updated' );
+ $res = $block->renderApi( array(
+ ) );
+ $this->assertEquals( 'newest', $res['sortby'], 'Sort still defaults to newest' );
+
+ $res = $block->renderApi( array(
+ 'sortby' => 'updated',
+ 'savesortby' => '1',
+ ) );
+ $this->assertEquals( 'updated', $res['sortby'], 'Request saving sortby option' );
+
+ $res = $block->renderApi( array(
+ ) );
+ $this->assertEquals( 'updated', $res['sortby'], 'Default sortby now changed to updated' );
+
+ $res = $block->renderApi( array(
+ 'sortby' => '',
+ ) );
+ $this->assertEquals( 'updated', $res['sortby'], 'Default sortby with blank sortby still uses user default' );
+ }
+}
diff --git a/Flow/tests/phpunit/BlockFactoryTest.php b/Flow/tests/phpunit/BlockFactoryTest.php
new file mode 100644
index 00000000..3413da99
--- /dev/null
+++ b/Flow/tests/phpunit/BlockFactoryTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\BlockFactory;
+use Flow\NotificationController;
+
+/**
+ * @group Flow
+ */
+class BlockFactoryTest extends FlowTestCase {
+
+ public function provideDataCreateBlocks() {
+ return array (
+ array( 'discussion', array( 'Flow\Block\HeaderBlock', 'Flow\Block\TopicListBlock', 'Flow\Block\BoardHistoryBlock' ) ),
+ array( 'topic', array( 'Flow\Block\TopicBlock', 'Flow\Block\TopicSummaryBlock' ) ),
+ );
+ }
+
+ /**
+ * @dataProvider provideDataCreateBlocks
+ */
+ public function testCreateBlocks( $workflowType, $expectedResults ) {
+ $factory = $this->createBlockFactory();
+ $workflow = $this->mockWorkflow( $workflowType );
+
+ $blocks = $factory->createBlocks( $workflow );
+ $this->assertEquals( count( $blocks ), count( $expectedResults ) );
+
+ $results = array();
+ foreach ( $blocks as $obj ) {
+ $results[] = get_class( $obj );
+ }
+ $this->assertEquals( $results, $expectedResults );
+ }
+
+ /**
+ * @expectedException \Flow\Exception\InvalidInputException
+ */
+ public function testCreateBlocksWithInvalidInputException() {
+ $factory = $this->createBlockFactory();
+ $workflow = $this->mockWorkflow( 'a-bad-database-flow-workflow' );
+ // Trigger InvalidInputException
+ $factory->createBlocks( $workflow );
+ }
+
+ protected function createBlockFactory() {
+ $storage = $this->getMockBuilder( '\Flow\Data\ManagerGroup' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $rootPostLoader = $this->getMockBuilder( '\Flow\Repository\RootPostLoader' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ return new BlockFactory( $storage, $rootPostLoader );
+ }
+
+ protected function mockWorkflow( $type ) {
+ $workflow = $this->getMockBuilder( '\Flow\Model\Workflow' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $workflow->expects( $this->any() )
+ ->method( 'getType' )
+ ->will( $this->returnValue( $type ) );
+
+ return $workflow;
+ }
+}
diff --git a/Flow/tests/phpunit/Collection/PostCollectionTest.php b/Flow/tests/phpunit/Collection/PostCollectionTest.php
new file mode 100644
index 00000000..629f48a9
--- /dev/null
+++ b/Flow/tests/phpunit/Collection/PostCollectionTest.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Flow\Tests\Collection;
+
+use Flow\Collection\PostCollection;
+use Flow\Tests\PostRevisionTestCase;
+
+/**
+ * @group Flow
+ * @group Database
+ */
+class PostCollectionTest extends PostRevisionTestCase {
+ /**
+ * @var array
+ */
+ protected $tablesUsed = array( 'flow_revision', 'flow_tree_revision' );
+
+ protected function setUp() {
+ parent::setUp();
+
+ // recent changes isn't fully setup here, just skip it
+ $this->clearExtraLifecycleHandlers();
+
+ // generate a post with multiple revisions
+ $revision = $this->generateObject( array(
+ 'rev_content' => 'first revision',
+ ) );
+ $this->store( $revision );
+
+ $revision = $this->generateObject( array(
+ 'rev_content' => 'second revision',
+ 'rev_change_type' => 'edit-post',
+ 'rev_parent_id' => $revision->getRevisionId()->getBinary(),
+ 'tree_rev_descendant_id' => $revision->getPostId()->getBinary(),
+ 'rev_type_id' => $revision->getPostId()->getBinary(),
+ ) );
+ $this->store( $revision );
+
+ $revision = $this->generateObject( array(
+ 'rev_content' => 'third revision',
+ 'rev_change_type' => 'edit-post',
+ 'rev_parent_id' => $revision->getRevisionId()->getBinary(),
+ 'tree_rev_descendant_id' => $revision->getPostId()->getBinary(),
+ 'rev_type_id' => $revision->getPostId()->getBinary(),
+ ) );
+ $this->store( $revision );
+ }
+
+ public function testGetCollection() {
+ $revision = $this->revisions[0];
+ $collection = $revision->getCollection();
+ $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection );
+ }
+
+ public function testNewFromId() {
+ $uuidPost = $this->revisions[0]->getPostId();
+ $collection = PostCollection::newFromId( $uuidPost );
+ $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection );
+ }
+
+ public function testNewFromRevision() {
+ $revision = $this->revisions[0];
+ $collection = PostCollection::newFromRevision( $revision );
+ $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection );
+ }
+
+ public function testGetRevision() {
+ $collection = $this->revisions[0]->getCollection();
+
+ $expected = $this->revisions[1];
+ $revision = $collection->getRevision( $expected->getRevisionId() );
+ $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision );
+ $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) );
+ }
+
+ public function testGetLastRevision() {
+ $collection = $this->revisions[0]->getCollection();
+
+ $expected = end( $this->revisions );
+ $revision = $collection->getLastRevision();
+
+ $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision );
+ $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) );
+ }
+
+ public function testGetFirstRevision() {
+ $collection = $this->revisions[1]->getCollection();
+
+ $expected = reset( $this->revisions );
+ $revision = $collection->getFirstRevision();
+
+ $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision );
+ $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) );
+ }
+
+ public function testGetNextRevision() {
+ $start = $this->revisions[0];
+ $collection = $start->getCollection();
+
+ $expected = $this->revisions[1];
+ $revision = $collection->getNextRevision( $start );
+
+ $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision );
+ $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) );
+ }
+
+ public function testGetPrevRevision() {
+ $start = $this->revisions[1];
+ $collection = $start->getCollection();
+
+ $expected = $this->revisions[0];
+ $revision = $collection->getPrevRevision( $start );
+
+ $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision );
+ $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) );
+ }
+
+ public function testGetAllRevision() {
+ $collection = $this->revisions[1]->getCollection();
+
+ $revisions = $collection->getAllRevisions();
+
+ $this->assertEquals( count( $this->revisions ), count( $revisions ) );
+ }
+}
diff --git a/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php b/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php
new file mode 100644
index 00000000..e95fa589
--- /dev/null
+++ b/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php
@@ -0,0 +1,290 @@
+<?php
+
+namespace Flow\Tests\Collection;
+
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\PostRevision;
+use Flow\Model\AbstractRevision;
+use Flow\RevisionActionPermissions;
+use Flow\Tests\PostRevisionTestCase;
+use Block;
+use User;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class RevisionCollectionPermissionsTest extends PostRevisionTestCase {
+ /**
+ * @var array
+ */
+ protected $tablesUsed = array( 'flow_revision', 'flow_tree_revision' );
+
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ /**
+ * Map of action name to moderation status, as helper for
+ * $this->generateRevision()
+ *
+ * @var array
+ */
+ protected $moderation = array(
+ 'restore-post' => AbstractRevision::MODERATED_NONE,
+ 'hide-post' => AbstractRevision::MODERATED_HIDDEN,
+ 'delete-post' => AbstractRevision::MODERATED_DELETED,
+ 'suppress-post' => AbstractRevision::MODERATED_SUPPRESSED,
+ );
+
+ /**
+ * @var User
+ */
+ protected
+ $blockedUser,
+ $anonUser,
+ $unconfirmedUser,
+ $confirmedUser,
+ $sysopUser,
+ $oversightUser;
+
+ /**
+ * @var Block
+ */
+ protected $block;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->clearExtraLifecycleHandlers();
+
+ // We don't want local config getting in the way of testing whether or
+ // not our permissions implementation works well.
+ // This will load default $wgGroupPermissions + Flow settings, so we can
+ // test if permissions work well, regardless of any custom config.
+ global $IP, $wgFlowGroupPermissions;
+ $wgGroupPermissions = array();
+ require "$IP/includes/DefaultSettings.php";
+ $wgGroupPermissions = array_merge_recursive( $wgGroupPermissions, $wgFlowGroupPermissions );
+ $this->setMwGlobals( 'wgGroupPermissions', $wgGroupPermissions );
+
+ // When external store is used, data is written to "blobs" table, which
+ // by default doesn't exist - let's just not use externalstorage in test
+ $this->setMwGlobals( 'wgFlowExternalStore', false );
+
+ // load actions object
+ $this->actions = Container::get( 'flow_actions' );
+
+ // block a user
+ $blockedUser = $this->blockedUser();
+ $this->block = new Block( $blockedUser->getName(), $blockedUser->getID() );
+ $this->block->insert();
+ // ensure that block made it into the database
+ wfGetDB( DB_MASTER )->commit( __METHOD__, 'flush' );
+ }
+
+ /**
+ * Provides User, permissions test action, and revision actions (with
+ * expected permission results for test action).
+ *
+ * Basically: a new post is created and the actions in $actions are
+ * performed. After that, we'll check if $action is allowed on all of those
+ * revisions, with the expected true/false value from $actions as result.
+ *
+ * @return array
+ */
+ public function permissionsProvider() {
+ return array(
+ // irregardless of current status, if a user has no permissions for
+ // a specific revision, he can't see it
+ array( $this->confirmedUser(), 'view', array(
+ // Key is the moderation action; value is the 'view' permission
+ // for that corresponding revision after all moderation is done.
+ // In this case, a post will be created with 3 revisions:
+ // [1] create post, [2] suppress, [3] restore
+ // After creating all revisions, all of these will be tested for
+ // 'view' permissions against that specific revision. Here:
+ // [1] should be visible (this + last rev not suppressed)
+ // [2] should not (was suppressed)
+ // [3] should be visible again (undid suppression)
+ array( 'new-post' => true ),
+ array( 'suppress-post' => false ),
+ array( 'restore-post' => true ),
+ ) ),
+ array( $this->oversightUser(), 'view', array(
+ array( 'new-post' => true ),
+ array( 'suppress-post' => true ),
+ array( 'restore-post' => true ),
+ ) ),
+
+ // last moderation status should always bubble down to previous revs
+ array( $this->confirmedUser(), 'view', array(
+ array( 'new-post' => false ),
+ array( 'suppress-post' => false ),
+ array( 'restore-post' => false ),
+ array( 'suppress-post' => false ),
+ ) ),
+ array( $this->oversightUser(), 'view', array(
+ array( 'new-post' => true ),
+ array( 'suppress-post' => true ),
+ array( 'restore-post' => true ),
+ array( 'suppress-post' => true ),
+ ) ),
+
+ // bug 61715
+ array( $this->confirmedUser(), 'history', array(
+ array( 'new-post' => false ),
+ array( 'suppress-post' => false ),
+ ) ),
+ array( $this->confirmedUser(), 'history', array(
+ array( 'new-post' => true ),
+ array( 'suppress-post' => false ),
+ array( 'restore-post' => false ),
+ ) ),
+ );
+ }
+
+ /**
+ * @dataProvider permissionsProvider
+ */
+ public function testPermissions( User $user, $permissionAction, $actions ) {
+ $permissions = new RevisionActionPermissions( $this->actions, $user );
+
+ // we'll have to process this in 2 steps: first do all of the actions,
+ // so we have a full tree of moderated revisions
+ $revision = null;
+ $revisions = array();
+ $debug = array();
+ foreach ( $actions as $action ) {
+ $expect = current( $action );
+ $action = key( $action );
+ $debug[] = $action . ':' . ( $expect ? 'true' : 'false' );
+ $revisions[] = $revision = $this->generateRevision( $action, $revision );
+ }
+
+ // commit pending db transaction
+ Container::get( 'db.factory' )->getDB( DB_MASTER )->commit( __METHOD__, 'flush' );
+
+ $debug = implode( ' ', $debug );
+ // secondly, iterate all revisions & see if expected permissions line up
+ foreach ( $actions as $action ) {
+ $expected = current( $action );
+ $revision = array_shift( $revisions );
+ $this->assertEquals(
+ $expected,
+ $permissions->isAllowed( $revision, $permissionAction ),
+ 'User ' . $user->getName() . ' should ' . ( $expected ? '' : 'not ' ) . 'be allowed action ' . $permissionAction . ' on revision ' . key( $action ) . ' : ' . $debug . ' : ' . json_encode( $revision::toStorageRow( $revision ) )
+ );
+ }
+ }
+
+ protected function blockedUser() {
+ if ( !$this->blockedUser ) {
+ $this->blockedUser = User::newFromName( 'UTFlowBlockee' );
+ $this->blockedUser->addToDatabase();
+ // note: the block will be added in setUp & deleted in tearDown;
+ // otherwise this is just any regular user
+ }
+
+ return $this->blockedUser;
+ }
+
+ protected function anonUser() {
+ if ( !$this->anonUser ) {
+ $this->anonUser = new User;
+ }
+
+ return $this->anonUser;
+ }
+
+ protected function unconfirmedUser() {
+ if ( !$this->unconfirmedUser ) {
+ $this->unconfirmedUser = User::newFromName( 'UTFlowUnconfirmed' );
+ $this->unconfirmedUser->addToDatabase();
+ $this->unconfirmedUser->addGroup( 'user' );
+ }
+
+ return $this->unconfirmedUser;
+ }
+
+ protected function confirmedUser() {
+ if ( !$this->confirmedUser ) {
+ $this->confirmedUser = User::newFromName( 'UTFlowConfirmed' );
+ $this->confirmedUser->addToDatabase();
+ $this->confirmedUser->addGroup( 'autoconfirmed' );
+ }
+
+ return $this->confirmedUser;
+ }
+
+ protected function sysopUser() {
+ if ( !$this->sysopUser ) {
+ $this->sysopUser = User::newFromName( 'UTFlowSysop' );
+ $this->sysopUser->addToDatabase();
+ $this->sysopUser->addGroup( 'sysop' );
+ }
+
+ return $this->sysopUser;
+ }
+
+ protected function oversightUser() {
+ if ( !$this->oversightUser ) {
+ $this->oversightUser = User::newFromName( 'UTFlowOversight' );
+ $this->oversightUser->addToDatabase();
+ $this->oversightUser->addGroup( 'oversight' );
+ }
+
+ return $this->oversightUser;
+ }
+
+ /**
+ * @param string $action
+ * @param AbstractRevision|null $parent
+ * @param array $overrides
+ * @return PostRevision
+ */
+ public function generateRevision( $action, AbstractRevision $parent = null, array $overrides = array() ) {
+ $overrides['rev_change_type'] = $action;
+
+ if ( $parent ) {
+ $overrides['rev_parent_id'] = $parent->getRevisionId()->getBinary();
+ $overrides['tree_rev_descendant_id'] = $parent->getPostId()->getBinary();
+ $overrides['rev_type_id'] = $parent->getPostId()->getBinary();
+ }
+
+ switch ( $action ) {
+ case 'restore-post':
+ $overrides += array(
+ 'rev_mod_state' => $this->moderation[$action], // AbstractRevision::MODERATED_NONE
+ 'rev_mod_user_id' => null,
+ 'rev_mod_user_ip' => null,
+ 'rev_mod_timestamp' => null,
+ 'rev_mod_reason' => 'unit test',
+ );
+ break;
+
+ case 'hide-post':
+ case 'delete-post':
+ case 'suppress-post':
+ $overrides += array(
+ 'rev_mod_state' => $this->moderation[$action], // AbstractRevision::MODERATED_(HIDDEN|DELETED|SUPPRESSED)
+ 'rev_mod_user_id' => 1,
+ 'rev_mod_user_ip' => null,
+ 'rev_mod_timestamp' => wfTimestampNow(),
+ 'rev_mod_reason' => 'unit test',
+ );
+ break;
+
+ default:
+ // nothing special
+ break;
+ }
+
+ $revision = $this->generateObject( $overrides );
+ $this->store( $revision );
+
+ return $revision;
+ }
+}
diff --git a/Flow/tests/phpunit/ContainerTest.php b/Flow/tests/phpunit/ContainerTest.php
new file mode 100644
index 00000000..9dc7a1d8
--- /dev/null
+++ b/Flow/tests/phpunit/ContainerTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+
+/**
+ * @group Flow
+ */
+class ContainerTest extends FlowTestCase {
+
+ public function testInstantiateAll() {
+ $this->setMwGlobals( 'wgTitle', \Title::newMainPage() );
+ $container = Container::getContainer();
+
+ foreach ( $container->keys() as $key ) {
+ $this->assertNotNull( $container[$key], $key );
+ }
+ }
+
+ public function objectManagerKeyProvider() {
+ $tests = array();
+ foreach ( array_unique( Container::get( 'storage.manager_list' ) ) as $key ) {
+ $tests[] = array( $key );
+ }
+ return $tests;
+ }
+
+ /**
+ * @dataProvider objectManagerKeyProvider
+ */
+ public function testSomething( $key ) {
+ $c = Container::getContainer();
+ $this->assertNotNull( $c[$key] );
+ foreach ( $c["$key.indexes"] as $pos => $index ) {
+ $this->assertInstanceOf( 'Flow\Data\Index', $index, "At $key.indexes[$pos]" );
+ }
+ if ( isset( $c["$key.listeners"] ) ) {
+ foreach ( $c["$key.listeners"] as $pos => $listener ) {
+ $this->assertInstanceOf( "Flow\Data\LifecycleHandler", $listener, "At $key.listeners[$pos]" );
+ }
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php b/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php
new file mode 100644
index 00000000..1b5368eb
--- /dev/null
+++ b/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php
@@ -0,0 +1,273 @@
+<?php
+
+namespace Flow\Tests;
+
+use BagOStuff;
+use EmptyBagOStuff;
+use Flow\Data\BagOStuff\BufferedBagOStuff;
+use HashBagOStuff;
+use MediaWikiTestCase;
+use MultiWriteBagOStuff;
+use ObjectCache;
+
+/**
+ * @group Flow
+ */
+class BufferedBagOStuffTest extends MediaWikiTestCase {
+ /**
+ * @var BagOStuff
+ */
+ protected $cache;
+
+ /**
+ * @var BufferedBagOStuff
+ */
+ protected $bufferedCache;
+
+ /**
+ * Array of keys used in these tests, so we can clear them on tearDown.
+ *
+ * @var string[]
+ */
+ protected $keys = array( 'key', 'key2' );
+
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+ } else {
+ // no type defined - use simple hash
+ $this->cache = new HashBagOStuff;
+ }
+
+ $this->bufferedCache = new BufferedBagOStuff( $this->cache );
+ $this->bufferedCache->begin();
+ }
+
+ protected function tearDown() {
+ // make sure all keys written to in any of these tests are deleted from
+ // the real cache
+ foreach ( $this->keys as $key ) {
+ $this->cache->delete( $key );
+ }
+
+ parent::tearDown();
+ }
+
+ public function testGetAndSet() {
+ $this->bufferedCache->set( 'key', 'value' );
+
+ // check that the value is only set on bufferedCache, not yet on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+
+ $this->bufferedCache->commit();
+
+ // check that the value is also set on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value', $this->cache->get( 'key' ) );
+ }
+
+ public function testAdd() {
+ $this->bufferedCache->add( 'key', 'value' );
+
+ // check that the value is only set on bufferedCache, not yet on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+
+ $this->bufferedCache->commit();
+
+ // check that the value is also set on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value', $this->cache->get( 'key' ) );
+ }
+
+ public function testAddFailImmediately() {
+ $this->cache->set( 'key', 'value' );
+ $this->bufferedCache->add( 'key', 'value-2' );
+
+ $this->bufferedCache->commit();
+
+ // check that the value is not added on bufferedCache, nor on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value', $this->cache->get( 'key' ) );
+ }
+
+ public function testAddFailDeferred() {
+ $this->bufferedCache->add( 'key', 'value' );
+
+ // something else directly sets the key in the meantime...
+ $this->cache->set( 'key', 'value-2' );
+
+ // check that the value has been added to buffered cache but not yet to real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value-2', $this->cache->get( 'key' ) );
+
+ $this->bufferedCache->commit();
+
+ // check that the value failed to add and the key was properly cleared
+ $this->assertEquals( false, $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+ }
+
+ public function testDelete() {
+ $this->cache->set( 'key', 'value' );
+
+ $this->bufferedCache->delete( 'key' );
+
+ // check that the value has been deleted from bufferedcache (only)
+ $this->assertEquals( false, $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value', $this->cache->get( 'key' ) );
+
+ $this->bufferedCache->commit();
+
+ // check that the value has also been deleted from real cache
+ $this->assertEquals( false, $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+ }
+
+ public function testGetMulti() {
+ $localValues = array(
+ 'key' => 'value',
+ );
+ $cacheValues = array(
+ 'key2' => 'value2',
+ );
+
+ foreach ( $localValues as $key => $value ) {
+ $this->bufferedCache->set( $key, $value );
+ }
+
+ foreach ( $cacheValues as $key => $value ) {
+ $this->cache->set( $key, $value );
+ }
+
+ // check that we're able to read the values from both buffered & real cache
+ $this->assertEquals( $localValues + $cacheValues, $this->bufferedCache->getMulti( array_keys( $localValues + $cacheValues ) ) );
+
+ // tearDown will cleanup everything that's been stored via buffered cache,
+ // however, this one went directly to real cache - clean up!
+ $this->cache->delete( 'key2' );
+ }
+
+ public function testSetMulti() {
+ $this->bufferedCache->setMulti( array(
+ 'key' => 'value',
+ 'key2' => 'value2',
+ ) );
+
+ // check that the values are only set on bufferedCache, not yet on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value2', $this->bufferedCache->get( 'key2' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key2' ) );
+
+ $this->bufferedCache->commit();
+
+ // check that the values are also set on real cache
+ $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'value2', $this->bufferedCache->get( 'key2' ) );
+ $this->assertEquals( 'value', $this->cache->get( 'key' ) );
+ $this->assertEquals( 'value2', $this->cache->get( 'key2' ) );
+ }
+
+ public function testMerge() {
+ $this->cache->set( 'key', 'value' );
+
+ $callback = function( \BagOStuff $cache, $key, $value ) {
+ return 'merged-value';
+ };
+ $this->bufferedCache->merge( 'key', $callback );
+
+ $this->bufferedCache->commit();
+
+ // check that the values are merged both in buffered & real cache
+ $this->assertEquals( 'merged-value', $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( 'merged-value', $this->cache->get( 'key' ) );
+ }
+
+ // Can't make a test for merge to fail immediately: the buffered part is
+ // only in memory, for the current process. There is no way something else
+ // will be able to overwrite something in there.
+
+ public function testMergeFailDelayed() {
+ /*
+ * Test concurrent merges by forking this process, if:
+ * - not manually called with --use-bagostuff
+ * - pcntl_fork is supported by the system
+ * - cache type will correctly support calls over forks
+ */
+ $fork = (bool) $this->getCliArg( 'use-bagostuff' );
+ $fork &= function_exists( 'pcntl_fork' );
+ $fork &= !$this->cache instanceof HashBagOStuff;
+ $fork &= !$this->cache instanceof EmptyBagOStuff;
+ $fork &= !$this->cache instanceof MultiWriteBagOStuff;
+
+ if ( !$fork ) {
+ $this->markTestSkipped( "Unable to fork, can't test merge" );
+ }
+
+ $this->cache->set( 'key', 'value' );
+
+ $callback = function ( \BagOStuff $cache, $key, $value ) {
+ // prepend merged to whatever is in cache
+ return 'merged-' . (string) $value;
+ };
+ $this->bufferedCache->merge( 'key', $callback, 0, 1 );
+
+ // callback should take awhile now so that we can test concurrent merge attempts
+ $pid = pcntl_fork();
+ if ( $pid == -1 ) {
+ // can't fork, ignore this test...
+ } elseif ( $pid ) {
+ // wait a little, making sure that the child process is calling merge
+ usleep( 3000 );
+
+ // attempt a merge - this should fail to persist to real cache
+ $this->bufferedCache->commit();
+
+ // make sure the child's merge is completed and verify
+ usleep( 3000 );
+
+ // check that the values failed to merge
+ $this->assertEquals( false, $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+ } else {
+ $this->bufferedCache->commit();
+
+ // before exiting, tear down - MediaWikiTestCase will check for it
+ // on destruct & throw an exception if it wasn't called
+ $this->tearDown();
+
+ // Note: I'm not even going to check if the merge worked, I'll
+ // compare values in the parent process to test if this merge worked.
+ // I'm just going to exit this child process, since I don't want the
+ // child to output any test results (would be rather confusing to
+ // have test output twice)
+ exit;
+ }
+ }
+
+ public function testRollback() {
+ $this->cache->set( 'key', 'value' );
+
+ $this->bufferedCache->set( 'key', 'value-2' );
+ $this->bufferedCache->add( 'key2', 'value-2' );
+
+ // something else directly sets the key in the meantime...
+ $this->cache->set( 'key2', 'value' );
+
+ $this->bufferedCache->commit();
+
+ // both changes should have been "rolled back" and both keys should've
+ // been cleared, in both buffered & real cache
+ $this->assertEquals( false, $this->bufferedCache->get( 'key' ) );
+ $this->assertEquals( false, $this->bufferedCache->get( 'key2' ) );
+ $this->assertEquals( false, $this->cache->get( 'key' ) );
+ $this->assertEquals( false, $this->cache->get( 'key2' ) );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php b/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php
new file mode 100644
index 00000000..a386be08
--- /dev/null
+++ b/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Data\BagOStuff\LocalBufferedBagOStuff;
+use HashBagOStuff;
+use ObjectCache;
+
+/**
+ * Runs the exact same set of tests as BufferedBagOStuffTest, but with a
+ * LocalBufferedBagOStuff object (where get requests are also cached)
+ * @group Flow
+ */
+class LocalBufferedBagOStuffTest extends BufferedBagOStuffTest {
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+ } else {
+ // no type defined - use simple hash
+ $this->cache = new HashBagOStuff;
+ }
+
+ $this->bufferedCache = new LocalBufferedBagOStuff( $this->cache );
+ $this->bufferedCache->begin();
+ }
+}
diff --git a/Flow/tests/phpunit/Data/BufferedCacheTest.php b/Flow/tests/phpunit/Data/BufferedCacheTest.php
new file mode 100644
index 00000000..945b721b
--- /dev/null
+++ b/Flow/tests/phpunit/Data/BufferedCacheTest.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Data\BagOStuff\LocalBufferedBagOStuff;
+use Flow\Data\BufferedCache;
+use HashBagOStuff;
+use ObjectCache;
+
+/**
+ * Runs the exact same set of tests as BufferedBagOStuffTest, but with a
+ * LocalBufferedCache object (with static expiry time)
+ * @group Flow
+ */
+class BufferedCacheTest extends BufferedBagOStuffTest {
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+ } else {
+ // no type defined - use simple hash
+ $this->cache = new HashBagOStuff;
+ }
+
+ $cache = new LocalBufferedBagOStuff( $this->cache );
+ $this->bufferedCache = new BufferedCache( $cache, 30 );
+ $this->bufferedCache->begin();
+ }
+}
diff --git a/Flow/tests/phpunit/Data/CachingObjectMapperTest.php b/Flow/tests/phpunit/Data/CachingObjectMapperTest.php
new file mode 100644
index 00000000..3c7ec013
--- /dev/null
+++ b/Flow/tests/phpunit/Data/CachingObjectMapperTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Data\Mapper\CachingObjectMapper;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class CachingObjectManagerTest extends FlowTestCase {
+
+ public function testReturnsSameObject() {
+ $mapper = $this->createMapper();
+ $object = $mapper->fromStorageRow( array( 'id' => 1 ) );
+ $this->assertSame( $object, $mapper->fromStorageRow( array( 'id' => 1 ) ) );
+ }
+
+ public function testAllowsNullPkOnPut() {
+ $this->createMapper()->toStorageRow( (object)array( 'id' => null ) );
+ $this->assertTrue( true );
+ }
+
+ protected function createMapper() {
+ $toStorageRow = function( $object ) { return (array)$object; };
+ $fromStorageRow = function( array $row, $object ) {
+ if ( $object === null ) {
+ return (object)$row;
+ } else {
+ return (object)( $row + (array)$object );
+ }
+ };
+ return new CachingObjectMapper( $toStorageRow, $fromStorageRow, array( 'id' ) );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php b/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php
new file mode 100644
index 00000000..c2e41057
--- /dev/null
+++ b/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Flow\Tests\Data\Index;
+
+use Flow\Data\Index\FeatureIndex;
+
+/**
+ * @group Flow
+ */
+class FeatureIndexTest extends \MediaWikiTestCase {
+
+ public function testOffsetIdReturnsCorrectPortionOfIndexedValues() {
+ global $wgFlowCacheVersion;
+ $cache = $this->getMockBuilder( 'Flow\Data\BufferedCache' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $storage = $this->getMockBuilder( 'Flow\Data\ObjectStorage' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $dbId = FeatureIndex::cachedDbId();
+ $cache->expects( $this->any() )
+ ->method( 'getMulti' )
+ ->will( $this->returnValue( array(
+ "$dbId:foo:5:$wgFlowCacheVersion" => array(
+ array( 'some_row' => 40 ),
+ array( 'some_row' => 41 ),
+ array( 'some_row' => 42 ),
+ array( 'some_row' => 43 ),
+ array( 'some_row' => 44 ),
+ ),
+ ) ) );
+ $storage->expects( $this->never() )
+ ->method( 'findMulti' );
+
+ $index = new MockFeatureIndex( $cache, $storage, 'foo', array( 'bar' ) );
+
+ $res = $index->find(
+ array( 'bar' => 5 ),
+ array( 'offset-id' => 42 )
+ );
+
+ $this->assertEquals(
+ array(
+ array( 'some_row' => 43, 'bar' => 5 ),
+ array( 'some_row' => 44, 'bar' => 5 ),
+ ),
+ array_values( $res ),
+ 'Returns items with some_row > provided offset-id of 42'
+ );
+ }
+
+ public function testReversePagination() {
+ global $wgFlowCacheVersion;
+ $cache = $this->getMockBuilder( 'Flow\Data\BufferedCache' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $storage = $this->getMockBuilder( 'Flow\Data\ObjectStorage' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $dbId = FeatureIndex::cachedDbId();
+ $cache->expects( $this->any() )
+ ->method( 'getMulti' )
+ ->will( $this->returnValue( array(
+ "$dbId:foo:5:$wgFlowCacheVersion" => array(
+ array( 'some_row' => 40 ),
+ array( 'some_row' => 41 ),
+ array( 'some_row' => 42 ),
+ array( 'some_row' => 43 ),
+ array( 'some_row' => 44 ),
+ ),
+ ) ) );
+ $storage->expects( $this->never() )
+ ->method( 'findMulti' );
+
+ $index = new MockFeatureIndex( $cache, $storage, 'foo', array( 'bar' ) );
+
+ $res = $index->find(
+ array( 'bar' => 5 ),
+ array( 'offset-id' => 43, 'offset-dir' => 'rev', 'limit' => 2 )
+ );
+ $this->assertEquals(
+ array(
+ array( 'some_row' => 41, 'bar' => 5 ),
+ array( 'some_row' => 42, 'bar' => 5 ),
+ ),
+ array_values( $res ),
+ 'Data should retain original sort, taking selected items from before the offset'
+ );
+ }
+}
+
+class MockFeatureIndex extends FeatureIndex {
+ public function getLimit() { return 42; }
+ public function queryOptions() { return array(); }
+ public function limitIndexSize( array $values ) { return $values; }
+ public function addToIndex( array $indexed, array $row ) {}
+ public function removeFromIndex( array $indexed, array $row ) {}
+
+ // not abstract, but override for convenience
+ public function getSort() { return array( 'some_row' ); }
+ public function getOrder() { return 'ASC'; }
+}
diff --git a/Flow/tests/phpunit/Data/IndexTest.php b/Flow/tests/phpunit/Data/IndexTest.php
new file mode 100644
index 00000000..02f69496
--- /dev/null
+++ b/Flow/tests/phpunit/Data/IndexTest.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Container;
+use Flow\Data\BagOStuff\BufferedBagOStuff;
+use Flow\Data\BufferedCache;
+use Flow\Data\Index\FeatureIndex;
+use Flow\Data\Index\TopKIndex;
+use Flow\Data\Index\UniqueFeatureIndex;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class IndexTest extends FlowTestCase {
+
+ public function testShallow() {
+ global $wgFlowCacheTime;
+
+ $bag = new BufferedBagOStuff( new \HashBagOStuff );
+ $cache = new BufferedCache( $bag, $wgFlowCacheTime );
+
+ // As we are only testing the cached result, storage should never be called
+ // not sure how to test that
+ $storage = $this->getMock( 'Flow\\Data\\ObjectStorage' );
+
+ $unique = new UniqueFeatureIndex(
+ $cache, $storage, 'unique',
+ array( 'id' )
+ );
+
+ $secondary = new TopKIndex(
+ $cache, $storage, 'secondary',
+ array( 'name' ), // keys indexed in this array
+ array(
+ 'shallow' => $unique,
+ 'sort' => 'id',
+ )
+ );
+
+ $db = FeatureIndex::cachedDbId();
+ $v = Container::get( 'cache.version' );
+ $bag->set( "$db:unique:1:$v", array( array( 'id' => 1, 'name' => 'foo', 'other' => 'ppp' ) ) );
+ $bag->set( "$db:unique:2:$v", array( array( 'id' => 2, 'name' => 'foo', 'other' => 'qqq' ) ) );
+ $bag->set( "$db:unique:3:$v", array( array( 'id' => 3, 'name' => 'baz', 'other' => 'lll' ) ) );
+
+ $bag->set( "$db:secondary:foo:$v", array( array( 'id' => 1 ), array( 'id' => 2 ) ) );
+ $bag->set( "$db:secondary:baz:$v", array( array( 'id' => 3 ) ) );
+
+ $expect = array(
+ array( 'id' => 1, 'name' => 'foo', 'other' => 'ppp', ),
+ array( 'id' => 2, 'name' => 'foo', 'other' => 'qqq', ),
+ );
+ $this->assertEquals( $expect, $secondary->find( array( 'name' => 'foo' ) ) );
+
+ $expect = array(
+ array( 'id' => 3, 'name' => 'baz', 'other' => 'lll' ),
+ );
+ $this->assertEquals( $expect, $secondary->find( array( 'name' => 'baz' ) ) );
+ }
+
+ public function testCompositeShallow() {
+ global $wgFlowCacheTime;
+
+ $bag = new BufferedBagOStuff( new \HashBagOStuff );
+ $cache = new BufferedCache( $bag, $wgFlowCacheTime );
+ $storage = $this->getMock( 'Flow\\Data\\ObjectStorage' );
+
+ $unique = new UniqueFeatureIndex(
+ $cache, $storage, 'unique',
+ array( 'id', 'ot' )
+ );
+
+ $secondary = new TopKIndex(
+ $cache, $storage, 'secondary',
+ array( 'name' ), // keys indexed in this array
+ array(
+ 'shallow' => $unique,
+ 'sort' => 'id',
+ )
+ );
+
+ // remember: unique index still stores an array of results to be consistent with other indexes
+ // even though, due to uniqueness, there is only one value per set of keys
+ $db = FeatureIndex::cachedDbId();
+ $v = Container::get( 'cache.version' );
+ $bag->set( "$db:unique:1:9:$v", array( array( 'id' => 1, 'ot' => 9, 'name' => 'foo' ) ) );
+ $bag->set( "$db:unique:1:8:$v", array( array( 'id' => 1, 'ot' => 8, 'name' => 'foo' ) ) );
+ $bag->set( "$db:unique:3:7:$v", array( array( 'id' => 3, 'ot' => 7, 'name' => 'baz' ) ) );
+
+ $bag->set( "$db:secondary:foo:$v", array(
+ array( 'id' => 1, 'ot' => 9 ),
+ array( 'id' => 1, 'ot' => 8 ),
+ ) );
+ $bag->set( "$db:secondary:baz:$v", array(
+ array( 'id' => 3, 'ot' => 7 ),
+ ) );
+
+ $expect = array(
+ array( 'id' => 1, 'ot' => 9, 'name' => 'foo' ),
+ array( 'id' => 1, 'ot' => 8, 'name' => 'foo' ),
+ );
+ $this->assertEquals( $expect, $secondary->find( array( 'name' => 'foo' ) ) );
+
+ $expect = array(
+ array( 'id' => 3, 'ot' => 7, 'name' => 'baz' ),
+ );
+ $this->assertEquals( $expect, $secondary->find( array( 'name' => 'baz' ) ) );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php b/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php
new file mode 100644
index 00000000..2841a627
--- /dev/null
+++ b/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Flow\Tests\Data\Listener;
+
+use Flow\Container;
+use Flow\Data\Listener\RecentChangesListener;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class RecentChangesListenerTest extends \MediaWikiTestCase {
+
+ public function somethingProvider() {
+ return array(
+ array(
+ 'New topic recent change goes to the board',
+ // expect
+ NS_MAIN,
+ // something
+ function( $workflow, $user ) {
+ return PostRevision::create( $workflow, $user, 'blah blah', 'wikitext' );
+ }
+ ),
+
+ array(
+ 'Reply recent change goes to the topic',
+ NS_TOPIC,
+ function( $workflow, $user ) {
+ $first = PostRevision::create( $workflow, $user, 'blah blah', 'wikitext' );
+ return $first->reply( $workflow, $user, 'fofofo', 'wikitext' );
+ },
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider somethingProvider
+ */
+ public function testSomething( $message, $expect, $init ) {
+ $actions = Container::get( 'flow_actions' );
+ $usernames = $this->getMockBuilder( 'Flow\Repository\UserNameBatch' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $rcFactory = $this->getMockBuilder( 'Flow\Data\Utils\RecentChangeFactory' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $ircFormatter = $this->getMockBuilder( 'Flow\Formatter\IRCLineUrlFormatter' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $rc = new RecentChangesListener( $actions, $usernames, $rcFactory, $ircFormatter );
+ $change = $this->getMock( 'RecentChange' );
+ $rcFactory->expects( $this->once() )
+ ->method( 'newFromRow' )
+ ->will( $this->returnCallback( function( $obj ) use ( &$ref, $change ) {
+ $ref = $obj;
+ return $change;
+ } ) );
+
+ $title = Title::newMainPage();
+ $user = User::newFromName( '127.0.0.1', false );
+ $workflow = Workflow::create( 'topic', $title );
+
+ $revision = $init( $workflow, $user );
+
+ $rc->onAfterInsert(
+ $revision,
+ array( 'rev_user_id' => 0, 'rev_user_ip' => '127.0.0.1' ),
+ array( 'workflow' => $workflow )
+ );
+ $this->assertNotNull( $ref );
+ $this->assertEquals( $expect, $ref->rc_namespace, $message );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/ManagerGroupTest.php b/Flow/tests/phpunit/Data/ManagerGroupTest.php
new file mode 100644
index 00000000..dfbe5086
--- /dev/null
+++ b/Flow/tests/phpunit/Data/ManagerGroupTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+
+/**
+ * @group Flow
+ */
+class ManagerGroupTest extends \MediaWikiTestCase {
+ protected function mockStorage() {
+ $container = new Container;
+ foreach ( range( 'A', 'D' ) as $letter ) {
+ $container[$letter] = $this->getMockBuilder( 'Flow\Data\ObjectManager' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ $storage = new ManagerGroup( $container, array(
+ 'A' => 'A',
+ 'B' => 'B',
+ 'C' => 'C',
+ 'D' => 'D',
+ 'stdClass' => 'D',
+ ) );
+
+ return array( $storage, $container );
+ }
+
+ public function testClearOnlyCallsRequestedManagers() {
+ list( $storage, $container ) = $this->mockStorage();
+ $container['A']->expects( $this->never() )->method( 'clear' );
+ $container['B']->expects( $this->once() )->method( 'clear' );
+ $container['C']->expects( $this->never() )->method( 'clear' );
+ $container['D']->expects( $this->never() )->method( 'clear' );
+
+ $storage->getStorage( 'B' );
+ $storage->clear();
+ }
+
+ public function testClearCallsNoManagersWhenUnused() {
+ list( $storage, $container ) = $this->mockStorage();
+ $container['A']->expects( $this->never() )->method( 'clear' );
+ $container['B']->expects( $this->never() )->method( 'clear' );
+ $container['C']->expects( $this->never() )->method( 'clear' );
+ $container['D']->expects( $this->never() )->method( 'clear' );
+
+ $storage->clear();
+ }
+
+ public function testCachePurgeCallsAppropriateManager() {
+ $object = new \stdClass;
+
+ list( $storage, $container ) = $this->mockStorage();
+ $container['A']->expects( $this->never() )->method( 'clear' );
+ $container['B']->expects( $this->never() )->method( 'clear' );
+ $container['C']->expects( $this->never() )->method( 'clear' );
+ $container['D']->expects( $this->once() )
+ ->method( 'cachePurge' )
+ ->with( $this->identicalTo( $object ) );
+
+ $storage->cachePurge( $object );
+ }
+}
+
diff --git a/Flow/tests/phpunit/Data/NothingTest.php b/Flow/tests/phpunit/Data/NothingTest.php
new file mode 100644
index 00000000..4cec1671
--- /dev/null
+++ b/Flow/tests/phpunit/Data/NothingTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Data\Utils\SortArrayByKeys;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class FlowNothingTest extends FlowTestCase {
+
+ public function sortArrayByKeysProvider() {
+ return array(
+
+ array(
+ 'Basic one key sort',
+ // keys to sort by
+ array( 'id' ),
+ // array to sort
+ array(
+ array( 'id' => 5 ),
+ array( 'id' => 7 ),
+ array( 'id' => 6 ),
+ ),
+ // expected result
+ array(
+ array( 'id' => 5 ),
+ array( 'id' => 6 ),
+ array( 'id' => 7 ),
+ ),
+ ),
+
+ array(
+ 'Multi-key sort',
+ // keys to sort by
+ array( 'id', 'qq' ),
+ // array to sort
+ array(
+ array( 'id' => 5, 'qq' => 4 ),
+ array( 'id' => 5, 'qq' => 2 ),
+ array( 'id' => 7, 'qq' => 1 ),
+ array( 'id' => 6, 'qq' => 3 ),
+ array( 'qq' => 9, 'id' => 4 ),
+ ),
+ // expected result
+ array(
+ array( 'qq' => 9, 'id' => 4 ),
+ array( 'id' => 5, 'qq' => 2 ),
+ array( 'id' => 5, 'qq' => 4 ),
+ array( 'id' => 6, 'qq' => 3 ),
+ array( 'id' => 7, 'qq' => 1 ),
+ ),
+ ),
+
+ );
+ }
+
+ /**
+ * @dataProvider sortArrayByKeysProvider
+ */
+ public function testSortArrayByKeys( $message, array $keys, array $array, array $sorted, $strict = true ) {
+ usort( $array, new SortArrayByKeys( $keys, $strict ) );
+ $this->assertEquals( $sorted, $array );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/ObjectLocatorTest.php b/Flow/tests/phpunit/Data/ObjectLocatorTest.php
new file mode 100644
index 00000000..59dc508a
--- /dev/null
+++ b/Flow/tests/phpunit/Data/ObjectLocatorTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class ObjectLocatorTest extends FlowTestCase {
+
+ public function testUselessTest() {
+ $mapper = $this->getMock( 'Flow\Data\ObjectMapper' );
+ $storage = $this->getMock( 'Flow\Data\ObjectStorage' );
+
+ $locator = new \Flow\Data\ObjectLocator( $mapper, $storage );
+
+ $storage->expects( $this->any() )
+ ->method( 'findMulti' )
+ ->will( $this->returnValue( array( array( null, null ) ) ) );
+
+ $this->assertEquals( array(), $locator->findMulti( array( array( 'foo' => 'random crap' ) ) ) );
+ }
+}
diff --git a/Flow/tests/phpunit/Data/Pager/PagerTest.php b/Flow/tests/phpunit/Data/Pager/PagerTest.php
new file mode 100644
index 00000000..038bfcf5
--- /dev/null
+++ b/Flow/tests/phpunit/Data/Pager/PagerTest.php
@@ -0,0 +1,513 @@
+<?php
+
+namespace Flow\Tests\Data\Pager;
+
+use Flow\Data\BagOStuff;
+use Flow\Data\BagOStuff\LocalBufferedBagOStuff;
+use Flow\Data\BufferedCache;
+use Flow\Data\Index\TopKIndex;
+use Flow\Data\Pager\Pager;
+use stdClass;
+
+/**
+ * @group Flow
+ */
+class PagerTest extends \MediaWikiTestCase {
+
+ public static function getPageResultsProvider() {
+ $objs = array();
+ foreach ( range( 'A', 'J' ) as $letter ) {
+ $objs[$letter] = (object)array( 'foo' => $letter );
+ }
+
+ return array(
+ array(
+ 'Gracefully returns nothing',
+ // expect
+ array(),
+ // find results
+ array(),
+ // query options,
+ array(),
+ // filter
+ null
+ ),
+
+ array(
+ 'Returns found objects',
+ // expect
+ array( $objs['A'], $objs['B'] ),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'] ),
+ ),
+ // query options
+ array( 'pager-limit' => 10 ),
+ // filter
+ null
+ ),
+
+ array(
+ 'Applies filter',
+ // expect
+ array( $objs['A'] ),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'] )
+ ),
+ // query options
+ array( 'pager-limit' => 10 ),
+ // filter
+ function( $found ) {
+ return array_filter( $found, function( $obj ) { return $obj->foo !== 'B'; } );
+ },
+ ),
+
+ array(
+ 'Repeats query when filtered',
+ // expect
+ array( $objs['A'], $objs['D'] ),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'], $objs['C'] ),
+ array( $objs['D'], $objs['E'] ),
+ ),
+ // query options
+ array( 'pager-limit' => 2 ),
+ // query filter
+ function( $found ) {
+ return array_filter( $found, function( $obj ) {
+ return $obj->foo !== 'B' && $obj->foo !== 'C';
+ } );
+ },
+ ),
+
+ array(
+ 'Reverse pagination with filter',
+ // expect
+ array( $objs['B'], $objs['F'], $objs['I'] ),
+ // find results
+ array(
+ // note thate feature index will return these in the normal
+ // forward sort order, the provided direction just means to
+ // get items before rather than after the offset.
+ // verified at FeatureIndexTest::testReversePagination()
+ array( $objs['G'], $objs['H'], $objs['I'], $objs['J'] ),
+ array( $objs['C'], $objs['D'], $objs['E'], $objs['F'] ),
+ array( $objs['A'], $objs['B'] ),
+ ),
+ // query options
+ array( 'pager-limit' => 3, 'pager-dir' => 'rev', 'pager-offset' => 'K' ),
+ // query filter
+ function( $found ) {
+ return array_filter( $found, function( $obj ) {
+ return in_array( $obj->foo, array( 'I', 'F', 'B', 'A' ) );
+ } );
+ },
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider getPageResultsProvider
+ */
+ public function testGetPageResults( $message, $expect, $found, array $options, $filter ) {
+ $pager = new Pager(
+ $this->mockObjectManager( $found ),
+ array( 'otherthing' => 42 ),
+ $options
+ );
+ $page = $pager->getPage( $filter );
+ $this->assertInstanceOf( 'Flow\Data\Pager\PagerPage', $page, $message );
+ $this->assertEquals( $expect, $page->getResults(), $message );
+ }
+
+
+ public static function getPagingLinkOptionsProvider() {
+ $objs = array();
+ foreach ( range( 'A', 'G' ) as $letter ) {
+ $objs[$letter] = (object)array( 'foo' => $letter );
+ }
+
+ return array(
+ array(
+ 'Gracefully returns nothing',
+ // expect
+ array(),
+ // find results
+ array(),
+ // pager options
+ array(),
+ // filter
+ null
+ ),
+
+ array(
+ 'No next page with exact number of results',
+ // expect
+ array(),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'] ),
+ ),
+ // pager options
+ array( 'pager-limit' => 2 ),
+ // filter
+ null
+ ),
+
+ array(
+ 'Forward pagination when direction forward and extra result',
+ // expect
+ array(
+ 'fwd' => array(
+ 'offset-dir' => 'fwd',
+ 'limit' => 2,
+ 'offset' => 'serialized-B',
+ ),
+ ),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'], $objs['C'] ),
+ ),
+ // pager options
+ array( 'pager-limit' => 2 ),
+ // filter
+ null
+ ),
+
+ array(
+ 'Forward pagination when multi-query filtered',
+ // expect
+ array(
+ 'fwd' => array(
+ 'offset-dir' => 'fwd',
+ 'limit' => 2,
+ 'offset' => 'serialized-D',
+ ),
+ ),
+ // find results
+ array(
+ array( $objs['A'], $objs['B'], $objs['C'] ),
+ array( $objs['D'], $objs['E'] ),
+ ),
+ // pager options
+ array( 'pager-limit' => 2 ),
+ // filter
+ function( $found ) {
+ return array_filter( $found, function( $obj ) { return $obj->foo > 'B'; } );
+ },
+ ),
+
+ array(
+ 'Multi-query edge case must issue second query',
+ // expect
+ array(
+ 'fwd' => array(
+ 'offset-dir' => 'fwd',
+ 'limit' => 2,
+ 'offset' => 'serialized-C',
+ ),
+ ),
+ array(
+ array( $objs['A'], $objs['B'], $objs['C'] ),
+ array( $objs['D'], $objs['E'], $objs['F'] ),
+ ),
+ array( 'pager-limit' => 2 ),
+ // filter
+ function( $found ) {
+ return array_filter( $found, function( $obj ) { return $obj->foo !== 'A'; } );
+ },
+ ),
+
+ array(
+ 'Reverse pagination when offset-id is present in options',
+ // expect
+ array(
+ 'rev' => array(
+ 'offset-dir' => 'rev',
+ 'limit' => 2,
+ 'offset' => 'serialized-B',
+ ),
+ 'fwd' => array(
+ 'offset-dir' => 'fwd',
+ 'limit' => 2,
+ 'offset' => 'serialized-C',
+ ),
+ ),
+ // find results
+ array(
+ array( $objs['B'], $objs['C'], $objs['D'] ),
+ ),
+ // pager options
+ array(
+ 'pager-limit' => 2,
+ 'pager-offset' => 'serialized-A',
+ 'pager-dir' => 'fwd',
+ ),
+ // filter
+ null,
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider getPagingLinkOptionsProvider
+ */
+ public function testGetPagingLinkOptions( $message, $expect, $found, array $options, $filter ) {
+ $pager = new Pager(
+ $this->mockObjectManager( $found ),
+ array( 'otherthing' => 42 ),
+ $options
+ );
+ $page = $pager->getPage( $filter );
+ $this->assertInstanceOf( 'Flow\Data\Pager\PagerPage', $page, $message );
+ $this->assertEquals( $expect, $page->getPagingLinksOptions(), $message );
+ }
+
+ public static function optionsPassedToObjectManagerFindProvider() {
+ return array(
+ array(
+ 'Requests one more object than pagination is for',
+ // expect
+ array( 'limit' => 3 ),
+ // pager options
+ array(
+ 'pager-limit' => 2,
+ )
+ ),
+
+ array(
+ 'Pager limit cannot be negative',
+ // expect
+ array( 'limit' => 2 ),
+ // pager options
+ array( 'pager-limit' => -99 ),
+ ),
+
+ array(
+ 'Pager limit cannot exceed 500',
+ // expect
+ array( 'limit' => 2 ),
+ // pager options
+ array( 'pager-limit' => 501 ),
+ ),
+
+ array(
+ 'Offset dir defaults to fwd',
+ // expect
+ array( 'offset-dir' => 'fwd' ),
+ // pager options
+ array(),
+ ),
+
+ array(
+ 'Offset dir can be reversed',
+ // expect
+ array( 'offset-dir' => 'rev' ),
+ // pager options
+ array( 'pager-dir' => 'rev' ),
+ ),
+
+ array(
+ 'Gracefully handles unknown offset dir',
+ // expect
+ array( 'offset-dir' => 'fwd' ),
+ // pager options
+ array( 'pager-dir' => 'yabba dabba do' ),
+ ),
+
+ array(
+ 'offset-id defaults to null',
+ // expect
+ array( 'offset-id' => null ),
+ // pager options
+ array()
+ ),
+
+ array(
+ 'initial offset-id is set by providing pager-offset',
+ // expect
+ array( 'offset-id' => 'echo and flow' ),
+ // pager options
+ array( 'pager-offset' => 'echo and flow' ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider optionsPassedToObjectManagerFindProvider
+ */
+ public function testOptionsPassedToObjectManagerFind( $message, $expect, $options ) {
+ $om = $this->mockObjectManager();
+ $om->expects( $this->any() )
+ ->method( 'find' )
+ ->with( $this->anything(), $this->callback( function ( $opts ) use ( &$options ) {
+ $options = $opts;
+ return true;
+ } ) );
+
+ $pager = new Pager(
+ $om,
+ array( 'otherthing' => 42 ),
+ $options
+ );
+ $page = $pager->getPage();
+
+ $this->assertNotNull( $options );
+ $optionsString = json_encode( $options );
+ foreach ( $expect as $key => $value ) {
+ $this->assertArrayHasKey( $key, $options, $optionsString );
+ $this->assertEquals( $value, $options[$key], $optionsString );
+ }
+ }
+
+ public function includeOffsetProvider() {
+ return array(
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 5, 4, 3, 2, 1 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => true,
+ ),
+ ),
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 4, 3, 2, 1 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => false,
+ ),
+ ),
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 9, 8, 7, 6, 5 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => true,
+ 'offset-dir' => 'rev',
+ 'offset-elastic' => false,
+ ),
+ ),
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 9, 8, 7, 6 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => false,
+ 'offset-dir' => 'rev',
+ 'offset-elastic' => false,
+ ),
+ ),
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 9, 8, 7, 6, 5, 4, 3, 2, 1 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => true,
+ 'offset-dir' => 'rev',
+ 'offset-elastic' => true,
+ ),
+ ),
+ array(
+ '',
+ // expected returned series of 'bar' values
+ array( 9, 8, 7, 6, 5, 4, 3, 2, 1 ),
+ // query options
+ array(
+ 'offset-id' => 5,
+ 'include-offset' => false,
+ 'offset-dir' => 'rev',
+ 'offset-elastic' => true,
+ ),
+ ),
+
+ );
+ }
+
+ /**
+ * @dataProvider includeOffsetProvider
+ */
+ public function testIncludeOffset( $message, $expect, $queryOptions ) {
+ global $wgFlowCacheVersion;
+
+ $bag = new \HashBagOStuff();
+ $innerCache = new LocalBufferedBagOStuff( $bag );
+ $cache = new BufferedCache( $innerCache );
+
+ // preload our answer
+ $bag->set( wfWikiId() . ":prefix:1:$wgFlowCacheVersion", array(
+ array( 'foo' => 1, 'bar' => 9 ),
+ array( 'foo' => 1, 'bar' => 8 ),
+ array( 'foo' => 1, 'bar' => 7 ),
+ array( 'foo' => 1, 'bar' => 6 ),
+ array( 'foo' => 1, 'bar' => 5 ),
+ array( 'foo' => 1, 'bar' => 4 ),
+ array( 'foo' => 1, 'bar' => 3 ),
+ array( 'foo' => 1, 'bar' => 2 ),
+ array( 'foo' => 1, 'bar' => 1 ),
+ ) );
+
+ $storage = $this->getMock( 'Flow\Data\ObjectStorage' );
+
+ $index = new TopKIndex(
+ $cache,
+ $storage,
+ 'prefix',
+ array( 'foo' ),
+ array(
+ 'sort' => 'bar',
+ )
+ );
+
+ $result = $index->find( array( 'foo' => '1' ), $queryOptions );
+ foreach ( $result as $row ) {
+ $found[] = $row['bar'];
+ }
+
+ $this->assertEquals(
+ $expect,
+ $found
+ );
+ }
+
+ protected function mockObjectManager( array $found = array() ) {
+ $index = $this->getMock( 'Flow\Data\Index' );
+ $index->expects( $this->any() )
+ ->method( 'getSort' )
+ ->will( $this->returnValue( array( 'something' ) ) );
+ $om = $this->getMockBuilder( 'Flow\Data\ObjectManager' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $om->expects( $this->any() )
+ ->method( 'getIndexFor' )
+ ->will( $this->returnValue( $index ) );
+ $om->expects( $this->any() )
+ ->method( 'serializeOffset' )
+ ->will( $this->returnCallback( function( $obj, $sort ) {
+ return 'serialized-' . $obj->foo;
+ } ) );
+
+ if ( $found ) {
+ $om->expects( $this->any() )
+ ->method( 'find' )
+ ->will( call_user_func_array(
+ array( $this, 'onConsecutiveCalls' ),
+ array_map( array( $this, 'returnValue' ), $found )
+ ) );
+ }
+
+ return $om;
+ }
+}
diff --git a/Flow/tests/phpunit/Data/RevisionStorageTest.php b/Flow/tests/phpunit/Data/RevisionStorageTest.php
new file mode 100644
index 00000000..025f1d58
--- /dev/null
+++ b/Flow/tests/phpunit/Data/RevisionStorageTest.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Data\Storage\PostRevisionStorage;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class RevisionStorageTest extends FlowTestCase {
+
+ public static function issuesQueryCountProvider() {
+ return array(
+ array(
+ 'Query by rev_id issues one query',
+ // db queries issued
+ 1,
+ // queries
+ array(
+ array( 'rev_id' => 1 ),
+ array( 'rev_id' => 8 ),
+ array( 'rev_id' => 3 ),
+ ),
+ // query options
+ array( 'LIMIT' => 1 )
+ ),
+
+ array(
+ 'Query by rev_id issues one query with string limit',
+ // db queries issued
+ 1,
+ // queries
+ array(
+ array( 'rev_id' => 1 ),
+ array( 'rev_id' => 8 ),
+ array( 'rev_id' => 3 ),
+ ),
+ // query options
+ array( 'LIMIT' => '1' )
+ ),
+
+ array(
+ 'Query for most recent revision issues two queries',
+ // db queries issued
+ 2,
+ // queries
+ array(
+ array( 'rev_type_id' => 19 ),
+ array( 'rev_type_id' => 22 ),
+ array( 'rev_type_id' => 4 ),
+ array( 'rev_type_id' => 44 ),
+ ),
+ // query options
+ array( 'LIMIT' => 1, 'ORDER BY' => array( 'rev_id DESC' ) ),
+ ),
+
+ );
+ }
+
+ /**
+ * @dataProvider issuesQueryCountProvider
+ */
+ public function testIssuesQueryCount( $msg, $count, $queries, $options ) {
+ if ( !isset( $options['LIMIT'] ) || $options['LIMIT'] != 1 ) {
+ $this->fail( 'Can only generate result set for LIMIT = 1' );
+ }
+ if ( count( $queries ) <= 2 && count( $queries ) != $count ) {
+ $this->fail( '<= 2 queries always issues the same number of queries' );
+ }
+
+ $result = array();
+ foreach ( $queries as $query ) {
+ // this is not in any way a real result, but enough to get through
+ // the result processing
+ $result[] = (object)( $query + array( 'rev_id' => 42, 'tree_rev_id' => 42, 'rev_flags' => '' ) );
+ }
+
+ $treeRepo = $this->getMockBuilder( 'Flow\Repository\TreeRepository' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory = $this->mockDbFactory();
+ // this expect is the assertion for the test
+ $factory->getDB( null )->expects( $this->exactly( $count ) )
+ ->method( 'select' )
+ ->will( $this->returnValue( $result ) );
+
+ $storage = new PostRevisionStorage( $factory, false, $treeRepo );
+
+ $storage->findMulti( $queries, $options );
+ }
+
+ public function testPartialResult() {
+ $treeRepo = $this->getMockBuilder( 'Flow\Repository\TreeRepository' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory = $this->mockDbFactory();
+ $factory->getDB( null )->expects( $this->once() )
+ ->method( 'select' )
+ ->will( $this->returnValue( array(
+ (object)array( 'rev_id' => 42, 'rev_flags' => '' )
+ ) ) );
+
+ $storage = new PostRevisionStorage( $factory, false, $treeRepo );
+
+ $res = $storage->findMulti(
+ array(
+ array( 'rev_id' => 12 ),
+ array( 'rev_id' => 42 ),
+ array( 'rev_id' => 17 ),
+ ),
+ array( 'LIMIT' => 1 )
+ );
+
+ $this->assertSame(
+ array(
+ null,
+ array( array( 'rev_id' => 42, 'rev_flags' => '', 'rev_content_url' => null ) ),
+ null,
+ ),
+ $res,
+ 'Unfound items must be represented with null in the result array'
+ );
+ }
+
+ protected function mockDbFactory() {
+ $dbw = $this->getMockBuilder( 'DatabaseMysql' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $factory = $this->getMock( 'Flow\DbFactory' );
+ $factory->expects( $this->any() )
+ ->method( 'getDB' )
+ ->will( $this->returnValue( $dbw ) );
+
+ return $factory;
+ }
+}
diff --git a/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php b/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php
new file mode 100644
index 00000000..3c17fa28
--- /dev/null
+++ b/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Flow\Tests\Data\Storage;
+
+use Flow\Data\Storage\HeaderRevisionStorage;
+use Flow\Model\UUID;
+
+/**
+ * @group Flow
+ */
+class RevisionStorageTest extends \MediaWikiTestCase {
+
+ public function testUpdateConvertsPrimaryKeyToBinary() {
+ $dbw = $this->getMockBuilder( 'DatabaseMysql' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory = $this->getMockBuilder( 'Flow\DbFactory' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory->expects( $this->any() )
+ ->method( 'getDB' )
+ ->will( $this->returnValue( $dbw ) );
+
+ $id = UUID::create();
+ $dbw->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ $this->equalTo( 'flow_revision' ),
+ $this->equalTo( array(
+ 'rev_mod_user_id' => 42,
+ ) ),
+ $this->equalTo( array(
+ 'rev_id' => $id->getBinary(),
+ ) )
+ )
+ ->will( $this->returnValue( true ) );
+ $dbw->expects( $this->any() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 1 ) );
+
+ // Header is bare bones implementation, sufficient for testing
+ // the parent class.
+ $storage = new HeaderRevisionStorage( $factory, /* $externalStore = */false );
+ $storage->update(
+ array(
+ 'rev_id' => $id->getAlphadecimal(),
+ 'rev_mod_user_id' => 0,
+ ),
+ array(
+ 'rev_id' => $id->getAlphadecimal(),
+ 'rev_mod_user_id' => 42,
+ )
+ );
+
+ }
+}
diff --git a/Flow/tests/phpunit/Data/UserNameBatchTest.php b/Flow/tests/phpunit/Data/UserNameBatchTest.php
new file mode 100644
index 00000000..24fc06bc
--- /dev/null
+++ b/Flow/tests/phpunit/Data/UserNameBatchTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Flow\Repository\UserNameBatch;
+use Flow\Repository\UserName\UserNameQuery;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class UserNameBatchTest extends FlowTestCase {
+
+ public function testAllowsAddingNames() {
+ $batch = new UserNameBatch( $this->createUncalledQuery() );
+ $batch->add( 'fakewiki', 42, 'Whale' );
+ $this->assertEquals( 'Whale', $batch->get( 'fakewiki', 42 ) );
+ }
+
+ static public function acceptsStringOrIntIdsProvider() {
+ return array(
+ array( 42, 42 ),
+ array( 42, '42' ),
+ array( '42', 42 ),
+ array( '42', '42' ),
+ );
+ }
+
+ /**
+ * @dataProvider acceptsStringOrIntIdsProvider
+ */
+ public function testAcceptsStringOrIntIds( $a, $b ) {
+ $batch = new UserNameBatch( $this->createUncalledQuery() );
+ $batch->add( 'fakewiki', $a, 'Whale' );
+ $this->assertEquals( 'Whale', $batch->get( 'fakewiki', $b ) );
+ }
+
+ public function testQueueUsernames() {
+ $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' );
+ $query->expects( $this->once() )
+ ->method( 'execute' )
+ ->with( 'fakewiki', array( 12, 27, 18 ) );
+
+ $batch = new UserNameBatch( $query );
+ $batch->add( 'fakewiki', 12 );
+ $batch->add( 'fakewiki', '27' );
+ $batch->add( 'fakewiki', 18 );
+ $batch->resolve( 'fakewiki' );
+ }
+
+ public function testMissingAsFalse() {
+ $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' );
+ $query->expects( $this->once() )
+ ->method( 'execute' )
+ ->with( 'fakewiki', array( 42 ) );
+ $batch = new UserNameBatch( $query );
+
+ $this->assertEquals( false, $batch->get( 'fakewiki', 42 ) );
+ }
+
+ public function testPartialMissingAsFalse() {
+ $query = $this->getMock( 'Flow\Repository\UserName\\UserNameQuery' );
+ $query->expects( $this->once() )
+ ->method( 'execute' )
+ ->with( 'fakewiki', array( 610, 408 ) )
+ ->will( $this->returnValue( array(
+ (object)array( 'user_id' => '408', 'user_name' => 'chuck' )
+ ) ) );
+
+ $batch = new UserNameBatch( $query );
+ $batch->add( 'fakewiki', 610 );
+ $batch->add( 'fakewiki', 408 );
+
+ $this->assertEquals( false, $batch->get( 'fakewiki', 610 ) );
+ }
+
+ /**
+ * Create a mock UserNameQuery that must not be called
+ * @return UserNameQuery
+ */
+ protected function createUncalledQuery() {
+ $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' );
+ $query->expects( $this->never() )
+ ->method( 'execute' );
+
+ return $query;
+ }
+}
diff --git a/Flow/tests/phpunit/Data/UserNameListenerTest.php b/Flow/tests/phpunit/Data/UserNameListenerTest.php
new file mode 100644
index 00000000..216afac0
--- /dev/null
+++ b/Flow/tests/phpunit/Data/UserNameListenerTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Flow\Tests\Data;
+
+use Closure;
+use ReflectionClass;
+use Flow\Repository\UserNameBatch;
+use Flow\Data\Listener\UserNameListener;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class UserNameListenerTest extends FlowTestCase {
+
+ public function onAfterLoadDataProvider() {
+ return array (
+ array( array( 'user_id' => '1', 'user_wiki' => 'frwiki' ), array( 'user_id' => 'user_wiki' ), 'frwiki', 'enwiki' ),
+ array( array( 'user_id' => '2' ), array( 'user_id' => null ), 'enwiki', 'enwiki' ),
+ array( array( 'user_id' => '3' ), array( 'user_id' => 'user_wiki' ), null ),
+ // Use closure because wfWikiId() in testxxx() functions appends -unittest_ at the end
+ array( array( 'user_id' => '4' ), array( 'user_id' => null ), function() { return wfWikiId(); } ),
+ );
+ }
+
+ /**
+ * @dataProvider onAfterLoadDataProvider
+ */
+ public function testOnAfterLoad( array $row, array $key, $expectedWiki, $defaultWiki = null ) {
+ $batch = new UserNameBatch( $this->getMock( '\Flow\Repository\UserName\UserNameQuery' ) );
+ $listener = new UserNameListener( $batch, $key, $defaultWiki );
+ $listener->onAfterLoad( (object)$row, $row );
+
+ $reflection = new ReflectionClass( $batch );
+ $prop = $reflection->getProperty( 'queued' );
+ $prop->setAccessible( true );
+ $queued = $prop->getValue( $batch );
+
+ if ( $expectedWiki instanceof Closure ) {
+ $expectedWiki = call_user_func( $expectedWiki );
+ }
+
+ if ( $expectedWiki ) {
+ $this->assertTrue( in_array( $row['user_id'], $queued[$expectedWiki] ) );
+ } else {
+ $this->assertEmpty( $queued );
+ }
+ }
+
+}
diff --git a/Flow/tests/phpunit/FlowActionsTest.php b/Flow/tests/phpunit/FlowActionsTest.php
new file mode 100644
index 00000000..5a29435a
--- /dev/null
+++ b/Flow/tests/phpunit/FlowActionsTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\FlowActions;
+
+/**
+ * @group Flow
+ */
+class FlowActionsTest extends \MediaWikiTestCase {
+
+ public function testAliasedTopLevelValues() {
+ $actions = new FlowActions( array(
+ 'something' => 'aliased',
+ 'aliased' => array(
+ 'real' => 'value',
+ ),
+ ) );
+
+ $this->assertEquals( 'value', $actions->getValue( 'something', 'real' ) );
+ }
+}
diff --git a/Flow/tests/phpunit/FlowTestCase.php b/Flow/tests/phpunit/FlowTestCase.php
new file mode 100644
index 00000000..decc326c
--- /dev/null
+++ b/Flow/tests/phpunit/FlowTestCase.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Flow\Tests;
+
+use Status;
+
+use Flow\Container;
+use Flow\Model\UUID;
+
+class FlowTestCase extends \MediaWikiTestCase {
+ protected function setUp() {
+ Container::reset();
+ parent::setUp();
+ }
+
+ /**
+ * @param mixed $data
+ * @return string
+ */
+ protected function dataToString( $data ) {
+ foreach ( $data as $key => $value ) {
+ if ( $value instanceof UUID ) {
+ $data[$key] = 'UUID: ' . $value->getAlphadecimal();
+ }
+ }
+
+ return parent::dataToString( $data );
+ }
+}
diff --git a/Flow/tests/phpunit/Formatter/FormatterTest.php b/Flow/tests/phpunit/Formatter/FormatterTest.php
new file mode 100644
index 00000000..c8945c66
--- /dev/null
+++ b/Flow/tests/phpunit/Formatter/FormatterTest.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Flow\Tests\Formatter;
+
+use Flow\Container;
+use Flow\Formatter\FormatterRow;
+use Flow\Formatter\RevisionFormatter;
+use Flow\Model\UUID;
+use Flow\Tests\FlowTestCase;
+use Flow\UrlGenerator;
+use Title;
+
+/**
+ * @group Flow
+ */
+class FormatterTest extends FlowTestCase {
+
+ static public function checkUserProvider() {
+ $topicId = UUID::create();
+ $revId = UUID::create();
+ $postId = UUID::create();
+
+ return array(
+ array(
+ 'With only a topicId reply should not fail',
+ // result must contain
+ function( $test, $message, $result ) {
+ $test->assertNotNull( $result );
+ $test->assertArrayHasKey( 'links', $result, $message );
+ },
+ // cuc_comment parameters
+ 'reply', $topicId, $revId, null
+ ),
+
+ array(
+ 'With topicId and postId should not fail',
+ function( $test, $message, $result ) {
+ $test->assertNotNull( $result );
+ $test->assertArrayHasKey( 'links', $result, $message );
+ },
+ 'reply', $topicId, $revId, $postId,
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider checkUserProvider
+ */
+ public function testCheckUserFormatter( $message, $test, $action, UUID $workflowId, UUID $revId, UUID $postId = null ) {
+ global $wgLang;
+
+ if ( !class_exists( 'CheckUser' ) ) {
+ $this->markTestSkipped( 'CheckUser is not available' );
+ return;
+ }
+
+ $title = Title::newFromText( 'Test', NS_USER_TALK );
+ $row = new FormatterRow;
+ $row->workflow = $this->mockWorkflow( $workflowId, $title );
+ $row->revision = $this->mockRevision( $action, $revId, $postId );
+ $row->currentRevision = $row->revision;
+
+ $ctx = $this->getMock( 'IContextSource' );
+ $ctx->expects( $this->any() )
+ ->method( 'getLanguage' )
+ ->will( $this->returnValue( $wgLang ) );
+ $ctx->expects( $this->any() )
+ ->method( 'msg' )
+ ->will( $this->returnCallback( 'wfMessage' ) );
+
+ // Code uses wfWarn as a louder wfDebugLog in error conditions.
+ // but phpunit considers a warning a fail.
+ wfSuppressWarnings();
+ $links = $this->createFormatter( 'Flow\Formatter\CheckUserFormatter' )->format( $row, $ctx );
+ wfRestoreWarnings();
+ $test( $this, $message, $links );
+ }
+
+ protected function mockWorkflow( UUID $workflowId, Title $title ) {
+ $workflow = $this->getMock( 'Flow\\Model\\Workflow' );
+ $workflow->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $workflowId ) );
+ $workflow->expects( $this->any() )
+ ->method( 'getArticleTitle' )
+ ->will( $this->returnValue( $title ) );
+ return $workflow;
+ }
+
+ protected function mockRevision( $changeType, UUID $revId, UUID $postId = null ) {
+ if ( $postId ) {
+ $revision = $this->getMock( 'Flow\\Model\\PostRevision' );
+ } else {
+ $revision = $this->getMock( 'Flow\\Model\\Header' );
+ }
+ $revision->expects( $this->any() )
+ ->method( 'getChangeType' )
+ ->will( $this->returnValue( $changeType ) );
+ $revision->expects( $this->any() )
+ ->method( 'getRevisionId' )
+ ->will( $this->returnValue( $revId ) );
+ if ( $postId ) {
+ $revision->expects( $this->any() )
+ ->method( 'getPostId' )
+ ->will( $this->returnValue( $postId ) );
+ }
+ return $revision;
+ }
+
+ protected function createFormatter( $class ) {
+ $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $permissions->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $permissions->expects( $this->any() )
+ ->method( 'getActions' )
+ ->will( $this->returnValue( Container::get( 'flow_actions' ) ) );
+
+ $templating = $this->getMockBuilder( 'Flow\Templating' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $occupier = $this->getMockBuilder( 'Flow\OccupationController' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $urlGenerator = new UrlGenerator( $occupier );
+ $templating->expects( $this->any() )
+ ->method( 'getUrlGenerator' )
+ ->will( $this->returnValue( $urlGenerator ) );
+
+ $usernames = $this->getMockBuilder( 'Flow\Repository\UserNameBatch' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ global $wgFlowMaxThreadingDepth;
+ $serializer = new RevisionFormatter( $permissions, $templating, $usernames, $wgFlowMaxThreadingDepth );
+
+ return new $class( $permissions, $serializer );
+ }
+
+ protected function dataToString( $data ) {
+ foreach ( $data as $key => $value ) {
+ if ( $value instanceof UUID ) {
+ $data[$key] = "UUID: " . $value->getAlphadecimal();
+ }
+ }
+ return parent::dataToString( $data );
+ }
+}
diff --git a/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php b/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php
new file mode 100644
index 00000000..34bba91b
--- /dev/null
+++ b/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php
@@ -0,0 +1,165 @@
+<?php
+
+namespace Flow\Tests\Formatter;
+
+use Flow\FlowActions;
+use Flow\Formatter\FormatterRow;
+use Flow\Formatter\RevisionFormatter;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use RequestContext;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class RevisionFormatterTest extends \MediaWikiTestCase {
+ protected $user;
+
+ public function setUp() {
+ parent::setUp();
+ $this->user = User::newFromName( '127.0.0.1', false );
+ }
+
+ public function testMockFormatterBasicallyWorks() {
+ list( $formatter, $ctx ) = $this->mockFormatter();
+ $result = $formatter->formatApi( $this->generateRow( 'my new topic' ), $ctx );
+ $this->assertEquals( 'new-post', $result['changeType'] );
+ $this->assertEquals( 'my new topic', $result['content']['content'] );
+ }
+
+ public function testFormattingEditedTitle() {
+ list( $formatter, $ctx ) = $this->mockFormatter();
+ $row = $this->generateRow();
+ $row->previousRevision = $row->revision;
+ $row->revision = $row->revision->newNextRevision(
+ $this->user,
+ 'replacement content',
+ 'wikitext',
+ 'edit-title',
+ $row->workflow->getArticleTitle()
+ );
+ $result = $formatter->formatApi( $row, $ctx );
+ $this->assertEquals( 'edit-title', $result['changeType'] );
+ $this->assertEquals( 'replacement content', $result['content']['content'] );
+ }
+
+ public function testFormattingContentLength() {
+ $content = 'something something';
+ $nextContent = 'ברוכים הבאים לוויקיפדיה!';
+
+ list( $formatter, $ctx, $permissions, $templating, $usernames, $actions ) = $this->mockFormatter( true );
+
+ $row = $this->generateRow( $content );
+ $result = $formatter->formatApi( $row, $ctx );
+ $this->assertEquals(
+ strlen( $content ),
+ $result['size']['new'],
+ 'New topic content reported correctly'
+ );
+ $this->assertEquals(
+ 0,
+ $result['size']['old'],
+ 'With no previous revision the old size is 0'
+ );
+
+ $row->previousRevision = $row->revision;
+ // @todo newNextRevision feels too generic, there should be an editTitle method?
+ $row->revision = $row->currentRevision = $row->revision->newNextRevision(
+ $this->user,
+ $nextContent,
+ 'wikitext',
+ 'edit-title',
+ $row->workflow->getArticleTitle()
+ );
+ $result = $formatter->formatApi( $row, $ctx );
+ $this->assertEquals(
+ mb_strlen( $nextContent ),
+ $result['size']['new'],
+ 'After editing topic content the new size has been updated'
+ );
+ $this->assertEquals(
+ mb_strlen( $content ),
+ $result['size']['old'],
+ 'After editing topic content the old size has been updated'
+ );
+ }
+
+ public function generateRow( $plaintext = 'titlebar content' ) {
+ $row = new FormatterRow;
+ $row->workflow = Workflow::create( 'topic', Title::newMainPage() );
+ $row->rootPost = PostRevision::create( $row->workflow, $this->user, $plaintext, 'wikitext' );
+ $row->revision = $row->currentRevision = $row->rootPost;
+
+ return $row;
+ }
+
+ protected function mockActions() {
+ return $this->getMockBuilder( 'Flow\FlowActions' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function mockPermissions( FlowActions $actions ) {
+ $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ // bit of a code smell, should pass actions directly in constructor?
+ $permissions->expects( $this->any() )
+ ->method( 'getActions' )
+ ->will( $this->returnValue( $actions ) );
+ // perhaps another code smell, should have a method that does whatever this
+ // uses the user for
+ $permissions->expects( $this->any() )
+ ->method( 'getUser' )
+ ->will( $this->returnValue( $this->user ) );
+
+ return $permissions;
+ }
+
+ protected function mockTemplating() {
+ $templating = $this->getMockBuilder( 'Flow\Templating' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $templating->expects( $this->any() )
+ ->method( 'getModeratedRevision' )
+ ->will( $this->returnArgument( 0 ) );
+ $templating->expects( $this->any() )
+ ->method( 'getContent' )
+ ->will( $this->returnCallback( function( $revision, $contentFormat ) {
+ return $revision->getContent( $contentFormat );
+ } ) );
+
+ return $templating;
+ }
+
+ protected function mockUserNameBatch() {
+ return $this->getMockBuilder( 'Flow\Repository\UserNameBatch' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ // @todo name seems wrong, the Formatter is real everything else is mocked
+ public function mockFormatter( $returnAll = false ) {
+ $actions = $this->mockActions();
+ $permissions = $this->mockPermissions( $actions );
+ // formatting only proceedes when this is true
+ $permissions->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $templating = $this->mockTemplating();
+ $usernames = $this->mockUserNameBatch();
+ $formatter = new RevisionFormatter( $permissions, $templating, $usernames, 3 );
+
+ $ctx = RequestContext::getMain();
+ $ctx->setUser( $this->user );
+
+
+ if ( $returnAll ) {
+ return array( $formatter, $ctx, $permissions, $templating, $usernames, $actions );
+ } else {
+ return array( $formatter, $ctx );
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php b/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php
new file mode 100644
index 00000000..8e8c778c
--- /dev/null
+++ b/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Flow\Tests\Handlebars;
+
+use Flow\Container;
+use Flow\TemplateHelper;
+use LightnCandy;
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * @group Flow
+ */
+class FlowPostMetaActionsTest extends \MediaWikiTestCase {
+
+ /**
+ * The specific timestamps used inside are not anything
+ * in particular, they just match the post and last edit
+ * uuid's we use in the test.
+ */
+ public function timestampEditedProvider() {
+ return array(
+ array(
+ 'never been edited',
+ // expected
+ '02:52, 1 October 2014',
+ // args
+ array(
+ 'isOriginalContent' => true,
+ 'author' => 'creator',
+ 'creator' => 'creator',
+ 'lastEditUser' => null,
+ ),
+ ),
+
+ array(
+ 'last edited by post creator',
+ // expected
+ 'Edited 04:21, 9 October 2014',
+ // args
+ array(
+ 'isOriginalContent' => false,
+ 'author' => 'creator',
+ 'creator' => 'creator',
+ 'lastEditUser' => 'creator',
+ ),
+ ),
+
+ array(
+ 'last edited by other than post creator',
+ // expected
+ 'Edited by author 04:21, 9 October 2014',
+ // args
+ array(
+ 'isOriginalContent' => false,
+ 'author' => 'author',
+ 'creator' => 'creator',
+ 'lastEditUser' => 'author',
+ ),
+ ),
+
+ array(
+ 'most recent revision not a content edit',
+ // expected
+ 'Edited 04:21, 9 October 2014',
+ // args
+ array(
+ 'isOriginalContent' => false,
+ 'author' => 'author',
+ 'creator' => 'creator',
+ 'lastEditUser' => 'creator',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider timestampEditedProvider
+ */
+ public function testTimestampEdited( $message, $expect, $args ) {
+ if ( !class_exists( 'Symfony\Component\DomCrawler\Crawler' ) ) {
+ $this->markTestSkipped( 'DomCrawler component is not available.' );
+ return;
+ }
+
+ $crawler = $this->renderTemplate(
+ 'flow_post_meta_actions',
+ array(
+ 'actions' => array(),
+ 'postId' => 's3chebds95i0atkw',
+ 'lastEditId' => 's3ufwcms95i0atkw',
+ 'isOriginalContent' => $args['isOriginalContent'],
+ 'author' => array(
+ 'name' => $args['author'],
+ ),
+ 'creator' => array(
+ 'name' => $args['creator'],
+ ),
+ 'lastEditUser' => array(
+ 'name' => $args['lastEditUser'],
+ ),
+ )
+ );
+
+ $text = $crawler->filter( '.flow-post-timestamp' )->text();
+ // normalize whitespace
+ $text = trim( preg_replace( '/\s+/', ' ', $text ) );
+ $this->assertStringStartsWith( $expect, $text, $message );
+ }
+
+ protected function renderTemplate( $templateName, array $args = array() ) {
+ $lc = Container::get( 'lightncandy' );
+ $filenames = $lc->getTemplateFilenames( $templateName );
+ $phpCode = $lc::compile(
+ file_get_contents( $filenames['template'] ),
+ Container::get( 'lightncandy.template_dir' )
+ );
+ $renderer = LightnCandy::prepare( $phpCode );
+
+ return new Crawler( $renderer( $args ) );
+ }
+}
diff --git a/Flow/tests/phpunit/HookTest.php b/Flow/tests/phpunit/HookTest.php
new file mode 100644
index 00000000..cfc2c913
--- /dev/null
+++ b/Flow/tests/phpunit/HookTest.php
@@ -0,0 +1,206 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+use Flow\Data\Listener\RecentChangesListener;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\TopicListEntry;
+use Flow\Model\Workflow;
+use FlowHooks;
+use RecentChange;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class HookTest extends \MediaWikiTestCase {
+ protected $tablesUsed = array(
+ 'flow_revision',
+ 'flow_topic_list',
+ 'flow_tree_node',
+ 'flow_tree_revision',
+ 'flow_workflow',
+ );
+
+ static public function onIRCLineURLProvider() {
+ $user = User::newFromName( '127.0.0.1', false );
+ $title = Title::newMainPage();
+
+ // data providers do not run in the same context as the actual test, as such we
+ // can't create Title objects because they can have the wrong wikiID. Instead we
+ // pass closures into the test that create the objects within the correct context.
+ $newHeader = function() use( $user ) {
+ $workflow = Workflow::create( 'discussion', Title::newMainPage() );
+ $header = Header::create( $workflow, $user, 'header content', 'wikitext' );
+ $metadata = array(
+ 'workflow' => $workflow,
+ 'revision' => $header,
+ );
+
+ Container::get( 'storage' )->put( $workflow, $metadata );
+
+ return $metadata;
+ };
+ $freshTopic = function() use( $user ) {
+ $boardWorkflow = Workflow::create( 'discussion', Title::newMainPage() );
+ $topicWorkflow = Workflow::create( 'topic', $boardWorkflow->getArticleTitle() );
+ $topicList = TopicListEntry::create( $boardWorkflow, $topicWorkflow );
+ $topicTitle = PostRevision::create( $topicWorkflow, $user, 'some content', 'wikitext' );
+ $metadata = array(
+ 'workflow' => $topicWorkflow,
+ 'board-workflow' => $boardWorkflow,
+ 'topic-title' => $topicTitle,
+
+ 'revision' => $topicTitle,
+ );
+
+ $storage = Container::get( 'storage' );
+ $storage->put( $topicWorkflow, $metadata );
+ $storage->put( $boardWorkflow, $metadata );
+ $storage->put( $topicList, $metadata );
+ $storage->put( $topicTitle, $metadata );
+
+ return $metadata;
+ };
+ $replyToTopic = function() use( $freshTopic, $user ) {
+ $metadata = $freshTopic();
+ $firstPost = $metadata['topic-title']->reply( $metadata['workflow'], $user, 'ffuts dna ylper', 'wikitext' );
+ $metadata = array(
+ 'first-post' => $firstPost,
+
+ 'revision' => $firstPost,
+ ) + $metadata;
+
+ Container::get( 'storage.post' )->put( $firstPost, $metadata );
+
+ return $metadata;
+ };
+
+ return array(
+ array(
+ // test message
+ 'Freshly created topic',
+ // flow-workflow-change attribute within rc_params
+ $freshTopic,
+ // expected query parameters
+ array(
+ 'action' => 'history',
+ ),
+ ),
+
+ array(
+ 'Reply to topic',
+ $replyToTopic,
+ array(
+ 'action' => 'history',
+ ),
+ ),
+
+ array(
+ 'Edit topic title',
+ function() use( $freshTopic, $user, $title ) {
+ $metadata = $freshTopic();
+
+ return array(
+ 'revision' => $metadata['revision']->newNextRevision( $user, 'gnihtemos gnihtemos', 'wikitext', 'edit-title', $title ),
+ ) + $metadata;
+ },
+ array(
+ 'action' => 'compare-post-revisions',
+ ),
+ ),
+
+ array(
+ 'Edit post',
+ function() use( $replyToTopic, $user, $title ) {
+ $metadata = $replyToTopic();
+ return array(
+ 'revision' => $metadata['revision']->newNextRevision( $user, 'IT\'S CAPS LOCKS DAY!', 'wikitext', 'edit-post', $title ),
+ ) + $metadata;
+ },
+ array(
+ 'action' => 'compare-post-revisions',
+ ),
+ ),
+
+ array(
+ 'Edit board header',
+ function() use ( $newHeader, $user, $title ) {
+ $metadata = $newHeader();
+ return array(
+ 'revision' => $metadata['revision']->newNextRevision( $user, 'STILL CAPS LOCKS DAY!', 'wikitext', 'edit-header', $title ),
+ ) + $metadata;
+ },
+ array(
+ 'action' => 'compare-header-revisions',
+ ),
+ ),
+
+ array(
+ 'Moderate a post',
+ function() use ( $replyToTopic, $user ) {
+ $metadata = $replyToTopic();
+ return array(
+ 'revision' => $metadata['revision']->moderate(
+ $user,
+ $metadata['revision']::MODERATED_DELETED,
+ 'delete-post',
+ 'something about cruise control'
+ ),
+ ) + $metadata;
+ },
+ array(
+ 'action' => 'history',
+ ),
+ ),
+
+ array(
+ 'Moderate a topic',
+ function() use ( $freshTopic, $user ) {
+ $metadata = $freshTopic();
+ return array(
+ 'revision' => $metadata['revision']->moderate(
+ $user,
+ $metadata['revision']::MODERATED_HIDDEN,
+ 'hide-topic',
+ 'adorable kittens'
+ ),
+ ) + $metadata;
+ },
+ array(
+ 'action' => 'history',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider onIRCLineUrlProvider
+ */
+ public function testOnIRCLineUrl( $message, $metadataGen, $expectedQuery ) {
+ $rc = new RecentChange;
+ $rc->mAttribs = array(
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Main Page',
+ 'rc_source' => RecentChangesListener::SRC_FLOW,
+ );
+ $metadata = $metadataGen();
+ Container::get( 'formatter.irclineurl' )->associate( $rc, $metadata );
+
+ $url = 'unset';
+ $query = 'unset';
+ $this->assertTrue( FlowHooks::onIRCLineURL( $url, $query, $rc ) );
+ $expectedQuery['title'] = $metadata['workflow']->getArticleTitle()->getPrefixedDBkey();
+
+ $parts = parse_url( $url );
+ $this->assertArrayHasKey( 'query', $parts, $url );
+ parse_str( $parts['query'], $queryParts );
+ foreach ( $expectedQuery as $key => $value ) {
+ $this->assertEquals( $value, $queryParts[$key], "Query part $key" );
+ }
+ $this->assertEquals( '', $query, $message );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/ConverterTest.php b/Flow/tests/phpunit/Import/ConverterTest.php
new file mode 100644
index 00000000..e5cbb07d
--- /dev/null
+++ b/Flow/tests/phpunit/Import/ConverterTest.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Flow\Tests\Import;
+
+use DatabaseBase;
+use Flow\Import\Converter;
+use Flow\Import\IConversionStrategy;
+use Flow\Import\Importer;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class ConverterTest extends \MediaWikiTestCase {
+ public function testConstruction() {
+ $this->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,
+ Importer $importer = null,
+ LoggerInterface $logger = null,
+ User $user = null,
+ IConversionStrategy $strategy = null
+ ) {
+ return new Converter(
+ $dbr ?: wfGetDB( DB_SLAVE ),
+ $importer ?: $this->getMockBuilder( 'Flow\Import\Importer' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ $logger ?: new NullLogger,
+ $user ?: User::newFromId( 1 ),
+ $strategy ?: $this->getMockBuilder( 'Flow\Import\IConversionStrategy' )
+ ->disableOriginalConstructor()
+ ->getMock()
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php b/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php
new file mode 100644
index 00000000..798c1812
--- /dev/null
+++ b/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Flow\Tests\Import;
+
+use Flow\Import\HistoricalUIDGenerator;
+use Flow\Model\UUID;
+
+/**
+ * @group Flow
+ */
+class HistoricalUIDGeneratorTest extends \MediaWikiTestCase {
+
+ public function roundTripProvider() {
+ $now = time();
+
+ return array(
+ array( $now - 86400 ),
+ array( $now - ( 365 * 86400 ) ),
+ );
+ }
+
+ /**
+ * @dataProvider roundTripProvider
+ */
+ public function testRoundTrip( $timestamp ) {
+ $timestamp = wfTimestamp( TS_UNIX, $timestamp );
+ $uid = HistoricalUIDGenerator::historicalTimestampedUID88( $timestamp );
+ $uuid = UUID::create( $uid );
+
+ $returned = $uuid->getTimestampObj()->getTimestamp( TS_UNIX );
+ $this->assertEquals( $timestamp, $returned );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php b/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php
new file mode 100644
index 00000000..8b861c87
--- /dev/null
+++ b/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Flow\Tests\Import\LiquidThreadsApi;
+
+use DatabaseBase;
+use DateTime;
+use DateTimeZone;
+use Flow\Import\ImportSourceStore;
+use Flow\Import\NullImportSourceStore;
+use Flow\Import\LiquidThreadsApi\ConversionStrategy;
+use Flow\Import\LiquidThreadsApi\ApiBackend;
+use Title;
+use WikitextContent;
+
+/**
+ * @group Flow
+ */
+class ConversionStrategyTest extends \MediaWikiTestCase {
+
+ public function testCanConstruct() {
+ $this->assertInstanceOf(
+ 'Flow\Import\LiquidThreadsApi\ConversionStrategy',
+ $this->createStrategy()
+ );
+ }
+
+ public function testGeneratesMoveComment() {
+ $from = Title::newFromText( 'Talk:Blue_birds' );
+ $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' );
+ $this->assertGreaterThan(
+ 1,
+ strlen( $this->createStrategy()->getMoveComment( $from, $to ) )
+ );
+ }
+
+ public function testGeneratesCleanupComment() {
+ $from = Title::newFromText( 'Talk:Blue_birds' );
+ $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' );
+ $this->assertGreaterThan(
+ 1,
+ strlen( $this->createStrategy()->getCleanupComment( $from, $to ) )
+ );
+ }
+
+ public function testCreatesValidImportSource() {
+ $this->assertInstanceOf(
+ 'Flow\Import\IImportSource',
+ $this->createStrategy()->createImportSource( Title::newFromText( 'Talk:Blue_birds' ) )
+ );
+ }
+
+ public function testReturnsValidSourceStore() {
+ $this->assertInstanceOf(
+ 'Flow\Import\ImportSourceStore',
+ $this->createStrategy()->getSourceStore()
+ );
+ }
+
+ public function testDecidesArchiveTitle() {
+ // we don't have control of the Title::exists() calls that are made here,
+ // so just assume the page doesn't exist and we get format = 0 n = 1
+ $this->assertEquals(
+ 'Talk:Blue birds/LQT Archive 1',
+ $this->createStrategy()
+ ->decideArchiveTitle( Title::newFromText( 'Talk:Blue_birds' ) )
+ ->getPrefixedText()
+ );
+ }
+
+ public function provideArchiveCleanupRevisionContent() {
+ // @todo superm401 suggested finding library that lets us control time during tests,
+ // would probably be better
+ $now = new DateTime( "now", new DateTimeZone( "GMT" ) );
+ $date = $now->format( 'Y-m-d' );
+
+ return array(
+ array(
+ 'Blank input page',
+ // expect
+ "\n\n{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}}",
+ // input content
+ '',
+ ),
+ array(
+ 'Page containing lqt magic word',
+ // expect
+ "\n\n{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}}",
+ // input content
+ '{{#useliquidthreads:1}}',
+ ),
+
+ array(
+ 'Page containing some stuff and the lqt magic word',
+ // expect
+ <<<EOD
+Four score and seven years ago our fathers brought forth
+on this continent, a new nation, conceived in Liberty, and
+dedicated to the proposition that all men are created equal.
+
+
+{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}}
+EOD
+ ,
+ // input content
+ <<<EOD
+Four score and seven years ago our fathers brought forth
+on this continent, a new nation, conceived in Liberty, and
+dedicated to the proposition that all men are created equal.
+{{#useliquidthreads:
+ 1
+}}
+EOD
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider provideArchiveCleanupRevisionContent
+ * @param string $content
+ */
+ public function testCreateArchiveCleanupRevisionContent( $message, $expect, $content ) {
+ $result = $this->createStrategy()->createArchiveCleanupRevisionContent(
+ new WikitextContent( $content ),
+ Title::newFromText( 'Talk:Blue_birds' )
+ );
+ if ( $result !== null ) {
+ $this->assertInstanceOf( 'WikitextContent', $result );
+ }
+ $this->assertEquals( $expect, $result->getNativeData(), $message );
+ }
+
+ public function testGetPostprocessor() {
+ $result = $this->createStrategy()->getPostprocessor();
+
+ $this->assertEquals( get_class( $result ), 'Flow\Import\Postprocessor\LqtRedirector');
+ }
+
+ protected function createStrategy(
+ DatabaseBase $dbr = null,
+ ImportSourceStore $sourceStore = null,
+ ApiBackend $api = null
+ ) {
+ return new ConversionStrategy(
+ $dbr ?: wfGetDB( DB_SLAVE ),
+ $sourceStore ?: new NullImportSourceStore,
+ $api ?: $this->getMockBuilder( 'Flow\Import\LiquidThreadsApi\ApiBackend' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ $this->getMockBuilder( 'Flow\UrlGenerator' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ $this->getMockBuilder( 'User' )
+ ->disableOriginalConstructor()
+ ->getMock()
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/PageImportStateTest.php b/Flow/tests/phpunit/Import/PageImportStateTest.php
new file mode 100644
index 00000000..bceaf55d
--- /dev/null
+++ b/Flow/tests/phpunit/Import/PageImportStateTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Flow\Tests\Import;
+
+use Flow\Import\NullImportSourceStore;
+use Flow\Import\PageImportState;
+use Flow\Import\Postprocessor\ProcessorGroup;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Psr\Log\NullLogger;
+use SplQueue;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class PageImportStateTest extends \MediaWikiTestCase {
+
+ protected function createState( $returnAll = false ) {
+ $storage = $this->getMockBuilder( 'Flow\Data\ManagerGroup' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $workflow = Workflow::create(
+ 'discussion',
+ Title::newMainPage()
+ );
+
+ $state = new PageImportState(
+ $workflow,
+ $storage,
+ new NullImportSourceStore(),
+ new NullLogger(),
+ $this->getMockBuilder( 'Flow\Data\BufferedCache' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ $this->getMockBuilder( 'Flow\DbFactory' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ new ProcessorGroup,
+ new SplQueue
+ );
+ if ( $returnAll ) {
+ return array( $state, $workflow, $storage );
+ } else {
+ return $state;
+ }
+ }
+
+ public function testGetTimestampIdReturnsUUID() {
+ $state = $this->createState();
+ $this->assertInstanceOf(
+ 'Flow\Model\UUID',
+ $state->getTimestampId( time() - 123456 ),
+ 'PageImportState::getTimestampId must return a UUID object'
+ );
+ }
+
+ public function testSetsWorkflowIdByTimestamp() {
+ list( $state, $workflow ) = $this->createState( true );
+ $now = time();
+ $state->setWorkflowTimestamp( $workflow, $now - 123456 );
+ $this->assertEquals(
+ $now - 123456,
+ $workflow->getId()->getTimestampObj()->getTimestamp( TS_UNIX )
+ );
+ }
+
+ public function testSetsOnlyRevIdByTimestampForTopicTitle() {
+ $state = $this->createState();
+ $topicWorkflow = Workflow::create(
+ 'topic',
+ Title::newMainPage()
+ );
+ $topicTitle = PostRevision::create(
+ $topicWorkflow,
+ User::newFromName( '127.0.0.1', false ),
+ 'sing song',
+ 'wikitext'
+ );
+
+ $now = time();
+ $state->setRevisionTimestamp( $topicTitle, $now - 54321 );
+ $this->assertTrue(
+ $topicTitle->getPostId()->equals( $topicWorkflow->getId() ),
+ 'Topic title postId must still match workflow id'
+ );
+ $this->assertEquals(
+ $now - 54321,
+ $topicTitle->getRevisionId()->getTimestampObj()->getTimestamp( TS_UNIX )
+ );
+ }
+
+ public function testSetsRevIdAndPostIdForReplys() {
+ $state = $this->createState();
+ $user = User::newFromName( '127.0.0.1', false );
+ $title = Title::newMainPage();
+ $topicWorkflow = Workflow::create( 'topic', $title );
+ $topicTitle = PostRevision::create( $topicWorkflow, $user, 'sing song', 'wikitext' );
+ $reply = $topicTitle->reply( $topicWorkflow, $user, 'fantastic!', 'wikitext' );
+
+ $now = time();
+
+ $state->setRevisionTimestamp( $reply, $now - 54321 );
+ $this->assertEquals(
+ $now - 54321,
+ $reply->getRevisionId()->getTimestampObj()->getTimestamp( TS_UNIX ),
+ 'The first reply revision must have its revision id set appropriatly'
+ );
+ $this->assertTrue(
+ $reply->getPostId()->equals( $reply->getRevisionId() ),
+ 'The first revision of a reply shares its postId and revId'
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php b/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php
new file mode 100644
index 00000000..86a2fdbf
--- /dev/null
+++ b/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php
@@ -0,0 +1,164 @@
+<?php
+
+namespace Flow\Tests\Import;
+
+use Flow\Container;
+use Flow\Import\NullImportSourceStore;
+use Flow\Import\PageImportState;
+use Flow\Import\Postprocessor\ProcessorGroup;
+use Flow\Import\TalkpageImportOperation;
+use Flow\Model\Header;
+use Flow\Model\PostRevision;
+use Flow\Model\PostSummary;
+use Flow\Model\TopicListEntry;
+use Flow\Model\Workflow;
+use Flow\Tests\Mock\MockImportHeader;
+use Flow\Tests\Mock\MockImportPost;
+use Flow\Tests\Mock\MockImportRevision;
+use Flow\Tests\Mock\MockImportSource;
+use Flow\Tests\Mock\MockImportSummary;
+use Flow\Tests\Mock\MockImportTopic;
+use Psr\Log\NullLogger;
+use SplQueue;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class TalkpageImportOperationTest extends \MediaWikiTestCase {
+
+ /**
+ * This is a horrible test, it basically runs the whole thing
+ * and sees if it falls over.
+ */
+ public function testImportDoesntCompletelyFail() {
+ $workflow = Workflow::create(
+ 'discussion',
+ Title::newMainPage()
+ );
+ $storage = $this->getMockBuilder( 'Flow\Data\ManagerGroup' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stored = array();
+ $storage->expects( $this->any() )
+ ->method( 'put' )
+ ->will( $this->returnCallback( function( $obj ) use( &$stored ) {
+ $stored[] = $obj;
+ } ) );
+ $storage->expects( $this->any() )
+ ->method( 'multiPut' )
+ ->will( $this->returnCallback( function( $objs ) use( &$stored ) {
+ $stored = array_merge( $stored, $objs );
+ } ) );
+
+ $now = time();
+ $source = new MockImportSource(
+ new MockImportHeader( array(
+ // header revisions
+ new MockImportRevision( array( 'createdTimestamp' => $now ) ),
+ ) ),
+ array(
+ new MockImportTopic(
+ new MockImportSummary( array(
+ new MockImportRevision( array( 'createdTimestamp' => $now - 250 ) ),
+ ) ),
+ array(
+ // topic title revisions
+ new MockImportRevision( array( 'createdTimestamp' => $now - 1000 ) ),
+ ),
+ array(
+ //replies
+ new MockImportPost(
+ array(
+ // revisions
+ new MockImportRevision( array( 'createdTimestmap' => $now - 1000 ) ),
+ ),
+ array(
+ // replies
+ new MockImportPost(
+ array(
+ // revisions
+ new MockImportRevision( array(
+ 'createdTimestmap' => $now - 500,
+ 'user' => User::newFromNAme( '10.0.0.2', false ),
+ ) ),
+ ),
+ array(
+ // replies
+ )
+ ),
+ )
+ ),
+ )
+ )
+ )
+ );
+
+ $op = new TalkpageImportOperation( $source, Container::get( 'occupation_controller' ) );
+ $store = new NullImportSourceStore;
+ $op->import( new PageImportState(
+ $workflow,
+ $storage,
+ $store,
+ new NullLogger(),
+ $this->getMockBuilder( 'Flow\Data\BufferedCache' )
+ ->disableOriginalConstructor()
+ ->getMock(),
+ Container::get( 'db.factory' ),
+ new ProcessorGroup,
+ new SplQueue
+ ) );
+
+ // Count what actually came through
+ $storedHeader = $storedDiscussion = $storedTopics = $storedTopicListEntry = $storedSummary = $storedPosts = 0;
+ foreach ( $stored as $obj ) {
+ if ( $obj instanceof Workflow ) {
+ if ( $obj->getType() === 'discussion' ) {
+ $this->assertSame( $workflow, $obj );
+ $storedDiscussion++;
+ } else {
+ $alpha = $obj->getId()->getAlphadecimal();
+ if ( !isset( $seenWorkflow[$alpha] ) ) {
+ $seenWorkflow[$alpha] = true;
+ $this->assertEquals( 'topic', $obj->getType() );
+ $storedTopics++;
+ $topicWorkflow = $obj;
+ }
+ }
+ } elseif ( $obj instanceof PostSummary ) {
+ $storedSummary++;
+ } elseif ( $obj instanceof PostRevision ) {
+ $storedPosts++;
+ if ( $obj->isTopicTitle() ) {
+ $topicTitle = $obj;
+ }
+ } elseif ( $obj instanceof TopicListEntry ) {
+ $storedTopicListEntry++;
+ } elseif ( $obj instanceof Header ) {
+ $storedHeader++;
+ } else {
+ $this->fail( 'Unexpected object stored:' . get_class( $obj ) );
+ }
+ }
+
+ // Verify we wrote the expected objects to storage
+
+ $this->assertEquals( 1, $storedHeader );
+
+ $this->assertEquals( 1, $storedDiscussion );
+ $this->assertEquals( 1, $storedTopics );
+ $this->assertEquals( 1, $storedTopicListEntry );
+ $this->assertEquals( 1, $storedSummary );
+ $this->assertEquals( 3, $storedPosts );
+
+ // This total expected number of insertions should match the sum of the left assertEquals parameters above.
+ $this->assertCount( 8, array_unique( array_map( 'spl_object_hash', $stored ) ) );
+
+ // Other special cases we need to check
+ $this->assertTrue(
+ $topicTitle->getPostId()->equals( $topicWorkflow->getId() ),
+ 'Root post id must match its workflow'
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php b/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php
new file mode 100644
index 00000000..4208a352
--- /dev/null
+++ b/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Flow\Tests\Import\Wikitext;
+
+use DateTime;
+use DateTimeZone;
+use Flow\Import\ImportSourceStore;
+use Flow\Import\NullImportSourceStore;
+use Flow\Import\Wikitext\ConversionStrategy;
+use Parser;
+use Title;
+use WikitextContent;
+
+/**
+ * @group Flow
+ */
+class ConversionStrategyTest extends \MediaWikiTestCase {
+ public function testCanConstruct() {
+ $this->assertInstanceOf(
+ 'Flow\Import\Wikitext\ConversionStrategy',
+ $this->createStrategy()
+ );
+ }
+
+ public function testGeneratesMoveComment() {
+ $from = Title::newFromText( 'Talk:Blue_birds' );
+ $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' );
+ $this->assertGreaterThan(
+ 1,
+ strlen( $this->createStrategy()->getMoveComment( $from, $to ) )
+ );
+ }
+
+ public function testGeneratesCleanupComment() {
+ $from = Title::newFromText( 'Talk:Blue_birds' );
+ $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' );
+ $this->assertGreaterThan(
+ 1,
+ strlen( $this->createStrategy()->getCleanupComment( $from, $to ) )
+ );
+ }
+
+ public function testCreatesValidImportSource() {
+ $this->assertInstanceOf(
+ 'Flow\Import\IImportSource',
+ $this->createStrategy()->createImportSource( Title::newFromText( 'Talk:Blue_birds' ) )
+ );
+ }
+
+ public function testReturnsValidSourceStore() {
+ $this->assertInstanceOf(
+ 'Flow\Import\ImportSourceStore',
+ $this->createStrategy()->getSourceStore()
+ );
+ }
+
+ public function testDecidesArchiveTitle() {
+ // we don't have control of the Title::exists() calls that are made here,
+ // so just assume the page doesn't exist and we get format = 0 n = 1
+ $this->assertEquals(
+ 'Talk:Blue birds/Archive 1',
+ $this->createStrategy()
+ ->decideArchiveTitle( Title::newFromText( 'Talk:Blue_birds' ) )
+ ->getPrefixedText()
+ );
+ }
+
+ public function testCreateArchiveCleanupRevisionContent() {
+ // @todo superm401 suggested finding library that lets us control time during tests,
+ // would probably be better
+ $now = new DateTime( "now", new DateTimeZone( "GMT" ) );
+ $date = $now->format( 'Y-m-d' );
+
+ $result = $this->createStrategy()->createArchiveCleanupRevisionContent(
+ new WikitextContent( "Four score and..." ),
+ Title::newFromText( 'Talk:Blue_birds' )
+ );
+ $this->assertInstanceOf( 'WikitextContent', $result );
+ $this->assertEquals(
+ "{{Archive for converted wikitext talk page|from=Talk:Blue birds|date=$date}}\n\nFour score and...",
+ $result->getNativeData()
+ );
+ }
+
+ protected function createStrategy(
+ Parser $parser = null,
+ ImportSourceStore $sourceStore = null
+ ) {
+ global $wgParser;
+
+ return new ConversionStrategy(
+ $parser ?: $wgParser,
+ $sourceStore ?: new NullImportSourceStore
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php b/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php
new file mode 100644
index 00000000..3f6571a8
--- /dev/null
+++ b/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow\Tests\Import\Wikitext;
+
+use DateTime;
+use DateTimeZone;
+use Flow\Import\Wikitext\ImportSource;
+use Parser;
+use Title;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Flow
+ * @group Database
+ */
+class ImportSourceTest extends \MediaWikiTestCase {
+
+ protected $tablesUsed = array( 'page', 'revision' );
+
+ public function testGetHeader() {
+ $now = new DateTime( "now", new DateTimeZone( "GMT" ) );
+ $date = $now->format( 'Y-m-d' );
+
+ // create a page with some content
+ $status = WikiPage::factory( Title::newMainPage() )
+ ->doEditContent(
+ new WikitextContent( "This is some content\n" ),
+ "and an edit summary"
+ );
+ if ( !$status->isGood() ) {
+ $this->fail( $status->getMessage()->plain() );
+ }
+
+ $source = new ImportSource( Title::newMainPage(), new Parser );
+ $header = $source->getHeader();
+ $this->assertNotNull( $header );
+ $this->assertGreaterThan( 1, strlen( $header->getObjectKey() ) );
+
+ $revisions = iterator_to_array( $header->getRevisions() );
+ $this->assertCount( 1, $revisions );
+
+ $revision = reset( $revisions );
+ $this->assertInstanceOf( 'Flow\Import\IObjectRevision', $revision );
+ $this->assertEquals(
+ "This is some content\n\n{{Wikitext talk page converted to Flow|archive=Main Page|date=$date}}",
+ $revision->getText()
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/LinksTableTest.php b/Flow/tests/phpunit/LinksTableTest.php
new file mode 100644
index 00000000..afdaaade
--- /dev/null
+++ b/Flow/tests/phpunit/LinksTableTest.php
@@ -0,0 +1,473 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+use Flow\Data\ManagerGroup;
+use Flow\Data\Listener\ReferenceRecorder;
+use Flow\Exception\WikitextException;
+use Flow\LinksTableUpdater;
+use Flow\Model\AbstractRevision;
+use Flow\Model\Workflow;
+use Flow\Parsoid\ReferenceExtractor;
+use Flow\Parsoid\ReferenceFactory;
+use Flow\Parsoid\Utils;
+use ParserOutput;
+use Title;
+
+/**
+ * @group Flow
+ * @group Database
+ */
+class LinksTableTest extends PostRevisionTestCase {
+ /**
+ * @var array
+ */
+ protected $tablesUsed = array( 'flow_ext_ref', 'flow_wiki_ref', 'flow_revision', 'flow_tree_revision', 'flow_workflow' );
+
+ /**
+ * @var ManagerGroup
+ */
+ protected $storage;
+
+ /**
+ * @var ReferenceExtractor
+ */
+ protected $extractor;
+
+ /**
+ * @var ReferenceRecorder
+ */
+ protected $recorder;
+
+ /**
+ * @var LinksTableUpdater
+ */
+ protected $updater;
+
+ public function setUp() {
+ parent::setUp();
+ $this->storage = Container::get( 'storage' );
+ $this->extractor = Container::get( 'reference.extractor' );
+ $this->recorder = Container::get( 'reference.recorder' );
+ $this->updater = Container::get( 'reference.updater.links-tables' );
+
+ // Check for Parsoid
+ try {
+ Utils::convert( 'html', 'wikitext', 'Foo', $this->workflow->getOwnerTitle() );
+ } catch ( WikitextException $excep ) {
+ $this->markTestSkipped( 'Parsoid not enabled' );
+ }
+
+ // These tests don't provide sufficient data to properly run all listeners
+ $this->clearExtraLifecycleHandlers();
+ }
+
+ protected function generatePost( $overrides ) {
+ $parentRevision = $this->generateObject();
+
+ $revision = $this->generateObject( $overrides + array(
+ 'tree_parent_id' => $parentRevision->getRevisionId(),
+ ) );
+
+ return $revision;
+ }
+
+ protected static function getTestTitle() {
+ return Title::newFromText( 'UTPage' );
+ }
+
+ public static function provideGetReferencesFromRevisionContent() {
+ return array(
+ array(
+ '[[Foo]]',
+ array(
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Foo',
+ ),
+ ),
+ ),
+ array(
+ '[http://www.google.com Foo]',
+ array(
+ array(
+ 'factoryMethod' => 'createUrlReference',
+ 'refType' => 'link',
+ 'value' => 'http://www.google.com',
+ ),
+ ),
+ ),
+ array(
+ '[[File:Foo.jpg]]',
+ array(
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'file',
+ 'value' => 'File:Foo.jpg',
+ ),
+ ),
+ ),
+ array(
+ '{{Foo}}',
+ array(
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'template',
+ 'value' => 'Template:Foo',
+ ),
+ ),
+ ),
+ array(
+ '{{Foo}} [[Foo]] [[File:Foo.jpg]] {{Foo}} [[Bar]]',
+ array(
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'template',
+ 'value' => 'Template:Foo',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Foo',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'file',
+ 'value' => 'File:Foo.jpg',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Bar',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * @dataProvider provideGetReferencesFromRevisionContent
+ */
+ public function testGetReferencesFromRevisionContent( $content, $expectedReferences ) {
+ $content = Utils::convert( 'wikitext', 'html', $content, $this->workflow->getOwnerTitle() );
+ $revision = $this->generatePost( array( 'rev_content' => $content ) );
+
+ $expectedReferences = $this->expandReferences( $this->workflow, $revision, $expectedReferences );
+
+ $foundReferences = $this->recorder->getReferencesFromRevisionContent( $this->workflow, $revision );
+
+ $this->assertReferenceListsEqual( $expectedReferences, $foundReferences );
+ }
+
+ /**
+ * @dataProvider provideGetReferencesFromRevisionContent
+ */
+ public function testGetReferencesAfterRevisionInsert( $content, $expectedReferences ) {
+ $content = Utils::convert( 'wikitext', 'html', $content, $this->workflow->getOwnerTitle() );
+ $revision = $this->generatePost( array( 'rev_content' => $content ) );
+
+ // Save to storage to test if ReferenceRecorder listener picks this up
+ $this->store( $revision );
+
+ $expectedReferences = $this->expandReferences( $this->workflow, $revision, $expectedReferences );
+
+ // References will be stored as linked from Topic:<id>
+ $title = Title::newFromText( $revision->getPostId()->getAlphadecimal(), NS_TOPIC );
+
+ // Retrieve references from storage
+ $foundReferences = $this->updater->getReferencesForTitle( $title );
+
+ $this->assertReferenceListsEqual( $expectedReferences, $foundReferences );
+ }
+
+ public static function provideGetExistingReferences() {
+ return array( /* list of test runs */
+ array( /* list of arguments */
+ array( /* list of references */
+ array( /* list of parameters */
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'template',
+ 'value' => 'Template:Foo',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Foo',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'file',
+ 'value' => 'File:Foo.jpg',
+ ),
+ array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Bar',
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider provideGetExistingReferences
+ */
+ public function testGetExistingReferences( array $references ) {
+ list( $workflow, $revision, $title ) = $this->getBlandTestObjects();
+
+ $references = $this->expandReferences( $workflow, $revision, $references );
+
+ $this->storage->multiPut( $references );
+
+ $foundReferences = $this->recorder
+ ->getExistingReferences( $revision->getRevisionType(), $revision->getCollectionId() );
+
+ $this->assertReferenceListsEqual( $references, $foundReferences );
+ }
+
+ public static function provideReferenceDiff() {
+ $references = self::getSampleReferences();
+
+ return array(
+ // Just adding a few
+ array(
+ array(),
+ array(
+ $references['fooLink'],
+ $references['barLink']
+ ),
+ array(
+ $references['fooLink'],
+ $references['barLink'],
+ ),
+ array(),
+ ),
+ // Removing one
+ array(
+ array(
+ $references['fooLink'],
+ $references['barLink']
+ ),
+ array(
+ $references['fooLink'],
+ ),
+ array(
+ ),
+ array(
+ $references['barLink'],
+ ),
+ ),
+ // Equality robustness
+ array(
+ array(
+ $references['fooLink'],
+ ),
+ array(
+ $references['FooLink'],
+ ),
+ array(
+ ),
+ array(
+ ),
+ array( // test is only valid if Foo and foo are same page
+ 'wgCapitalLinks' => true,
+ )
+ ),
+ // Inequality robustness
+ array(
+ array(
+ $references['fooLink'],
+ ),
+ array(
+ $references['barLink'],
+ ),
+ array(
+ $references['barLink'],
+ ),
+ array(
+ $references['fooLink'],
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider provideReferenceDiff
+ */
+ public function testReferenceDiff( $old, $new, $expectedAdded, $expectedRemoved, $globals = array() ) {
+ if ( $globals ) {
+ $this->setMwGlobals( $globals );
+ }
+ list( $workflow, $revision, $title ) = $this->getBlandTestObjects();
+
+ foreach( array( 'old', 'new', 'expectedAdded', 'expectedRemoved' ) as $varName ) {
+ $$varName = $this->expandReferences( $workflow, $revision, $$varName );
+ }
+
+ list( $added, $removed ) = $this->recorder->referencesDifference( $old, $new );
+
+ $this->assertReferenceListsEqual( $added, $expectedAdded );
+ $this->assertReferenceListsEqual( $removed, $expectedRemoved );
+ }
+
+ public static function provideMutateParserOutput() {
+ $references = self::getSampleReferences();
+
+ return array(
+ array(
+ array( // references
+ $references['fooLink'],
+ $references['fooTemplate'],
+ $references['googleLink'],
+ $references['fooImage'],
+ ),
+ array(
+ 'getLinks' => array(
+ NS_MAIN => array( 'Foo' => 0, ),
+ ),
+ 'getTemplates' => array(
+ NS_TEMPLATE => array( 'Foo' => 0, ),
+ ),
+ 'getImages' => array(
+ 'Foo.jpg' => true,
+ ),
+ 'getExternalLinks' => array(
+ 'http://www.google.com' => true,
+ ),
+ ),
+ ),
+ array(
+ array(
+ $references['subpageLink'],
+ ),
+ array(
+ 'getLinks' => array(
+ // NS_MAIN is the namespace of static::getTestTitle()
+ NS_MAIN => array( static::getTestTitle()->getDBkey() . '/Subpage' => 0, )
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider provideMutateParserOutput
+ */
+ public function testMutateParserOutput( $references, $expectedItems ) {
+ list( $workflow, $revision, $title ) = $this->getBlandTestObjects();
+
+ /*
+ * Because the data provider is static, we can't access $this->workflow
+ * in there. Once of the things being tested is a subpage link.
+ * Thus, we would have to provide the correct namespace & title for
+ * $this->workflow->getArticleTitle(), under which the subpage will be
+ * created.
+ * Let's work around this by overwriting $workflow->title to a "known"
+ * value, so that we can hardcode that into the expected return value in
+ * the static provider.
+ */
+ $title = static::getTestTitle();
+ $reflectionWorkflow = new \ReflectionObject( $workflow );
+ $reflectionProperty = $reflectionWorkflow->getProperty( 'title' );
+ $reflectionProperty->setAccessible( true );
+ $reflectionProperty->setValue( $workflow, $title );
+
+ $references = $this->expandReferences( $workflow, $revision, $references );
+ $parserOutput = new \ParserOutput;
+
+ // Clear the LinksUpdate to allow clean testing
+ foreach( array_keys( $expectedItems ) as $fieldName ) {
+ $parserOutput->$fieldName = array();
+ }
+
+ $this->updater->mutateParserOutput( $title, $parserOutput, $references );
+
+ foreach( $expectedItems as $method => $content ) {
+ $this->assertEquals( $content, $parserOutput->$method(), $method );
+ }
+ }
+
+ protected function getBlandTestObjects() {
+ return array(
+ /* workflow = */ $this->workflow,
+ /* revision = */ $this->revision,
+ /* title = */ $this->workflow->getArticleTitle(),
+ );
+ }
+
+ protected function expandReferences( Workflow $workflow, AbstractRevision $revision, array $references ) {
+ $referenceObjs = array();
+ $factory = new ReferenceFactory( $workflow, $revision->getRevisionType(), $revision->getCollectionId() );
+
+ foreach( $references as $ref ) {
+ $referenceObjs[] = $factory->{$ref['factoryMethod']}( $ref['refType'], $ref['value'] );
+ }
+
+ return $referenceObjs;
+ }
+
+ protected static function getSampleReferences() {
+ return array(
+ 'fooLink' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Foo',
+ ),
+ 'subpageLink' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => '/Subpage',
+ ),
+ 'FooLink' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'foo',
+ ),
+ 'barLink' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Bar',
+ ),
+ 'fooTemplate' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'template',
+ 'value' => 'Template:Foo',
+ ),
+ 'googleLink' => array(
+ 'factoryMethod' => 'createUrlReference',
+ 'refType' => 'link',
+ 'value' => 'http://www.google.com'
+ ),
+ 'fooImage' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'file',
+ 'value' => 'File:Foo.jpg',
+ ),
+ 'foreignFoo' => array(
+ 'factoryMethod' => 'createWikiReference',
+ 'refType' => 'link',
+ 'value' => 'Foo',
+ ),
+ );
+ }
+
+ protected function flattenReferenceList( $input ) {
+ $list = array();
+
+ foreach( $input as $reference ) {
+ $list[$reference->getUniqueIdentifier()] = $reference;
+ }
+
+ ksort( $list );
+ return array_keys( $list );
+ }
+
+ protected function assertReferenceListsEqual( $input1, $input2 ) {
+ $list1 = $this->flattenReferenceList( $input1 );
+ $list2 = $this->flattenReferenceList( $input2 );
+
+ $this->assertEquals( $list1, $list2 );
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportHeader.php b/Flow/tests/phpunit/Mock/MockImportHeader.php
new file mode 100644
index 00000000..c451cce5
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportHeader.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use ArrayIterator;
+use Flow\Import\IImportHeader;
+
+class MockImportHeader implements IImportHeader {
+ /**
+ * @var MockImportRevision
+ */
+ protected $revisions;
+
+ /**
+ * @param MockImportRevision[] $revisions
+ */
+ public function __construct( array $revisions ) {
+ $this->revisions = $revisions;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getRevisions() {
+ return new ArrayIterator( $this->revisions );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getObjectKey() {
+ return 'mock-header:1';
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportPost.php b/Flow/tests/phpunit/Mock/MockImportPost.php
new file mode 100644
index 00000000..f9e1a4c6
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportPost.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use ArrayIterator;
+use Flow\Import\IImportPost;
+use Flow\Import\IObjectRevision;
+use User;
+
+class MockImportPost implements IImportPost {
+ /**
+ * @var IObjectRevision[]
+ */
+ protected $revisions;
+
+ /**
+ * @var IImportPost[]
+ */
+ protected $replies;
+
+ /**
+ * @param IObjectRevision[] $revisions
+ * @param IImportPost[] $replies
+ */
+ public function __construct( array $revisions, array $replies ) {
+ $this->revisions = $revisions;
+ $this->replies = $replies;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getRevisions() {
+ return new ArrayIterator( $this->revisions );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getReplies() {
+ return new ArrayIterator( $this->replies );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getObjectKey() {
+ return 'mock-post:1';
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportRevision.php b/Flow/tests/phpunit/Mock/MockImportRevision.php
new file mode 100644
index 00000000..1a55a29c
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportRevision.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use Flow\Import\IObjectRevision;
+use User;
+
+class MockImportRevision implements IObjectRevision {
+ /**
+ * @var array
+ */
+ protected $attribs;
+
+ /**
+ * @param array $attribs
+ */
+ public function __construct( array $attribs = array() ) {
+ $this->attribs = $attribs + array(
+ 'text' => 'dvorak',
+ 'timestamp' => time(),
+ 'author' => User::newFromName( '127.0.0.1', false ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getText() {
+ return $this->attribs['text'];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTimestamp() {
+ return $this->attribs['timestamp'];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getAuthor() {
+ return $this->attribs['author'];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getObjectKey() {
+ return 'mock-revision:1';
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportSource.php b/Flow/tests/phpunit/Mock/MockImportSource.php
new file mode 100644
index 00000000..c9514675
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportSource.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use ArrayIterator;
+use Flow\Import\IImportHeader;
+use Flow\Import\IImportSource;
+
+class MockImportSource implements IImportSource {
+ /**
+ * @var IImportTopic[]
+ */
+ protected $topics;
+
+ /**
+ * @var IImportHeader|null $header
+ */
+ protected $header;
+
+ /**
+ * @param IImportHeader|null $header
+ * @param IImportTopic[]
+ */
+ public function __construct( MockImportHeader $header = null, array $topics = array() ) {
+ $this->topics = $topics;
+ $this->header = $header;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTopics() {
+ return new ArrayIterator( $this->topics );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getHeader() {
+ return $this->header;
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportSummary.php b/Flow/tests/phpunit/Mock/MockImportSummary.php
new file mode 100644
index 00000000..608183d9
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportSummary.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use ArrayIterator;
+use Flow\Import\IObjectRevision;
+use Flow\Import\IImportSummary;
+use User;
+
+class MockImportSummary implements IImportSummary {
+ /**
+ * @var IObjectRevision[]
+ */
+ protected $revisions;
+
+ /**
+ * @param IObjectRevision[] $revisions
+ */
+ public function __construct( array $revisions = array() ) {
+ $this->revisions = $revisions;
+ }
+
+ public function getRevisions() {
+ return new ArrayIterator( $this->revisions );
+ }
+
+ public function getObjectKey() {
+ return 'mock-summary:1';
+ }
+}
diff --git a/Flow/tests/phpunit/Mock/MockImportTopic.php b/Flow/tests/phpunit/Mock/MockImportTopic.php
new file mode 100644
index 00000000..7ff79a75
--- /dev/null
+++ b/Flow/tests/phpunit/Mock/MockImportTopic.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Flow\Tests\Mock;
+
+use Flow\Import\IObjectRevision;
+use Flow\Import\IImportSummary;
+use Flow\Import\IImportTopic;
+
+class MockImportTopic extends MockImportPost implements IImportTopic {
+ /**
+ * @var IImportSummary
+ */
+ protected $summary;
+
+ /**
+ * @param IImportSummary $summary
+ * @param IObjectRevision[] $revisions
+ * @param IImportPost[] $replies
+ */
+ public function __construct( IImportSummary $summary = null, array $revisions, array $replies ) {
+ parent::__construct( $revisions, $replies );
+ $this->summary = $summary;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getTopicSummary() {
+ return $this->summary;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getLogType() {
+ "mock-flow-topic-import";
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getLogParameters() {
+ return array();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getObjectKey() {
+ return 'mock-topic:1';
+ }
+}
diff --git a/Flow/tests/phpunit/Model/PostRevisionTest.php b/Flow/tests/phpunit/Model/PostRevisionTest.php
new file mode 100644
index 00000000..ce8849bc
--- /dev/null
+++ b/Flow/tests/phpunit/Model/PostRevisionTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Flow\Tests\Model;
+
+use Flow\Model\PostRevision;
+use Flow\Model\UUID;
+use Flow\Model\Workflow;
+use Flow\Tests\PostRevisionTestCase;
+use User;
+use Title;
+
+/**
+ * @group Flow
+ */
+class PostRevisionTest extends PostRevisionTestCase {
+ /**
+ * Tests that a PostRevision::fromStorageRow & ::toStorageRow roundtrip
+ * returns the same DB data.
+ */
+ public function testRoundtrip() {
+ $row = $this->generateRow();
+ $object = PostRevision::fromStorageRow( $row );
+
+ // toStorageRow will add a bogus column 'rev_content_url' - that's ok.
+ // It'll be caught in code to distinguish between external content and
+ // content to be saved in rev_content, and, before inserting into DB,
+ // it'll be unset. We'll ignore this column here.
+ $roundtripRow = PostRevision::toStorageRow( $object );
+ unset( $roundtripRow['rev_content_url'] );
+
+ // Due to our desire to store alphadecimal values in cache and binary values on
+ // disk we need to perform uuid conversion before comparing
+ $roundtripRow = UUID::convertUUIDs( $roundtripRow, 'binary' );
+ $this->assertEquals( $row, $roundtripRow );
+ }
+
+ public function testContentLength() {
+ $content = 'This is a topic title';
+ $nextContent = 'Changed my mind';
+
+ $title = Title::newMainPage();
+ $user = User::newFromName( '127.0.0.1', false );
+ $workflow = Workflow::create( 'topic', $title );
+
+ $topic = PostRevision::create( $workflow, $user, $content, 'wikitext' );
+ $this->assertEquals( 0, $topic->getPreviousContentLength() );
+ $this->assertEquals( mb_strlen( $content ), $topic->getContentLength() );
+
+ $next = $topic->newNextRevision( $user, $nextContent, 'wikitext', 'edit-title', $title );
+ $this->assertEquals( mb_strlen( $content ), $next->getPreviousContentLength() );
+ $this->assertEquals( mb_strlen( $nextContent ), $next->getContentLength() );
+ }
+}
diff --git a/Flow/tests/phpunit/Model/UUIDTest.php b/Flow/tests/phpunit/Model/UUIDTest.php
new file mode 100644
index 00000000..d6ad604c
--- /dev/null
+++ b/Flow/tests/phpunit/Model/UUIDTest.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Flow\Tests\Model;
+
+use Flow\Model\UUID;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ */
+class UUIDTest extends FlowTestCase {
+
+ public function testFixesCapitalizedDataWhenUnserializing() {
+ $uuid = UUID::create();
+ $serialized = serialize( $uuid );
+ // We are targeting this portion of the serialized string:
+ // s:16:"s3xyjucl93jtq2ci"
+ $broken = preg_replace_callback(
+ '/(s:16:")([a-z0-9])/',
+ function( $matches ) {
+ return $matches[1] . strtoupper( $matches[2] );
+ },
+ $serialized
+ );
+ $this->assertNotEquals( $broken, $serialized, 'Failed to create a broken uuid to test unserializing' );
+ $fixed = unserialize( $broken );
+ $this->assertTrue( $uuid->equals( $fixed ) );
+ $this->assertEquals( $uuid->getAlphadecimal(), $fixed->getAlphadecimal() );
+ }
+
+ public function invalidInputProvider() {
+ $valid = UUID::create()->getAlphadecimal();
+
+ return array(
+ array( '' ),
+ array( strtoupper( $valid ) ),
+ array( strtoupper( UUID::alnum2hex( $valid ) ) ),
+ array( ucfirst( $valid ) ),
+ );
+ }
+
+ /**
+ * @dataProvider invalidInputProvider
+ * @expectedException Flow\Exception\InvalidInputException
+ */
+ public function testInvalidInputOnCreate( $invalidInput ) {
+ UUID::create( $invalidInput );
+ }
+
+ static public function uuidConversionProvider() {
+ $dbr = wfGetDB( DB_SLAVE );
+
+ // sample uuid from UIDGenerator::newTimestampedUID128()
+ $numeric_128 = '6709199728898751234959525538795913762';
+ $hex_128 = wfBaseConvert( $numeric_128, 10, 16, 32 );
+ $bin_128 = $dbr->encodeBlob( pack( 'H*', $hex_128 ) );
+ $pretty_128 = wfBaseConvert( $numeric_128, 10, 36 );
+
+ // Conversion from 128 bit to 88 bit takes the left
+ // most 88 bits.
+ $bits_88 = substr( wfBaseConvert( $numeric_128, 10, 2, 128 ), 0, 88 );
+ $numeric_88 = wfBaseConvert( $bits_88, 2, 10 );
+ $hex_88 = wfBaseConvert( $numeric_88, 10, 16, 22 );
+ $bin_88 = $dbr->encodeBlob( pack( 'H*', $hex_88 ) );
+ $pretty_88 = wfBaseConvert( $numeric_88, 10, 36 );
+
+ return array(
+ array(
+ '128 bit hex input must be truncated to 88bit output',
+ // input
+ $hex_128,
+ // binary
+ $bin_88,
+ // hex
+ $hex_88,
+ // base36 output
+ $pretty_88,
+ ),
+
+ array(
+ '88 bit binary input',
+ // input
+ $bin_88,
+ // binary
+ $bin_88,
+ // hex
+ $hex_88,
+ // pretty
+ $pretty_88,
+ ),
+
+ array(
+ '88 bit numeric input',
+ // input
+ $numeric_88,
+ // binary
+ $bin_88,
+ // hex
+ $hex_88,
+ // pretty
+ $pretty_88,
+ ),
+
+ array(
+ '88 bit hex input',
+ // input
+ $hex_88,
+ // binary
+ $bin_88,
+ // hex
+ $hex_88,
+ // pretty
+ $pretty_88,
+ ),
+
+ array(
+ '88 bit pretty input',
+ // input
+ $pretty_88,
+ // binary
+ $bin_88,
+ // hex
+ $hex_88,
+ // pretty
+ $pretty_88,
+ ),
+
+ );
+ }
+
+ /**
+ * @dataProvider uuidConversionProvider
+ */
+ public function testUUIDConversion( $msg, $input, $binary, $hex, $pretty ) {
+ $uuid = UUID::create( $input );
+
+ $this->assertEquals( $binary, $uuid->getBinary(), "Compare binary: $msg" );
+ //$this->assertEquals( $hex, $uuid->getHex(), "Compare hex: $msg" );
+ $this->assertEquals( $pretty, $uuid->getAlphadecimal(), "Compare pretty: $msg" );
+ }
+
+ static public function prettyProvider() {
+ return array(
+ // maximal base 36 value ( 2^88 )
+ array( '12vwzoefjlykjgcnwf' ),
+ // current unpadded values from uidgenerator
+ array( 'rlnn1941hqtdtn8a' ),
+ );
+ }
+
+ /**
+ * @dataProvider prettyProvider
+ */
+ public function testUnpaddedPrettyUuid( $uuid ) {
+ $this->assertEquals( $uuid, UUID::create( $uuid )->getAlphadecimal() );
+ }
+
+ public function testConversionToTimestamp() {
+ $this->assertEquals( '20150303221220', UUID::create( 'scv3pvbt40kcyy4g' )->getTimestamp() );
+ }
+}
diff --git a/Flow/tests/phpunit/Model/UserTupleTest.php b/Flow/tests/phpunit/Model/UserTupleTest.php
new file mode 100644
index 00000000..fc14453b
--- /dev/null
+++ b/Flow/tests/phpunit/Model/UserTupleTest.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Flow\Tests\Model;
+
+use Flow\Model\UserTuple;
+
+/**
+ * @group Flow
+ */
+class UserTupleTest extends \MediaWikiTestCase {
+
+ public function invalidInputProvider() {
+ return array(
+ array( 'foo', 0, ''),
+ array( 'foo', 1234, '127.0.0.1' ),
+ array( '', 0, '127.0.0.1' ),
+ array( 'foo', -25, '' ),
+ array( 'foo', null, '127.0.0.1' ),
+ array( null, 55, '' ),
+ array( 'foo', 0, null ),
+ );
+ }
+
+ /**
+ * @dataProvider invalidInputProvider
+ * @expectedException Flow\Exception\InvalidDataException
+ */
+ public function testInvalidInput( $wiki, $id, $ip ) {
+ new UserTuple( $wiki, $id, $ip );
+ }
+
+ public function validInputProvider() {
+ return array(
+ array( 'foo', 42, null ),
+ array( 'foo', 42, '' ),
+ array( 'foo', 0, '127.0.0.1' ),
+ array( 'foo', '0', '10.1.2.3' ),
+ );
+ }
+
+ /**
+ * @dataProvider validInputProvider
+ */
+ public function testValidInput( $wiki, $id, $ip ) {
+ new UserTuple( $wiki, $id, $ip );
+ // no error thrown from constructor
+ $this->assertTrue( true );
+ }
+}
diff --git a/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php b/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php
new file mode 100644
index 00000000..6ec70826
--- /dev/null
+++ b/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\NotificationController;
+use EchoNotificationController;
+use User;
+use WatchedItem;
+
+/**
+ * @group Flow
+ */
+class NotifiedUsersTest extends PostRevisionTestCase {
+ public function setUp() {
+ parent::setUp();
+
+ if ( !class_exists( 'EchoEvent' ) ) {
+ $this->markTestSkipped();
+ return;
+ }
+ }
+ public function testWatchingTopic() {
+ $data = $this->getTestData();
+ if ( !$data ) {
+ $this->markTestSkipped();
+ return;
+ }
+
+ WatchedItem::fromUserTitle( $data['user'], $data['topicWorkflow']->getArticleTitle() )->addWatch();
+
+ $events = $data['notificationController']->notifyPostChange( 'flow-post-reply',
+ array(
+ 'topic-workflow' => $data['topicWorkflow'],
+ 'title' => $data['boardWorkflow']->getOwnerTitle(),
+ 'user' => $data['agent'],
+ 'reply-to' => $data['topic'],
+ 'topic-title' => $data['topic'],
+ 'revision' => $data['post'],
+ ) );
+
+ $this->assertNotifiedUser( $events, $data['user'], $data['agent'] );
+ }
+
+ public function testWatchingBoard() {
+ $data = $this->getTestData();
+ if ( !$data ) {
+ $this->markTestSkipped();
+ return;
+ }
+
+ WatchedItem::fromUserTitle( $data['user'], $data['boardWorkflow']->getArticleTitle() )->addWatch();
+
+ $events = $data['notificationController']->notifyNewTopic( array(
+ 'board-workflow' => $data['boardWorkflow'],
+ 'topic-workflow' => $data['topicWorkflow'],
+ 'topic-title' => $data['topic'],
+ 'first-post' => $data['post'],
+ 'user' => $data['agent'],
+ ) );
+
+ $this->assertNotifiedUser( $events, $data['user'], $data['agent'] );
+ }
+
+ protected function assertNotifiedUser( array $events, User $notifiedUser, User $notNotifiedUser ) {
+ $users = array();
+ foreach( $events as $event ) {
+ $iterator = EchoNotificationController::getUsersToNotifyForEvent( $event );
+ foreach( $iterator as $user ) {
+ $users[] = $user;
+ }
+ }
+
+ // convert user objects back into user ids to simplify assertion
+ $users = array_map( function( $user ) { return $user->getId(); }, $users );
+
+ $this->assertContains( $notifiedUser->getId(), $users );
+ $this->assertNotContains( $notNotifiedUser->getId(), $users );
+ }
+
+ /**
+ * @return bool|array
+ * {
+ * False on failure, or array with these keys:
+ *
+ * @type Workflow $boardWorkflow
+ * @type Workflow $topicWorkflow
+ * @type PostRevision $post
+ * @type PostRevision $topic
+ * @type User $user
+ * @type User $agent
+ * @type NotificationController $notificationController
+ * }
+ */
+ protected function getTestData() {
+ $this->generateWorkflowForPost();
+ $topicWorkflow = $this->workflow;
+ $post = $this->generateObject( array(), array(), 1 );
+ $topic = $this->generateObject( array(), array( $post ) );
+ $user = User::newFromName( 'Flow Test User' );
+ $user->addToDatabase();
+ $agent = User::newFromName( 'Flow Test Agent' );
+ $agent->addToDatabase();
+
+ $notificationController = Container::get( 'controller.notification' );
+
+ // The data of this global varaible is loaded into occupationListener
+ // even before the test starts, so modifying this global in setUP()
+ // won't have any effect on the occupationListener. The trick is to
+ // fake the workflow to have a title in the global varaible
+ global $wgFlowOccupyPages;
+
+ $page = reset( $wgFlowOccupyPages );
+ if ( !$page ) {
+ return false;
+ }
+ $title = \Title::newFromText( $page );
+ if ( !$title ) {
+ return false;
+ }
+ $object = new \ReflectionObject( $topicWorkflow );
+ $ownerTitle = $object->getProperty( 'ownerTitle' );
+ $ownerTitle->setAccessible( true );
+ $ownerTitle->setValue( $topicWorkflow, $title );
+
+ $boardWorkflow = Container::get( 'factory.loader.workflow' )
+ ->createWorkflowLoader( $topicWorkflow->getOwnerTitle() )
+ ->getWorkflow();
+
+ return array(
+ 'boardWorkflow' => $boardWorkflow,
+ 'topicWorkflow' => $topicWorkflow,
+ 'post' => $post,
+ 'topic' => $topic,
+ 'user' => $user,
+ 'agent' => $agent,
+ 'notificationController' => $notificationController,
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/PagerTest.php b/Flow/tests/phpunit/PagerTest.php
new file mode 100644
index 00000000..b4a46298
--- /dev/null
+++ b/Flow/tests/phpunit/PagerTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Data\Pager\PagerPage;
+use Flow\Data\Pager\Pager;
+use Flow\Model\UUID;
+
+/**
+ * @group Flow
+ */
+class PagerTest extends FlowTestCase {
+
+ public function provideDataMakePagingLink() {
+ return array (
+ array(
+ $this->mockStorage(
+ array(
+ $this->mockTopicListEntry(),
+ $this->mockTopicListEntry(),
+ $this->mockTopicListEntry()
+ ),
+ UUID::create(),
+ array( 'topic_id' )
+ ),
+ array( 'topic_list_id' => '123456' ),
+ array( 'pager-limit' => 2, 'order' => 'desc', 'sort' => 'topic_id' ),
+ 'offset-id'
+ ),
+ array(
+ $this->mockStorage(
+ array(
+ $this->mockTopicListEntry(),
+ $this->mockTopicListEntry()
+ ),
+ UUID::create(),
+ array( 'workflow_last_update_timestamp' )
+ ),
+ array( 'topic_list_id' => '123456' ),
+ array( 'pager-limit' => 1, 'order' => 'desc', 'sort' => 'workflow_last_update_timestamp', 'sortby' => 'updated' ),
+ 'offset'
+ )
+ );
+ }
+
+ /**
+ * @dataProvider provideDataMakePagingLink
+ */
+ public function testMakePagingLink( $storage, $query, $options, $offsetKey ) {
+ $pager = new Pager( $storage, $query, $options );
+ $page = $pager->getPage();
+ $pagingOption = $page->getPagingLinksOptions();
+ foreach ( $pagingOption as $option ) {
+ $this->assertArrayHasKey( $offsetKey, $option );
+ $this->assertArrayHasKey( 'offset-dir', $option );
+ $this->assertArrayHasKey( 'limit', $option );
+ if ( isset( $options['sortby'] ) ) {
+ $this->assertArrayHasKey( 'sortby', $option );
+ }
+ }
+ }
+
+ /**
+ * Mock the storage
+ */
+ protected function mockStorage( $return, $offset, $sort ) {
+ $storage = $this->getMockBuilder( 'Flow\Data\ObjectManager' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $storage->expects( $this->any() )
+ ->method( 'find' )
+ ->will( $this->returnValue( $return ) );
+ $storage->expects( $this->any() )
+ ->method( 'serializeOffset' )
+ ->will( $this->returnValue( $offset ) );
+ $storage->expects( $this->any() )
+ ->method( 'getIndexFor' )
+ ->will( $this->returnValue( $this->mockIndex( $sort ) ) );
+ return $storage;
+ }
+
+ /**
+ * Mock TopicListEntry
+ */
+ protected function mockTopicListEntry() {
+ $entry = $this->getMockBuilder( 'Flow\Model\TopicListEntry' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ return $entry;
+ }
+
+ /**
+ * Mock TopKIndex
+ */
+ protected function mockIndex( $sort ) {
+ $index = $this->getMockBuilder( 'Flow\Data\Index\TopKIndex' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $index->expects( $this->any() )
+ ->method( 'getSort' )
+ ->will( $this->returnValue( $sort ) );
+ return $index;
+ }
+
+}
diff --git a/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php b/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php
new file mode 100644
index 00000000..e9781ea4
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Flow\Tests\Parsoid;
+
+use Flow\Parsoid\Fixer\BadImageRemover;
+use Flow\Parsoid\ContentFixer;
+use Flow\Parsoid\Utils;
+use Title;
+
+/**
+ * @group Flow
+ */
+class BadImageRemoverTest extends \MediaWikiTestCase {
+
+ /**
+ * Note that this must return html rather than roundtripping wikitext
+ * through parsoid because that is not current available from the jenkins
+ * test runner/
+ */
+ public static function imageRemovalProvider() {
+ return array(
+ array(
+ 'Passes through allowed good images',
+ // expected html after filtering
+ '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>',
+ // input html
+ '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>',
+ // accept/decline callback
+ function() { return false; }
+ ),
+
+ array(
+ 'Keeps unknown images',
+ // expected html after filtering
+ '<meta typeof="mw:Placeholder" data-parsoid="...">',
+ // input html
+ '<meta typeof="mw:Placeholder" data-parsoid="...">',
+ // accept/decline callback
+ function() { return true; }
+ ),
+
+ array(
+ 'Strips declined images',
+ // expected html after filtering
+ '<p> and other stuff</p>',
+ // input html
+ '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>',
+ // accept/decline callback
+ function() { return true; }
+ ),
+ );
+ }
+ /**
+ * @dataProvider imageRemovalProvider
+ */
+ public function testImageRemoval( $message, $expect, $content, $badImageFilter ) {
+ $fixer = new ContentFixer( new BadImageRemover( $badImageFilter ) );
+ $result = $fixer->apply( $content, Title::newMainPage() );
+ $this->assertEquals( $expect, $result, $message );
+ }
+}
diff --git a/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php b/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php
new file mode 100644
index 00000000..1f3119a1
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Flow\Tests\Parsoid;
+
+use Flow\Parsoid\Fixer\BaseHrefFixer;
+use Flow\Parsoid\ContentFixer;
+use Title;
+
+/**
+ * @group Flow
+ */
+class BaseHrefFixerTest extends \MediaWikiTestCase {
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgServer', 'http://mywiki' );
+ }
+
+ public static function baseHrefProvider() {
+ return array(
+ array(
+ 'Rewrites href of link surrounding image',
+ '<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid=\'{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"[[test]] caption"}],"dsr":[0,43,2,2]}\'><a href="http://mywiki/wiki/./File:Example.jpg" data-parsoid=\'{"a":{"href":"./File:Example.jpg"},"sa":{},"dsr":[2,null,null,null]}\'><img resource="./File:Example.jpg" src="//upload.wikimedia.org/wikipedia/mediawiki/thumb/a/a9/Example.jpg/220px-Example.jpg" data-parsoid=\'{"a":{"resource":"./File:Example.jpg","height":"147","width":"220"},"sa":{"resource":"File:example.jpg"}}\' height="147" width="220"></a><figcaption data-parsoid=\'{"dsr":[null,41,null,null]}\'> caption</figcaption></figure>',
+ '<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid=\'{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"[[test]] caption"}],"dsr":[0,43,2,2]}\'><a href="./File:Example.jpg" data-parsoid=\'{"a":{"href":"./File:Example.jpg"},"sa":{},"dsr":[2,null,null,null]}\'><img resource="./File:Example.jpg" src="//upload.wikimedia.org/wikipedia/mediawiki/thumb/a/a9/Example.jpg/220px-Example.jpg" data-parsoid=\'{"a":{"resource":"./File:Example.jpg","height":"147","width":"220"},"sa":{"resource":"File:example.jpg"}}\' height="147" width="220"></a><figcaption data-parsoid=\'{"dsr":[null,41,null,null]}\'> caption</figcaption></figure>',
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider baseHrefProvider
+ */
+ public function testBaseHrefFixer( $message, $expectedAfter, $before ) {
+ $fixer = new ContentFixer( new BaseHrefFixer( '/wiki/$1' ) );
+ $result = $fixer->apply( $before, Title::newMainPage() );
+ $this->assertEquals( $expectedAfter, $result, $message );
+ }
+}
diff --git a/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php b/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php
new file mode 100644
index 00000000..308d8b23
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Flow\Tests\Parsoid\Fixer;
+
+use Flow\Model\UUID;
+use Flow\Parsoid\ContentFixer;
+use Flow\Parsoid\Fixer\WikiLinkFixer;
+use Flow\Parsoid\Utils;
+use Flow\Tests\PostRevisionTestCase;
+use Html;
+use Title;
+
+/**
+ * @group Flow
+ */
+class WikiLinkFixerTest extends PostRevisionTestCase {
+
+ static public function redLinkProvider() {
+ return array(
+ array(
+ 'Basic redlink application',
+ // html from parsoid for: [[Talk:Flow/Bugs]]
+ '<a rel="mw:WikiLink" href="./Talk:Flow/Bugs" data-parsoid=\'{"stx":"simple","a":{"href":"./Talk:Flow/Bugs"},"sa":{"href":"Talk:Flow/Bugs"},"dsr":[0,18,2,2]}\'>Talk:Flow/Bugs</a>',
+ // expect string
+ // @fixme easily breakable, depends on url order
+ htmlentities( 'Talk:Flow/Bugs&action=edit&redlink=1' ),
+ ),
+
+ array(
+ 'Subpage redlink application',
+ // html from parsoid for: [[/SubPage]]
+ '<a rel="mw:WikiLink" href=".//SubPage" data-parsoid=\'{"stx":"simple","a":{"href":".//SubPage"},"sa":{"href":"/SubPage"},"dsr":[0,12,2,2]}\'>/SubPage</a>',
+ // expect string
+ htmlentities( 'Main_Page/SubPage&action=edit&redlink=1' ),
+ ),
+
+ array(
+ 'Link containing html entities should be properly handled',
+ // html from parsoid for: [[Foo&Bar]]
+ '<a rel="mw:WikiLink" href="./Foo&amp;Bar" data-parsoid=\'{"stx":"simple","a":{"href":"./Foo&amp;Bar"},"sa":{"href":"Foo&amp;Bar"},"dsr":[0,11,2,2]}\'>Foo&amp;Bar</a>',
+ // expect string
+ '>Foo&amp;Bar</a>',
+ ),
+
+ array(
+ 'Link containing UTF-8 anchor content passes through as UTF-8',
+ // html from parsoid for: [[Foo|test – test]]
+ '<a rel="mw:WikiLink" href="./Foo" data-parsoid=\'{"stx":"piped","a":{"href":"./Foo"},"sa":{"href":"Foo"},"dsr":[0,19,6,2]}\'>test – test</a>',
+ // title text from parsoid
+ // expect string
+ 'test – test',
+ ),
+
+ array(
+ 'Link containing urlencoded UTF-8 href works',
+ // html from parsoid for: [[Viquipèdia:La taverna/Tecnicismes/Arxius_2]]
+ '<a rel="mw:WikiLink" href="./Viquip%C3%A8dia:La_taverna/Tecnicismes/Arxius_2" title="Viquipdia:La taverna/Tecnicismes/Arxius 2" data-parsoid=\'{"stx":"simple","a":{"href":"./Viquipdia:La_taverna/Tecnicismes/Arxius_2"},"sa":{"href":"Viquipdia:La taverna/Tecnicismes/Arxius 2"},"dsr":[59,105,2,2]}\'>Viquipdia:La taverna/Tecnicismes/Arxius 2</a>',
+ // anchor should be transformed to /wiki/Viquip...
+ // annoyingly we don't control Title::exists() so just assume redlink
+ // with index.php
+ 'index.php?title=Viquip%C3%A8dia:La_taverna/Tecnicismes/Arxius_2'
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider redLinkProvider
+ */
+ public function testAppliesRedLinks( $message, $anchor, $expect ) {
+ $fixer = new ContentFixer( new WikiLinkFixer( $this->getMock( 'LinkBatch' ) ) );
+ $result = $fixer->apply( $anchor, Title::newMainPage() );
+ $this->assertContains( $expect, $result, $message );
+ }
+}
+
+class MethodReturnsConstraint extends \PHPUnit_Framework_Constraint {
+ public function __construct( $method, \PHPUnit_Framework_Constraint $constraint ) {
+ $this->method = $method;
+ $this->constraint = $constraint;
+ }
+
+ protected function matches( $other ) {
+ return $this->constraint->matches( call_user_func( array( $other, $this->method ) ) );
+ }
+
+ public function toString() {
+ return $this->constraint->toString();
+ }
+
+ protected function failureDescription( $other ) {
+ return $this->constraint->failureDescription( $other ) . " from {$this->method} method";
+ }
+}
diff --git a/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php b/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php
new file mode 100644
index 00000000..b7af6509
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Flow\Tests\Parsoid;
+
+use Flow\Container;
+use Flow\Exception\WikitextException;
+use Flow\Model\UUID;
+use Flow\Parsoid\ReferenceFactory;
+use Flow\Parsoid\Utils;
+use Flow\Tests\FlowTestCase;
+use ReflectionMethod;
+use Title;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class ReferenceExtractorTestCase extends FlowTestCase {
+ public function setUp() {
+ parent::setUp();
+
+ // Check for Parsoid
+ try {
+ Utils::convert( 'html', 'wikitext', 'Foo', Title::newFromText( 'UTPage' ) );
+ } catch ( WikitextException $excep ) {
+ $this->markTestSkipped( 'Parsoid not enabled' );
+ }
+ }
+
+ public static function referenceExtractorProvider() {
+ return array(
+ array(
+ 'Normal link',
+ // source wiki text
+ '[[My page]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'link',
+ // expected target
+ 'title:My_page',
+ ),
+ array(
+ 'Link with URL encoding issues',
+ // source wiki text
+ '[[User talk:Werdna?]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'link',
+ // expected target
+ 'title:User_talk:Werdna?',
+ ),
+ array(
+ 'Subpage link',
+ // source wiki text
+ '[[/Subpage]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'link',
+ // expected target
+ 'title:Talk:UTPage/Subpage',
+ // ???
+ 'Talk:UTPage',
+ ),
+ array(
+ 'External link',
+ // source wiki text
+ '[http://www.google.com Google]',
+ // expected factory method
+ 'Flow\Model\UrlReference',
+ // expected type
+ 'link',
+ // expected target
+ 'url:http://www.google.com',
+ ),
+ array(
+ 'File',
+ // source wiki text
+ '[[File:Image.png]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'file',
+ // expected target
+ 'title:File:Image.png',
+ ),
+ array(
+ 'File with parameters',
+ // source wiki text
+ '[[File:Image.png|25px]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'file',
+ // expected target
+ 'title:File:Image.png',
+ ),
+ array(
+ 'File with encoding issues',
+ // source wiki text
+ '[[File:Image?.png]]',
+ // expected class
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'file',
+ // expected target
+ 'title:File:Image?.png',
+ ),
+ array(
+ 'Template',
+ // source wiki text
+ '{{Foo}}',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'template',
+ // expected target
+ 'title:Template:Foo',
+ ),
+
+ array(
+ 'Non-existent File',
+ // source wiki text
+ '[[File:Some/Files/Really/Should_Not_Ex/ist.png]]',
+ // expected factory method
+ 'Flow\Model\WikiReference',
+ // expected type
+ 'file',
+ // expected target
+ 'title:File:Some/Files/Really/Should_Not_Ex/ist.png',
+ )
+ );
+ }
+
+ /**
+ * @dataProvider referenceExtractorProvider
+ */
+ public function testReferenceExtractor(
+ $description,
+ $wikitext,
+ $expectedClass,
+ $expectedType,
+ $expectedTarget,
+ $page = 'UTPage'
+ ) {
+ $referenceExtractor = Container::get( 'reference.extractor' );
+
+ $workflow = $this->getMock( 'Flow\Model\Workflow' );
+ $workflow->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( UUID::create() ) );
+ $workflow->expects( $this->any() )
+ ->method( 'getArticleTitle' )
+ ->will( $this->returnValue( Title::newMainPage() ) );
+ $factory = new ReferenceFactory( $workflow, 'foo', UUID::create() );
+
+ $reflMethod = new ReflectionMethod( $referenceExtractor, 'extractReferences' );
+ $reflMethod->setAccessible( true );
+
+ $reflProperty = new \ReflectionProperty( $referenceExtractor, 'extractors' );
+ $reflProperty->setAccessible( true );
+ $extractors = $reflProperty->getValue( $referenceExtractor );
+
+ $html = Utils::convert( 'wt', 'html', $wikitext, Title::newFromText( $page ) );
+ $result = $reflMethod->invoke(
+ $referenceExtractor,
+ $factory,
+ $extractors['post'],
+ $html
+ );
+ $this->assertCount( 1, $result, $html );
+
+ $result = reset( $result );
+ $this->assertInstanceOf( $expectedClass, $result, $description );
+ $this->assertEquals( $expectedType, $result->getType(), $description );
+ $this->assertEquals( $expectedTarget, $result->getTargetIdentifier(), $description );
+ }
+}
diff --git a/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php b/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php
new file mode 100644
index 00000000..a47b8977
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Flow\Tests\Parsoid;
+
+use Flow\Model\UUID;
+use Flow\Parsoid\ReferenceFactory;
+use Title;
+
+/**
+ * @group Flow
+ */
+class ReferenceFactoryTest extends \MediaWikiTestCase {
+ public function testAcceptsParsoidHrefs() {
+ $workflow = $this->getMock( 'Flow\Model\Workflow' );
+ $workflow->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( UUID::create() ) );
+ $workflow->expects( $this->any() )
+ ->method( 'getArticleTitle' )
+ ->will( $this->returnValue( Title::newMainPage() ) );
+
+ $factory = new ReferenceFactory(
+ $workflow,
+ 'foo',
+ UUID::create()
+ );
+
+ $ref = $factory->createWikiReference( 'file', './File:Foo.jpg' );
+ $this->assertInstanceOf( 'Flow\\Model\\WikiReference', $ref );
+ $this->assertEquals( 'title:File:Foo.jpg', $ref->getTargetIdentifier() );
+ }
+}
diff --git a/Flow/tests/phpunit/Parsoid/UtilsTest.php b/Flow/tests/phpunit/Parsoid/UtilsTest.php
new file mode 100644
index 00000000..fc2165b0
--- /dev/null
+++ b/Flow/tests/phpunit/Parsoid/UtilsTest.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Flow\Tests\Parsoid;
+
+use Flow\Exception\WikitextException;
+use Flow\Parsoid\Utils;
+use Flow\Tests\FlowTestCase;
+use Title;
+
+/**
+ * @group Flow
+ */
+class ParsoidUtilsTest extends FlowTestCase {
+
+ static public function createDomProvider() {
+ return array(
+ array(
+ 'A document with multiple matching ids is valid parser output',
+ '<body><a id="foo">foo</a><a id="foo">bar</a></body>'
+ ),
+ array(
+ 'HTML5 tags, such as figcaption, are valid html',
+ '<body><figcaption /></body>'
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider createDomProvider
+ */
+ public function testCreateDomErrorModes( $message, $content ) {
+ $this->assertInstanceOf( 'DOMDocument', Utils::createDOM( $content ), $message );
+ }
+
+ static public function createRelativeTitleProvider() {
+ return array(
+ array(
+ 'strips leading ./ and treats as non-relative',
+ // expect
+ Title::newFromText( 'File:Foo.jpg' ),
+ // input text
+ './File:Foo.jpg',
+ // relative to title
+ Title::newMainPage()
+ ),
+
+ array(
+ 'two level upwards traversal',
+ // expect
+ Title::newFromText( 'File:Bar.jpg' ),
+ // input text
+ '../../File:Bar.jpg',
+ // relative to title
+ Title::newFromText( 'Main_Page/And/Subpage' ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider createRelativeTitleProvider
+ */
+ public function testResolveSubpageTraversal( $message, $expect, $text, Title $title ) {
+ $result = Utils::createRelativeTitle( $text, $title );
+
+ if ( $expect === null ) {
+ $this->assertNull( $expect, $message );
+ } elseif ( $expect instanceof Title ) {
+ $this->assertInstanceOf( 'Title', $result, $message );
+ $this->assertEquals( $expect->getPrefixedText(), $result->getPrefixedText(), $message );
+ } else {
+ $this->assertEquals( $expect, $result, $message );
+ }
+ }
+
+ static public function wikitextRoundtripProvider() {
+ return array(
+ array(
+ 'italic text',
+ // text & expect
+ "''italic text''",
+ // title
+ Title::newMainPage(),
+ ),
+ array(
+ 'bold text',
+ // text & expect
+ "'''bold text'''",
+ // title
+ Title::newMainPage(),
+ ),
+ );
+ }
+
+ /**
+ * Test full roundtrip (wikitext -> html -> wikitext)
+ *
+ * It doesn't make sense to test only a specific path, since Parsoid's HTML
+ * may change beyond our control & it doesn't really matter to us what
+ * exactly the HTML looks like, as long as Parsoid is able to understand it.
+ *
+ * @dataProvider wikitextRoundtripProvider
+ */
+ public function testwikitextRoundtrip( $message, $expect, Title $title ) {
+ // Check for Parsoid
+ try {
+ $html = Utils::convert( 'wikitext', 'html', $expect, $title );
+ $wikitext = Utils::convert( 'html', 'wikitext', $html, $title );
+ $this->assertEquals( $expect, trim( $wikitext ), $message );
+ } catch ( WikitextException $excep ) {
+ $this->markTestSkipped( 'Parsoid not enabled' );
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/PermissionsTest.php b/Flow/tests/phpunit/PermissionsTest.php
new file mode 100644
index 00000000..cfe7f478
--- /dev/null
+++ b/Flow/tests/phpunit/PermissionsTest.php
@@ -0,0 +1,373 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+use Flow\FlowActions;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\RevisionActionPermissions;
+use Block;
+use User;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class PermissionsTest extends PostRevisionTestCase {
+ /**
+ * @var array
+ */
+ protected $tablesUsed = array( 'user', 'user_groups' );
+
+ /**
+ * @var FlowActions
+ */
+ protected $actions;
+
+ /**
+ * @var PostRevision
+ */
+ protected
+ $topic,
+ $hiddenTopic,
+ $deletedTopic,
+ $suppressedTopic,
+ $post,
+ $hiddenPost,
+ $deletedPost,
+ $suppressedPost;
+
+ /**
+ * @var User
+ */
+ protected
+ $anonUser,
+ $unconfirmedUser,
+ $confirmedUser,
+ $sysopUser,
+ $oversightUser;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // We don't want local config getting in the way of testing whether or
+ // not our permissions implementation works well.
+ // This will load default $wgGroupPermissions + Flow settings, so we can
+ // test if permissions work well, regardless of any custom config.
+ global $IP, $wgFlowGroupPermissions;
+ $wgGroupPermissions = array();
+ require "$IP/includes/DefaultSettings.php";
+ $wgGroupPermissions = array_merge_recursive( $wgGroupPermissions, $wgFlowGroupPermissions );
+ $this->setMwGlobals( 'wgGroupPermissions', $wgGroupPermissions );
+
+ // load actions object
+ $this->actions = Container::get( 'flow_actions' );
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Provides User, PostRevision (or null) & action to testPermissions, as
+ * well as the expected result: whether or not a certain user should be
+ * allowed to perform a certain action on a certain revision.
+ *
+ * I'm calling functions to fetch users & revisions. This is done because
+ * setUp is called only after dataProvider is executed, so it's impossible
+ * to create all these objects in setUp.
+ *
+ * "All data providers are executed before both the call to the
+ * setUpBeforeClass static method and the first call to the setUp method.
+ * Because of that you can't access any variables you create there from
+ * within a data provider. This is required in order for PHPUnit to be able
+ * to compute the total number of tests."
+ *
+ * @see http://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html
+ *
+ * @return array
+ */
+ public function permissionsProvider() {
+ return array(
+ // anon users can submit content, but not moderate
+ array( $this->anonUser(), null, 'create-header', true ),
+// array( $this->anonUser(), $this->header(), 'edit-header', true ),
+ array( $this->anonUser(), $this->topic(), 'edit-title', true ),
+ array( $this->anonUser(), null, 'new-post', true ),
+ array( $this->anonUser(), $this->post(), 'edit-post', false ),
+ array( $this->anonUser(), $this->post(), 'hide-post', true ),
+ array( $this->anonUser(), $this->topic(), 'hide-topic', true ),
+ array( $this->anonUser(), $this->topic(), 'lock-topic', false ),
+ array( $this->anonUser(), $this->post(), 'delete-post', false ),
+ array( $this->anonUser(), $this->topic(), 'delete-topic', false ),
+ array( $this->anonUser(), $this->post(), 'suppress-post', false ),
+ array( $this->anonUser(), $this->topic(), 'suppress-topic', false ),
+ array( $this->anonUser(), $this->post(), 'restore-post', false ),
+ array( $this->anonUser(), $this->topic(), 'restore-topic', false ),
+ array( $this->anonUser(), $this->post(), 'history', true ),
+ array( $this->anonUser(), $this->post(), 'view', true ),
+ array( $this->anonUser(), $this->post(), 'reply', true ),
+
+ // unconfirmed users can also hide posts...
+ array( $this->unconfirmedUser(), null, 'create-header', true ),
+// array( $this->unconfirmedUser(), $this->header(), 'edit-header', true ),
+ array( $this->unconfirmedUser(), $this->topic(), 'edit-title', true ),
+ array( $this->unconfirmedUser(), null, 'new-post', true ),
+ array( $this->unconfirmedUser(), $this->post(), 'edit-post', true ), // can edit own post
+ array( $this->unconfirmedUser(), $this->post(), 'hide-post', true ),
+ array( $this->unconfirmedUser(), $this->topic(), 'hide-topic', true ),
+ array( $this->unconfirmedUser(), $this->topic(), 'lock-topic', true ),
+ array( $this->unconfirmedUser(), $this->post(), 'delete-post', false ),
+ array( $this->unconfirmedUser(), $this->topic(), 'delete-topic', false ),
+ array( $this->unconfirmedUser(), $this->post(), 'suppress-post', false ),
+ array( $this->unconfirmedUser(), $this->topic(), 'suppress-topic', false ),
+ array( $this->unconfirmedUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden
+ array( $this->unconfirmedUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden
+ array( $this->unconfirmedUser(), $this->post(), 'history', true ),
+ array( $this->unconfirmedUser(), $this->post(), 'view', true ),
+ array( $this->unconfirmedUser(), $this->post(), 'reply', true ),
+
+ // ... as well as restore hidden posts
+ array( $this->unconfirmedUser(), $this->hiddenPost(), 'restore-post', true ),
+ array( $this->unconfirmedUser(), $this->hiddenTopic(), 'restore-topic', true ),
+
+ // ... but not restore deleted/suppressed posts
+ array( $this->unconfirmedUser(), $this->deletedPost(), 'restore-post', false ),
+ array( $this->unconfirmedUser(), $this->deletedTopic(), 'restore-topic', false ),
+ array( $this->unconfirmedUser(), $this->suppressedPost(), 'restore-post', false ),
+ array( $this->unconfirmedUser(), $this->suppressedTopic(), 'restore-topic', false ),
+
+ // confirmed users are the same as unconfirmed users, in terms of permissions
+ array( $this->confirmedUser(), null, 'create-header', true ),
+// array( $this->confirmedUser(), $this->header(), 'edit-header', true ),
+ array( $this->confirmedUser(), $this->topic(), 'edit-title', true ),
+ array( $this->confirmedUser(), null, 'new-post', true ),
+ array( $this->confirmedUser(), $this->post(), 'edit-post', false ),
+ array( $this->confirmedUser(), $this->post(), 'hide-post', true ),
+ array( $this->confirmedUser(), $this->topic(), 'hide-topic', true ),
+ array( $this->confirmedUser(), $this->post(), 'delete-post', false ),
+ array( $this->confirmedUser(), $this->topic(), 'delete-topic', false ),
+ array( $this->confirmedUser(), $this->topic(), 'lock-topic', true ),
+ array( $this->confirmedUser(), $this->post(), 'suppress-post', false ),
+ array( $this->confirmedUser(), $this->topic(), 'suppress-topic', false ),
+ array( $this->confirmedUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden
+ array( $this->confirmedUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden
+ array( $this->confirmedUser(), $this->post(), 'history', true ),
+ array( $this->confirmedUser(), $this->post(), 'view', true ),
+ array( $this->confirmedUser(), $this->post(), 'reply', true ),
+ array( $this->confirmedUser(), $this->hiddenPost(), 'restore-post', true ),
+ array( $this->confirmedUser(), $this->hiddenTopic(), 'restore-topic', true ),
+ array( $this->confirmedUser(), $this->deletedPost(), 'restore-post', false ),
+ array( $this->confirmedUser(), $this->deletedTopic(), 'restore-topic', false ),
+ array( $this->confirmedUser(), $this->suppressedPost(), 'restore-post', false ),
+ array( $this->confirmedUser(), $this->suppressedTopic(), 'restore-topic', false ),
+
+ // sysops can do all (incl. editing posts) but suppressing
+ array( $this->sysopUser(), null, 'create-header', true ),
+// array( $this->sysopUser(), $this->header(), 'edit-header', true ),
+ array( $this->sysopUser(), $this->topic(), 'edit-title', true ),
+ array( $this->sysopUser(), null, 'new-post', true ),
+ array( $this->sysopUser(), $this->post(), 'edit-post', true ),
+ array( $this->sysopUser(), $this->post(), 'hide-post', true ),
+ array( $this->sysopUser(), $this->topic(), 'hide-topic', true ),
+ array( $this->sysopUser(), $this->topic(), 'lock-topic', true ),
+ array( $this->sysopUser(), $this->post(), 'delete-post', true ),
+ array( $this->sysopUser(), $this->topic(), 'delete-topic', true ),
+ array( $this->sysopUser(), $this->post(), 'suppress-post', false ),
+ array( $this->sysopUser(), $this->topic(), 'suppress-topic', false ),
+ array( $this->sysopUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden
+ array( $this->sysopUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden
+ array( $this->sysopUser(), $this->topic(), 'history', true ),
+ array( $this->sysopUser(), $this->post(), 'view', true ),
+ array( $this->sysopUser(), $this->post(), 'reply', true ),
+ array( $this->sysopUser(), $this->hiddenPost(), 'restore-post', true ),
+ array( $this->sysopUser(), $this->hiddenTopic(), 'restore-topic', true ),
+ array( $this->sysopUser(), $this->deletedPost(), 'restore-post', true ),
+ array( $this->sysopUser(), $this->deletedTopic(), 'restore-topic', true ),
+ array( $this->sysopUser(), $this->suppressedPost(), 'restore-post', false ),
+ array( $this->sysopUser(), $this->suppressedTopic(), 'restore-topic', false ),
+
+ // oversighters can do everything + suppress (but not edit!)
+ array( $this->oversightUser(), null, 'create-header', true ),
+// array( $this->oversightUser(), $this->header(), 'edit-header', true ),
+ array( $this->oversightUser(), $this->topic(), 'edit-title', true ),
+ array( $this->oversightUser(), null, 'new-post', true ),
+ array( $this->oversightUser(), $this->post(), 'edit-post', false ),
+ array( $this->oversightUser(), $this->post(), 'hide-post', true ),
+ array( $this->oversightUser(), $this->topic(), 'hide-topic', true ),
+ array( $this->oversightUser(), $this->topic(), 'lock-topic', true ),
+ array( $this->oversightUser(), $this->post(), 'delete-post', true ),
+ array( $this->oversightUser(), $this->topic(), 'delete-topic', true ),
+ array( $this->oversightUser(), $this->post(), 'suppress-post', true ),
+ array( $this->oversightUser(), $this->topic(), 'suppress-topic', true ),
+ array( $this->oversightUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden
+ array( $this->oversightUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden
+ array( $this->oversightUser(), $this->post(), 'history', true ),
+ array( $this->oversightUser(), $this->post(), 'view', true ),
+ array( $this->oversightUser(), $this->post(), 'reply', true ),
+ array( $this->oversightUser(), $this->hiddenPost(), 'restore-post', true ),
+ array( $this->oversightUser(), $this->hiddenTopic(), 'restore-topic', true ),
+ array( $this->oversightUser(), $this->deletedPost(), 'restore-post', true ),
+ array( $this->oversightUser(), $this->deletedTopic(), 'restore-topic', true ),
+ array( $this->oversightUser(), $this->suppressedPost(), 'restore-post', true ),
+ array( $this->oversightUser(), $this->suppressedTopic(), 'restore-topic', true ),
+ );
+ }
+
+ /**
+ * @dataProvider permissionsProvider
+ */
+ public function testPermissions( User $user, PostRevision $revision = null, $action, $expected ) {
+ $permissions = new RevisionActionPermissions( $this->actions, $user );
+ $this->assertEquals( $expected, $permissions->isAllowed( $revision, $action ) );
+ }
+
+ protected function anonUser() {
+ if ( !$this->anonUser ) {
+ $this->anonUser = new User;
+ }
+
+ return $this->anonUser;
+ }
+
+ protected function unconfirmedUser() {
+ if ( !$this->unconfirmedUser ) {
+ $this->unconfirmedUser = User::newFromName( 'UTFlowUnconfirmed' );
+ $this->unconfirmedUser->addToDatabase();
+ $this->unconfirmedUser->addGroup( 'user' );
+ }
+
+ return $this->unconfirmedUser;
+ }
+
+ protected function confirmedUser() {
+ if ( !$this->confirmedUser ) {
+ $this->confirmedUser = User::newFromName( 'UTFlowConfirmed' );
+ $this->confirmedUser->addToDatabase();
+ $this->confirmedUser->addGroup( 'autoconfirmed' );
+ }
+
+ return $this->confirmedUser;
+ }
+
+ protected function sysopUser() {
+ if ( !$this->sysopUser ) {
+ $this->sysopUser = User::newFromName( 'UTFlowSysop' );
+ $this->sysopUser->addToDatabase();
+ $this->sysopUser->addGroup( 'sysop' );
+ }
+
+ return $this->sysopUser;
+ }
+
+ protected function oversightUser() {
+ if ( !$this->oversightUser ) {
+ $this->oversightUser = User::newFromName( 'UTFlowOversight' );
+ $this->oversightUser->addToDatabase();
+ $this->oversightUser->addGroup( 'oversight' );
+ }
+
+ return $this->oversightUser;
+ }
+
+ protected function topic() {
+ if ( !$this->topic ) {
+ $this->topic = $this->generateObject();
+ }
+
+ return $this->topic;
+ }
+
+ protected function hiddenTopic() {
+ if ( !$this->hiddenTopic ) {
+ $this->hiddenTopic = $this->generateObject( array(
+ 'rev_change_type' => 'hide-topic',
+ 'rev_mod_state' => AbstractRevision::MODERATED_HIDDEN
+ ) );
+ }
+
+ return $this->hiddenTopic;
+ }
+
+ protected function deletedTopic() {
+ if ( !$this->deletedTopic ) {
+ $this->deletedTopic = $this->generateObject( array(
+ 'rev_change_type' => 'delete-topic',
+ 'rev_mod_state' => AbstractRevision::MODERATED_DELETED
+ ) );
+ }
+
+ return $this->deletedTopic;
+ }
+
+ protected function suppressedTopic() {
+ if ( !$this->suppressedTopic ) {
+ $this->suppressedTopic = $this->generateObject( array(
+ 'rev_change_type' => 'suppress-topic',
+ 'rev_mod_state' => AbstractRevision::MODERATED_SUPPRESSED
+ ) );
+ }
+
+ return $this->suppressedTopic;
+ }
+
+ protected function post() {
+ if ( !$this->post ) {
+ $this->post = $this->generateObject( array(
+ 'tree_orig_user_id' => $this->unconfirmedUser()->getId(),
+ 'tree_orig_user_ip' => '',
+ 'tree_parent_id' => $this->topic()->getPostId()->getBinary()
+ ), array(), 1 );
+ $this->post->setRootPost( $this->generateObject( array(
+ 'tree_orig_user_id' => $this->unconfirmedUser()->getId(),
+ 'tree_orig_user_ip' => '',
+ 'tree_parent_id' => $this->topic()->getPostId()->getBinary()
+ ), array(), 1 ) );
+ }
+
+ return $this->post;
+ }
+
+ protected function hiddenPost() {
+ if ( !$this->hiddenPost ) {
+ $this->hiddenPost = $this->generateObject( array(
+ 'tree_orig_user_id' => $this->unconfirmedUser()->getId(),
+ 'tree_orig_user_ip' => '',
+ 'tree_parent_id' => $this->topic()->getPostId()->getBinary(),
+ 'rev_change_type' => 'hide-post',
+ 'rev_mod_state' => AbstractRevision::MODERATED_HIDDEN
+ ), array(), 1 );
+ }
+
+ return $this->hiddenPost;
+ }
+
+ protected function deletedPost() {
+ if ( !$this->deletedPost ) {
+ $this->deletedPost = $this->generateObject( array(
+ 'tree_orig_user_id' => $this->unconfirmedUser()->getId(),
+ 'tree_orig_user_ip' => '',
+ 'tree_parent_id' => $this->topic()->getPostId()->getBinary(),
+ 'rev_change_type' => 'delete-post',
+ 'rev_mod_state' => AbstractRevision::MODERATED_DELETED
+ ), array(), 1 );
+ }
+
+ return $this->deletedPost;
+ }
+
+ protected function suppressedPost() {
+ if ( !$this->suppressedPost ) {
+ $this->suppressedPost = $this->generateObject( array(
+ 'tree_orig_user_id' => $this->unconfirmedUser()->getId(),
+ 'tree_orig_user_ip' => '',
+ 'tree_parent_id' => $this->topic()->getPostId()->getBinary(),
+ 'rev_change_type' => 'suppress-post',
+ 'rev_mod_state' => AbstractRevision::MODERATED_SUPPRESSED
+ ), array(), 1 );
+ }
+
+ return $this->suppressedPost;
+ }
+}
diff --git a/Flow/tests/phpunit/PostRevisionTestCase.php b/Flow/tests/phpunit/PostRevisionTestCase.php
new file mode 100644
index 00000000..8950dc91
--- /dev/null
+++ b/Flow/tests/phpunit/PostRevisionTestCase.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Flow\Tests;
+
+use DeferredUpdates;
+use Flow\Container;
+use Flow\Data\Index\BoardHistoryIndex;
+use Flow\Data\Listener\NotificationListener;
+use Flow\Data\Listener\RecentChangesListener;
+use Flow\Data\ObjectManager;
+use Flow\Model\AbstractRevision;
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\Model\UserTuple;
+use Flow\Model\UUID;
+use SplQueue;
+use User;
+
+/**
+ * @group Flow
+ * @group Database
+ */
+class PostRevisionTestCase extends FlowTestCase {
+ /**
+ * @var PostRevision
+ */
+ protected $revision;
+
+ /**
+ * @var PostRevision[]
+ */
+ protected $revisions = array();
+
+ /**
+ * @var Workflow
+ */
+ protected $workflow;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->generateWorkflowForPost();
+ $this->revision = $this->generateObject();
+ // Revisions must be blanked here otherwise phpunit run with --repeat will remember
+ // ths revision list between multiple invocations of the test causing issues.
+ $this->revisions = array();
+ }
+
+ /**
+ * Reset the container and with it any state
+ */
+ protected function tearDown() {
+ parent::tearDown();
+
+ foreach ( $this->revisions as $revision ) {
+ try {
+ $this->getStorage()->remove( $revision );
+ } catch ( \MWException $e ) {
+ // ignore - lifecyclehandlers may cause issues with tests, where
+ // not all related stuff is loaded
+ }
+ }
+
+ // Needed because not all cases do the reset in setUp yet
+ Container::reset();
+ }
+
+ /**
+ * @return ObjectManager
+ */
+ protected function getStorage() {
+ return Container::get( 'storage.post' );
+ }
+
+ /**
+ * Returns an array, representing flow_revision & flow_tree_revision db
+ * columns.
+ *
+ * You can pass in arguments to override default data.
+ * With no arguments tossed in, default data (resembling a newly-created
+ * topic title) will be returned.
+ *
+ * @param array[optional] $row DB row data (only specify override columns)
+ * @return array
+ */
+ protected function generateRow( array $row = array() ) {
+ $this->generateWorkflowForPost();
+ $uuidRevision = UUID::create();
+
+ $user = User::newFromName( 'UTSysop' );
+ $tuple = UserTuple::newFromUser( $user );
+
+ return $row + array(
+ // flow_revision
+ 'rev_id' => $uuidRevision->getBinary(),
+ 'rev_type' => 'post',
+ 'rev_type_id' => $this->workflow->getId()->getBinary(),
+ 'rev_user_wiki' => $tuple->wiki,
+ 'rev_user_id' => $tuple->id,
+ 'rev_user_ip' => $tuple->ip,
+ 'rev_parent_id' => null,
+ 'rev_flags' => 'html',
+ 'rev_content' => 'test content',
+ 'rev_change_type' => 'new-post',
+ 'rev_mod_state' => AbstractRevision::MODERATED_NONE,
+ 'rev_mod_user_wiki' => null,
+ 'rev_mod_user_id' => null,
+ 'rev_mod_user_ip' => null,
+ 'rev_mod_timestamp' => null,
+ 'rev_mod_reason' => null,
+ 'rev_last_edit_id' => null,
+ 'rev_edit_user_wiki' => null,
+ 'rev_edit_user_id' => null,
+ 'rev_edit_user_ip' => null,
+ 'rev_content_length' => 0,
+ 'rev_previous_content_length' => 0,
+
+ // flow_tree_revision
+ 'tree_rev_descendant_id' => $this->workflow->getId()->getBinary(),
+ 'tree_rev_id' => $uuidRevision->getBinary(),
+ 'tree_orig_user_wiki' => $tuple->wiki,
+ 'tree_orig_user_id' => $tuple->id,
+ 'tree_orig_user_ip' => $tuple->ip,
+ 'tree_parent_id' => null,
+ );
+ }
+
+ /**
+ * Populate a fake workflow in the unittest database
+ *
+ * @return Workflow
+ */
+ protected function generateWorkflowForPost() {
+ if ( $this->workflow ) {
+ return $this->workflow;
+ }
+
+ $row = array(
+ 'workflow_id' => UUID::create()->getBinary(),
+ 'workflow_type' => 'topic',
+ 'workflow_wiki' => wfWikiId(),
+ // The test workflow has no real associated page, this is
+ // just a random page number
+ 'workflow_page_id' => 1,
+ 'workflow_namespace' => NS_USER_TALK,
+ 'workflow_title_text' => 'Test',
+ 'workflow_lock_state' => 0,
+ 'workflow_last_update_timestamp' => wfTimestampNow(),
+ );
+ $this->workflow = Workflow::fromStorageRow( $row );
+
+ return $this->workflow;
+ }
+
+ /**
+ * Returns a PostRevision object.
+ *
+ * You can pass in arguments to override default data.
+ * With no arguments tossed in, a default revision (resembling a newly-
+ * created topic title) will be returned.
+ *
+ * @param array[optional] $row DB row data (only specify override columns)
+ * @param array[optional] $children Array of child PostRevision objects
+ * @param int[optional] $depth Depth of the PostRevision object
+ * @return PostRevision
+ */
+ protected function generateObject( array $row = array(), $children = array(), $depth = 0 ) {
+ $row = $this->generateRow( $row );
+
+ $revision = PostRevision::fromStorageRow( $row );
+ $revision->setChildren( $children );
+ $revision->setDepth( $depth );
+
+ return $revision;
+ }
+
+ /**
+ * Saves a PostRevision to storage.
+ * Be sure to add the required tables to $tablesUsed and add @group Database
+ * to the class' phpDoc.
+ *
+ * @param PostRevision $revision
+ */
+ protected function store( PostRevision $revision ) {
+ $this->getStorage()->put(
+ $revision,
+ array(
+ 'workflow' => $this->generateWorkflowForPost(),
+ // @todo: Topic.php also adds 'topic-title'
+ )
+ );
+
+ /** @var SplQueue $deferredQueue */
+ $deferredQueue = Container::get( 'deferred_queue' );
+ while( !$deferredQueue->isEmpty() ) {
+ try {
+ DeferredUpdates::addCallableUpdate( $deferredQueue->dequeue() );
+
+ // doing updates 1 by 1 so an exception doesn't break others in
+ // the queue
+ DeferredUpdates::doUpdates();
+ } catch ( \MWException $e ) {
+ // ignoring exceptions for now, not all are phpunit-proof yet
+ }
+ }
+
+ // save for removal at end of tests
+ $this->revisions[] = $revision;
+ }
+
+ protected function clearExtraLifecycleHandlers() {
+ $c = Container::getContainer();
+ foreach( array_unique( $c['storage.manager_list'] ) as $key ) {
+ if ( !isset( $c["$key.listeners"] ) ) {
+ continue;
+ }
+ $c->extend( "$key.listeners", function( $listeners ) use ( $key ) {
+ return array_filter(
+ $listeners,
+ function( $handler ) {
+ // Recent changes logging is outside the scope of this test, and
+ // causes interaction issues
+ return !$handler instanceof RecentChangesListener
+ // putting together the right metadata for a commit is beyond the
+ // scope of these tests
+ && !$handler instanceof NotificationListener
+ // BoardHistory requires we also wire together TopicListEntry objects for
+ // each revision, but that's also beyond our scope.
+ && !$handler instanceof BoardHistoryIndex;
+ }
+ );
+ } );
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php b/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php
new file mode 100644
index 00000000..a439b8a9
--- /dev/null
+++ b/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Flow\Tests\Repository;
+
+use Flow\Container;
+use Flow\Data\BagOStuff\BufferedBagOStuff;
+use Flow\Data\BufferedCache;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\Tests\FlowTestCase;
+
+/**
+ * @group Flow
+ * @group Database
+ */
+class TreeRepositorydbTest extends FlowTestCase {
+ protected $tablesUsed = array( 'flow_tree_node' );
+
+ public function testSomething() {
+ // meaningless set of ids used for repeatability
+ $ids = array_map( array( 'Flow\Model\UUID', 'create' ), array(
+ "s3z44zhp93j5vvc8", "s3z44zhqt7yt8220", "s46w00pmmw0otc0q",
+ "s3qvc7cnor86wvb4", "s3qvc7bbcxr3f340",
+ "s3gre9r27pobtg0n", "s3cdl3dfqf8brx18", "s3cdl3dhajnz43r0",
+ ) );
+
+ // Use 2 repos with 2 caches, the one you insert with reads from cache
+ // the other reads from db due to different cache
+ $cache[] = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), 600 );
+ $cache[] = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), 600 );
+ $dbf = Container::get( 'db.factory' );
+ $repo[] = new TreeRepository( $dbf, $cache[0] );
+ $repo[] = new TreeRepository( $dbf, $cache[1] );
+
+ // id0 as new root
+ wfDebugLog( 'Flow', "\n\n************** id0 as new root ************" );
+ $repo[0]->insert( $ids[0] );
+ $this->assertEquals(
+ array( $ids[0] ),
+ $repo[0]->findRootPath( $ids[0] )
+ );
+ $this->assertEquals(
+ array( $ids[0] ),
+ $repo[1]->findRootPath( $ids[0] )
+ );
+
+ // id1 as child of id0
+ wfDebugLog( 'Flow', "\n\n************** id1 as child of id0 ************" );
+ $repo[0]->insert( $ids[1], $ids[0] );
+ $this->assertEquals(
+ array( $ids[0], $ids[1] ),
+ $repo[0]->findRootPath( $ids[1] )
+ );
+ $this->assertEquals(
+ array( $ids[0], $ids[1] ),
+ $repo[1]->findRootPath( $ids[1] )
+ );
+
+ // id2 as child of id0
+ wfDebugLog( 'Flow', "\n\n************** id2 as child of id0 ************" );
+ $repo[0]->insert( $ids[2], $ids[0] );
+ $this->assertEquals(
+ array( $ids[0], $ids[2] ),
+ $repo[0]->findRootPath( $ids[2] )
+ );
+ $this->assertEquals(
+ array( $ids[0], $ids[2] ),
+ $repo[1]->findRootPath( $ids[2] )
+ );
+
+ // id3 as child of id1
+ wfDebugLog( 'Flow', "\n\n************** id3 as child of id1 ************" );
+ $repo[0]->insert( $ids[3], $ids[1] );
+ $this->assertEquals(
+ array( $ids[0], $ids[1], $ids[3] ),
+ $repo[0]->findRootPath( $ids[3] )
+ );
+ $this->assertEquals(
+ array( $ids[0], $ids[1], $ids[3] ),
+ $repo[1]->findRootPath( $ids[3] )
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/Repository/TreeRepositoryTest.php b/Flow/tests/phpunit/Repository/TreeRepositoryTest.php
new file mode 100644
index 00000000..6c25d014
--- /dev/null
+++ b/Flow/tests/phpunit/Repository/TreeRepositoryTest.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Flow\Tests\Repository;
+
+use Flow\Data\BagOStuff\BufferedBagOStuff;
+use Flow\Data\BufferedCache;
+use Flow\Model\UUID;
+use Flow\Repository\TreeRepository;
+use Flow\Tests\FlowTestCase;
+use ReflectionClass;
+
+/**
+ * @group Flow
+ */
+class TreeRepositoryTest extends FlowTestCase {
+
+ protected $ancestor;
+ protected $descendant;
+
+ public function setUp() {
+ parent::setUp();
+ $this->ancestor = UUID::create( false );
+ $this->descendant = UUID::create( false );
+ }
+
+ public function testSuccessfulInsert() {
+ global $wgFlowCacheTime;
+ $cache = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), $wgFlowCacheTime );
+ $treeRepository = new TreeRepository( $this->mockDbFactory( true ), $cache );
+ $this->assertTrue( $treeRepository->insert( $this->descendant, $this->ancestor ) );
+
+ $reflection = new ReflectionClass( '\Flow\Repository\TreeRepository' );
+ $method = $reflection->getMethod( 'cacheKey' );
+ $method->setAccessible( true );
+
+ $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'subtree', $this->descendant ) ), false );
+ $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'rootpath', $this->descendant ) ), false );
+ $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'parent', $this->descendant ) ), false );
+ }
+
+ /**
+ * @expectedException \Flow\Exception\DataModelException
+ */
+ public function testFailingInsert() {
+ global $wgFlowCacheTime;
+ // Catch the exception and test the cache result then re-throw the exception,
+ // otherwise the exception would skip the cache result test
+ $cache = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), $wgFlowCacheTime );
+ try {
+ $treeRepository = new TreeRepository( $this->mockDbFactory( false ), $cache );
+ $this->assertNull( $treeRepository->insert( $this->descendant, $this->ancestor ) );
+ } catch ( \Exception $e ) {
+ $reflection = new ReflectionClass( '\Flow\Repository\TreeRepository' );
+ $method = $reflection->getMethod( 'cacheKey' );
+ $method->setAccessible( true );
+
+ $this->assertSame( $cache->get( $method->invoke( $treeRepository, 'rootpath', $this->descendant ) ), false );
+ $this->assertSame( $cache->get( $method->invoke( $treeRepository, 'parent', $this->descendant ) ), false );
+
+ throw $e;
+ }
+ }
+
+ protected function mockDbFactory( $dbResult ) {
+ $dbFactory = $this->getMockBuilder( '\Flow\DbFactory' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $dbFactory->expects( $this->any() )
+ ->method( 'getDB' )
+ ->will( $this->returnValue( $this->mockDb( $dbResult) ) );
+ return $dbFactory;
+ }
+
+ protected function mockDb( $dbResult ) {
+ $db = $this->getMockBuilder( '\DatabaseMysql' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $db->expects( $this->any() )
+ ->method( 'insert' )
+ ->will( $this->returnValue( $dbResult ) );
+ $db->expects( $this->any() )
+ ->method( 'insertSelect' )
+ ->will( $this->returnValue( $dbResult ) );
+ $db->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnValue( '' ) );
+ return $db;
+ }
+
+}
diff --git a/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php b/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php
new file mode 100644
index 00000000..c6a6a9e6
--- /dev/null
+++ b/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Flow\Tests\SpamFilter;
+
+use Flow\Model\PostRevision;
+use Flow\SpamFilter\AbuseFilter;
+use Flow\Tests\PostRevisionTestCase;
+use Title;
+use User;
+
+/**
+ * @group Database
+ * @group Flow
+ */
+class AbuseFilterTest extends PostRevisionTestCase {
+ /**
+ * @var AbuseFilter
+ */
+ protected $spamFilter;
+
+ /**
+ * @var array
+ */
+ protected $tablesUsed = array( 'abuse_filter', 'abuse_filter_action', 'abuse_filter_history', 'abuse_filter_log' );
+
+ protected $filters = array(
+ // no CSS screen hijack
+ '(new_wikitext rlike "position\s*:\s*(fixed|absolute)|style\s*=\s*\"[a-z0-9:;\s]*&|z-index\s*:\s*\d|\|([4-9]\d{3}|\d{5,})px")' => 'disallow',
+ );
+
+ public function spamProvider() {
+ return array(
+ array(
+ // default new topic title revision - no spam
+ $this->generateObject(),
+ null,
+ true
+ ),
+ array(
+ // revision with spam
+ // https://www.mediawiki.org/w/index.php?title=Talk:Sandbox&workflow=050bbdd07b64a1c028b2782bcb087b42#flow-post-050bbdd07b70a1c028b2782bcb087b42
+ $this->generateObject( array( 'rev_content' => '<div style="background: yellow; position: fixed; top: 0; left: 0; width: 3000px; height: 3000px; z-index: 1111;">test</div>', 'rev_flags' => 'html' ) ),
+ null,
+ false
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider spamProvider
+ */
+ public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) {
+ $title = Title::newFromText( 'UTPage' );
+
+ $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title );
+ $this->assertEquals( $expected, $status->isOK() );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ global $wgFlowAbuseFilterGroup,
+ $wgFlowAbuseFilterEmergencyDisableThreshold,
+ $wgFlowAbuseFilterEmergencyDisableCount,
+ $wgFlowAbuseFilterEmergencyDisableAge;
+
+ // Needed because abuse filter tries to read the title out and then
+ // set it back. If we never provide one it tries to set a null title
+ // and bails.
+ \RequestContext::getMain()->setTitle( Title::newMainPage() );
+
+ $user = User::newFromName( 'UTSysop' );
+
+ $this->spamFilter = new AbuseFilter( $user, $wgFlowAbuseFilterGroup );
+ if ( !$this->spamFilter->enabled() ) {
+ $this->markTestSkipped( 'AbuseFilter not enabled' );
+ }
+
+ $this->spamFilter->setup( array(
+ 'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold,
+ 'count' => $wgFlowAbuseFilterEmergencyDisableCount,
+ 'age' => $wgFlowAbuseFilterEmergencyDisableAge,
+ ) );
+
+ foreach ( $this->filters as $pattern => $action ) {
+ $this->createFilter( $pattern, $action );
+ }
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ foreach ( $this->tablesUsed as $table ) {
+ $this->db->delete( $table, '*', __METHOD__ );
+ }
+ }
+
+ /**
+ * Inserts a filter into stub database.
+ *
+ * @param string $pattern
+ * @param string[optional] $action
+ */
+ protected function createFilter( $pattern, $action = 'disallow' ) {
+ global $wgFlowAbuseFilterGroup;
+ $user = User::newFromName( 'UTSysop' );
+
+ $this->db->replace(
+ 'abuse_filter',
+ array( 'af_id' ),
+ array(
+// 'af_id',
+ 'af_pattern' => $pattern,
+ 'af_user' => $user->getId(),
+ 'af_user_text' => $user->getName(),
+ 'af_timestamp' => wfTimestampNow(),
+ 'af_enabled' => 1,
+ 'af_comments' => null,
+ 'af_public_comments' => 'Test filter',
+ 'af_hidden' => 0,
+ 'af_hit_count' => 0,
+ 'af_throttled' => 0,
+ 'af_deleted' => 0,
+ 'af_actions' => $action,
+ 'af_group' => $wgFlowAbuseFilterGroup,
+ ),
+ __METHOD__
+ );
+
+ $this->db->replace(
+ 'abuse_filter_action',
+ array( 'afa_filter' ),
+ array(
+ 'afa_filter' => $this->db->insertId(),
+ 'afa_consequence' => $action,
+ 'afa_parameters' => '',
+ ),
+ __METHOD__
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php b/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php
new file mode 100644
index 00000000..548d4291
--- /dev/null
+++ b/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Flow\Tests\SpamFilter;
+
+
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\SpamFilter\ConfirmEdit;
+use Title;
+use User;
+
+class ConfirmEditTest extends \MediaWikiTestCase {
+
+ public function testValidateDoesntBlowUp() {
+ $filter = new ConfirmEdit;
+ if ( !$filter->enabled() ) {
+ $this->markTestSkipped( 'ConfirmEdit is not enabled' );
+ }
+
+ $user = User::newFromName( '127.0.0.1', false );
+ $title = Title::newMainPage();
+ $workflow = Workflow::create( 'topic', $title );
+
+ $oldRevision = PostRevision::create( $workflow, $user, 'foo', 'wikitext' );
+ $newRevision = $oldRevision->newNextRevision( $user, 'bar', 'wikitext', 'change-type', $title );
+
+ $context = $this->getMock( 'IContextSource' );
+ $context->expects( $this->any() )
+ ->method( 'getUser' )
+ ->will( $this->returnValue( $user ) );
+
+ $status = $filter->validate( $context, $newRevision, $oldRevision, $title );
+ $this->assertInstanceOf( 'Status', $status );
+ $this->assertTrue( $status->isGood() );
+ }
+}
diff --git a/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php b/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php
new file mode 100644
index 00000000..bfa548cc
--- /dev/null
+++ b/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Flow\Tests\SpamFilter;
+
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\SpamFilter\ContentLengthFilter;
+use User;
+use Title;
+
+/**
+ * @group Flow
+ */
+class ContentLengthFilterTest extends \MediaWikiTestCase {
+ /**
+ * @var SpamRegex
+ */
+ protected $spamFilter;
+
+ public function spamProvider() {
+ return array(
+ array(
+ 'With content shorter than max length allow through filter',
+ // expect
+ true,
+ // content
+ 'blah',
+ // max length
+ 100
+ ),
+
+ array(
+ 'With content longer than max length dissalow through filter',
+ // expect
+ false,
+ // content
+ 'blah',
+ // max length
+ 2
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider spamProvider
+ */
+ public function testSpam( $message, $expect, $content, $maxLength ) {
+ $title = Title::newFromText( 'UTPage' );
+ $user = User::newFromName( '127.0.0.1', false );
+ $workflow = Workflow::create( 'topic', $title );
+ $topic = PostRevision::create( $workflow, $user, 'title content', 'wikitext' );
+ $reply = $topic->reply( $workflow, $user, $content, 'wikitext' );
+
+ $spamFilter = new ContentLengthFilter( $maxLength );
+ $status = $spamFilter->validate( $this->getMock( 'IContextSource' ), $reply, null, $title );
+ $this->assertEquals( $expect, $status->isOK() );
+ }
+}
diff --git a/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php b/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php
new file mode 100644
index 00000000..584d78be
--- /dev/null
+++ b/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Flow\Tests\SpamFilter;
+
+use BaseBlacklist;
+use Flow\Model\PostRevision;
+use Flow\SpamFilter\SpamBlacklist;
+use Flow\Tests\PostRevisionTestCase;
+use Title;
+
+/**
+ * @group Flow
+ */
+class SpamBlacklistTest extends PostRevisionTestCase {
+ /**
+ * @var SpamBlacklist
+ */
+ protected $spamFilter;
+
+ /**
+ * Spam blacklist & whitelist regexes. Examples taken from:
+ *
+ * @see http://meta.wikimedia.org/wiki/Spam_blacklist
+ * @see http://en.wikipedia.org/wiki/MediaWiki:Spam-blacklist
+ * @see http://en.wikipedia.org/wiki/MediaWiki:Spam-whitelist
+ *
+ * @var array
+ */
+ protected
+ $blacklist = array( '\b01bags\.com\b', 'sytes\.net' ),
+ $whitelist = array( 'a5b\.sytes\.net' );
+
+ public function spamProvider() {
+ return array(
+ array(
+ // default new topic title revision - no spam
+ $this->generateObject(),
+ null,
+ true
+ ),
+ array(
+ // revision with spam
+ $this->generateObject( array( 'rev_content' => 'http://01bags.com', 'rev_flags' => 'html' ) ),
+ null,
+ false
+ ),
+ array(
+ // revision with domain blacklisted as spam, but subdomain whitelisted
+ $this->generateObject( array( 'rev_content' => 'http://a5b.sytes.net', 'rev_flags' => 'html' ) ),
+ null,
+ true
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider spamProvider
+ */
+ public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) {
+ $title = Title::newFromText( 'UTPage' );
+
+ $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title );
+ $this->assertEquals( $expected, $status->isOK() );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ // create spam filter
+ $this->spamFilter = new SpamBlacklist;
+ if ( !$this->spamFilter->enabled() ) {
+ $this->markTestSkipped( 'SpamBlacklist not enabled' );
+ }
+
+ $this->setMwGlobals( 'wgBlacklistSettings', array(
+ 'files' => array(),
+ ) );
+
+ // local spam lists are read from spam-blacklist & spam-whitelist
+ // messages, so change them for this test
+ $msgCache = \MessageCache::singleton();
+ $msgCache->enable();
+ $msgCache->replace( 'Spam-blacklist', implode( "\n", $this->blacklist ) );
+ $msgCache->replace( 'Spam-whitelist', implode( "\n", $this->whitelist ) );
+ // That only works if the spam blacklist is really reset
+ $instance = BaseBlacklist::getInstance( 'spam' );
+ $reflProp = new \ReflectionProperty( $instance, 'regexes' );
+ $reflProp->setAccessible( true );
+ $reflProp->setValue( $instance, false );
+ }
+
+ protected function tearDown() {
+ // we don't have to restore the original messages, disable() will make
+ // sure they're ignored
+ $msgCache = \MessageCache::singleton();
+ $msgCache->disable();
+ parent::tearDown();
+ }
+}
diff --git a/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php b/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php
new file mode 100644
index 00000000..4537b08a
--- /dev/null
+++ b/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Flow\Tests\SpamFilter;
+
+use Flow\Model\PostRevision;
+use Flow\SpamFilter\SpamRegex;
+use Flow\Tests\PostRevisionTestCase;
+use Title;
+
+/**
+ * @group Flow
+ */
+class SpamRegexTest extends PostRevisionTestCase {
+ /**
+ * @var SpamRegex
+ */
+ protected $spamFilter;
+
+ public function spamProvider() {
+ return array(
+ array(
+ // default new topic title revision - no spam
+ $this->generateObject(),
+ null,
+ true
+ ),
+ array(
+ // revision with spam
+ $this->generateObject( array( 'rev_content' => 'http://spam', 'rev_flags' => 'html' ) ),
+ null,
+ false
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider spamProvider
+ */
+ public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) {
+ $title = Title::newFromText( 'UTPage' );
+
+ $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title );
+ $this->assertEquals( $expected, $status->isOK() );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ // create a dummy filter
+ $this->setMwGlobals( 'wgSpamRegex', array( '/http:\/\/spam/' ) );
+
+ // create spam filter
+ $this->spamFilter = new SpamRegex;
+ if ( !$this->spamFilter->enabled() ) {
+ $this->markTestSkipped( 'SpamRegex not enabled' );
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/TemplateHelperTest.php b/Flow/tests/phpunit/TemplateHelperTest.php
new file mode 100644
index 00000000..4e6adfe6
--- /dev/null
+++ b/Flow/tests/phpunit/TemplateHelperTest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Flow\Tests;
+
+use Lightncandy;
+use Flow\TemplateHelper;
+
+/**
+ * @group Flow
+ */
+class TemplateHelperTest extends \MediaWikiTestCase {
+
+ public function provideTraversalAttackFilenames() {
+ return array_map( function( $x ) { return array( $x ); }, array(
+ '.',
+ '..',
+ './foo',
+ '../foo',
+ 'foo/./bar',
+ 'foo/../bar',
+ 'foo/bar/.',
+ 'foo/bar/..',
+ ) );
+ }
+
+ /**
+ * @dataProvider provideTraversalAttackFilenames
+ * @expectedException \Flow\Exception\FlowException
+ */
+ public function testGetTemplateFilenamesTraversalAttack( $templateName ) {
+ $helper = new TemplateHelper( '/does/not/exist' );
+ $helper->getTemplateFilenames( $templateName );
+ }
+
+ public function testIfCond() {
+ $code = TemplateHelper::compile( "{{#ifCond foo \"or\" bar}}Works{{/ifCond}}", '' );
+ $renderer = Lightncandy::prepare( $code );
+
+ $this->assertEquals( 'Works', $renderer( array( 'foo' => true, 'bar' => false ) ) );
+ $this->assertEquals( '', $renderer( array( 'foo' => false, 'bar' => false ) ) );
+ /*
+ FIXME: Why won't this work!?
+ $code2 = TemplateHelper::compile( "{{#ifCond foo \"===\" bar}}Works{{/ifCond}}", '' );
+ $renderer2 = Lightncandy::prepare( $code2 );
+ $this->assertEquals( 'Works', $renderer2( array( 'foo' => 1, 'bar' => 1 ) ) );
+ $this->assertEquals( '', $renderer2( array( 'foo' => 2, 'bar' => 3 ) ) );*/
+ }
+}
diff --git a/Flow/tests/phpunit/TemplatingTest.php b/Flow/tests/phpunit/TemplatingTest.php
new file mode 100644
index 00000000..31167ea7
--- /dev/null
+++ b/Flow/tests/phpunit/TemplatingTest.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Model\PostRevision;
+use Flow\Model\Workflow;
+use Flow\Repository\UserNameBatch;
+use Flow\Templating;
+use Title;
+use User;
+
+/**
+ * @group Flow
+ */
+class TemplatingTest extends \MediaWikiTestCase {
+
+ protected function mockTemplating() {
+ $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' );
+ $usernames = new UserNameBatch( $query );
+ $urlGenerator = $this->getMockBuilder( 'Flow\UrlGenerator' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $output = $this->getMockBuilder( 'OutputPage' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $fixer = $this->getMockBuilder( 'Flow\Parsoid\ContentFixer' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ return new Templating( $usernames, $urlGenerator, $output, $fixer, $permissions );
+ }
+
+ /**
+ * There was a bug where all anonymous users got the same
+ * user links output, this checks that they are distinct.
+ */
+ public function testNonRepeatingUserLinksForAnonymousUsers() {
+ $templating = $this->mockTemplating();
+
+ $user = User::newFromName( '127.0.0.1', false );
+ $title = Title::newMainPage();
+ $workflow = Workflow::create( 'topic', $title );
+ $topicTitle = PostRevision::create( $workflow, $user, 'some content', 'wikitext' );
+
+ $hidden = $topicTitle->moderate(
+ $user,
+ $topicTitle::MODERATED_HIDDEN,
+ 'hide-topic',
+ 'hide and go seek'
+ );
+
+ $this->assertContains(
+ 'Special:Contributions/127.0.0.1',
+ $templating->getUserLinks( $hidden ),
+ 'User links should include anonymous contributions'
+ );
+
+ $hidden = $topicTitle->moderate(
+ User::newFromName( '10.0.0.2', false ),
+ $topicTitle::MODERATED_HIDDEN,
+ 'hide-topic',
+ 'hide and go seek'
+ );
+ $this->assertContains(
+ 'Special:Contributions/10.0.0.2',
+ $templating->getUserLinks( $hidden ),
+ 'An alternate user should have the correct anonymous contributions'
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/UrlGeneratorTest.php b/Flow/tests/phpunit/UrlGeneratorTest.php
new file mode 100644
index 00000000..341d1491
--- /dev/null
+++ b/Flow/tests/phpunit/UrlGeneratorTest.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Container;
+use Flow\Model\UUID;
+use Title;
+
+/**
+ * @group Flow
+ */
+class UrlGeneratorTest extends FlowTestCase {
+
+ protected $urlGenerator;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->urlGenerator = Container::get( 'url_generator' );
+ }
+
+ public function provideDataBoardLink() {
+ return array (
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ 'updated',
+ true
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ 'updated',
+ false
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ 'created',
+ true
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ 'created',
+ false
+ )
+ );
+ }
+
+ /**
+ * @dataProvider provideDataBoardLink
+ */
+ public function testBoardLink( Title $title, $sortBy = null, $saveSortBy = false ) {
+ $anchor = $this->urlGenerator->boardLink( $title, $sortBy, $saveSortBy );
+ $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor );
+
+ $link = $anchor->getFullURL();
+ $option = parse_url( $link );
+ $this->assertArrayHasKey( 'query', $option );
+ parse_str( $option['query'], $query );
+
+ if ( $sortBy !== null ) {
+ $this->assertEquals( $sortBy, $query['topiclist_sortby'] );
+ if ( $saveSortBy ) {
+ $this->assertEquals( '1', $query['topiclist_savesortby'] );
+ }
+ }
+ }
+
+ public function provideDataWatchTopicLink() {
+ return array (
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ UUID::create()
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ UUID::create()
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ UUID::create()
+ ),
+ array(
+ Title::makeTitle( NS_MAIN, 'Test' ),
+ UUID::create()
+ )
+ );
+ }
+
+ /**
+ * @dataProvider provideDataWatchTopicLink
+ */
+ public function testWatchTopicLink( Title $title, $workflowId ) {
+ $anchor = $this->urlGenerator->watchTopicLink( $title, $workflowId );
+ $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor );
+
+ $link = $anchor->getFullURL();
+ $option = parse_url( $link );
+ $this->assertArrayHasKey( 'query', $option );
+ parse_str( $option['query'], $query );
+ $this->assertEquals( 'watch', $query['action'] );
+ }
+
+ /**
+ * @dataProvider provideDataWatchTopicLink
+ */
+ public function testUnwatchTopicLink( Title $title, $workflowId ) {
+ $anchor = $this->urlGenerator->unwatchTopicLink( $title, $workflowId );
+ $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor );
+
+ $link = $anchor->getFullURL();
+ $option = parse_url( $link );
+ $this->assertArrayHasKey( 'query', $option );
+ parse_str( $option['query'], $query );
+ $this->assertEquals( 'unwatch', $query['action'] );
+ }
+}
diff --git a/Flow/tests/phpunit/WatchedTopicItemsTest.php b/Flow/tests/phpunit/WatchedTopicItemsTest.php
new file mode 100644
index 00000000..f6b5a62e
--- /dev/null
+++ b/Flow/tests/phpunit/WatchedTopicItemsTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Flow\Tests;
+
+use Flow\Model\UUID;
+use Flow\WatchedTopicItems;
+use User;
+
+/**
+ * @group Flow
+ */
+class WatchedTopicItemTest extends FlowTestCase {
+
+ public function provideDataGetWatchStatus() {
+ // number of test cases
+ $testCount = 10;
+ $tests = array();
+ while ( $testCount > 0 ) {
+ $testCount--;
+ // number of uuid per test case
+ $uuidCount = 10;
+ $uuids = $dbResult = $result = array();
+ while( $uuidCount > 0 ) {
+ $uuidCount--;
+ $uuid = UUID::create()->getAlphadecimal();
+ $rand = rand( 0, 1 );
+ // put in the query result
+ if ( $rand ) {
+ $dbResult[] = ( object )array( 'wl_title' => $uuid );
+ $result[$uuid] = true;
+ } else {
+ $result[$uuid] = false;
+ }
+ $uuids[] = $uuid;
+ }
+ $dbResult = new \ArrayObject( $dbResult );
+ $tests[] = array( $uuids, $dbResult->getIterator(), $result );
+ }
+
+ // attach empty uuids array to query
+ $uuids = $dbResult = $result = array();
+ $emptyCount = 10;
+ while ( $emptyCount > 0 ) {
+ $emptyCount--;
+ $uuid = UUID::create()->getAlphadecimal();
+ $dbResult[] = ( object )array( 'wl_title' => $uuid );
+ }
+ $dbResult = new \ArrayObject( $dbResult );
+ $tests[] = array( $uuids, $dbResult->getIterator(), $result );
+ return $tests;
+ }
+
+ /**
+ * @dataProvider provideDataGetWatchStatus
+ *
+ */
+ public function testGetWatchStatus( $uuids, $dbResult, $result ) {
+ // give it a fake user id
+ $watchedTopicItems = new WatchedTopicItems( User::newFromId( 1 ), $this->mockDb( $dbResult ) );
+ $res = $watchedTopicItems->getWatchStatus( $uuids );
+ $this->assertEquals( count( $res ), count( $result ) );
+ foreach ( $res as $key => $value ) {
+ $this->assertArrayHasKey( $key, $result );
+ $this->assertEquals( $value, $result[$key] );
+ }
+
+ // false values for all uuids for anon users
+ $watchedTopicItems = new WatchedTopicItems( User::newFromId( 0 ), $this->mockDb( $dbResult ) );
+ foreach ( $watchedTopicItems->getWatchStatus( $uuids ) as $value ) {
+ $this->assertFalse( $value );
+ }
+ }
+
+ protected function mockDb( $dbResult ) {
+ $db = $this->getMockBuilder( '\DatabaseMysql' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $db->expects( $this->any() )
+ ->method( 'select' )
+ ->will( $this->returnValue( $dbResult ) );
+ return $db;
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php b/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php
new file mode 100644
index 00000000..a45e8c89
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowEditHeaderTest extends ApiTestCase {
+ public function testEditHeader() {
+ $data = $this->doApiRequest( array(
+ 'page' => "Talk:Flow_QA",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'edit-header',
+ 'ehprev_revision' => '',
+ 'ehcontent' => '(._.)'
+ ) );
+
+ $result = $data[0]['flow']['edit-header']['result']['header'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $result );
+
+ $this->assertArrayHasKey( 'revision', $result, $debug );
+ $revision = $result['revision'];
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'create-header', $revision['changeType'], $debug );
+ $this->assertEquals(
+ '(._.)',
+ trim( strip_tags( $revision['content']['content'] ) ),
+ $debug
+ );
+ $this->assertEquals( 'html', $revision['content']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowEditPostTest.php b/Flow/tests/phpunit/api/ApiFlowEditPostTest.php
new file mode 100644
index 00000000..d4558ee7
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowEditPostTest.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowEditPostTest extends ApiTestCase {
+ public function testEditPost() {
+ $result = $this->createTopic( 'result' );
+ $workflowId = $result['roots'][0];
+ $topicRevisionId = $result['posts'][$workflowId][0];
+ $topic = $result['revisions'][$topicRevisionId];
+
+ $replyPostId = $topic['replies'][0];
+ $replyRevisionId = $result['posts'][$replyPostId][0];
+
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'edit-post',
+ 'eppostId' => $replyPostId,
+ 'epprev_revision' => $replyRevisionId,
+ 'epcontent' => '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞'
+ ) );
+
+ $result = $data[0]['flow']['edit-post']['result']['topic'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $result );
+
+ $newRevisionId = $result['posts'][$replyPostId][0];
+ $revision = $result['revisions'][$newRevisionId];
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'edit-post', $revision['changeType'], $debug );
+ $this->assertEquals(
+ '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞',
+ trim( strip_tags( $revision['content']['content'] ) ),
+ $debug
+ );
+ $this->assertEquals( 'html', $revision['content']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php b/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php
new file mode 100644
index 00000000..17b9a0fe
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowEditTitleTest extends ApiTestCase {
+ public function testEditTitle() {
+ $result = $this->createTopic( 'result' );
+ $workflowId = $result['roots'][0];
+ $revisionId = $result['posts'][$workflowId][0];
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'edit-title',
+ 'etprev_revision' => $revisionId,
+ 'etcontent' => '(ノ◕ヮ◕)ノ*:・ ゚ ゚ ゚ ゚ ゚ ゚ ゚ ゚✧'
+ ) );
+
+ $result = $data[0]['flow']['edit-title']['result']['topic'];
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) );
+
+ $revisionId = $result['posts'][$workflowId][0];
+ $revision = $result['revisions'][$revisionId];
+ $debug = json_encode( $revision );
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'edit-title', $revision['changeType'], $debug );
+ $this->assertEquals( '(ノ◕ヮ◕)ノ*:・ ゚ ゚ ゚ ゚ ゚ ゚ ゚ ゚✧', $revision['content']['content'], $debug );
+ $this->assertEquals( 'plaintext', $revision['content']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php b/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php
new file mode 100644
index 00000000..73a899ed
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowEditTopicSummaryTest extends ApiTestCase {
+ public function testEditTopicSummary() {
+ $workflowId = $this->createTopic();
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'edit-topic-summary',
+ 'etsprev_revision' => '',
+ 'etssummary' => '( ●_●)-((⌼===((() ≍≍≍≍≍ ♒ ✺ ♒ ZAP!'
+ ) );
+
+ $result = $data[0]['flow']['edit-topic-summary']['result']['topicsummary'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $result );
+
+ $revision = $result['revision'];
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'create-topic-summary', $revision['changeType'], $debug );
+ $this->assertEquals(
+ '( ●_●)-((⌼===((() ≍≍≍≍≍ ♒ ✺ ♒ ZAP!',
+ trim( strip_tags( $revision['content']['content'] ) ),
+ $debug
+ );
+ $this->assertEquals( 'html', $revision['content']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php b/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php
new file mode 100644
index 00000000..7019a9da
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowLockTopicTest extends ApiTestCase {
+ public function testLockTopic() {
+ $workflowId = $this->createTopic();
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'lock-topic',
+ 'cotmoderationState' => 'lock',
+ 'cotreason' => 'fiddle faddle',
+ 'cotprev_revision' => null,
+ ) );
+
+ $result = $data[0]['flow']['lock-topic']['result']['topic'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $debug );
+ $this->assertArrayHasKey( 'workflowId', $result, $debug );
+ $this->assertEquals( $workflowId, $result['workflowId'], $debug );
+ $this->assertArrayHasKey( 'changeType', $result, $debug );
+ $this->assertEquals( 'lock-topic', $result['changeType'], $debug );
+ $this->assertArrayHasKey( 'isModerated', $result, $debug );
+ $this->assertTrue( $result['isModerated'], $debug );
+ $this->assertArrayHasKey( 'actions', $result, $debug );
+ $this->assertArrayHasKey( 'unlock', $result['actions'], $debug );
+ $this->assertArrayHasKey( 'moderateReason', $result, $debug );
+ $this->assertEquals( 'fiddle faddle', $result['moderateReason']['content'], $debug );
+ $this->assertEquals( 'plaintext', $result['moderateReason']['format'], $debug );
+ }
+
+ public function testUnlockTopic() {
+ $workflowId = $this->createTopic();
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'lock-topic',
+ 'cotmoderationState' => 'lock',
+ 'cotreason' => 'fiddle faddle',
+ ) );
+ $result = $data[0]['flow']['lock-topic']['result']['topic'];
+ $this->assertCount( 0, $result['errors'] );
+
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'lock-topic',
+ 'cotmoderationState' => 'unlock',
+ 'cotreason' => 'Ether',
+ ) );
+
+ $result = $data[0]['flow']['lock-topic']['result']['topic'];
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertCount( 0, $result['errors'] );
+ $this->assertArrayHasKey( 'changeType', $result );
+ $this->assertEquals( 'restore-topic', $result['changeType'] );
+ $this->assertArrayHasKey( 'isModerated', $result );
+ $this->assertFalse( $result['isModerated'] );
+ $this->assertArrayHasKey( 'actions', $result );
+ $this->assertArrayHasKey( 'lock', $result['actions'] );
+ // Is this intentional? We don't display it by default
+ // but perhaps it should still be in the api output.
+ $this->assertArrayNotHasKey( 'moderateReason', $result );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php b/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php
new file mode 100644
index 00000000..4ac36efb
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use Flow\Model\AbstractRevision;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowModeratePostTest extends ApiTestCase {
+ public function testModeratePost() {
+ $result = $this->createTopic( 'result' );
+ $workflowId = $result['roots'][0];
+ $topicRevisionId = $result['posts'][$workflowId][0];
+ $topic = $result['revisions'][$topicRevisionId];
+ $replyPostId = $topic['replies'][0];
+
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'moderate-post',
+ 'mpmoderationState' => AbstractRevision::MODERATED_HIDDEN,
+ 'mppostId' => $replyPostId,
+ 'mpreason' => '<>&{};'
+ ) );
+
+ $result = $data[0]['flow']['moderate-post']['result']['topic'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) );
+
+ $newRevisionId = $result['posts'][$replyPostId][0];
+ $revision = $result['revisions'][$newRevisionId];
+ $debug = json_encode( $revision );
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'hide-post', $revision['changeType'], $debug );
+ $this->assertArrayHasKey( 'isModerated', $revision, $debug );
+ $this->assertTrue( $revision['isModerated'], $debug );
+ $this->assertArrayHasKey( 'actions', $revision, $debug );
+ $this->assertArrayHasKey( 'unhide', $revision['actions'], $debug );
+ $this->assertArrayHasKey( 'moderateState', $revision, $debug );
+ $this->assertEquals( AbstractRevision::MODERATED_HIDDEN, $revision['moderateState'], $debug );
+ $this->assertArrayHasKey( 'moderateReason', $revision, $debug );
+ $this->assertArrayHasKey( 'content', $revision['moderateReason'], $debug );
+ $this->assertEquals( '<>&{};', $revision['moderateReason']['content'], $debug );
+ $this->assertArrayHasKey( 'format', $revision['moderateReason'], $debug );
+ $this->assertEquals( 'plaintext', $revision['moderateReason']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php b/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php
new file mode 100644
index 00000000..4fd36913
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use Flow\Model\AbstractRevision;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowModerateTopicTest extends ApiTestCase {
+ protected $tablesUsed = array(
+ 'flow_ext_ref',
+ 'flow_revision',
+ 'flow_subscription',
+ 'flow_topic_list',
+ 'flow_tree_node',
+ 'flow_tree_revision',
+ 'flow_wiki_ref',
+ 'flow_workflow',
+ 'logging',
+ );
+
+ public function testModerateTopic() {
+ $workflowId = $this->createTopic();
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'moderate-topic',
+ 'mtmoderationState' => AbstractRevision::MODERATED_DELETED,
+ 'mtreason' => '<>&{};'
+ ) );
+
+ $result = $data[0]['flow']['moderate-topic']['result']['topic'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) );
+
+ $newRevisionId = $result['posts'][$workflowId][0];
+ $revision = $result['revisions'][$newRevisionId];
+ $debug = json_encode( $revision );
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'delete-topic', $revision['changeType'], $debug );
+ $this->assertArrayHasKey( 'isModerated', $revision, $debug );
+ $this->assertTrue( $revision['isModerated'], $debug );
+ $this->assertArrayHasKey( 'actions', $revision, $debug );
+ $this->assertArrayHasKey( 'undelete', $revision['actions'], $debug );
+ $this->assertArrayHasKey( 'moderateState', $revision, $debug );
+ $this->assertEquals( AbstractRevision::MODERATED_DELETED, $revision['moderateState'], $debug );
+ $this->assertArrayHasKey( 'moderateReason', $revision, $debug );
+ $this->assertArrayHasKey( 'content', $revision['moderateReason'], $debug );
+ $this->assertEquals( '<>&{};', $revision['moderateReason']['content'], $debug );
+ $this->assertArrayHasKey( 'format', $revision['moderateReason'], $debug );
+ $this->assertEquals( 'plaintext', $revision['moderateReason']['format'], $debug );
+
+ // make sure our moderated topic made it into Special:Log
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'list' => 'logevents',
+ ) );
+ $debug = json_encode( $data );
+ $logEntry = $data[0]['query']['logevents'][0];
+ $logParams = isset( $logEntry['params'] ) ? $logEntry['params'] : $logEntry;
+ $this->assertArrayHasKey( 'topicId', $logParams, $debug );
+ $this->assertEquals( $workflowId, $logParams['topicId'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowReplyTest.php b/Flow/tests/phpunit/api/ApiFlowReplyTest.php
new file mode 100644
index 00000000..f69d4f4e
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowReplyTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowReplyTest extends ApiTestCase {
+ public function testTopLevelReply() {
+ $result = $this->createTopic( 'result' );
+ $workflowId = $result['roots'][0];
+ $topicRevId = $result['posts'][$workflowId][0];
+
+ $data = $this->doApiRequest( array(
+ 'page' => "Topic:$workflowId",
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'reply',
+ 'repreplyTo' => $workflowId,
+ 'repcontent' => '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞'
+ ) );
+
+ $result = $data[0]['flow']['reply']['result']['topic'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $result );
+
+ $newPostId = end( $result['revisions'][$topicRevId]['replies'] );
+ $newRevisionId = $result['posts'][$newPostId][0];
+ $revision = $result['revisions'][$newRevisionId];
+ $this->assertArrayHasKey( 'changeType', $revision, $debug );
+ $this->assertEquals( 'reply', $revision['changeType'], $debug );
+ $this->assertEquals(
+ '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞',
+ trim( strip_tags( $revision['content']['content'] ) ),
+ $debug
+ );
+ $this->assertEquals( 'html', $revision['content']['format'], $debug );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php b/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php
new file mode 100644
index 00000000..4f8c59aa
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Container;
+use FlowHooks;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowViewHeaderTest extends ApiTestCase {
+ public function testViewEmptyHeader() {
+ $data = $this->doApiRequest( array(
+ 'page' => "Talk:Flow_QA",
+ 'action' => 'flow',
+ 'submodule' => 'view-header',
+ ) );
+
+ $result = $data[0]['flow']['view-header']['result']['header'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $debug );
+
+ // a revision key should exist with only an action link
+ $this->assertArrayHasKey( 'revision', $result, $debug );
+ $revision = $result['revision'];
+ $this->assertEmpty( $revision['links'], $debug );
+ $this->assertEquals( array( 'edit' ), array_keys( $revision['actions'] ), $debug );
+ $this->assertArrayNotHasKey( 'content', $revision );
+ }
+
+ public function testViewHeader() {
+ $data = $this->doApiRequest( array(
+ 'page' => 'Talk:Flow_QA',
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'edit-header',
+ 'ehprev_revision' => '',
+ 'ehcontent' => 'swimmingly',
+ ) );
+ $result = $data[0]['flow']['edit-header']['result']['header'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $debug );
+
+ $data = $this->doApiRequest( array(
+ 'page' => "Talk:Flow_QA",
+ 'action' => 'flow',
+ 'submodule' => 'view-header',
+ 'vhcontentFormat' => 'html',
+ ) );
+ $result = $data[0]['flow']['view-header']['result']['header'];
+ $debug = json_encode( $result );
+ $this->assertArrayHasKey( 'errors', $result, $debug );
+ $this->assertCount( 0, $result['errors'], $debug );
+ $this->assertArrayHasKey( 'revision', $result );
+
+ $revision = $result['revision'];
+ $this->assertArrayHasKey( 'revisionId', $revision, $debug );
+ $this->assertArrayHasKey( 'content', $revision, $debug );
+ $this->assertArrayHasKey( 'content', $revision['content'], $debug );
+ $this->assertEquals(
+ 'swimmingly',
+ trim( strip_tags( $revision['content']['content'] ) ),
+ $debug
+ );
+ $this->assertArrayHasKey( 'format', $revision['content'], $debug );
+ $this->assertEquals( 'html', $revision['content']['format'], $debug );
+ }
+
+ /**
+ * @todo
+ *
+ public function testViewHistorical() {
+ }
+ */
+}
diff --git a/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php b/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php
new file mode 100644
index 00000000..b2f70d7b
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Flow\Model\UUID;
+use Title;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiFlowViewTopicListTest extends ApiTestCase {
+ const TITLE_PREFIX = 'VTL Test ';
+
+ public function testTocOnly() {
+ $topicData = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $title = self::TITLE_PREFIX . $i;
+ $topicData[$i]['response'] = $this->createTopic( 'result', $title );
+ $topicData[$i]['id'] = $topicData[$i]['response']['roots'][0];
+ $topicData[$i]['revisionId'] = $topicData[$i]['response']['posts'][$topicData[$i]['id']][0];
+ $actualRevision = $topicData[$i]['response']['revisions'][$topicData[$i]['revisionId']];
+ $topicData[$i]['expectedRevision'] = array(
+ 'content' => array(
+ 'content' => $title,
+ 'format' => 'plaintext'
+ ),
+ // This last_updated is used for the 'newest' test, then later changed for 'updated' test.
+ 'last_updated' => $actualRevision['last_updated'],
+ );
+ }
+
+ $flowQaTitle = Title::newFromText( 'Talk:Flow_QA' );
+
+ $expectedCommonResponse = array(
+ 'flow' => array(
+ 'view-topiclist' => array(
+ 'result' => array(
+ 'topiclist' => array(
+ 'submitted' => array(
+ 'savesortby' => false,
+ 'offset-dir' => 'fwd',
+ 'offset-id' => null,
+ 'offset' => null,
+ 'limit' => 2,
+ 'render' => false,
+ 'toconly' => true,
+ 'include-offset' => false,
+ ),
+ 'errors' => array(),
+ 'type' => 'topiclist',
+ ),
+ ),
+ 'status' => 'ok',
+ ),
+ ),
+ );
+
+ $expectedEmptyPageResponse = array_merge_recursive( array(
+ 'flow' => array(
+ 'view-topiclist' => array(
+ 'result' => array(
+ 'topiclist' => array(
+ 'submitted' => array(
+ 'sortby' => 'user',
+ ),
+ 'sortby' => 'newest',
+ 'roots' => array(),
+ 'posts' => array(),
+ 'revisions' => array(),
+ 'links' => array(
+ 'pagination' => array(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ), $expectedCommonResponse );
+
+ $actualEmptyPageResponse = $this->doApiRequest(
+ array(
+ 'action' => 'flow',
+ 'page' => 'Talk:Intentionally blank',
+ 'submodule' => 'view-topiclist',
+ 'vtllimit' => 2,
+ 'vtltoconly' => true,
+ )
+ );
+ $actualEmptyPageResponse = $actualEmptyPageResponse[0];
+
+ $this->assertEquals(
+ $expectedEmptyPageResponse,
+ $actualEmptyPageResponse,
+ 'TOC-only output for an empty, but occupied, Flow board'
+ );
+
+ $expectedNewestResponse = array_merge_recursive( array(
+ 'flow' => array(
+ 'view-topiclist' => array(
+ 'result' => array(
+ 'topiclist' => array(
+ 'submitted' => array(
+ 'sortby' => 'newest',
+ ),
+ 'sortby' => 'newest',
+ 'roots' => array(
+ $topicData[2]['id'],
+ $topicData[1]['id'],
+ ),
+ 'posts' => array(
+ $topicData[2]['id'] => $topicData[2]['response']['posts'][$topicData[2]['id']],
+ $topicData[1]['id'] => $topicData[1]['response']['posts'][$topicData[1]['id']],
+ ),
+ 'revisions' => array(
+ $topicData[2]['revisionId'] => $topicData[2]['expectedRevision'],
+ $topicData[1]['revisionId'] => $topicData[1]['expectedRevision'],
+ ),
+ 'links' => array(
+ 'pagination' => array(
+ 'fwd' => array(
+ 'url' => $flowQaTitle->getLinkURL( array(
+ 'topiclist_offset-dir' => 'fwd',
+ 'topiclist_limit' => '2',
+ 'topiclist_offset-id' => $topicData[1]['id'],
+ 'topiclist_sortby' => 'newest',
+ ) ),
+ 'title' => 'fwd',
+ 'text' => 'fwd',
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ )
+ ), $expectedCommonResponse );
+
+ $actualNewestResponse = $this->doApiRequest(
+ array(
+ 'action' => 'flow',
+ 'page' => 'Talk:Flow QA',
+ 'submodule' => 'view-topiclist',
+ 'vtllimit' => 2,
+ 'vtlsortby' => 'newest',
+ 'vtltoconly' => true,
+ )
+ );
+ $actualNewestResponse = $actualNewestResponse[0];
+
+ $this->assertEquals(
+ $expectedNewestResponse,
+ $actualNewestResponse,
+ 'TOC-only output for "newest" order'
+ );
+
+ // Make it so update order is chronologically (1, 0, 2)
+ // We then expect it to be returned reverse chronologically (2, 0)
+
+ $updateList = array( 1, 0, 2);
+
+ foreach ( $updateList as $updateListInd => $topicDataInd ) {
+ $replyResponse = $this->doApiRequest(
+ array(
+ 'action' => 'flow',
+ 'page' => Title::makeTitle( NS_TOPIC, $topicData[$topicDataInd]['id'] )->getPrefixedText(),
+ 'submodule' => 'reply',
+ 'token' => $this->getEditToken(),
+ 'repreplyTo' => $topicData[$topicDataInd]['id'],
+ 'repcontent' => "Reply to topic $topicDataInd",
+ )
+ );
+
+ // This is because we use timestamps with second granularity.
+ // Without this, the timestamp can be exactly the same
+ // for two topics, which means the ordering is undefined (and thus
+ // untestable). This was causing failures on Jenkins.
+ //
+ // Possible improvement: Make a simple class for getting the current
+ // time that normally calls wfTimestampNow. Have an alternative
+ // implementation for tests that can be controlled by an API like
+ // http://sinonjs.org/ (which we use on the client side).
+ // Pimple can be in charge of which is used.
+ if ( $updateListInd !== ( count( $updateList ) - 1 ) ) {
+ sleep( 1 );
+ }
+
+ $replyResponse = $replyResponse[0];
+
+ $responseTopic = $replyResponse['flow']['reply']['result']['topic'];
+ $topicRevisionId = $topicData[$topicDataInd]['revisionId'];
+ $newPostId = end( $responseTopic['revisions'][$topicRevisionId]['replies'] );
+ $topicData[$topicDataInd]['updateTimestamp'] = UUID::create( $newPostId )->getTimestamp();
+ $topicData[$topicDataInd]['expectedRevision']['last_updated'] = wfTimestamp( TS_UNIX, $topicData[$topicDataInd]['updateTimestamp'] ) * 1000;
+ }
+
+ $expectedUpdatedResponse = array_merge_recursive( array(
+ 'flow' => array(
+ 'view-topiclist' => array(
+ 'result' => array(
+ 'topiclist' => array(
+ 'submitted' => array(
+ 'sortby' => 'updated',
+ ),
+ 'sortby' => 'updated',
+ 'roots' => array(
+ $topicData[2]['id'],
+ $topicData[0]['id'],
+ ),
+ 'posts' => array(
+ $topicData[2]['id'] => $topicData[2]['response']['posts'][$topicData[2]['id']],
+ $topicData[0]['id'] => $topicData[0]['response']['posts'][$topicData[0]['id']],
+ ),
+ 'revisions' => array(
+ $topicData[2]['revisionId'] => $topicData[2]['expectedRevision'],
+ $topicData[0]['revisionId'] => $topicData[0]['expectedRevision'],
+ ),
+ 'links' => array(
+ 'pagination' => array(
+ 'fwd' => array(
+ 'url' => $flowQaTitle->getLinkURL( array(
+ 'topiclist_offset-dir' => 'fwd',
+ 'topiclist_limit' => '2',
+ 'topiclist_offset' => $topicData[0]['updateTimestamp'],
+ 'topiclist_sortby' => 'updated',
+ ) ),
+ 'title' => 'fwd',
+ 'text' => 'fwd',
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ )
+ ), $expectedCommonResponse );
+
+ $actualUpdatedResponse = $this->doApiRequest(
+ array(
+ 'action' => 'flow',
+ 'page' => 'Talk:Flow QA',
+ 'submodule' => 'view-topiclist',
+ 'vtllimit' => 2,
+ 'vtlsortby' => 'updated',
+ 'vtltoconly' => true,
+ )
+ );
+ $actualUpdatedResponse = $actualUpdatedResponse[0];
+
+ $this->assertEquals(
+ $expectedUpdatedResponse,
+ $actualUpdatedResponse,
+ 'TOC-only output for "updated" order'
+ );
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiTestCase.php b/Flow/tests/phpunit/api/ApiTestCase.php
new file mode 100644
index 00000000..402fe19c
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiTestCase.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use ApiTestCase as BaseApiTestCase;
+use Flow\Container;
+use FlowHooks;
+use TestUser;
+use User;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+abstract class ApiTestCase extends BaseApiTestCase {
+ protected $tablesUsed = array(
+ 'flow_ext_ref',
+ 'flow_revision',
+ 'flow_subscription',
+ 'flow_topic_list',
+ 'flow_tree_node',
+ 'flow_tree_revision',
+ 'flow_wiki_ref',
+ 'flow_workflow',
+ );
+
+ protected function setUp() {
+ $this->setMwGlobals( 'wgFlowOccupyPages', array(
+ // For testing use; shared with browser tests
+ 'Talk:Flow QA',
+
+ // Don't do any write operations on this. It's intentionally left
+ // blank for testing read operations on unused (but occupied) pages.
+ 'Talk:Intentionally blank',
+ ) );
+
+ Container::reset();
+ parent::setUp();
+ }
+
+ protected function getEditToken( $user = null, $token = 'edittoken' ) {
+ $tokens = $this->getTokenList( $user ?: self::$users['sysop'] );
+ return $tokens[$token];
+ }
+
+ /**
+ * Ensures Flow is reset before passing control on
+ * to parent::doApiRequest. Defaults all requests to
+ * the sysop user if not specified.
+ */
+ protected function doApiRequest(
+ array $params,
+ array $session = null,
+ $appendModule = false,
+ User $user = null
+ ) {
+ if ( $user === null ) {
+ $user = self::$users['sysop']->user;
+ }
+
+ // reset flow state before each request
+ FlowHooks::resetFlowExtension();
+ Container::reset();
+ $container = Container::getContainer();
+ $container['user'] = $user;
+ return parent::doApiRequest( $params, $session, $appendModule, $user );
+ }
+
+ /**
+ * Create a topic on a board using the default user
+ */
+ protected function createTopic( $return = '', $topicTitle = 'Hi there!' ) {
+ $data = $this->doApiRequest( array(
+ 'page' => 'Talk:Flow QA',
+ 'token' => $this->getEditToken(),
+ 'action' => 'flow',
+ 'submodule' => 'new-topic',
+ 'nttopic' => $topicTitle,
+ 'ntcontent' => '...',
+ ) );
+ $this->assertTrue(
+ // @todo we should return the new id much more directly than this
+ isset( $data[0]['flow']['new-topic']['result']['topiclist']['roots'][0] ),
+ 'Api response must contain new topic id'
+ );
+
+ if ( $return === 'all' ) {
+ return $data;
+ } elseif ( $return === 'result' ) {
+ return $data[0]['flow']['new-topic']['result']['topiclist'];
+ } else {
+ return $data[0]['flow']['new-topic']['result']['topiclist']['roots'][0];
+ }
+ }
+}
diff --git a/Flow/tests/phpunit/api/ApiWatchTopicTest.php b/Flow/tests/phpunit/api/ApiWatchTopicTest.php
new file mode 100644
index 00000000..f4320aa0
--- /dev/null
+++ b/Flow/tests/phpunit/api/ApiWatchTopicTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Flow\Tests\Api;
+
+use Title;
+use WatchedItem;
+
+/**
+ * @group Flow
+ * @group medium
+ */
+class ApiWatchTopicTest extends ApiTestCase {
+
+ public function watchTopicProvider() {
+ return array(
+ array(
+ 'Watch a topic',
+ // expected key in api result
+ 'watched',
+ // initialization
+ function( WatchedItem $item ) { $item->removeWatch(); },
+ // extra request parameters
+ array(),
+ ),
+ array(
+ 'Unwatch a topic',
+ // expected key in api result
+ 'unwatched',
+ // initialization
+ function( WatchedItem $item ) { $item->addWatch(); },
+ // extra request parameters
+ array( 'unwatch' => 1 ),
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider watchTopicProvider
+ */
+ public function testWatchTopic( $message, $expect, $init, array $request ) {
+ $topicWorkflowId = $this->createTopic();
+ $title = Title::newFromText( 'Topic:' . $topicWorkflowId );
+ $init( WatchedItem::fromUserTitle( self::$users['sysop']->user, $title, false ) );
+
+ // issue a watch api request
+ $data = $this->doApiRequest( $request + array(
+ 'action' => 'watch',
+ 'format' => 'json',
+ 'titles' => "Topic:$topicWorkflowId",
+ 'token' => $this->getEditToken( null, 'watchtoken' ),
+ ) );
+ $this->assertArrayHasKey( $expect, $data[0]['watch'][0], $message );
+ }
+}
diff --git a/Flow/tests/phpunit/bootstrap.php b/Flow/tests/phpunit/bootstrap.php
new file mode 100644
index 00000000..cddd5d5a
--- /dev/null
+++ b/Flow/tests/phpunit/bootstrap.php
@@ -0,0 +1,19 @@
+<?php
+/**
+ * Find the correct path to /tests/phpunit/bootstrap.php in core
+ *
+ * Takes MW_INSTALL_PATH environment variable into account. This is used by the
+ * test suite defined in mfe.suite.xml for MobileFrontend phpunit testing.
+ */
+
+$IP = getenv( 'MW_INSTALL_PATH' );
+if ( $IP === false ) {
+ if ( realpath( '../..' ) ) {
+ $IP = realpath( '../..' );
+ } else {
+ $IP = dirname( dirname( dirname( __DIR__ ) ) );
+ }
+}
+
+require_once( $IP . "/tests/phpunit/bootstrap.php" );
+
diff --git a/Flow/tests/phpunit/flow.suite.xml b/Flow/tests/phpunit/flow.suite.xml
new file mode 100644
index 00000000..a7bc5e64
--- /dev/null
+++ b/Flow/tests/phpunit/flow.suite.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!-- colors don't work on Windows! -->
+<phpunit bootstrap="bootstrap.php"
+ colors="true"
+ backupGlobals="false"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ stopOnFailure="false"
+ timeoutForSmallTests="2"
+ timeoutForMediumTests="10"
+ timeoutForLargeTests="60"
+ strict="true"
+ verbose="true">
+ <testsuites>
+ <testsuite name="extensions">
+ <file>../../../../tests/phpunit/suites/ExtensionsTestSuite.php</file>
+ </testsuite>
+ </testsuites>
+ <groups>
+ <exclude>
+ <group>Utility</group>
+ <group>Broken</group>
+ <group>ParserFuzz</group>
+ <group>Stub</group>
+ </exclude>
+ </groups>
+</phpunit>
diff --git a/Flow/tests/qunit/engine/components/board/test_flow-board.js b/Flow/tests/qunit/engine/components/board/test_flow-board.js
new file mode 100644
index 00000000..eb2a8e32
--- /dev/null
+++ b/Flow/tests/qunit/engine/components/board/test_flow-board.js
@@ -0,0 +1,178 @@
+( function ( $ ) {
+QUnit.module( 'ext.flow: Flow board' );
+
+QUnit.test( 'Check Flow is running', 1, function() {
+ strictEqual( 1, 1, 'Test to see if Flow has a qunit test.' );
+} );
+
+QUnit.module( 'ext.flow: FlowBoardComponent', {
+ setup: function() {
+ var stub, events;
+
+ this.$el = $( '<div class="flow-component" data-flow-component="board">' );
+ this.component = mw.flow.initComponent( this.$el );
+ stub = this.sandbox.stub( this.component.Api, 'apiCall' );
+
+ stub.withArgs( {
+ action: 'flow',
+ submodule: 'view-topic',
+ workflow: 's18cjkj1bs3rkt13',
+ page: 'Topic:S18cjkj1bs3rkt13'
+ } ).returns(
+ $.Deferred().resolve( {
+ flow: {
+ 'view-topic': {
+ result: {
+ topic: {
+ roots: [ 's18cjkj1bs3rkt13' ],
+ posts: {
+ s18cjkj1bs3rkt13: '4'
+ },
+ revisions: {
+ '4': {
+ content: {
+ format: 'html',
+ content: 'Hi'
+ },
+ changeType: "lock-topic",
+ isModerated: false
+ }
+ }
+ }
+ }
+ }
+ }
+ } )
+ );
+ stub.withArgs( {
+ action: 'flow',
+ submodule: 'view-topic',
+ workflow: 't18cjkj1bs3rkt13',
+ page: 'Topic:T18cjkj1bs3rkt13'
+ } ).returns(
+ $.Deferred().resolve( {
+ flow: {
+ 'view-topic': {
+ result: {
+ topic: {
+ roots: [ 't18cjkj1bs3rkt13' ],
+ posts: {
+ t18cjkj1bs3rkt13: '4'
+ },
+ revisions: {
+ '4': {
+ changeType: "restore-topic",
+ content: {
+ format: 'html',
+ content: 'Hi'
+ },
+ isModerated: true,
+ moderateState: 'lock'
+ }
+ }
+ }
+ }
+ }
+ }
+ } )
+ );
+
+ events = this.component.UI.events;
+ // This method is used to directly trigger callback methods
+ // It's needed, because the element we are triggering it from doesn't necessarily
+ // have the required data- attributes to cause the correct workflow
+ // @todo Correct these tests to test with real elements and their data attribs
+ this.triggerEvent = function ( handlerType, callbackName, context, args ) {
+ var returns = [];
+ args = Array.prototype.slice.call( arguments, 3 );
+
+ $.each( events[ handlerType ][ callbackName ], function ( i, callbackFn ) {
+ returns.push( callbackFn.apply( context, args ) );
+ } );
+
+ return returns;
+ };
+ }
+} );
+
+QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.lockTopic - perform unlock', 2, function( assert ) {
+ var
+ $topic = $( '<div class="flow-topic" data-flow-id="s18cjkj1bs3rkt13">' ).
+ addClass( 'flow-topic-moderatestate-lock flow-topic-moderated' ).
+ appendTo( this.$el ),
+ $titleBar = $( '<div class="flow-topic-titlebar">' ).appendTo( $topic ),
+ info = { status: 'done', $target: $topic };
+
+ this.triggerEvent( 'apiHandlers', 'lockTopic', $titleBar, info );
+ $topic = this.$el.children( '.flow-topic' );
+ assert.strictEqual( $topic.hasClass( 'flow-topic-moderated' ), false, 'No longer has the moderated state.' );
+ assert.strictEqual( $topic.hasClass( 'flow-topic-moderatestate-lock' ), false, 'No longer has the moderated lock state.' );
+} );
+
+QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.lockTopic - perform lock', 2, function( assert ) {
+ var
+ $topic = $( '<div class="flow-topic" data-flow-id="t18cjkj1bs3rkt13">' ).
+ appendTo( this.$el ),
+ $titleBar = $( '<div class="flow-topic-titlebar">' ).appendTo( $topic ),
+ info = { status: 'done', $target: $topic };
+
+ this.triggerEvent( 'apiHandlers', 'lockTopic', $titleBar, info );
+ $topic = this.$el.children( '.flow-topic' );
+ assert.strictEqual( $topic.hasClass( 'flow-topic-moderated' ), true, 'Has the moderated state.' );
+ assert.strictEqual( $topic.hasClass( 'flow-topic-moderatestate-lock' ), true, 'Has the moderated lock state.' );
+} );
+
+QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.preview', 3, function( assert ) {
+ var $container = this.$el,
+ $form = $( '<form>' ).appendTo( $container ),
+ $input = $( '<input value="HEADING">' ).appendTo( $form ),
+ $textarea = $( '<textarea data-flow-preview-template="flow_post">text</textarea>' ).appendTo( $form ),
+ $btn = $( '<button name="preview">' ).
+ appendTo( $form ),
+ info = {
+ $target: $textarea,
+ status: 'done'
+ },
+ data = {
+ 'flow-parsoid-utils': {
+ format: 'html',
+ content: 'hello'
+ }
+ };
+
+ this.triggerEvent( 'apiHandlers', 'preview', $btn, info, data );
+
+ // check all is well.
+ assert.strictEqual( $container.find( '.flow-preview-warning' ).length, 1, 'There is a preview warning.' );
+ assert.strictEqual( $textarea.hasClass( 'flow-preview-target-hidden' ), true, 'Textarea is hidden.' );
+ assert.strictEqual( $input.hasClass( 'flow-preview-target-hidden' ), true, 'Input is hidden.' );
+} );
+
+QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.preview (summary)', 3, function( assert ) {
+ var $container = this.$el,
+ $form = $( '<form>' ).appendTo( $container ),
+ $textarea = $( '<textarea data-flow-preview-template="flow_topic_titlebar_summary.partial" data-flow-preview-node="summary">text</textarea>' ).appendTo( $form ),
+ $btn = $( '<button name="preview">' ).
+ appendTo( $form ),
+ info = {
+ $target: $textarea,
+ status: 'done'
+ },
+ data = {
+ 'flow-parsoid-utils': {
+ format: 'html',
+ content: 'hello'
+ }
+ };
+
+ this.triggerEvent( 'apiHandlers', 'preview', $btn, info, data );
+
+ // check all is well.
+ assert.strictEqual( $container.find( '.flow-preview-warning' ).length, 1,
+ 'There is a preview warning.' );
+ assert.strictEqual( $container.find( '.flow-topic-summary' ).length, 1, 'Summary visible.' );
+ assert.strictEqual( $.trim( $container.find( '.flow-topic-summary' ).text() ),
+ 'hello', 'Check content of summary.' );
+} );
+
+} ( jQuery ) );
diff --git a/Flow/tests/qunit/engine/misc/test_flow-handlebars.js b/Flow/tests/qunit/engine/misc/test_flow-handlebars.js
new file mode 100644
index 00000000..cc0f70f2
--- /dev/null
+++ b/Flow/tests/qunit/engine/misc/test_flow-handlebars.js
@@ -0,0 +1,153 @@
+( function ( $ ) {
+QUnit.module( 'ext.flow: Handlebars helpers', {
+ setup: function() {
+ var stub = this.sandbox.stub( mw.template, 'get' ),
+ stubUser;
+
+ stub.withArgs( 'ext.flow.templating', 'foo.handlebars' ).returns ( {
+ render: function( data ) {
+ return data && data.val ? '<div>Magic.</div>' : 'Stubbed.';
+ }
+ } );
+ this.handlebarsProto = mw.flow.FlowHandlebars.prototype;
+ this.handlebarsProto._qunit_helper_test = function( a, b ) {
+ return a + b;
+ };
+
+ // Stub user
+ stubUser = this.sandbox.stub( mw.user, 'isAnon' );
+ stubUser.onCall( 0 ).returns( true );
+ stubUser.onCall( 1 ).returns( false );
+ this.opts = {
+ fn: function() {
+ return 'ok';
+ },
+ inverse: function() {
+ return 'nope';
+ }
+ };
+ }
+} );
+
+QUnit.test( 'Handlebars.prototype.processTemplate', 1, function( assert ) {
+ assert.strictEqual( this.handlebarsProto.processTemplate( 'foo', { val: 'Hello' } ),
+ '<div>Magic.</div>', 'Getting a template works.' );
+} );
+
+QUnit.test( 'Handlebars.prototype.processTemplateGetFragment', 1, function( assert ) {
+ assert.strictEqual( this.handlebarsProto.processTemplateGetFragment( 'foo', { val: 'Hello' } ).childNodes.length,
+ 1, 'Return a fragment with the div child node' );
+} );
+
+QUnit.test( 'Handlebars.prototype.getTemplate', 2, function( assert ) {
+ assert.strictEqual( this.handlebarsProto.getTemplate( 'foo' )(), 'Stubbed.', 'Getting a template works.' );
+ assert.strictEqual( this.handlebarsProto.getTemplate( 'foo' )(), 'Stubbed.', 'Getting a template from cache works.' );
+} );
+
+// Helpers
+QUnit.test( 'Handlebars.prototype.callHelper', 1, function( assert ) {
+ assert.strictEqual( this.handlebarsProto.callHelper( '_qunit_helper_test', 1, 2 ),
+ 3, 'Check the helper was called.' );
+} );
+
+QUnit.test( 'Handlebars.prototype.eachPost', 3, function( assert ) {
+ var ctx = {
+ posts: {
+ 1: [ 300 ],
+ // Purposely points to a missing revision to deal with edge case
+ 2: [ 500 ]
+ },
+ revisions: {
+ 300: { content: 'a' }
+ }
+ };
+
+ assert.deepEqual( this.handlebarsProto.eachPost( ctx, 1, {} ), { content: 'a' }, 'Matches given id.' );
+ assert.deepEqual( this.handlebarsProto.eachPost( ctx, 1, this.opts ), 'ok', 'Runs fn when given.' );
+ assert.deepEqual( this.handlebarsProto.eachPost( ctx, 2, {} ), { content: null }, 'Missing revision id.' );
+} );
+
+QUnit.test( 'Handlebars.prototype.ifCond', 8, function( assert ) {
+ assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '===', 'bar', this.opts ), 'nope', 'not equal' );
+ assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '===', 'foo', this.opts ), 'ok', 'equal' );
+ assert.strictEqual( this.handlebarsProto.ifCond( true, 'or', false, this.opts ), 'ok', 'true || false' );
+ assert.strictEqual( this.handlebarsProto.ifCond( true, 'or', true, this.opts ), 'ok', 'true || true' );
+ assert.strictEqual( this.handlebarsProto.ifCond( false, 'or', false, this.opts ), 'nope', 'false || false' );
+ assert.strictEqual( this.handlebarsProto.ifCond( false, 'monkeypunch', this.opts ), '', 'Unknown operator' );
+ assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '!==', 'foo', this.opts ), 'nope' );
+ assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '!==', 'bar', this.opts ), 'ok' );
+} );
+
+QUnit.test( 'Handlebars.prototype.ifAnonymous', 2, function() {
+ strictEqual( this.handlebarsProto.ifAnonymous( this.opts ), 'ok', 'User should be anonymous first time.' );
+ strictEqual( this.handlebarsProto.ifAnonymous( this.opts ), 'nope', 'User should be logged in on second call.' );
+} );
+
+QUnit.test( 'Handlebars.prototype.concat', 2, function() {
+ strictEqual( this.handlebarsProto.concat( 'a', 'b', 'c', this.opts ), 'abc', 'Check concat working fine.' );
+ strictEqual( this.handlebarsProto.concat( this.opts ), '', 'Without arguments.' );
+} );
+
+QUnit.test( 'Handlebars.prototype.progressiveEnhancement', 5, function() {
+ var opts = $.extend( { hash: { type: 'insert', target: 'abc', id: 'def' } }, this.opts ),
+ $div = $( document.createElement( 'div' ) );
+
+ // Render script tag
+ strictEqual(
+ this.handlebarsProto.progressiveEnhancement( opts ).string,
+ '<scr' + 'ipt' +
+ ' type="text/x-handlebars-template-progressive-enhancement"' +
+ ' data-type="' + opts.hash.type + '"' +
+ ' data-target="' + opts.hash.target +'"' +
+ ' id="' + opts.hash.id + '">' +
+ 'ok' +
+ '</scr' + 'ipt>',
+ 'Should output exact replica of script tag.'
+ );
+
+ // Replace itself: no target (default to self), no type (default to insert)
+ $div.empty().append( this.handlebarsProto.processTemplateGetFragment(
+ Handlebars.compile( "{{#progressiveEnhancement}}hello{{/progressiveEnhancement}}" )
+ ) );
+ strictEqual(
+ $div.html(),
+ 'hello',
+ 'progressiveEnhancement should be processed in template string.'
+ );
+
+ // Replace a target entirely: target + type=replace
+ $div.empty().append( this.handlebarsProto.processTemplateGetFragment(
+ Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="replace"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' )
+ ) );
+ strictEqual(
+ $div.html(),
+ 'hello',
+ 'progressiveEnhancement should replace target node.'
+ );
+
+ // Insert before a target: target + type=insert
+ $div.empty().append(
+ this.handlebarsProto.processTemplateGetFragment(
+ Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="insert"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' )
+ )
+ );
+ strictEqual(
+ $div.html(),
+ 'hello<div class="pgetest">foo</div>',
+ 'progressiveEnhancement should insert before target.'
+ );
+
+ // Replace target's content: target + type=content
+ $div.empty().append(
+ this.handlebarsProto.processTemplateGetFragment(
+ Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="content"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' )
+ )
+ );
+ strictEqual(
+ $div.html(),
+ '<div class="pgetest">hello</div>',
+ 'progressiveEnhancement should replace target content.'
+ );
+} );
+
+} ( jQuery ) );
diff --git a/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js b/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js
new file mode 100644
index 00000000..bb784e8e
--- /dev/null
+++ b/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js
@@ -0,0 +1,128 @@
+( function ( $ ) {
+ QUnit.module( 'ext.flow: mediawiki.ui.enhance' );
+
+ QUnit.test( 'Forms with required fields have certain buttons disabled by default', 6, function( assert ) {
+ var $forms = [
+ $( '<form><input class="mw-ui-input" required><button data-role="action" class="mw-ui-button">go</button></form>' ),
+ $( '<form><input class="mw-ui-input" required><button data-role="submit" class="mw-ui-button">go</button></form>' ),
+ $( '<form><textarea class="mw-ui-input"></textarea><input class="mw-ui-input"><button data-role="submit" class="mw-ui-button">go</button></form>' ),
+ $( '<form><textarea class="mw-ui-input" required></textarea><button data-role="submit" class="mw-ui-button">go</button></form>' ),
+ $( '<form><textarea class="mw-ui-input" required>foo</textarea><button data-role="submit" class="mw-ui-button">go</button></form>' ),
+ $( '<form><textarea class="mw-ui-input" required>foo</textarea><input class="mw-ui-input" required><button data-role="submit" class="mw-ui-button">go</button></form>' )
+ ];
+
+ $.each( $forms, function() {
+ this.appendTo( '#qunit-fixture' );
+ this.find( '.mw-ui-input' ).trigger( 'keyup' );
+ } );
+
+ assert.strictEqual( $forms[0].find( 'button' ).is( ':disabled' ), true,
+ 'Buttons with data-role=action are disabled when required fields are empty.' );
+ assert.strictEqual( $forms[1].find( 'button' ).is( ':disabled' ), true,
+ 'Buttons with data-role=action are disabled when required fields are empty.' );
+ assert.strictEqual( $forms[2].find( 'button' ).is( ':disabled' ), false,
+ 'Buttons with are enabled when no required fields in form.' );
+ assert.strictEqual( $forms[3].find( 'button' ).is( ':disabled' ), true,
+ 'Buttons are disabled when textarea is required but empty.' );
+ assert.strictEqual( $forms[4].find( 'button' ).is( ':disabled' ), false,
+ 'Buttons are enabled when required textarea has text.' );
+ assert.strictEqual( $forms[5].find( 'button' ).is( ':disabled' ), true,
+ 'Buttons are disabled when required textarea but required input does not.' );
+ } );
+
+ QUnit.test( 'mw-ui-tooltip', 4, function( assert ) {
+ assert.ok( mw.tooltip, 'mw.tooltip exists' );
+
+ // Create a tooltip using body
+ $( 'body' ).attr( 'title', 'test' );
+ assert.ok( mw.tooltip.show( $( 'body' ) ), 'mw.ui.tooltip.show returned something' );
+ assert.strictEqual( $('.flow-ui-tooltip-content' ).filter(':contains("test"):visible').length, 1,
+ 'Tooltip with text "test" is visible' );
+ mw.tooltip.hide( $( 'body' ) );
+ assert.strictEqual( $('.flow-ui-tooltip-content' ).filter(':contains("test")').length, 0,
+ 'Tooltip with text "test" is removed' );
+ $( 'body' ).attr( 'title', '' );
+ } );
+
+ QUnit.test( 'mw-ui-modal', 15, function( assert ) {
+ var modal, $node;
+
+ assert.ok( mw.tooltip, 'mw.Modal exists' );
+
+ // Instantiation
+ modal = mw.Modal();
+ assert.strictEqual( modal.constructor, mw.Modal,
+ 'mw.Modal() returns mw.Modal instance' );
+
+ modal = new mw.Modal();
+ assert.strictEqual( modal.constructor, mw.Modal,
+ 'new mw.Modal() returns mw.Modal instance' );
+
+ modal = mw.Modal( 'namefoo' );
+ assert.strictEqual( modal.getName(), 'namefoo',
+ 'Modal sets name to "namefoo"' );
+
+ // Title
+ assert.strictEqual( modal.getNode().find( modal.headingSelector ).css( 'display' ), 'none',
+ 'Modal heading should be hidden with no title' );
+
+ modal = mw.Modal( { title: 'titlefoo' } );
+ assert.strictEqual( modal.getNode().find( modal.headingSelector ).text().indexOf( 'titlefoo' ) > -1, true,
+ 'Modal instantiation sets title to "titlefoo"' );
+
+ modal.setTitle( 'titlebaz' );
+ assert.strictEqual( modal.getNode().find( modal.headingSelector ).text().indexOf( 'titlebaz' ) > -1, true,
+ 'Modal setTitle to "titlebaz"' );
+
+ // Content at instantiation
+ modal = mw.Modal( { open: 'contentfoo' } );
+ assert.strictEqual( modal.getContentNode().text(), 'contentfoo',
+ 'Modal instantiation sets content to "contentfoo"' );
+ $node = modal.getNode();
+ assert.strictEqual( $node.closest( 'body' ).length, 1,
+ 'Modal instantiation adds modal to body' );
+
+ // Close
+ modal.close();
+ assert.strictEqual( $node.closest( 'body' ).length, 0,
+ 'Modal close removes it from page' );
+ $node = null;
+
+ // Content after instantiation
+ modal = mw.Modal();
+
+ modal.open( 'contentfoo' );
+ assert.strictEqual( modal.getContentNode().html(), 'contentfoo',
+ 'Modal open string' );
+
+ modal.open( '<h1>contentfoo</h1>' );
+ assert.strictEqual( modal.getContentNode().html(), '<h1>contentfoo</h1>',
+ 'Modal open html string' );
+
+ modal.open( $( '<h2>contentfoo</h2>' ) );
+ assert.strictEqual( modal.getContentNode().html(), '<h2>contentfoo</h2>',
+ 'Modal open jQuery' );
+
+ // @todo content Array
+ // @todo content Object
+
+ // Get nodes
+ assert.strictEqual( modal.getNode().length, 1,
+ 'getNode has length' );
+ assert.strictEqual( modal.getContentNode().length, 1,
+ 'getContentNode has length' );
+
+ modal.close(); // kill the test modal
+
+ // @todo setInteractiveHandler
+ // @todo addSteps
+ // @todo setStep
+ // @todo getSteps
+ // @todo prevOrClose
+ // @todo nextOrSubmit
+ // @todo prev
+ // @todo next
+ // @todo go
+ } );
+
+} ( jQuery ) );
diff --git a/Flow/vendor/Pimple/Container.php b/Flow/vendor/Pimple/Container.php
new file mode 100644
index 00000000..26edefc9
--- /dev/null
+++ b/Flow/vendor/Pimple/Container.php
@@ -0,0 +1,281 @@
+<?php
+
+/*
+ * This file is part of Pimple.
+ *
+ * Copyright (c) 2009 Fabien Potencier
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is furnished
+ * to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+namespace Pimple;
+
+/**
+ * Container main class.
+ *
+ * @author Fabien Potencier
+ */
+class Container implements \ArrayAccess
+{
+ private $values = array();
+ private $factories;
+ private $protected;
+ private $frozen = array();
+ private $raw = array();
+ private $keys = array();
+
+ /**
+ * Instantiate the container.
+ *
+ * Objects and parameters can be passed as argument to the constructor.
+ *
+ * @param array $values The parameters or objects.
+ */
+ public function __construct(array $values = array())
+ {
+ $this->factories = new \SplObjectStorage();
+ $this->protected = new \SplObjectStorage();
+
+ foreach ($values as $key => $value) {
+ $this->offsetSet($key, $value);
+ }
+ }
+
+ /**
+ * Sets a parameter or an object.
+ *
+ * Objects must be defined as Closures.
+ *
+ * Allowing any PHP callable leads to difficult to debug problems
+ * as function names (strings) are callable (creating a function with
+ * the same name as an existing parameter would break your container).
+ *
+ * @param string $id The unique identifier for the parameter or object
+ * @param mixed $value The value of the parameter or a closure to define an object
+ * @throws \RuntimeException Prevent override of a frozen service
+ */
+ public function offsetSet($id, $value)
+ {
+ if (isset($this->frozen[$id])) {
+ throw new \RuntimeException(sprintf('Cannot override frozen service "%s".', $id));
+ }
+
+ $this->values[$id] = $value;
+ $this->keys[$id] = true;
+ }
+
+ /**
+ * Gets a parameter or an object.
+ *
+ * @param string $id The unique identifier for the parameter or object
+ *
+ * @return mixed The value of the parameter or an object
+ *
+ * @throws \InvalidArgumentException if the identifier is not defined
+ */
+ public function offsetGet($id)
+ {
+ if (!isset($this->keys[$id])) {
+ throw new \InvalidArgumentException(sprintf('Identifier "%s" is not defined.', $id));
+ }
+
+ if (
+ isset($this->raw[$id])
+ || !is_object($this->values[$id])
+ || isset($this->protected[$this->values[$id]])
+ || !method_exists($this->values[$id], '__invoke')
+ ) {
+ return $this->values[$id];
+ }
+
+ if (isset($this->factories[$this->values[$id]])) {
+ return $this->values[$id]($this);
+ }
+
+ $raw = $this->values[$id];
+ $val = $this->values[$id] = $raw($this);
+ $this->raw[$id] = $raw;
+
+ $this->frozen[$id] = true;
+
+ return $val;
+ }
+
+ /**
+ * Checks if a parameter or an object is set.
+ *
+ * @param string $id The unique identifier for the parameter or object
+ *
+ * @return bool
+ */
+ public function offsetExists($id)
+ {
+ return isset($this->keys[$id]);
+ }
+
+ /**
+ * Unsets a parameter or an object.
+ *
+ * @param string $id The unique identifier for the parameter or object
+ */
+ public function offsetUnset($id)
+ {
+ if (isset($this->keys[$id])) {
+ if (is_object($this->values[$id])) {
+ unset($this->factories[$this->values[$id]], $this->protected[$this->values[$id]]);
+ }
+
+ unset($this->values[$id], $this->frozen[$id], $this->raw[$id], $this->keys[$id]);
+ }
+ }
+
+ /**
+ * Marks a callable as being a factory service.
+ *
+ * @param callable $callable A service definition to be used as a factory
+ *
+ * @return callable The passed callable
+ *
+ * @throws \InvalidArgumentException Service definition has to be a closure of an invokable object
+ */
+ public function factory($callable)
+ {
+ if (!is_object($callable) || !method_exists($callable, '__invoke')) {
+ throw new \InvalidArgumentException('Service definition is not a Closure or invokable object.');
+ }
+
+ $this->factories->attach($callable);
+
+ return $callable;
+ }
+
+ /**
+ * Protects a callable from being interpreted as a service.
+ *
+ * This is useful when you want to store a callable as a parameter.
+ *
+ * @param callable $callable A callable to protect from being evaluated
+ *
+ * @return callable The passed callable
+ *
+ * @throws \InvalidArgumentException Service definition has to be a closure of an invokable object
+ */
+ public function protect($callable)
+ {
+ if (!is_object($callable) || !method_exists($callable, '__invoke')) {
+ throw new \InvalidArgumentException('Callable is not a Closure or invokable object.');
+ }
+
+ $this->protected->attach($callable);
+
+ return $callable;
+ }
+
+ /**
+ * Gets a parameter or the closure defining an object.
+ *
+ * @param string $id The unique identifier for the parameter or object
+ *
+ * @return mixed The value of the parameter or the closure defining an object
+ *
+ * @throws \InvalidArgumentException if the identifier is not defined
+ */
+ public function raw($id)
+ {
+ if (!isset($this->keys[$id])) {
+ throw new \InvalidArgumentException(sprintf('Identifier "%s" is not defined.', $id));
+ }
+
+ if (isset($this->raw[$id])) {
+ return $this->raw[$id];
+ }
+
+ return $this->values[$id];
+ }
+
+ /**
+ * Extends an object definition.
+ *
+ * Useful when you want to extend an existing object definition,
+ * without necessarily loading that object.
+ *
+ * @param string $id The unique identifier for the object
+ * @param callable $callable A service definition to extend the original
+ *
+ * @return callable The wrapped callable
+ *
+ * @throws \InvalidArgumentException if the identifier is not defined or not a service definition
+ */
+ public function extend($id, $callable)
+ {
+ if (!isset($this->keys[$id])) {
+ throw new \InvalidArgumentException(sprintf('Identifier "%s" is not defined.', $id));
+ }
+
+ if (!is_object($this->values[$id]) || !method_exists($this->values[$id], '__invoke')) {
+ throw new \InvalidArgumentException(sprintf('Identifier "%s" does not contain an object definition.', $id));
+ }
+
+ if (!is_object($callable) || !method_exists($callable, '__invoke')) {
+ throw new \InvalidArgumentException('Extension service definition is not a Closure or invokable object.');
+ }
+
+ $factory = $this->values[$id];
+
+ $extended = function ($c) use ($callable, $factory) {
+ return $callable($factory($c), $c);
+ };
+
+ if (isset($this->factories[$factory])) {
+ $this->factories->detach($factory);
+ $this->factories->attach($extended);
+ }
+
+ return $this[$id] = $extended;
+ }
+
+ /**
+ * Returns all defined value names.
+ *
+ * @return array An array of value names
+ */
+ public function keys()
+ {
+ return array_keys($this->values);
+ }
+
+ /**
+ * Registers a service provider.
+ *
+ * @param ServiceProviderInterface $provider A ServiceProviderInterface instance
+ * @param array $values An array of values that customizes the provider
+ *
+ * @return static
+ */
+ public function register(ServiceProviderInterface $provider, array $values = array())
+ {
+ $provider->register($this);
+
+ foreach ($values as $key => $value) {
+ $this[$key] = $value;
+ }
+
+ return $this;
+ }
+}
diff --git a/Flow/vendor/Pimple/ServiceProviderInterface.php b/Flow/vendor/Pimple/ServiceProviderInterface.php
new file mode 100644
index 00000000..9b122bd4
--- /dev/null
+++ b/Flow/vendor/Pimple/ServiceProviderInterface.php
@@ -0,0 +1,46 @@
+<?php
+
+/*
+ * This file is part of Pimple.
+ *
+ * Copyright (c) 2009 Fabien Potencier
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is furnished
+ * to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+namespace Pimple;
+
+/**
+ * Pimple service provider interface.
+ *
+ * @author Fabien Potencier
+ * @author Dominik Zogg
+ */
+interface ServiceProviderInterface
+{
+ /**
+ * Registers services on the given container.
+ *
+ * This method should only be used to configure services and parameters.
+ * It should not get services.
+ *
+ * @param Container $pimple An Container instance
+ */
+ public function register(Container $pimple);
+}
diff --git a/Flow/version b/Flow/version
new file mode 100644
index 00000000..e09a044e
--- /dev/null
+++ b/Flow/version
@@ -0,0 +1,4 @@
+Flow: REL1_25
+2015-06-16T21:07:29
+
+576f705