diff options
author | 2018-12-20 09:26:25 -0500 | |
---|---|---|
committer | 2018-12-20 09:26:25 -0500 | |
commit | 0e06ce158ac74849133e076fdc7320b4f1058888 (patch) | |
tree | d98171b4bd45d30ab8c504c552af996c4e4e5f9a /CommentStreams | |
parent | GentooPackages: Drop global/local descriptors (diff) | |
download | extensions-0e06ce158ac74849133e076fdc7320b4f1058888.tar.gz extensions-0e06ce158ac74849133e076fdc7320b4f1058888.tar.bz2 extensions-0e06ce158ac74849133e076fdc7320b4f1058888.zip |
Add CommentStreams
Signed-off-by: Brian Evans <grknight@gentoo.org>
Diffstat (limited to 'CommentStreams')
51 files changed, 5083 insertions, 0 deletions
diff --git a/CommentStreams/.gitignore b/CommentStreams/.gitignore new file mode 100644 index 00000000..a7abe44f --- /dev/null +++ b/CommentStreams/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +vendor/ + diff --git a/CommentStreams/.gitreview b/CommentStreams/.gitreview new file mode 100644 index 00000000..b3f8a452 --- /dev/null +++ b/CommentStreams/.gitreview @@ -0,0 +1,6 @@ +[gerrit] +host=gerrit.wikimedia.org +port=29418 +project=mediawiki/extensions/CommentStreams.git +track=1 +defaultrebase=0 diff --git a/CommentStreams/CODE_OF_CONDUCT.md b/CommentStreams/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d8e5d087 --- /dev/null +++ b/CommentStreams/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Code_of_Conduct). diff --git a/CommentStreams/COPYING b/CommentStreams/COPYING new file mode 100644 index 00000000..5fe3e109 --- /dev/null +++ b/CommentStreams/COPYING @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 The MITRE Corporation + +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. diff --git a/CommentStreams/Gruntfile.js b/CommentStreams/Gruntfile.js new file mode 100644 index 00000000..a45071e1 --- /dev/null +++ b/CommentStreams/Gruntfile.js @@ -0,0 +1,21 @@ +/*jshint node:true */ +module.exports = function ( grunt ) { + grunt.loadNpmTasks( 'grunt-jsonlint' ); + grunt.loadNpmTasks( 'grunt-banana-checker' ); + + grunt.initConfig( { + banana: { + all: 'i18n/' + }, + jsonlint: { + all: [ + '**/*.json', + '!node_modules/**', + '!vendor/**' + ] + } + } ); + + grunt.registerTask( 'test', [ 'jsonlint', 'banana' ] ); + grunt.registerTask( 'default', 'test' ); +}; diff --git a/CommentStreams/extension.json b/CommentStreams/extension.json new file mode 100644 index 00000000..7df4da33 --- /dev/null +++ b/CommentStreams/extension.json @@ -0,0 +1,160 @@ +{ + "name": "CommentStreams", + "version": "3.9", + "author": [ + "[http://www.mediawiki.org/wiki/User:Jji Jason Ji]", + "[http://www.mediawiki.org/wiki/User:Cindy.cicalese Cindy Cicalese]" + ], + "url": "https://www.mediawiki.org/wiki/Extension:CommentStreams", + "descriptionmsg": "commentstreams-desc", + "manifest_version": 1, + "type": "parserhook", + "SpecialPages": { + "CommentStreamsAllComments": "CommentStreamsAllComments" + }, + "MessagesDirs": { + "CommentStreams": [ + "i18n" + ] + }, + "ExtensionMessagesFiles": { + "CommentStreamsAlias": "includes/CommentStreamsAllComments.alias.php" + }, + "ResourceModules": { + "ext.CommentStreams": { + "styles": [ + "CommentStreams.css" + ], + "scripts": [ + "CommentStreamsQuerier.js", + "CommentStreams.js", + "spin.min.js" + ], + "dependencies": [ + "oojs-ui" + ], + "position": "top", + "targets": [ + "desktop", + "mobile" + ], + "messages": [ + "commentstreams-api-error-notloggedin", + "commentstreams-api-error-commentnotfound", + "commentstreams-api-error-notacomment", + "commentstreams-api-error-missingcommenttitle", + "commentstreams-api-error-post-permissions", + "commentstreams-api-error-post-parentandtitle", + "commentstreams-api-error-post-parentpagedoesnotexist", + "commentstreams-api-error-post-associatedpageidmismatch", + "commentstreams-api-error-post-associatedpagedoesnotexist", + "commentstreams-api-error-post", + "commentstreams-api-error-edit-notloggedin", + "commentstreams-api-error-edit-permissions", + "commentstreams-api-error-edit", + "commentstreams-api-error-delete-notloggedin", + "commentstreams-api-error-delete-permissions", + "commentstreams-api-error-delete-haschildren", + "commentstreams-api-error-delete", + "commentstreams-api-error-vote-notloggedin", + "commentstreams-api-error-vote-novoteonreply", + "commentstreams-api-error-vote", + "commentstreams-api-error-watch-notloggedin", + "commentstreams-api-error-watch-nowatchreply", + "commentstreams-api-error-watch", + "commentstreams-api-error-unwatch-notloggedin", + "commentstreams-api-error-unwatch-nounwatchreply", + "commentstreams-api-error-unwatch", + "commentstreams-validation-error-nocommenttitle", + "commentstreams-validation-error-nocommenttext", + "commentstreams-buttontooltip-add", + "commentstreams-buttontooltip-reply", + "commentstreams-buttontooltip-edit", + "commentstreams-buttontooltip-moderator-edit", + "commentstreams-buttontooltip-delete", + "commentstreams-buttontooltip-moderator-delete", + "commentstreams-buttontooltip-permalink", + "commentstreams-buttontooltip-collapse", + "commentstreams-buttontooltip-expand", + "commentstreams-buttontooltip-upvote", + "commentstreams-buttontooltip-downvote", + "commentstreams-buttontooltip-watch", + "commentstreams-buttontooltip-unwatch", + "commentstreams-buttontooltip-submit", + "commentstreams-buttontooltip-cancel", + "commentstreams-dialog-delete-message", + "commentstreams-dialog-buttontext-ok", + "commentstreams-dialog-buttontext-yes", + "commentstreams-dialog-buttontext-no", + "commentstreams-urldialog-instructions", + "commentstreams-datetext-postedon", + "commentstreams-datetext-lasteditedon", + "commentstreams-datetext-moderated", + "commentstreams-title-field-placeholder", + "commentstreams-body-field-placeholder" + ] + }, + "ext.CommentStreamsAllComments": { + "styles": [ + "CommentStreamsAllComments.css" + ], + "targets": [ + "desktop", + "mobile" + ] + } + }, + "ResourceFileModulePaths": { + "localBasePath": "resources", + "remoteExtPath": "CommentStreams/resources" + }, + "AutoloadClasses": { + "CommentStreamsHooks": "includes/CommentStreamsHooks.php", + "CommentStreams": "includes/CommentStreams.php", + "Comment": "includes/Comment.php", + "ApiCSBase": "includes/ApiCSBase.php", + "ApiCSPostComment": "includes/ApiCSPostComment.php", + "ApiCSQueryComment": "includes/ApiCSQueryComment.php", + "ApiCSEditComment": "includes/ApiCSEditComment.php", + "ApiCSDeleteComment": "includes/ApiCSDeleteComment.php", + "ApiCSVote": "includes/ApiCSVote.php", + "ApiCSWatch": "includes/ApiCSWatch.php", + "ApiCSUnwatch": "includes/ApiCSUnwatch.php", + "EchoCSPresentationModel": "includes/EchoCSPresentationModel.php", + "CommentStreamsAllComments": "includes/CommentStreamsAllComments.php" + }, + "APIModules": { + "csPostComment": "ApiCSPostComment", + "csQueryComment": "ApiCSQueryComment", + "csEditComment": "ApiCSEditComment", + "csDeleteComment": "ApiCSDeleteComment", + "csVote": "ApiCSVote", + "csWatch": "ApiCSWatch", + "csUnwatch": "ApiCSUnwatch" + }, + "Hooks": { + "LoadExtensionSchemaUpdates": "CommentStreamsHooks::addCommentTableToDatabase", + "CanonicalNamespaces": "CommentStreamsHooks::addCommentStreamsNamespaces", + "MediaWikiPerformAction": "CommentStreamsHooks::onMediaWikiPerformAction", + "MovePageIsValidMove": "CommentStreamsHooks::onMovePageIsValidMove", + "userCan": "CommentStreamsHooks::userCan", + "ParserFirstCallInit": "CommentStreamsHooks::onParserSetup", + "BeforePageDisplay": "CommentStreamsHooks::addCommentsAndInitializeJS", + "ShowSearchHitTitle": "CommentStreamsHooks::showSearchHitTitle", + "smwInitProperties": "CommentStreamsHooks::initProperties", + "SMWStore::updateDataBefore": "CommentStreamsHooks::updateData", + "BeforeCreateEchoEvent": "CommentStreamsHooks::onBeforeCreateEchoEvent" + }, + "callback" : "CommentStreamsHooks::onRegistration", + "config": { + "CommentStreamsNamespaceIndex": 844, + "CommentStreamsAllowedNamespaces" : null, + "CommentStreamsEnableTalk": false, + "CommentStreamsNewestStreamsOnTop": true, + "CommentStreamsModeratorFastDelete": false, + "CommentStreamsEnableVoting": false, + "CommentStreamsInitiallyCollapsedNamespaces" : [], + "CommentStreamsUserRealNamePropertyName" : null, + "CommentStreamsUserAvatarPropertyName" : null + } +} diff --git a/CommentStreams/gitinfo.json b/CommentStreams/gitinfo.json new file mode 100644 index 00000000..5eefb7ec --- /dev/null +++ b/CommentStreams/gitinfo.json @@ -0,0 +1 @@ +{"headSHA1": "08aec406a1dd80fce03133e93c07656a16f26437\n", "head": "08aec406a1dd80fce03133e93c07656a16f26437\n", "remoteURL": "https://gerrit.wikimedia.org/r/mediawiki/extensions/CommentStreams", "branch": "08aec406a1dd80fce03133e93c07656a16f26437\n", "headCommitDate": "1504900131"}
\ No newline at end of file diff --git a/CommentStreams/i18n/en.json b/CommentStreams/i18n/en.json new file mode 100644 index 00000000..d6cff7db --- /dev/null +++ b/CommentStreams/i18n/en.json @@ -0,0 +1,149 @@ +{ + "@metadata": { + "authors": [ + "Jason Ji", + "Cindy Cicalese" + ] + }, + "commentstreams-desc": "Allows commenting on wiki pages", + "commentstreams-error-prohibitedaction": "Action $1 is not allowed on comment pages.", + "apihelp-csQueryComment-description": "Return the title, user, creation timestamp, and wikitext of a comment. Either pageid or title must be provided.", + "apihelp-csQueryComment-summary": "Return the title, user, creation timestamp, and wikitext of a comment. Either pageid or title must be provided.", + "apihelp-csQueryComment-param-pageid": "page ID of the page which holds the comment to query", + "apihelp-csQueryComment-param-title": "title of the page which holds the comment to query", + "apihelp-csQueryComment-pageid-example": "query comment with page ID 3 in wikitext", + "apihelp-csQueryComment-title-example": "query comment with page title CommentStreams:3 in wikitext", + "apihelp-csDeleteComment-description": "Delete a comment. Either pageid or title must be provided.", + "apihelp-csDeleteComment-summary": "Delete a comment. Either pageid or title must be provided.", + "apihelp-csDeleteComment-param-pageid": "page ID of the page which holds the comment to delete", + "apihelp-csDeleteComment-param-title": "title of the page which holds the comment to delete", + "apihelp-csDeleteComment-pageid-example": "delete comment with page ID 3", + "apihelp-csDeleteComment-title-example": "delete comment with page title CommentStreams:3", + "apihelp-csPostComment-description": "Post a new comment.", + "apihelp-csPostComment-summary": "Post a new comment.", + "apihelp-csPostComment-param-commenttitle": "optional title for comment.", + "apihelp-csPostComment-param-wikitext": "wikitext for comment.", + "apihelp-csPostComment-param-associatedid": "page with which this comment is associated.", + "apihelp-csPostComment-param-parentid": "page ID of parent comment if this is a reply.", + "apihelp-csEditComment-description": "Edit an existing comment. Either pageid or title must be provided.", + "apihelp-csEditComment-summary": "Edit an existing comment. Either pageid or title must be provided.", + "apihelp-csEditComment-param-pageid": "page ID of the page which holds the comment to edit", + "apihelp-csEditComment-param-title": "title of the page which holds the comment to edit", + "apihelp-csEditComment-param-commenttitle": "optional title for comment.", + "apihelp-csEditComment-param-wikitext": "wikitext for comment.", + "apihelp-csVote-description": "Vote (up, down, or neutral) on a comment.", + "apihelp-csVote-summary": "Vote (up, down, or neutral) on a comment.", + "apihelp-csVote-param-pageid": "page ID of the page which holds the comment to be voted on", + "apihelp-csVote-param-title": "title of the page which holds the comment to be voted on", + "apihelp-csVote-param-vote": "vote (1, -1, or 0).", + "apihelp-csVote-pageid-example": "vote on comment with page ID 3", + "apihelp-csVote-title-example": "vote on comment with page title CommentStreams:3", + "apihelp-csWatch-description": "Watch a comment to be notified when it receives replies", + "apihelp-csWatch-summary": "Watch a comment to be notified when it receives replies", + "apihelp-csWatch-param-pageid": "page ID of the page which holds the comment to be watched", + "apihelp-csWatch-param-title": "title of the page which holds the comment to be watched", + "apihelp-csWatch-pageid-example": "watch comment with page ID 3", + "apihelp-csWatch-title-example": "watch comment with page title CommentStreams:3", + "apihelp-csUnwatch-description": "Unwatch a comment to no longer be notified when it receives replies", + "apihelp-csUnwatch-summary": "Unwatch a comment to no longer be notified when it receives replies", + "apihelp-csUnwatch-param-pageid": "page ID of the page which holds the comment to be unwatched", + "apihelp-csUnwatch-param-title": "title of the page which holds the comment to be unwatched", + "apihelp-csUnwatch-pageid-example": "unwatch comment with page ID 3", + "apihelp-csUnwatch-title-example": "unwatch comment with page title CommentStreams:3", + "commentstreams-api-error-notloggedin": "You must be logged in.", + "commentstreams-api-error-commentnotfound": "The requested comment was not found.", + "commentstreams-api-error-notacomment": "The supplied page ID does not refer to a valid comment.", + "commentstreams-api-error-missingcommenttitle": "A comment title must be supplied for comments that are not replies.", + "commentstreams-api-error-post-permissions": "User does not have permission to post a comment.", + "commentstreams-api-error-post-parentandtitle": "You may not specify both the parent id and the comment title.", + "commentstreams-api-error-post-parentpagedoesnotexist": "The comment being replied to does not exist.", + "commentstreams-api-error-post-associatedpageidmismatch": "The page being commented on does not match the page the parent comment is commenting on.", + "commentstreams-api-error-post-associatedpagedoesnotexist": "The page being commented on does not exist.", + "commentstreams-api-error-post": "Error adding comment.", + "commentstreams-api-error-edit-notloggedin": "You must be logged in to edit.", + "commentstreams-api-error-edit-permissions": "User does not have permission to edit the comment.", + "commentstreams-api-error-edit": "Error editing comment.", + "commentstreams-api-error-delete-notloggedin": "You must be logged in to delete.", + "commentstreams-api-error-delete-permissions": "User does not have permission to delete the comment.", + "commentstreams-api-error-delete-haschildren": "Cannot delete a topic that has replies. Please refresh the page to see updated comment stream.", + "commentstreams-api-error-delete": "Error deleting comment.", + "commentstreams-api-error-vote-notloggedin": "You must be logged in to vote.", + "commentstreams-api-error-vote-novoteonreply": "Voting on replies is not allowed.", + "commentstreams-api-error-vote": "Error voting on comment.", + "commentstreams-api-error-watch-notloggedin": "You must be logged in to watch a comment.", + "commentstreams-api-error-watch-nowatchreply": "Watching replies is not allowed.", + "commentstreams-api-error-watch": "Error watching comment.", + "commentstreams-api-error-unwatch-notloggedin": "You must be logged in to unwatch a comment.", + "commentstreams-api-error-unwatch-nounwatchreply": "Unwatching replies is not allowed.", + "commentstreams-api-error-unwatch": "Error unwatching comment.", + "commentstreams-validation-error-nocommenttitle": "You must enter a comment title.", + "commentstreams-validation-error-nocommenttext": "You must enter comment text.", + "commentstreams-buttontooltip-add": "add a comment", + "commentstreams-buttontooltip-reply": "reply", + "commentstreams-buttontooltip-edit": "edit", + "commentstreams-buttontooltip-moderator-edit": "moderator edit", + "commentstreams-buttontooltip-delete": "delete", + "commentstreams-buttontooltip-moderator-delete": "moderator delete", + "commentstreams-buttontooltip-permalink": "permalink", + "commentstreams-buttontooltip-collapse": "collapse", + "commentstreams-buttontooltip-expand": "expand", + "commentstreams-buttontooltip-upvote": "up vote", + "commentstreams-buttontooltip-downvote": "down vote", + "commentstreams-buttontooltip-watch": "watch", + "commentstreams-buttontooltip-unwatch": "unwatch", + "commentstreams-buttontooltip-submit": "submit", + "commentstreams-buttontooltip-cancel": "cancel", + "commentstreams-dialog-delete-message": "Are you sure you want to delete this comment?", + "commentstreams-dialog-buttontext-ok": "OK", + "commentstreams-dialog-buttontext-yes": "Yes", + "commentstreams-dialog-buttontext-no": "No", + "commentstreams-urldialog-instructions": "Copy and paste the URL below to share a permalink to this comment. Press escape to dismiss this dialog.", + "commentstreams-datetext-postedon": "Posted on", + "commentstreams-datetext-lasteditedon": "Last edited on", + "commentstreams-datetext-moderated": "moderated", + "commentstreams-title-field-placeholder": "Enter title...", + "commentstreams-body-field-placeholder": "Enter new comment text...", + "echo-category-title-commentstreams-notification-category": "New comments and replies", + "notification-header-commentstreams-comment-on-watched-page": "$1 {{GENDER:$4|commented}} \"<i>$2</i>\" on page \"<i>$3</i>\", which {{GENDER:$6|you}} are watching.", + "notification-header-commentstreams-reply-on-watched-page": "$1 {{GENDER:$4|replied}} to comment \"<i>$2</i>\" on page \"<i>$3</i>\", which {{GENDER:$6|you}} are watching.", + "notification-header-commentstreams-reply-to-watched-comment": "$1 {{GENDER:$4|replied}} to comment \"<i>$2</i>\", which {{GENDER:$6|you}} are watching, on page \"<i>$3</i>\".", + "notification-subject-commentstreams-comment-on-watched-page": "Somebody has commented on a page that you are watching", + "notification-subject-commentstreams-reply-on-watched-page": "Somebody has replied to a comment on a page that you are watching", + "notification-subject-commentstreams-reply-to-watched-comment": "Somebody has replied to a comment that you are watching", + "notification-body-commentstreams-comment-on-watched-page": "The comment is:\n\n\n$5", + "notification-body-commentstreams-reply-on-watched-page": "The reply is:\n\n\n$5", + "notification-body-commentstreams-reply-to-watched-comment": "The reply is:\n\n\n$5", + "notification-link-label-commentstreams-comment-on-watched-page": "Visit comment", + "notification-link-label-commentstreams-reply-on-watched-page": "Visit reply", + "notification-link-label-commentstreams-reply-to-watched-comment": "Visit reply", + "group-csmoderator": "Moderators (CommentStreams)", + "group-csmoderator-member": "{{GENDER:$1|moderator (CommentStreams)}}", + "grouppage-csmoderator": "{{ns:project}}:Moderators (CommentStreams)", + "right-cs-moderator-edit": "Edit comments by any user", + "action-cs-moderator-edit": "edit comments by other users", + "right-cs-moderator-delete": "Delete comments by any user", + "action-cs-moderator-delete": "delete comments by other users", + "log-name-commentstreams": "CommentStreams log", + "log-description-commentstreams": "These events track when CommentStreams events happen.", + "logentry-commentstreams-comment-create": "$1 {{GENDER:$2|created}} comment $3", + "logentry-commentstreams-reply-create": "$1 {{GENDER:$2|created}} reply $3", + "logentry-commentstreams-comment-edit": "$1 {{GENDER:$2|edited}} comment $3", + "logentry-commentstreams-comment-moderator-edit": "$1 {{GENDER:$2|(moderator) edited}} comment $3", + "logentry-commentstreams-reply-edit": "$1 {{GENDER:$2|edited}} reply $3", + "logentry-commentstreams-reply-moderator-edit": "$1 {{GENDER:$2|(moderator) edited}} reply $3", + "logentry-commentstreams-comment-delete": "$1 {{GENDER:$2|deleted}} comment $3", + "logentry-commentstreams-comment-moderator-delete": "$1 {{GENDER:$2|(moderator) deleted}} comment $3", + "logentry-commentstreams-reply-delete": "$1 {{GENDER:$2|deleted}} reply $3", + "logentry-commentstreams-reply-moderator-delete": "$1 {{GENDER:$2|(moderator) deleted}} reply $3", + "commentstreamsallcomments": "All Comments", + "commentstreams-allcomments-label-page": "Comment Page", + "commentstreams-allcomments-label-associatedpage": "Associated Page", + "commentstreams-allcomments-label-commenttitle": "Comment Title", + "commentstreams-allcomments-label-wikitext": "Comment", + "commentstreams-allcomments-label-author": "Author", + "commentstreams-allcomments-label-created": "Created", + "commentstreams-allcomments-label-lasteditor": "Last Editor", + "commentstreams-allcomments-label-lastedited": "Last Edited", + "commentstreams-allcomments-button-next": "Next", + "commentstreams-allcomments-button-previous": "Previous" +} diff --git a/CommentStreams/i18n/qqq.json b/CommentStreams/i18n/qqq.json new file mode 100644 index 00000000..d824cf43 --- /dev/null +++ b/CommentStreams/i18n/qqq.json @@ -0,0 +1,149 @@ +{ + "@metadata": { + "authors": [ + "Cindy Cicalese", + "Jason Ji" + ] + }, + "commentstreams-desc": "{{desc|name=CommentStreams|url=http://gestalt.mitre.org/gestaltd/index.php/CommentStreams}}", + "commentstreams-error-prohibitedaction": "Error message.", + "apihelp-csQueryComment-description": "{{doc-apihelp-description|csQueryComment}}", + "apihelp-csQueryComment-summary": "{{doc-apihelp-summary|csQueryComment}}", + "apihelp-csQueryComment-param-pageid": "{{doc-apihelp-param|csQueryComment|pageid}}", + "apihelp-csQueryComment-param-title": "{{doc-apihelp-param|csQueryComment|title}}", + "apihelp-csQueryComment-pageid-example": "{{doc-apihelp-example|csQueryComment}}", + "apihelp-csQueryComment-title-example": "{{doc-apihelp-example|csQueryComment}}", + "apihelp-csDeleteComment-description": "{{doc-apihelp-description|csDeleteComment}}", + "apihelp-csDeleteComment-summary": "{{doc-apihelp-summary|csDeleteComment}}", + "apihelp-csDeleteComment-param-pageid": "{{doc-apihelp-param|csDeleteComment|pageid}}", + "apihelp-csDeleteComment-param-title": "{{doc-apihelp-param|csDeleteComment|title}}", + "apihelp-csDeleteComment-pageid-example": "{{doc-apihelp-example|csDeleteComment}}", + "apihelp-csDeleteComment-title-example": "{{doc-apihelp-example|csDeleteComment}}", + "apihelp-csPostComment-description": "{{doc-apihelp-description|csPostComment}}", + "apihelp-csPostComment-summary": "{{doc-apihelp-summary|csPostComment}}", + "apihelp-csPostComment-param-commenttitle": "{{doc-apihelp-param|csPostComment|commenttitle}}", + "apihelp-csPostComment-param-wikitext": "{{doc-apihelp-param|csPostComment|wikitext}}", + "apihelp-csPostComment-param-associatedid": "{{doc-apihelp-param|csPostComment|associatedid}}", + "apihelp-csPostComment-param-parentid": "{{doc-apihelp-param|csPostComment|parentid}}", + "apihelp-csEditComment-description": "{{doc-apihelp-description|csEditComment}}", + "apihelp-csEditComment-summary": "{{doc-apihelp-summary|csEditComment}}", + "apihelp-csEditComment-param-pageid": "{{doc-apihelp-param|csEditComment|pageid}}", + "apihelp-csEditComment-param-title": "{{doc-apihelp-param|csEditComment|title}}", + "apihelp-csEditComment-param-commenttitle": "{{doc-apihelp-param|csEditComment|commenttitle}}", + "apihelp-csEditComment-param-wikitext": "{{doc-apihelp-param|csEditComment|wikitext}}", + "apihelp-csVote-description": "{{doc-apihelp-description|csVote}}", + "apihelp-csVote-summary": "{{doc-apihelp-summary|csVote}}", + "apihelp-csVote-param-pageid": "{{doc-apihelp-param|csVote|pageid}}", + "apihelp-csVote-param-title": "{{doc-apihelp-param|csVote|title}}", + "apihelp-csVote-param-vote": "{{doc-apihelp-param|csVote|vote}}", + "apihelp-csVote-pageid-example": "{{doc-apihelp-example|csVote}}", + "apihelp-csVote-title-example": "{{doc-apihelp-example|csVote}}", + "apihelp-csWatch-description": "{{doc-apihelp-description|csWatch}}", + "apihelp-csWatch-summary": "{{doc-apihelp-summary|csWatch}}", + "apihelp-csWatch-param-pageid": "{{doc-apihelp-param|csWatch|pageid}}", + "apihelp-csWatch-param-title": "{{doc-apihelp-param|csWatch|title}}", + "apihelp-csWatch-pageid-example": "{{doc-apihelp-example|csWatch}}", + "apihelp-csWatch-title-example": "{{doc-apihelp-example|csWatch}}", + "apihelp-csUnwatch-description": "{{doc-apihelp-description|csUnwatch}}", + "apihelp-csUnwatch-summary": "{{doc-apihelp-summary|csUnwatch}}", + "apihelp-csUnwatch-param-pageid": "{{doc-apihelp-param|csUnwatch|pageid}}", + "apihelp-csUnwatch-param-title": "{{doc-apihelp-param|csUnwatch|title}}", + "apihelp-csUnwatch-pageid-example": "{{doc-apihelp-example|csUnwatch}}", + "apihelp-csUnwatch-title-example": "{{doc-apihelp-example|csUnwatch}}", + "commentstreams-api-error-notloggedin": "Error message.", + "commentstreams-api-error-commentnotfound": "Error message.", + "commentstreams-api-error-notacomment": "Error message.", + "commentstreams-api-error-missingcommenttitle": "Error message.", + "commentstreams-api-error-post-permissions": "Error message.", + "commentstreams-api-error-post-parentandtitle": "Error message.", + "commentstreams-api-error-post-parentpagedoesnotexist": "Error message.", + "commentstreams-api-error-post-associatedpageidmismatch": "Error message.", + "commentstreams-api-error-post-associatedpagedoesnotexist": "Error message.", + "commentstreams-api-error-post": "Error message.", + "commentstreams-api-error-edit-notloggedin": "Error message.", + "commentstreams-api-error-edit-permissions": "Error message.", + "commentstreams-api-error-edit": "Error message.", + "commentstreams-api-error-delete-notloggedin": "Error message.", + "commentstreams-api-error-delete-permissions": "Error message.", + "commentstreams-api-error-delete-haschildren": "Error message.", + "commentstreams-api-error-delete": "Error message.", + "commentstreams-api-error-vote-notloggedin": "Error message.", + "commentstreams-api-error-vote-novoteonreply": "Error message.", + "commentstreams-api-error-vote": "Error message.", + "commentstreams-api-error-watch-notloggedin": "Error message.", + "commentstreams-api-error-watch-nowatchreply": "Error message.", + "commentstreams-api-error-watch": "Error message.", + "commentstreams-api-error-unwatch-notloggedin": "Error message.", + "commentstreams-api-error-unwatch-nounwatchreply": "Error message.", + "commentstreams-api-error-unwatch": "Error message.", + "commentstreams-validation-error-nocommenttitle": "Error message.", + "commentstreams-validation-error-nocommenttext": "Error message.", + "commentstreams-buttontooltip-add": "User interface button tooltip.", + "commentstreams-buttontooltip-reply": "User interface button tooltip.", + "commentstreams-buttontooltip-edit": "User interface button tooltip.", + "commentstreams-buttontooltip-moderator-edit": "User interface button tooltip.", + "commentstreams-buttontooltip-delete": "User interface button tooltip.", + "commentstreams-buttontooltip-moderator-delete": "User interface button tooltip.", + "commentstreams-buttontooltip-permalink": "User interface button tooltip.", + "commentstreams-buttontooltip-collapse": "User interface button tooltip.", + "commentstreams-buttontooltip-expand": "User interface button tooltip.", + "commentstreams-buttontooltip-upvote": "User interface button tooltip.", + "commentstreams-buttontooltip-downvote": "User interface button tooltip.", + "commentstreams-buttontooltip-watch": "User interface button tooltip.", + "commentstreams-buttontooltip-unwatch": "User interface button tooltip.", + "commentstreams-buttontooltip-submit": "User interface button text.", + "commentstreams-buttontooltip-cancel": "User interface button text.", + "commentstreams-dialog-delete-message": "User interface dialog message.", + "commentstreams-dialog-buttontext-ok": "User interface dialog button text.", + "commentstreams-dialog-buttontext-yes": "User interface dialog button text.", + "commentstreams-dialog-buttontext-no": "User interface dialog button text.", + "commentstreams-urldialog-instructions": "User interface dialog button text.", + "commentstreams-datetext-postedon": "User interface date text", + "commentstreams-datetext-lasteditedon": "User interface date text", + "commentstreams-datetext-moderated": "User interface date text", + "commentstreams-title-field-placeholder": "Text field placeholder text", + "commentstreams-body-field-placeholder": "Text field placeholder text", + "echo-category-title-commentstreams-notification-category": "Name of category on Prefences/Notifications page for CommentStreams notifications", + "notification-header-commentstreams-comment-on-watched-page": "Flyout-specific format for displaying notification header of a new comment on a watched page.\n\nParameters:\n* $1 - the formatted username of the person who commented.\n* $2 - the comment title.\n* $3 - the display name of the page being commented on.\n* $4 - the username of the person who commented (for gender).\n* $5 - the username of the viewing user (for gender).", + "notification-header-commentstreams-reply-on-watched-page": "Flyout-specific format for displaying notification header of a new reply on a watched page.\n\nParameters:\n* $1 - the formatted username of the person who commented.\n* $2 - the comment title.\n* $3 - the display name of the page being commented on.\n* $4 - the username of the person who commented (for gender).\n* $5 - the username of the viewing user (for gender).", + "notification-header-commentstreams-reply-to-watched-comment": "Flyout-specific format for displaying notification header of a reply to a comment by the user.\n\nParameters:\n* $1 - the formatted username of the person who commented.\n* $2 - the comment title.\n* $3 - the display name of the page being commented on.\n* $4 - the username of the person who commented (for gender).\n* $5 - the username of the viewing user (for gender).", + "notification-subject-commentstreams-comment-on-watched-page": "Echo email subject.", + "notification-body-commentstreams-comment-on-watched-page": "Echo email body.\n\nParameters:\n*$1 - the username of the user authoring the new comment.\n* $2 - the display name of the comment author.\n* $3 - the title of the comment.\n* $4 - the display title of the page being commented on.\n* $5 - a link to that page.\n* $6 - the wikitext of the new comment.", + "notification-subject-commentstreams-reply-on-watched-page": "Echo email subject.", + "notification-body-commentstreams-reply-on-watched-page": "Echo email body.\n\nParameters:\n*$1 - the username of the user authoring the new comment.\n* $2 - the display name of the comment author.\n* $3 - the title of the comment.\n* $4 - the display title of the page being commented on.\n* $5 - a link to that page.\n* $6 - the wikitext of the new comment.", + "notification-subject-commentstreams-reply-to-watched-comment": "Echo email subject.", + "notification-body-commentstreams-reply-to-watched-comment": "Echo email body.\n\nParameters:\n*$1 - the username of the user authoring the new comment.\n* $2 - the display name of the comment author.\n* $3 - the title of the comment.\n* $4 - the display title of the page being commented on.\n* $5 - a link to that page.\n* $6 - the wikitext of the new comment.", + "notification-link-label-commentstreams-comment-on-watched-page": "Label", + "notification-link-label-commentstreams-reply-on-watched-page": "Label", + "notification-link-label-commentstreams-reply-to-watched-comment": "Label", + "group-csmoderator": "{{doc-group|csmoderator|group}}", + "group-csmoderator-member": "{{doc-group|csmoderator|member}}", + "grouppage-csmoderator": "{{doc-group|csmoderator|page}}", + "right-cs-moderator-edit": "{{doc-right|csedit}}", + "action-cs-moderator-edit": "{{doc-action|csedit}}", + "right-cs-moderator-delete": "{{doc-right|csdelete}}", + "action-cs-moderator-delete": "{{doc-action|csdelete}}", + "log-name-commentstreams": "The Special:Log log name that appears in the drop-down on the Special:Log page", + "log-description-commentstreams": "The Special:Log description that appears on the Special:Log page when you filter logs on this specific log name", + "logentry-commentstreams-comment-create": "The template of the log entry message", + "logentry-commentstreams-reply-create": "The template of the log entry message", + "logentry-commentstreams-comment-edit": "The template of the log entry message", + "logentry-commentstreams-comment-moderator-edit": "The template of the log entry message", + "logentry-commentstreams-reply-edit": "The template of the log entry message", + "logentry-commentstreams-reply-moderator-edit": "The template of the log entry message", + "logentry-commentstreams-comment-delete": "The template of the log entry message", + "logentry-commentstreams-comment-moderator-delete": "The template of the log entry message", + "logentry-commentstreams-reply-delete": "The template of the log entry message", + "logentry-commentstreams-reply-moderator-delete": "The template of the log entry message", + "commentstreamsallcomments": "Special page title", + "commentstreams-allcomments-label-page": "Table column label", + "commentstreams-allcomments-label-associatedpage": "Table column label", + "commentstreams-allcomments-label-commenttitle": "Table column label", + "commentstreams-allcomments-label-wikitext": "Table column label", + "commentstreams-allcomments-label-author": "Table column label", + "commentstreams-allcomments-label-created": "Table column label", + "commentstreams-allcomments-label-lasteditor": "Table column label", + "commentstreams-allcomments-label-lastedited": "Table column label", + "commentstreams-allcomments-button-next": "Button label", + "commentstreams-allcomments-button-previous": "Button label" +} diff --git a/CommentStreams/images/CREDITS b/CommentStreams/images/CREDITS new file mode 100644 index 00000000..ec86b3e0 --- /dev/null +++ b/CommentStreams/images/CREDITS @@ -0,0 +1,5 @@ +The icons in this directory are derived from Farm-Fresh Web Icons +(http://www.fatcow.com/free-icons), which are licensed under a Creative +Commons Attribution 3.0 License. They were downloaded from Wikimedia Commons +(https://commons.wikimedia.org/wiki/Farm-Fresh_web_icons) and modified to suit +the purposes of this MediaWiki extension. diff --git a/CommentStreams/images/cancel.png b/CommentStreams/images/cancel.png Binary files differnew file mode 100644 index 00000000..29e9ee6a --- /dev/null +++ b/CommentStreams/images/cancel.png diff --git a/CommentStreams/images/collapse.png b/CommentStreams/images/collapse.png Binary files differnew file mode 100644 index 00000000..33047bb8 --- /dev/null +++ b/CommentStreams/images/collapse.png diff --git a/CommentStreams/images/comment_add.png b/CommentStreams/images/comment_add.png Binary files differnew file mode 100644 index 00000000..3682cd22 --- /dev/null +++ b/CommentStreams/images/comment_add.png diff --git a/CommentStreams/images/comment_delete.png b/CommentStreams/images/comment_delete.png Binary files differnew file mode 100644 index 00000000..ae8a6abb --- /dev/null +++ b/CommentStreams/images/comment_delete.png diff --git a/CommentStreams/images/comment_edit.png b/CommentStreams/images/comment_edit.png Binary files differnew file mode 100644 index 00000000..ca50070c --- /dev/null +++ b/CommentStreams/images/comment_edit.png diff --git a/CommentStreams/images/comment_moderator_delete.png b/CommentStreams/images/comment_moderator_delete.png Binary files differnew file mode 100644 index 00000000..4a51aa83 --- /dev/null +++ b/CommentStreams/images/comment_moderator_delete.png diff --git a/CommentStreams/images/comment_moderator_edit.png b/CommentStreams/images/comment_moderator_edit.png Binary files differnew file mode 100644 index 00000000..9176f1e9 --- /dev/null +++ b/CommentStreams/images/comment_moderator_edit.png diff --git a/CommentStreams/images/comment_reply.png b/CommentStreams/images/comment_reply.png Binary files differnew file mode 100644 index 00000000..05676052 --- /dev/null +++ b/CommentStreams/images/comment_reply.png diff --git a/CommentStreams/images/downvote-disabled.png b/CommentStreams/images/downvote-disabled.png Binary files differnew file mode 100644 index 00000000..dc10396b --- /dev/null +++ b/CommentStreams/images/downvote-disabled.png diff --git a/CommentStreams/images/downvote-enabled.png b/CommentStreams/images/downvote-enabled.png Binary files differnew file mode 100644 index 00000000..057cf47a --- /dev/null +++ b/CommentStreams/images/downvote-enabled.png diff --git a/CommentStreams/images/expand.png b/CommentStreams/images/expand.png Binary files differnew file mode 100644 index 00000000..993ab02a --- /dev/null +++ b/CommentStreams/images/expand.png diff --git a/CommentStreams/images/link.png b/CommentStreams/images/link.png Binary files differnew file mode 100644 index 00000000..0e10aedd --- /dev/null +++ b/CommentStreams/images/link.png diff --git a/CommentStreams/images/notwatching.png b/CommentStreams/images/notwatching.png Binary files differnew file mode 100644 index 00000000..a14af7ee --- /dev/null +++ b/CommentStreams/images/notwatching.png diff --git a/CommentStreams/images/submit.png b/CommentStreams/images/submit.png Binary files differnew file mode 100644 index 00000000..8ea8d8d4 --- /dev/null +++ b/CommentStreams/images/submit.png diff --git a/CommentStreams/images/upvote-disabled.png b/CommentStreams/images/upvote-disabled.png Binary files differnew file mode 100644 index 00000000..3816ef6a --- /dev/null +++ b/CommentStreams/images/upvote-disabled.png diff --git a/CommentStreams/images/upvote-enabled.png b/CommentStreams/images/upvote-enabled.png Binary files differnew file mode 100644 index 00000000..15f5bfea --- /dev/null +++ b/CommentStreams/images/upvote-enabled.png diff --git a/CommentStreams/images/watching.png b/CommentStreams/images/watching.png Binary files differnew file mode 100644 index 00000000..cf642b47 --- /dev/null +++ b/CommentStreams/images/watching.png diff --git a/CommentStreams/includes/ApiCSBase.php b/CommentStreams/includes/ApiCSBase.php new file mode 100644 index 00000000..78ac2e27 --- /dev/null +++ b/CommentStreams/includes/ApiCSBase.php @@ -0,0 +1,127 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +abstract class ApiCSBase extends ApiBase { + + private $edit; + protected $comment; + + /** + * @param ApiMain $main main module + * @param string $action name of this module + * @param boolean $edit whether this API module will be editing the database + */ + public function __construct( $main, $action, $edit = false ) { + parent::__construct( $main, $action ); + $this->edit = $edit; + } + + /** + * execute the API request + */ + public function execute() { + $params = $this->extractRequestParams(); + $wikipage = $this->getTitleOrPageId( $params, + $this->edit ? 'frommasterdb' : 'fromdb' ); + $this->comment = Comment::newFromWikiPage( $wikipage ); + if ( is_null( $this->comment ) ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-notacomment' ); + } + $result = $this->executeBody(); + if ( !is_null( $result ) ) { + $this->getResult()->addValue( null, $this->getModuleName(), $result ); + } + } + + /** + * the real body of the execute function + */ + protected abstract function executeBody(); + + /** + * @return array allowed parameters + */ + public function getAllowedParams() { + return [ + 'pageid' => [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_REQUIRED => false + ], + 'title' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => false + ] + ]; + } + + /** + * @return array examples of the use of this API module + */ + public function getExamplesMessages() { + return [ + 'action=' . $this->getModuleName() . '&pageid=3' => + 'apihelp-' . $this->getModuleName() . '-pageid-example', + 'action=' . $this->getModuleName() . '&title=CommentStreams:3' => + 'apihelp-' . $this->getModuleName() . '-title-example' + ]; + } + + /** + * @return string indicates that this API module requires a CSRF token + */ + public function needsToken() { + if ( $this->edit ) { + return 'csrf'; + } else { + return false; + } + } + + /** + * log action + * @param string $action the name of the action to be logged + */ + protected function logAction( $action, $title = null ) { + $logEntry = new ManualLogEntry( 'commentstreams', $action ); + $logEntry->setPerformer( $this->getUser() ); + if ( $title ) { + $logEntry->setTarget( $title ); + } else { + $logEntry->setTarget( $this->comment->getWikiPage()->getTitle() ); + } + $logid = $logEntry->insert(); + } + + /** + * die with a custom usage message + * @param string $message_name the name of the custom message + */ + protected function dieCustomUsageMessage( $message_name ) { + $error_message = wfMessage( $message_name ); + $this->dieUsageMsg( + [ + ApiMessage::create( $error_message ) + ] + ); + } +} diff --git a/CommentStreams/includes/ApiCSDeleteComment.php b/CommentStreams/includes/ApiCSDeleteComment.php new file mode 100644 index 00000000..460038cd --- /dev/null +++ b/CommentStreams/includes/ApiCSDeleteComment.php @@ -0,0 +1,114 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class ApiCSDeleteComment extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action, true ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + if ( $this->getUser()->isAnon() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-delete-notloggedin' ); + } + + if ( $this->getUser()->getId() === + $this->comment->getWikiPage()->getOldestRevision()->getUser() && + $this->comment->getNumReplies() === 0 ) { + $action = 'edit'; // need edit but not delete to delete a comment + } else { + $action = 'cs-moderator-delete'; + } + + if ( !$this->comment->getWikiPage()->getTitle()->userCan( $action, + $this->getUser() ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-delete-permissions' ); + } + + $childCount = $this->comment->getNumReplies(); + if ( $childCount > 0 ) { + if ( $GLOBALS['wgCommentStreamsModeratorFastDelete'] ) { + $result = $this->recursiveDelete( $this->comment ); + } else { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-delete-haschildren' ); + } + } else { + $result = $this->comment->delete(); + if ( $action === 'edit' ) { + if ( is_null( $this->comment->getParentId() ) ) { + $this->logAction( 'comment-delete' ); + } else { + $this->logAction( 'reply-delete' ); + } + } else { + if ( is_null( $this->comment->getParentId() ) ) { + $this->logAction( 'comment-moderator-delete' ); + } else { + $this->logAction( 'reply-moderator-delete' ); + } + } + } + + if ( !$result ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-delete' ); + } + + return null; + } + + /** + * recursively delete comment and replies + * + * @param Comment $comment the comment to recursively delete + */ + private function recursiveDelete( $comment ) { + $replies = Comment::getReplies( $comment->getId() ); + foreach ( $replies as $reply ) { + $result = $this->recursiveDelete( $reply ); + if ( !$result ) { + return $result; + } + } + $result = $comment->delete(); + $title = $comment->getWikiPage()->getTitle(); + if ( is_null( $comment->getParentId() ) ) { + $this->logAction( 'comment-moderator-delete', $title ); + } else { + $this->logAction( 'reply-moderator-delete', $title ); + } + return $result; + } +} diff --git a/CommentStreams/includes/ApiCSEditComment.php b/CommentStreams/includes/ApiCSEditComment.php new file mode 100644 index 00000000..0423fdb8 --- /dev/null +++ b/CommentStreams/includes/ApiCSEditComment.php @@ -0,0 +1,129 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class ApiCSEditComment extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action, true ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + if ( $this->getUser()->isAnon() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-edit-notloggedin' ); + } + + if ( $this->getUser()->getId() === + $this->comment->getWikiPage()->getOldestRevision()->getUser() ) { + $action = 'edit'; + } else { + $action = 'cs-moderator-edit'; + } + if ( !$this->comment->getWikiPage()->getTitle()->userCan( $action, + $this->getUser() ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-edit-permissions' ); + } + + $comment_title = $this->getMain()->getVal( 'commenttitle' ); + $wikitext = $this->getMain()->getVal( 'wikitext' ); + + if ( is_null( $this->comment->getParentId() ) && is_null( $comment_title ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-missingcommenttitle' ); + } + + $result = $this->comment->update( $comment_title, $wikitext, $this->getUser() ); + if ( !$result ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-edit' ); + } + + if ( $action === 'edit' ) { + if ( is_null( $this->comment->getParentId() ) ) { + $this->logAction( 'comment-edit' ); + } else { + $this->logAction( 'reply-edit' ); + } + } else { + if ( is_null( $this->comment->getParentId() ) ) { + $this->logAction( 'comment-moderator-edit' ); + } else { + $this->logAction( 'reply-moderator-edit' ); + } + } + + $json = $this->comment->getJSON(); + + if ( is_null( $this->comment->getParentId() ) ) { + if ( $GLOBALS['wgCommentStreamsEnableVoting'] ) { + $json['vote'] = $this->comment->getVote( $this->getUser() ); + } + $json['watching'] = $this->comment->isWatching( $this->getUser() ) ? 1 : 0; + } + + $this->getResult()->addValue( null, $this->getModuleName(), $json ); + } + + /** + * @return array allowed paramters + */ + public function getAllowedParams() { + return array_merge( parent::getAllowedParams(), + [ + 'commenttitle' => + [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => false + ], + 'wikitext' => + [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true + ] + ] + ); + } + + /** + * @return array examples of the use of this API module + */ + public function getExamplesMessages() { + return []; + } + + /** + * @return string indicates that this API module requires a CSRF toekn + */ + public function needsToken() { + return 'csrf'; + } +} diff --git a/CommentStreams/includes/ApiCSPostComment.php b/CommentStreams/includes/ApiCSPostComment.php new file mode 100644 index 00000000..adc1c797 --- /dev/null +++ b/CommentStreams/includes/ApiCSPostComment.php @@ -0,0 +1,228 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class ApiCSPostComment extends ApiBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action ); + } + + /** + * execute the API request + */ + public function execute() { + if ( !in_array( 'edit', $this->getUser()->getRights() ) || + $this->getUser()->isBlocked() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-post-permissions' ); + } + + $associatedid = $this->getMain()->getVal( 'associatedid' ); + $parentid = $this->getMain()->getVal( 'parentid' ); + $comment_title = $this->getMain()->getVal( 'commenttitle' ); + $wikitext = $this->getMain()->getVal( 'wikitext' ); + + if ( is_null( $parentid ) && is_null( $comment_title ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-missingcommenttitle' ); + } + + if ( !is_null( $parentid ) && !is_null( $comment_title ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-post-parentandtitle' ); + } + + if ( !is_null( $parentid ) ) { + $parent_page = WikiPage::newFromId( $parentid ); + if ( is_null( $parent_page ) || !$parent_page->getTitle()->exists() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-post-parentpagedoesnotexist' ); + } + $parent_comment = Comment::newFromWikiPage( $parent_page ); + if ( $parent_comment->getAssociatedId() !== (integer)$associatedid ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-post-associatedpageidmismatch' ); + } + } + + $associated_page = WikiPage::newFromId( $associatedid ); + if ( is_null( $associated_page ) || + !$associated_page->getTitle()->exists() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-post-associatedpagedoesnotexist' ); + } + + $comment = Comment::newFromValues( $associatedid, $parentid, + $comment_title, $wikitext, $this->getUser() ); + if ( !$comment ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-post' ); + } + + $title = $comment->getWikiPage()->getTitle(); + if ( is_null( $comment->getParentId() ) ) { + $this->logAction( 'comment-create', $title ); + } else { + $this->logAction( 'reply-create', $title ); + } + + $json = $comment->getJSON(); + if ( class_exists( 'EchoEvent' ) && is_null( $comment->getParentId() ) ) { + $json['watching'] = 1; + } + $this->getResult()->addValue( null, $this->getModuleName(), $json ); + + $this->sendNotifications( $comment, $associated_page ); + } + + /** + * @return array allowed paramters + */ + public function getAllowedParams() { + return [ + 'commenttitle' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => false + ], + 'wikitext' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true + ], + 'associatedid' => [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_REQUIRED => true + ], + 'parentid' => [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_REQUIRED => false + ] + ]; + } + + /** + * @return string indicates that this API module requires a CSRF token + */ + public function needstoken() { + return 'csrf'; + } + + /** + * Send Echo notifications if Echo is installed. + * + * @param Comment $comment the comment to send notifications for + * @param WikiPage $associated_page the associated page for the comment + * @return not used + */ + private function sendNotifications( $comment, $associated_page ) { + if ( !class_exists( 'EchoEvent' ) ) { + return; + } + + $parent_id = $comment->getParentId(); + if ( is_null( $parent_id ) ) { + $comment_title = $comment->getCommentTitle(); + } else { + $parent_page = WikiPage::newFromId( $parent_id ); + if ( is_null( $parent_page ) ) { + return; + } + $parent_comment = Comment::newFromWikiPage( $parent_page ); + if ( is_null( $parent_comment ) ) { + return; + } else { + $comment_title = $parent_comment->getCommentTitle(); + } + } + + $associated_page_display_title = + $associated_page->getTitle()->getPrefixedText(); + if ( class_exists( 'PageProps' ) ) { + $associated_title = $associated_page->getTitle(); + $values = PageProps::getInstance()->getProperties( $associated_title, + 'displaytitle' ); + if ( array_key_exists( $associated_title->getArticleID(), $values ) ) { + $associated_page_display_title = + $values[$associated_title->getArticleID()]; + } + } + + $extra = [ + 'comment_id' => $comment->getId(), + 'parent_id' => $comment->getParentId(), + 'comment_author_username' => $comment->getUsername(), + 'comment_author_display_name' => $comment->getUserDisplayNameUnlinked(), + 'comment_title' => $comment_title, + 'associated_page_display_title' => $associated_page_display_title, + 'comment_wikitext' => $comment->getWikitext() + ]; + + if ( !is_null( $parent_id ) ) { + EchoEvent::create( [ + 'type' => 'commentstreams-reply-on-watched-page', + 'title' => $associated_page->getTitle(), + 'extra' => $extra, + 'agent' => $this->getUser() + ] ); + EchoEvent::create( [ + 'type' => 'commentstreams-reply-to-watched-comment', + 'title' => $associated_page->getTitle(), + 'extra' => $extra, + 'agent' => $this->getUser() + ] ); + } else { + EchoEvent::create( [ + 'type' => 'commentstreams-comment-on-watched-page', + 'title' => $associated_page->getTitle(), + 'extra' => $extra, + 'agent' => $this->getUser() + ] ); + } + } + + /** + * log action + * @param string $action the name of the action to be logged + */ + protected function logAction( $action, $title ) { + $logEntry = new ManualLogEntry( 'commentstreams', $action ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->setTarget( $title ); + $logid = $logEntry->insert(); + } + + /** + * die with a custom usage message + * @param string $message_name the name of the custom message + */ + private function dieCustomUsageMessage( $message_name ) { + $error_message = wfMessage( $message_name ); + $this->dieUsageMsg( + [ + ApiMessage::create( $error_message ) + ] + ); + } +} diff --git a/CommentStreams/includes/ApiCSQueryComment.php b/CommentStreams/includes/ApiCSQueryComment.php new file mode 100644 index 00000000..d0d290fb --- /dev/null +++ b/CommentStreams/includes/ApiCSQueryComment.php @@ -0,0 +1,42 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class ApiCSQueryComment extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + return $this->comment->getJSON(); + } +} diff --git a/CommentStreams/includes/ApiCSUnwatch.php b/CommentStreams/includes/ApiCSUnwatch.php new file mode 100644 index 00000000..85d746e3 --- /dev/null +++ b/CommentStreams/includes/ApiCSUnwatch.php @@ -0,0 +1,76 @@ +<?php +/* + * Copyright (c) 2017 The MITRE Corporation + * + * 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. + */ + +class ApiCSUnwatch extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action, true ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + if ( $this->getUser()->isAnon() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-unwatch-notloggedin' ); + } + + if ( !is_null( $this->comment->getParentId() ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-unwatch-nounwatchonreply' ); + } + + $result = $this->comment->unwatch( $this->getUser() ); + if ( !$result ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-unwatch' ); + } + + $this->getResult()->addValue( null, $this->getModuleName(), '' ); + } + + /** + * @return array examples of the use of this API module + */ + public function getExamplesMessages() { + return [ + 'action=' . $this->getModuleName() . '&pageid=3' => + 'apihelp-' . $this->getModuleName() . '-pageid-example', + 'action=' . $this->getModuleName() . '&title=CommentStreams:3' => + 'apihelp-' . $this->getModuleName() . '-title-example' + ]; + } + + /** + * @return string indicates that this API module requires a CSRF toekn + */ + public function needsToken() { + return 'csrf'; + } +} diff --git a/CommentStreams/includes/ApiCSVote.php b/CommentStreams/includes/ApiCSVote.php new file mode 100644 index 00000000..e93021f1 --- /dev/null +++ b/CommentStreams/includes/ApiCSVote.php @@ -0,0 +1,93 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class ApiCSVote extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action, true ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + if ( $this->getUser()->isAnon() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-vote-notloggedin' ); + } + + $vote = $this->getMain()->getVal( 'vote' ); + + if ( !is_null( $this->comment->getParentId() ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-vote-novoteonreply' ); + } + + $result = $this->comment->vote( $vote, $this->getUser() ); + if ( !$result ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-vote' ); + } + + $this->getResult()->addValue( null, $this->getModuleName(), '' ); + } + + /** + * @return array allowed paramters + */ + public function getAllowedParams() { + return array_merge( parent::getAllowedParams(), + [ + 'vote' => + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_REQUIRED => true + ] + ] + ); + } + + /** + * @return array examples of the use of this API module + */ + public function getExamplesMessages() { + return [ + 'action=' . $this->getModuleName() . '&pageid=3&vote=1' => + 'apihelp-' . $this->getModuleName() . '-pageid-example', + 'action=' . $this->getModuleName() . '&title=CommentStreams:3&vote=-1' => + 'apihelp-' . $this->getModuleName() . '-title-example' + ]; + } + + /** + * @return string indicates that this API module requires a CSRF toekn + */ + public function needsToken() { + return 'csrf'; + } +} diff --git a/CommentStreams/includes/ApiCSWatch.php b/CommentStreams/includes/ApiCSWatch.php new file mode 100644 index 00000000..a1a67072 --- /dev/null +++ b/CommentStreams/includes/ApiCSWatch.php @@ -0,0 +1,76 @@ +<?php +/* + * Copyright (c) 2017 The MITRE Corporation + * + * 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. + */ + +class ApiCSWatch extends ApiCSBase { + + /** + * @param ApiMain $main main module + * @param string $action name of this module + */ + public function __construct( $main, $action ) { + parent::__construct( $main, $action, true ); + } + + /** + * the real body of the execute function + * + * @return result of API request + */ + protected function executeBody() { + if ( $this->getUser()->isAnon() ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-watch-notloggedin' ); + } + + if ( !is_null( $this->comment->getParentId() ) ) { + $this->dieCustomUsageMessage( + 'commentstreams-api-error-watch-nowatchonreply' ); + } + + $result = $this->comment->watch( $this->getUser() ); + if ( !$result ) { + $this->dieCustomUsageMessage( 'commentstreams-api-error-watch' ); + } + + $this->getResult()->addValue( null, $this->getModuleName(), '' ); + } + + /** + * @return array examples of the use of this API module + */ + public function getExamplesMessages() { + return [ + 'action=' . $this->getModuleName() . '&pageid=3' => + 'apihelp-' . $this->getModuleName() . '-pageid-example', + 'action=' . $this->getModuleName() . '&title=CommentStreams:3' => + 'apihelp-' . $this->getModuleName() . '-title-example' + ]; + } + + /** + * @return string indicates that this API module requires a CSRF toekn + */ + public function needsToken() { + return 'csrf'; + } +} diff --git a/CommentStreams/includes/Comment.php b/CommentStreams/includes/Comment.php new file mode 100644 index 00000000..84bf186f --- /dev/null +++ b/CommentStreams/includes/Comment.php @@ -0,0 +1,958 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class Comment { + + // wiki page object for this comment wiki page + private $wikipage = null; + + // data for this comment has been loaded from the database + private $loaded = false; + + // int page ID for the wikipage this comment is on + private $assoc_page_id; + + // int page ID for the wikipage this comment is in reply to or null + private $parent_page_id; + + // string title of comment + private $comment_title; + + // string wikitext of comment + private $wikitext = null; + + // string HTML of comment + private $html = null; + + // User user object for the author of this comment + private $user = null; + + // Avatar for author of this comment + private $avatar = null; + + // MWTimestamp the earliest revision date for this comment + private $creation_timestamp = null; + + // MWTimestamp the latest revision date for this comment + private $modification_timestamp = null; + + // number of replies to this comment + private $num_replies = null; + + // number of up votes for this comment + private $num_up_votes = null; + + // number of dow votes for this comment + private $num_down_votes = null; + + /** + * create a new Comment object from existing wiki page + * + * @param WikiPage $wikipage WikiPage object corresponding to comment page + * @return Comment|null the newly created comment or null if there was an + * error + */ + public static function newFromWikiPage( $wikipage ) { + if ( !is_null( $wikipage ) && + $wikipage->getTitle()->getNamespace() === NS_COMMENTSTREAMS ) { + $comment = new Comment( $wikipage ); + if ( $wikipage->exists() ) { + $comment->loadFromDatabase(); + } + return $comment; + } + return null; + } + + /** + * create a new Comment object from values and save to database + * NOTE: since only head comments can contain a comment title, either + * $comment_title or $parent_page_id must be non null, but not both + * + * @param int $assoc_page_id page ID for the wikipage this comment is on + * @param int $parent_page_id page ID for the wikipage this comment is in + * reply to or null + * @param string $comment_title string title of comment + * @param string $wikitext the wikitext to add + * @param User $user the user + * @return Comment|null new comment object or null if there was a problem + * creating it + */ + public static function newFromValues( $assoc_page_id, $parent_page_id, + $comment_title, $wikitext, $user ) { + if ( is_null( $comment_title ) && is_null( $parent_page_id ) ) { + return null; + } + if ( !is_null( $comment_title ) && !is_null( $parent_page_id ) ) { + return null; + } + $annotated_wikitext = self::addAnnotations( $wikitext, $comment_title, + $assoc_page_id ); + $content = new WikitextContent( $annotated_wikitext ); + $success = false; + while ( !$success ) { + $index = wfRandomString(); + $title = Title::newFromText( (string)$index, NS_COMMENTSTREAMS ); + if ( !$title->isDeletedQuick() && !$title->exists() ) { + $wikipage = new WikiPage( $title ); + $status = $wikipage->doEditContent( $content, '', + EDIT_NEW | EDIT_SUPPRESS_RC , false, $user, null ); + if ( !$status->isOK() && !$status->isGood() ) { + if ( $status->getMessage()->getKey() == 'edit-already-exists' ) { + $index = wfRandomString(); + } else { + return null; + } + } else { + $success = true; + } + } else { + $index = wfRandomString(); + } + } + $comment = new Comment( $wikipage ); + $comment->wikitext = $wikitext; + + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->insert( + 'cs_comment_data', + [ + 'page_id' => $wikipage->getId(), + 'assoc_page_id' => $assoc_page_id, + 'parent_page_id' => $parent_page_id, + 'comment_title' => $comment_title + ], + __METHOD__ + ); + if ( !$result ) { + return null; + } + $comment->loadFromValues( $assoc_page_id, $parent_page_id, $comment_title ); + + if ( is_null( $parent_page_id ) ) { + $comment->watch( $user ); + } else { + self::watchComment( $parent_page_id, $user ); + } + + if ( defined( 'SMW_VERSION' ) ) { + $job = new SMWUpdateJob( $title ); + JobQueueGroup::singleton()->push( $job ); + } + + return $comment; + } + + /** + * constructor + * + * @param WikiPage $wikipage WikiPage object corresponding to comment page + */ + private function __construct( $wikipage ) { + $this->wikipage = $wikipage; + } + + /** + * load comment data from database + */ + private function loadFromDatabase() { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->selectRow( + 'cs_comment_data', + [ 'assoc_page_id', 'parent_page_id', 'comment_title' ], + [ 'page_id' => $this->getId() ], + __METHOD__ + ); + if ( $result ) { + $this->assoc_page_id = (integer)$result->assoc_page_id; + $this->parent_page_id = $result->parent_page_id; + if ( !is_null( $this->parent_page_id ) ) { + $this->parent_page_id = (integer)$this->parent_page_id; + } + $this->comment_title = $result->comment_title; + $this->loaded = true; + } + } + + /** + * load comment data from values + * + * @param int $assoc_page_id page ID for the wikipage this comment is on + * @param int $parent_page_id page ID for the wikipage this comment is in + * reply to or null + * @param string $comment_title string title of comment + */ + private function loadFromValues( $assoc_page_id, $parent_page_id, + $comment_title ) { + $this->assoc_page_id = (integer)$assoc_page_id; + $this->parent_page_id = $parent_page_id; + if ( !is_null( $this->parent_page_id ) ) { + $this->parent_page_id = (integer)$this->parent_page_id; + } + $this->comment_title = $comment_title; + $this->loaded = true; + } + + /** + * @return int page ID of the comment's wikipage + */ + public function getId() { + return $this->wikipage->getId(); + } + + /** + * @return WikiPage wiki page object associate with this comment page + */ + public function getWikiPage() { + return $this->wikipage; + } + + /** + * @return int page ID for the wikipage this comment is on + */ + public function getAssociatedId() { + if ( $this->loaded === false ) { + $this->loadFromDatabase(); + } + return $this->assoc_page_id; + } + + /** + * @return int|null page ID for the wikipage this comment is in reply to or + * null if this comment is a discussion, not a reply + */ + public function getParentId() { + if ( $this->loaded === false ) { + $this->loadFromDatabase(); + } + return $this->parent_page_id; + } + + /** + * @return string the title of the comment + */ + public function getCommentTitle() { + if ( $this->loaded === false ) { + $this->loadFromDatabase(); + } + return $this->comment_title; + } + + /** + * @return string wikitext of the comment + */ + public function getWikiText() { + if ( is_null( $this->wikitext ) ) { + $wikitext = ContentHandler::getContentText( $this->wikipage->getContent( + Revision::RAW ) ); + $wikitext = $this->removeAnnotations( $wikitext ); + $this->wikitext = $wikitext; + } + return $this->wikitext; + } + + /** + * @return string parsed HTML of the comment + */ + public function getHTML() { + if ( is_null( $this->html ) ) { + $this->getWikiText(); + if ( !is_null( $this->wikitext ) ) { + $parser = new Parser; + $this->html = $parser->parse( $this->wikitext, + $this->wikipage->getTitle(), new ParserOptions )->getText(); + } + } + return $this->html; + } + + /** + * @return User the author of this comment + */ + public function getUser() { + if ( is_null( $this->user ) ) { + $user_id = $this->wikipage->getOldestRevision()->getUser(); + $this->user = User::newFromId( $user_id ); + } + return $this->user; + } + + /** + * @return boolean true if the last edit to this comment was not done by the + * original author + */ + public function isLastEditModerated() { + $author = $this->wikipage->getOldestRevision()->getUser(); + $lastEditor = $this->wikipage->getRevision()->getUser(); + return $author !== $lastEditor; + } + + /** + * @return string username of the author of this comment + */ + public function getUsername() { + return $this->getUser()->getName(); + } + + /** + * @return string display name of the author of this comment linked to + * the user's user page if it exists + */ + public function getUserDisplayName() { + return self::getDisplayNameFromUser( $this->getUser() ); + } + + /** + * @return string display name of the author of this comment + */ + public function getUserDisplayNameUnlinked() { + return self::getDisplayNameFromUser( $this->getUser(), false ); + } + + /** + * @return string the URL of the avatar of the author of this comment + */ + public function getAvatar() { + if ( is_null( $this->avatar ) ) { + if ( class_exists( 'wAvatar' ) ) { // from Extension:SocialProfile + $avatar = new wAvatar( $this->getUser()->getId(), 'l' ); + $this->avatar = $GLOBALS['wgUploadPath'] . '/avatars/' . + $avatar->getAvatarImage(); + } else { + $this->avatar = self::getAvatarFromUser( $this->getUser() ); + } + } + return $this->avatar; + } + + /** + * @return MWTimestamp the earliest revision date for this + */ + public function getCreationTimestamp() { + if ( is_null( $this->creation_timestamp ) ) { + $this->creation_timestamp = MWTimestamp::getLocalInstance( + $this->wikipage->getTitle()->getEarliestRevTime() ); + } + return $this->creation_timestamp; + } + + /** + * @return MWTimestamp the earliest revision date for this + */ + public function getCreationDate() { + if ( !is_null( $this->getCreationTimestamp() ) ) { + return $this->creation_timestamp->format( "M j \a\\t g:i a" ); + } + return ""; + } + + /** + * @return MWTimestamp the latest revision date for this + */ + public function getModificationTimestamp() { + if ( is_null( $this->modification_timestamp ) ) { + $title = $this->wikipage->getTitle(); + if ( $title->getFirstRevision()->getId() === $title->getLatestRevID() ) { + return null; + } + $timestamp = Revision::getTimestampFromId( $title, + $title->getLatestRevID() ); + $this->modification_timestamp = MWTimestamp::getLocalInstance( + $timestamp ); + } + return $this->modification_timestamp; + } + + /** + * @return MWTimestamp the earliest revision date for this + */ + public function getModificationDate() { + if ( !is_null( $this->getModificationTimestamp() ) ) { + return $this->modification_timestamp->format( "M j \a\\t g:i a" ); + } + return null; + } + + /** + * @return int number of replies + */ + public function getNumReplies() { + if ( is_null( $this->num_replies ) ) { + $dbr = wfGetDB( DB_SLAVE ); + $this->num_replies = $dbr->selectRowCount( + 'cs_comment_data', + '*', + [ 'parent_page_id' => $this->getId() ], + __METHOD__ + ); + } + return $this->num_replies; + } + + /** + * @return array get comment data in array suitable for JSON + */ + public function getJSON() { + $json = [ + 'commenttitle' => $this->getCommentTitle(), + 'username' => $this->getUsername(), + 'userdisplayname' => $this->getUserDisplayName(), + 'avatar' => $this->getAvatar(), + 'created' => $this->getCreationDate(), + 'created_timestamp' => $this->getCreationTimestamp()->format( "U" ), + 'modified' => $this->getModificationDate(), + 'moderated' => $this->isLastEditModerated() ? "moderated" : null, + 'wikitext' => $this->getWikiText(), + 'html' => $this->getHTML(), + 'pageid' => $this->getId(), + 'associatedid' => $this->getAssociatedId(), + 'parentid' => $this->getParentId(), + 'numreplies' => $this->getNumReplies(), + ]; + if ( $GLOBALS['wgCommentStreamsEnableVoting'] ) { + $json['numupvotes'] = $this->getNumUpVotes(); + $json['numdownvotes'] = $this->getNumDownVotes(); + } + return $json; + } + + /** + * get vote for user + * + * @param User $user the author of the edit + * @return +1 for up vote, -1 for down vote, 0 for no vote + */ + public function getVote( $user ) { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->selectRow( + 'cs_votes', + [ 'vote' ], + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId() + ], + __METHOD__ + ); + if ( $result ) { + $vote = (integer)$result->vote; + if ( $vote > 0 ) { + return 1; + } + if ( $vote < 0 ) { + return -1; + } + } + return 0; + } + + /** + * @return int number of up votes + */ + public function getNumUpVotes() { + if ( is_null( $this->num_up_votes ) ) { + $dbr = wfGetDB( DB_SLAVE ); + $this->num_up_votes = $dbr->selectRowCount( + 'cs_votes', + '*', + [ + 'page_id' => $this->getId(), + 'vote' => 1 + ], + __METHOD__ + ); + } + return $this->num_up_votes; + } + + /** + * @return int number of down votes + */ + public function getNumDownVotes() { + if ( is_null( $this->num_down_votes ) ) { + $dbr = wfGetDB( DB_SLAVE ); + $this->num_down_votes = $dbr->selectRowCount( + 'cs_votes', + '*', + [ + 'page_id' => $this->getId(), + 'vote' => -1 + ], + __METHOD__ + ); + } + return $this->num_down_votes; + } + + /** + * record a vote + * + * @param vote 1 for up vote, -1 for down vote, 0 for no vote + * @param User $user the user voting on the comment + * @return database status code + */ + public function vote( $vote, $user ) { + if ( $vote !== "-1" && $vote !== "0" && $vote !== "1" ) { + return false; + } + $vote = (integer)$vote; + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->selectRow( + 'cs_votes', + [ 'vote' ], + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId() + ], + __METHOD__ + ); + if ( $result ) { + if ( $vote === (integer)$result->vote ) { + return true; + } + if ( $vote === 1 || $vote === -1 ) { + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->update( + 'cs_votes', + [ 'vote' => $vote ], + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId() + ], + __METHOD__ + ); + } else { + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->delete( + 'cs_votes', + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId() + ], + __METHOD__ + ); + } + } else { + if ( $vote === 0 ) { + return true; + } + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->insert( + 'cs_votes', + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId(), + 'vote' => $vote + ], + __METHOD__ + ); + } + return $result; + } + + /** + * watch a comment (get page ID from this comment) + * + * @param User $user the user watching the comment + * @return database true for OK, false for error + */ + public function watch( $user ) { + return self::watchComment( $this->getID(), $user ); + } + + /** + * watch a comment (get page ID from parameter) + * + * @param $pageid the page ID of the comment to watch + * @param User $user the user watching the comment + * @return database true for OK, false for error + */ + private static function watchComment( $pageid, $user ) { + if ( self::isWatchingComment( $pageid, $user ) ) { + return true; + } + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->insert( + 'cs_watchlist', + [ + 'page_id' => $pageid, + 'user_id' => $user->getId() + ], + __METHOD__ + ); + return $result; + } + + /** + * unwatch a comment + * + * @param User $user the user unwatching the comment + * @return database true for OK, false for error + */ + public function unwatch( $user ) { + if ( !$this->isWatching( $user ) ) { + return true; + } + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->delete ( + 'cs_watchlist', + [ + 'page_id' => $this->getId(), + 'user_id' => $user->getId() + ], + __METHOD__ + ); + return $result; + } + + /** + * Check if a particular user is watching this comment + * + * @param User $user the user watching the comment + * @return database true for OK, false for error + */ + public function isWatching( $user ) { + return self::isWatchingComment( $this->getId(), $user ); + } + + /** + * Check if a particular user is watching a comment + * + * @param $pageid the page ID of the comment to check + * @param User $user the user watching the comment + * @return database true for OK, false for error + */ + private static function isWatchingComment( $pageid, $user ) { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->selectRow( + 'cs_watchlist', + [ 'page_id' ], + [ + 'page_id' => $pageid, + 'user_id' => $user->getId() + ], + __METHOD__ + ); + if ( $result ) { + return true; + } + return false; + } + + /** + * Get an array of watchers for this comment + * + * @return array of user IDs + */ + public function getWatchers() { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->select( + 'cs_watchlist', + [ 'user_id' ], + [ 'page_id' => $this->getId() ], + __METHOD__ + ); + $users = []; + foreach ( $result as $row ) { + $user_id = $row->user_id; + $user = User::newFromId( $user_id ); + $users[$user_id] = $user; + } + return $users; + } + + /** + * update comment in database + * NOTE: since only head comments can contain a comment title, + * $comment_title may only be non null if this comment has a null parent id + * and vice versa + * + * @param string $comment_title the new title for the comment + * @param string $wikitext the wikitext to add + * @param User $user the author of the edit + * @return boolean true if successful + */ + public function update( $comment_title, $wikitext, $user ) { + if ( is_null( $comment_title ) && is_null( $this->getParentId() ) ) { + return false; + } + if ( !is_null( $comment_title ) && !is_null( $this->getParentId() ) ) { + return false; + } + $annotated_wikitext = + self::addAnnotations( $wikitext, $comment_title, + $this->getAssociatedId() ); + $content = new WikitextContent( $annotated_wikitext ); + $status = $this->wikipage->doEditContent( $content, '', + EDIT_UPDATE | EDIT_SUPPRESS_RC , false, $user, null ); + if ( !$status->isOK() && !$status->isGood() ) { + return false; + } + $this->wikitext = $wikitext; + $this->modification_timestamp = null; + $this->wikipage = WikiPage::newFromID( $this->wikipage->getId() ); + + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->update( + 'cs_comment_data', + [ 'comment_title' => $comment_title ], + [ 'page_id' => $this->getId() ], + __METHOD__ + ); + if ( !$result ) { + return false; + } + $this->comment_title = $comment_title; + + return true; + } + + /** + * delete comment from database + * + * @return boolean true if successful + */ + public function delete() { + $pageid = $this->getId(); + + $status = $this->getWikiPage()->doDeleteArticleReal( 'comment deleted', + true, 0 ); + if ( !$status->isOK() && !$status->isGood() ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $result = $dbw->delete( + 'cs_comment_data', + [ 'page_id' => $pageid ], + __METHOD__ + ); + return $result; + } + + /** + * add extra information to wikitext before storage + * + * @param string $wikitext the wikitext to which to add + * @param string $comment_title string title of comment + * @param int $assoc_page_id page ID for the wikipage this comment is on + * @return string annotated wikitext + */ + public static function addAnnotations( $wikitext, $comment_title, + $assoc_page_id ) { + if ( !is_null( $comment_title ) ) { + $wikitext .= <<<EOT +{{DISPLAYTITLE: +$comment_title +}} +EOT; + } + return $wikitext; + } + + /** + * add extra information to wikitext before storage + * + * @param string $wikitext the wikitext to which to add + * @return string wikitext without annotations + */ + public function removeAnnotations( $wikitext ) { + $comment_title = $this->getCommentTitle(); + if ( !is_null( $comment_title ) ) { + $strip = <<<EOT +{{DISPLAYTITLE: +$comment_title +}} +EOT; + $wikitext = str_replace( $strip, '', $wikitext ); + } + return $wikitext; + } + + /** + * get comments for the given page + * + * @param int $assoc_page_id ID of page to get comments for + * @return array array of comments for the given page + */ + public static function getAssociatedComments( $assoc_page_id ) { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->select( + 'cs_comment_data', + [ 'page_id' ], + [ 'assoc_page_id' => $assoc_page_id ], + __METHOD__ + ); + $comments = []; + foreach ( $result as $row ) { + $page_id = $row->page_id; + $wikipage = WikiPage::newFromId( $page_id ); + $comment = self::newFromWikiPage( $wikipage ); + if ( !is_null( $comment ) ) { + $comments[] = $comment; + } + } + return $comments; + } + + /** + * get replies for the given comment + * + * @param int $parent_page_id ID of page to get comments for + * @return array array of comments for the given page + */ + public static function getReplies( $parent_page_id ) { + $dbr = wfGetDB( DB_SLAVE ); + $result = $dbr->select( + 'cs_comment_data', + [ 'page_id' ], + [ 'parent_page_id' => $parent_page_id ], + __METHOD__ + ); + $comments = []; + foreach ( $result as $row ) { + $page_id = $row->page_id; + $wikipage = WikiPage::newFromId( $page_id ); + $comment = self::newFromWikiPage( $wikipage ); + if ( !is_null( $comment ) ) { + $comments[] = $comment; + } + } + return $comments; + } + + /** + * return the text to use to represent the user at the top of a comment + * + * @param User $user the user + * @param boolean $linked whether to link the display name to the user page, + * if it exists + * @return string display name for user + */ + public static function getDisplayNameFromUser( $user, $linked = true ) { + $userpage = $user->getUserPage(); + $displayname = null; + if ( !is_null( $GLOBALS['wgCommentStreamsUserRealNamePropertyName'] ) ) { + $displayname = self::getUserProperty( $user, + $GLOBALS['wgCommentStreamsUserRealNamePropertyName'] ); + } + if ( is_null( $displayname ) || strlen( $displayname ) == 0 ) { + if ( class_exists( 'PageProps' ) ) { + $values = PageProps::getInstance()->getProperties( $userpage, + 'displaytitle' ); + if ( array_key_exists( $userpage->getArticleID(), $values ) ) { + $displayname = $values[$userpage->getArticleID()]; + } + } + } + if ( is_null( $displayname ) || strlen( $displayname ) == 0 ) { + $displayname = $user->getRealName(); + } + if ( is_null( $displayname ) || strlen( $displayname ) == 0 ) { + $displayname = $user->getName(); + } + if ( $linked && $userpage->exists() ) { + $displayname = Linker::link( $userpage, $displayname ); + } + return $displayname; + } + + /** + * return the name of the file page containing the user's avatar + * + * @param User $user the user + * @return string URL of avatar + */ + public static function getAvatarFromUser( $user ) { + $avatar = null; + if ( !is_null( $GLOBALS['wgCommentStreamsUserAvatarPropertyName'] ) ) { + $avatar = self::getUserProperty( $user, + $GLOBALS['wgCommentStreamsUserAvatarPropertyName'] ); + if ( !is_null( $avatar ) ) { + if ( gettype( $avatar ) === 'string' ) { + $avatar = Title::newFromText( $avatar ); + if ( is_null( $avatar ) ) { + return null; + } + } + if ( !get_class( $avatar ) === 'Title' ) { + return null; + } + if ( $avatar->isKnown() && $avatar->getNamespace() === NS_FILE ) { + $file = wfFindFile( $avatar ); + if ( $file ) { + return $file->getFullUrl(); + } + } + } + } + return null; + } + + /** + * return the value of a property on a user page + * + * @param User $user the user + * @param string $propertyName the name of the property + * @return string|null the value of the property + */ + private static function getUserProperty( $user, $propertyName ) { + if ( defined( 'SMW_VERSION' ) ) { + $userpage = $user->getUserPage(); + if ( $userpage->exists() ) { + $store = \SMW\StoreFactory::getStore(); + $subject = SMWDIWikiPage::newFromTitle( $userpage ); + $data = $store->getSemanticData( $subject ); + $property = SMWDIProperty::newFromUserLabel( $propertyName ); + $values = $data->getPropertyValues( $property ); + if ( count( $values ) > 0 ) { + // this property should only have one value so pick the first one + $value = $values[0]; + if ( $value->getDIType() == SMWDataItem::TYPE_STRING + || $value->getDIType() == SMWDataItem::TYPE_BLOB ) { + return $value->getString(); + } elseif ( $value->getDIType() == SMWDataItem::TYPE_WIKIPAGE ) { + return $value->getTitle(); + } + } + } + } + return null; + } + + /** + * Used by Echo to locate the users watching a comment being replied to. + * @param EchoEvent $event the Echo event + * @return array array mapping user id to User object + */ + public static function locateUsersWatchingComment( $event ) { + $id = $event->getExtraParam( 'parent_id' ); + $wikipage = WikiPage::newFromId( $id ); + if ( !is_null( $wikipage ) ) { + $comment = Comment::newFromWikiPage( $wikipage ); + if ( !is_null( $comment ) ) { + return $comment->getWatchers(); + } + } + return []; + } +} diff --git a/CommentStreams/includes/CommentStreams.php b/CommentStreams/includes/CommentStreams.php new file mode 100644 index 00000000..07d4e4a2 --- /dev/null +++ b/CommentStreams/includes/CommentStreams.php @@ -0,0 +1,282 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class CommentStreams { + + // CommentStreams singleton instance + private static $instance = null; + + /** + * create a CommentStreams singleton instance + * + * @return CommentStreams a singleton CommentStreams instance + */ + public static function singleton() { + if ( is_null( self::$instance ) ) { + self::$instance = new CommentStreams(); + } + return self::$instance; + } + + // no CommentStreams flag + private $noCommentStreams = false; + + /** + * disables the display of comments on the current page + */ + public function disableCommentsOnPage() { + $this->noCommentStreams = true; + } + + // initially collapse CommentStreams flag + private $initiallyCollapseCommentStreams = false; + + /** + * makes the comments appear initially collapsed when the current page + * is viewed + */ + public function initiallyCollapseCommentsOnPage() { + $this->initiallyCollapseCommentStreams = true; + } + + /** + * initializes the display of comments + * + * @param OutputPage $output OutputPage object + */ + public function init( $output ) { + if ( $this->checkDisplayComments( $output ) ) { + $comments = $this->getComments( $output ); + $this->initJS( $output, $comments ); + } + } + + /** + * checks to see if comments should be displayed on this page + * + * @param OutputPage $output the OutputPage object + * @return boolean true if comments should be displayed on this page + */ + private function checkDisplayComments( $output ) { + // don't display comments on this page if they are explicitly disabled + if ( $this->noCommentStreams ) { + return false; + } + + // don't display comments on any page action other than view action + if ( Action::getActionName( $output->getContext() ) !== "view" ) { + return false; + } + + // if $wgCommentStreamsAllowedNamespaces is not set, display comments + // in all content namespaces + $csAllowedNamespaces = $GLOBALS['wgCommentStreamsAllowedNamespaces']; + if ( is_null( $csAllowedNamespaces ) ) { + $csAllowedNamespaces = $GLOBALS['wgContentNamespaces']; + } elseif ( !is_array( $csAllowedNamespaces ) ) { + $csAllowedNamespaces = [ $csAllowedNamespaces ]; + } + + // don't display comments in a talk namespace unless: + // 1) $wgCommentStreamsEnableTalk is true, OR + // 2) the namespace is a talk namespace for a namespace in the array of + // allowed namespaces + $title = $output->getTitle(); + $namespace = $title->getNamespace(); + if ( $title->isTalkPage() ) { + $subject_namespace = MWNamespace::getSubject( $namespace ); + if ( !$GLOBALS['wgCommentStreamsEnableTalk'] && + !in_array( $subject_namespace, $csAllowedNamespaces ) ) { + return false; + } + } elseif ( !in_array( $namespace, $csAllowedNamespaces ) ) { + return false; + } + + // don't display comments in CommentStreams namespace + if ( $namespace === NS_COMMENTSTREAMS ) { + return false; + } + + // don't display comments on pages that do not exist + if ( !$title->exists() ) { + return false; + } + + return true; + } + + /** + * retrieve all comments for the current page + * + * @param OutputPage $output the OutputPage object for the current page + * @return Comment[] array of comments + */ + private function getComments( $output ) { + $commentData = []; + $pageId = $output->getTitle()->getArticleID(); + $allComments = Comment::getAssociatedComments( $pageId ); + $parentComments = $this->getDiscussions( $allComments, + $GLOBALS['wgCommentStreamsNewestStreamsOnTop'], + $GLOBALS['wgCommentStreamsEnableVoting'] ); + foreach ( $parentComments as $parentComment ) { + $parentJSON = $parentComment->getJSON(); + if ( $GLOBALS['wgCommentStreamsEnableVoting'] ) { + $parentJSON['vote'] = $parentComment->getVote( $output->getUser() ); + } + if ( class_exists( 'EchoEvent' ) ) { + $parentJSON['watching'] = $parentComment->isWatching( $output->getUser() ); + } + $childComments = $this->getReplies( $allComments, + $parentComment->getId() ); + foreach ( $childComments as $childComment ) { + $childJSON = $childComment->getJSON(); + $parentJSON['children'][] = $childJSON; + } + $commentData[] = $parentJSON; + } + return $commentData; + } + + /** + * initialize JavaScript + * + * @param OutputPage $output the OutputPage object + * @param Comment[] $comments array of comments on the current page + */ + private function initJS( $output, $comments ) { + // determine if comments should be initially collapsed or expanded + // if the namespace is a talk namespace, use state of its subject namespace + $title = $output->getTitle(); + $namespace = $title->getNamespace(); + if ( $title->isTalkPage() ) { + $namespace = MWNamespace::getSubject( $namespace ); + } + + if ( $this->initiallyCollapseCommentStreams ) { + $initiallyCollapsed = true; + } else { + $initiallyCollapsed = in_array( $namespace, + $GLOBALS['wgCommentStreamsInitiallyCollapsedNamespaces'] ); + } + + $commentStreamsParams = [ + 'moderatorEdit' => in_array( 'cs-moderator-edit', + $output->getUser()->getRights() ), + 'moderatorDelete' => in_array( 'cs-moderator-delete', + $output->getUser()->getRights() ), + 'moderatorFastDelete' => + $GLOBALS['wgCommentStreamsModeratorFastDelete'] ? 1 : 0, + 'userDisplayName' => + Comment::getDisplayNameFromUser( $output->getUser() ), + 'userAvatar' => + Comment::getAvatarFromUser( $output->getUser() ), + 'newestStreamsOnTop' => + $GLOBALS['wgCommentStreamsNewestStreamsOnTop'] ? 1 : 0, + 'initiallyCollapsed' => $initiallyCollapsed, + 'enableVoting' => + $GLOBALS['wgCommentStreamsEnableVoting'] ? 1 : 0, + 'enableWatchlist' => + class_exists( 'EchoEvent' ) ? 1 : 0, + 'comments' => $comments + ]; + $output->addJsConfigVars( 'CommentStreams', $commentStreamsParams ); + $output->addModules( 'ext.CommentStreams' ); + } + + /** + * return all discussions (top level comments) in an array of comments + * + * @param array $allComments an array of all comments on a page + * @param boolean $newestOnTop true if array should be sorted from newest to + * @return array an array of all discussions + * oldest + */ + private function getDiscussions( $allComments, $newestOnTop, $enableVoting ) { + $array = array_filter( + $allComments, function ( $comment ) { + return is_null( $comment->getParentId() ); + } + ); + usort( $array, function ( $comment1, $comment2 ) + use ( $newestOnTop, $enableVoting ) { + $date1 = $comment1->getCreationTimestamp()->timestamp; + $date2 = $comment2->getCreationTimestamp()->timestamp; + if ( $enableVoting ) { + $upvotes1 = $comment1->getNumUpVotes(); + $downvotes1 = $comment1->getNumDownVotes(); + $votediff1 = $upvotes1 - $downvotes1; + $upvotes2 = $comment2->getNumUpVotes(); + $downvotes2 = $comment2->getNumDownVotes(); + $votediff2 = $upvotes2 - $downvotes2; + if ( $votediff1 === $votediff2 ) { + if ( $upvotes1 === $upvotes2 ) { + if ( $newestOnTop ) { + return $date1 > $date2 ? -1 : 1; + } else { + return $date1 < $date2 ? -1 : 1; + } + } else { + return $upvotes1 > $upvotes2 ? -1 : 1; + } + } else { + return $votediff1 > $votediff2 ? -1 : 1; + } + } else { + if ( $newestOnTop ) { + return $date1 > $date2 ? -1 : 1; + } else { + return $date1 < $date2 ? -1 : 1; + } + } + } + ); + return $array; + } + + /** + * return all replies for a given discussion in an array of comments + * + * @param array $allComments an array of all comments on a page + * @param int $parentId the page ID of the discussion to get replies for + * @return array an array of replies for the given discussion + */ + private function getReplies( $allComments, $parentId ) { + $array = array_filter( + $allComments, function ( $comment ) use ( $parentId ) { + if ( $comment->getParentId() === $parentId ) { + return true; + } + return false; + } + ); + usort( + $array, function ( $comment1, $comment2 ) { + $date1 = $comment1->getCreationTimestamp()->timestamp; + $date2 = $comment2->getCreationTimestamp()->timestamp; + return $date1 < $date2 ? -1 : 1; + } + ); + return $array; + } +} diff --git a/CommentStreams/includes/CommentStreamsAllComments.alias.php b/CommentStreams/includes/CommentStreamsAllComments.alias.php new file mode 100644 index 00000000..76c5d1f4 --- /dev/null +++ b/CommentStreams/includes/CommentStreamsAllComments.alias.php @@ -0,0 +1,30 @@ +<?php + +/* + * Copyright (c) 2017 The MITRE Corporation + * + * 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. + */ + +$specialPageAliases = []; + +/** English */ +$specialPageAliases['en'] = [ + 'CommentStreamsAllComments' => [ 'AllComments' ] +]; diff --git a/CommentStreams/includes/CommentStreamsAllComments.php b/CommentStreams/includes/CommentStreamsAllComments.php new file mode 100644 index 00000000..9631842d --- /dev/null +++ b/CommentStreams/includes/CommentStreamsAllComments.php @@ -0,0 +1,177 @@ +<?php + +/* + * Copyright (c) 2017 The MITRE Corporation + * + * 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. + */ + +class CommentStreamsAllComments extends SpecialPage { + + function __construct() { + parent::__construct( 'CommentStreamsAllComments' ); + } + + function execute( $par ) { + $request = $this->getRequest(); + $this->setHeaders(); + $this->getOutput()->addModuleStyles( 'ext.CommentStreamsAllComments' ); + + $offset = $request->getText( 'offset', 0 ); + $limit = 20; + $pages = self::getCommentPages( $limit + 1, $offset ); + + if ( !$pages->valid() ) { + $offset = 0; + $pages = self::getCommentPages( $limit + 1, $offset ); + if ( !$pages->valid() ) { + $this->displayMessage( + wfMessage( 'commentstreams-allcomments-nocommentsfound' ) + ); + return; + } + } + + $wikitext = '{| class="wikitable csall-wikitable"' . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-page' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-associatedpage' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-commenttitle' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-wikitext' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-author' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-lasteditor' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-created' ) . PHP_EOL; + $wikitext .= + '!' . wfMessage( 'commentstreams-allcomments-label-lastedited' ) . PHP_EOL; + + $index = 0; + $more = false; + foreach ( $pages as $page ) { + if ( $index < $limit ) { + $wikipage = WikiPage::newFromId( $page->page_id ); + $comment = Comment::newFromWikiPage( $wikipage ); + $pagename = $comment->getWikiPage()->getTitle()->getPrefixedText() ; + $associatedpageid = $comment->getAssociatedId(); + $associatedpagename = + WikiPage::newFromId( $associatedpageid )->getTitle()->getPrefixedText(); + $author = $comment->getUser()->getName(); + $lasteditor = User::newFromId( $wikipage->getRevision()->getUser() )->getName(); + if ( $lasteditor === $author ) { + $lasteditor = ''; + } + $wikitext .= '|-' . PHP_EOL; + $wikitext .= '|[[' . $pagename . ']]' . PHP_EOL; + $wikitext .= '|[[' . $associatedpagename . ']]' . PHP_EOL; + $wikitext .= '|' . $comment->getCommentTitle() . PHP_EOL; + $wikitext .= '|' . $comment->getWikiText() . PHP_EOL; + $wikitext .= '|' . $author . PHP_EOL; + $wikitext .= '|' . $lasteditor . PHP_EOL; + $wikitext .= '|' . $comment->getCreationDate() . PHP_EOL; + $wikitext .= '|' . $comment->getModificationDate() . PHP_EOL; + $index ++; + } else { + $more = true; + } + } + + $wikitext .= '|}' . PHP_EOL; + $this->getOutput()->addWikiText( $wikitext ); + + if ( $offset > 0 || $more ) { + $this->addTableNavigation( $offset, $more, $limit, 'offset' ); + } + } + + private function displayMessage( $message ) { + $html = Html::openElement( 'p', [ + 'class' => 'csall-message' + ] ) + . $message + . Html::closeElement( 'p' ); + $this->getOutput()->addHtml( $html ); + } + + private function addTableNavigation( $offset, $more, $limit, $paramname ) { + + $title = Title::newFromText( 'Special:' . __CLASS__ ); + $url = $title->getFullURL(); + + $html = Html::openElement( 'table', [ + 'class' => 'csall-navigationtable' + ] ) + . Html::openElement( 'tr' ) + . Html::openElement( 'td' ); + + if ( $offset > 0 ) { + $prevurl = $url . '?' . $paramname . '=' . ( $offset - $limit ); + $html .= Html::openElement( 'a', [ + 'href' => $prevurl, + 'class' => 'csall-button' + ] ) + . wfMessage( 'commentstreams-allcomments-button-previous' ) + . Html::closeElement( 'a' ); + } + + $html .= Html::closeElement( 'td' ) + . Html::openElement( 'td', [ + 'style' => 'text-align:right;' + ] ); + + if ( $more ) { + $nexturl = $url . '?' . $paramname . '=' . ( $offset + $limit ); + $html .= Html::openElement( 'a', [ + 'href' => $nexturl, + 'class' => 'csall-button' + ] ) + . wfMessage( 'commentstreams-allcomments-button-next' ) + . Html::closeElement( 'a' ); + } + + $html .= Html::closeElement( 'td' ) + . Html::closeElement( 'tr' ) + . Html::closeElement( 'table' ); + $this->getOutput()->addHtml( $html ); + } + + private static function getCommentPages( $limit, $offset ) { + $dbr = wfGetDB( DB_SLAVE ); + $pages = $dbr->select( + 'page', + [ + 'page_id' + ], + [ + 'page_namespace' => $GLOBALS['wgCommentStreamsNamespaceIndex'] + ], + __METHOD__, + [ + 'ORDER BY' => 'page_latest DESC' , + 'LIMIT' => $limit, + 'OFFSET' => $offset + ] + ); + return $pages; + } +} diff --git a/CommentStreams/includes/CommentStreamsHooks.php b/CommentStreams/includes/CommentStreamsHooks.php new file mode 100644 index 00000000..ab21cbd9 --- /dev/null +++ b/CommentStreams/includes/CommentStreamsHooks.php @@ -0,0 +1,436 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class CommentStreamsHooks { + + /** + * Implements LoadExtensionSchemaUpdates hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/LoadExtensionSchemaUpdates + * Updates database schema. + * + * @param DatabaseUpdater $updater database updater + * @return bool continue checking hooks + */ + public static function addCommentTableToDatabase( DatabaseUpdater $updater ) { + $dir = $GLOBALS['wgExtensionDirectory'] . DIRECTORY_SEPARATOR . + 'CommentStreams' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR; + $updater->addExtensionTable( 'cs_comment_data', $dir . 'commentData.sql', + true ); + $updater->addExtensionTable( 'cs_votes', $dir . 'votes.sql', true ); + $updater->addExtensionTable( 'cs_watchlist', $dir . 'watch.sql', true ); + return true; + } + + /** + * Implements CanonicalNamespaces hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/CanonicalNamespaces + * Adds CommentStreams namespaces. + * + * @param array &$namespaces modifiable array of namespace numbers with + * corresponding canonical names + * @return bool continue checking hooks + */ + public static function addCommentStreamsNamespaces( array &$namespaces ) { + $namespaces[NS_COMMENTSTREAMS] = 'CommentStreams'; + $namespaces[NS_COMMENTSTREAMS_TALK] = 'CommentStreams_Talk'; + return true; + } + + /** + * Implement MediaWikiPerformAction hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/MediaWikiPerformAction + * Prevents comment pages from being edited or deleted. Displays + * comment title and link to associated page when comment is viewed. + * + * @param OutputPage $output OutputPage object + * @param Article $article Article object + * @param Title $title Title object + * @param User $user User object + * @param WebRequest $request WebRequest object + * @param MediaWiki $wiki MediaWiki object + * @return bool continue checking hooks + */ + public static function onMediaWikiPerformAction( OutputPage $output, + Article $article, Title $title, User $user, WebRequest $request, + MediaWiki $wiki ) { + if ( $title->getNamespace() !== NS_COMMENTSTREAMS ) { + return true; + } + $action = $wiki->getAction(); + if ( $action === 'info' || $action === 'history' ) { + return true; + } + + if ( $action !== 'view' ) { + $message = + wfMessage( 'commentstreams-error-prohibitedaction', $action )->text(); + $output->addHTML( '<p class="error">' . $message . '</p>' ); + } + $wikipage = new WikiPage( $title ); + $comment = Comment::newFromWikiPage( $wikipage ); + if ( !is_null( $comment ) ) { + $commentTitle = $comment->getCommentTitle(); + if ( !is_null( $commentTitle ) ) { + $output->setPageTitle( $commentTitle ); + } + $associatedTitle = Title::newFromId( $comment->getAssociatedId() ); + if ( !is_null( $associatedTitle ) ) { + $values = []; + if ( class_exists( 'PageProps' ) ) { + $values = PageProps::getInstance()->getProperties( $associatedTitle, + 'displaytitle' ); + } + if ( array_key_exists( $comment->getAssociatedId(), $values ) ) { + $displaytitle = $values[$comment->getAssociatedId()]; + } else { + $displaytitle = $associatedTitle->getPrefixedText(); + } + $link = Linker::link( $associatedTitle, '< ' . $displaytitle ); + $output->setSubtitle( $link ); + $output->addWikitext( $comment->getHTML() ); + } + } + return false; + } + + /** + * Implement MovePageIsValidMove hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/MovePageIsValidMove + * Prevents comment pages from being moved. + * + * @param Title $oldTitle Title object of the current (old) location + * @param Title $newTitle Title object of the new location + * @param Status $status Status object to pass error messages to + * @return bool continue checking hooks + */ + public static function onMovePageIsValidMove( Title $oldTitle, + Title $newTitle, Status $status ) { + if ( $oldTitle->getNamespace() === NS_COMMENTSTREAMS || + $newTitle->getNamespace() === NS_COMMENTSTREAMS ) { + $status->fatal( wfMessage( 'commentstreams-error-prohibitedaction', + 'move' ) ); + return false; + } + return true; + } + + /** + * Implements userCan hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/userCan + * Ensures that only the original author can edit a comment + * + * @param Title &$title the title object in question + * @param User &$user the user performing the action + * @param string $action the action being performed + * @param boolean &$result true means the user is allowed, false means the + * user is not allowed, untouched means this hook has no opinion + * @return bool continue checking hooks + */ + public static function userCan( Title &$title, User &$user, $action, + &$result ) { + if ( $title->getNamespace() !== NS_COMMENTSTREAMS ) { + return true; + } + + $wikipage = new WikiPage( $title ); + + if ( !$wikipage->exists() ) { + return true; + } + + if ( $user->isBlocked() ) { + $result = false; + return false; + } + + if ( $action === 'edit' ) { + if ( $user->getId() === $wikipage->getOldestRevision()->getUser() ) { + $result = true; + } else { + $result = false; + } + return false; + } + + if ( $action === 'cs-moderator-edit' ) { + if ( in_array( 'cs-moderator-edit', $user->getRights() ) ) { + $result = true; + } else { + $result = false; + } + return false; + } + + if ( $action === 'cs-moderator-delete' ) { + if ( in_array( 'cs-moderator-delete', $user->getRights() ) ) { + $result = true; + } else { + $result = false; + } + return false; + } + + return true; + } + + /** + * Implements ParserFirstCallInit hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/ParserFirstCallInit + * Adds no-comment-streams and comment-streams-initially-collapsed magic + * words. + * + * @param Parser $parser the parser + * @return bool continue checking hooks + */ + public static function onParserSetup( Parser $parser ) { + $parser->setHook( 'no-comment-streams', + 'CommentStreamsHooks::hideCommentStreams' ); + $parser->setHook( 'comment-streams-initially-collapsed', + 'CommentStreamsHooks::initiallyCollapseCommentStreams' ); + return true; + } + + /** + * Implements tag function, <no-comment-streams/>, which disables + * CommentStreams on a page. + * + * @param string $input input between the tags (ignored) + * @param array $args tag arguments + * @param Parser $parser the parser + * @param PPFrame $frame the parent frame + * @return string to replace tag with + */ + public static function hideCommentStreams( $input, array $args, + Parser $parser, PPFrame $frame ) { + $parser->disableCache(); + $cs = CommentStreams::singleton(); + $cs->disableCommentsOnPage(); + return ""; + } + + /** + * Implements tag function, <comment-streams-initially-collapsed/>, which + * makes CommentStreams on a page start as collapsed when the page is viewed. + * + * @param string $input input between the tags (ignored) + * @param array $args tag arguments + * @param Parser $parser the parser + * @param PPFrame $frame the parent frame + * @return string to replace tag with + */ + public static function initiallyCollapseCommentStreams( $input, array $args, + Parser $parser, PPFrame $frame ) { + $parser->disableCache(); + $cs = CommentStreams::singleton(); + $cs->initiallyCollapseCommentsOnPage(); + return ""; + } + + /** + * Implements BeforePageDisplay hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay + * Updates database schema. + * + * @param OutputPage &$output OutputPage object + * @param Skin &$skin Skin object that will be used to generate the page + * @return bool continue checking hooks + */ + public static function addCommentsAndInitializeJS( OutputPage &$output, + Skin &$skin ) { + $cs = CommentStreams::singleton(); + $cs->init( $output ); + return true; + } + + /** + * Implements ShowSearchHitTitle hook. + * See https://www.mediawiki.org/wiki/Manual:Hooks/ShowSearchHitTitle + * Modifies search results pointing to comment pages to point to the + * associated content page instead. + * + * @param Title &$title title to link to + * @param string &$text text to use for the link + * @param SearchResult $result the search result + * @param array $terms the search terms entered + * @param SpecialSearch $page the SpecialSearch object + * @return bool continue checking hooks + */ + public static function showSearchHitTitle( Title &$title, &$text, + SearchResult $result, array $terms, SpecialSearch $page ) { + $comment = Comment::newFromWikiPage( WikiPage::factory( $title ) ); + if ( !is_null( $comment ) ) { + $title = Title::newFromId( $comment->getAssociatedId() ); + } + return true; + } + + /** + * Implements extension registration callback. + * See https://www.mediawiki.org/wiki/Manual:Extension_registration#Customizing_registration + * Defines CommentStreams namespace constants. + * + */ + public static function onRegistration() { + define( 'NS_COMMENTSTREAMS', $GLOBALS['wgCommentStreamsNamespaceIndex'] ); + define( 'NS_COMMENTSTREAMS_TALK', + $GLOBALS['wgCommentStreamsNamespaceIndex'] + 1 ); + $GLOBALS['wgNamespacesToBeSearchedDefault'][NS_COMMENTSTREAMS] = true; + $GLOBALS['smwgNamespacesWithSemanticLinks'][NS_COMMENTSTREAMS] = true; + if ( !isset( $GLOBALS['wgGroupPermissions']['csmoderator'] + ['cs-moderator-delete'] ) ) { + $GLOBALS['wgGroupPermissions']['csmoderator']['cs-moderator-delete'] = + true; + } + if ( !isset( $GLOBALS['wgGroupPermissions']['csmoderator'] + ['cs-moderator-edit'] ) ) { + $GLOBALS['wgGroupPermissions']['csmoderator']['cs-moderator-edit'] = + false; + } + $GLOBALS['wgAvailableRights'][] = 'cs-moderator-edit'; + $GLOBALS['wgAvailableRights'][] = 'cs-moderator-delete'; + $GLOBALS['wgLogTypes'][] = 'commentstreams'; + $GLOBALS['wgLogActionsHandlers']['commentstreams/*'] = 'LogFormatter'; + } + + /** + * Initialize extra Semantic MediaWiki properties. + * This won't get called unless Semantic MediaWiki is installed. + */ + public static function initProperties() { + $pr = SMW\PropertyRegistry::getInstance(); + $pr->registerProperty( '___CS_ASSOCPG', '_wpg', 'Comment on' ); + $pr->registerProperty( '___CS_REPLYTO', '_wpg', 'Reply to' ); + $pr->registerProperty( '___CS_TITLE', '_txt', 'Comment title of' ); + $pr->registerProperty( '___CS_UPVOTES', '_num', 'Comment up votes' ); + $pr->registerProperty( '___CS_DOWNVOTES', '_num', 'Comment down votes' ); + $pr->registerProperty( '___CS_VOTEDIFF', '_num', 'Comment vote diff' ); + } + + /** + * Implements Semantic MediaWiki SMWStore::updateDataBefore callback. + * This won't get called unless Semantic MediaWiki is installed. + * If the comment has not been added to the database yet, which is indicated + * by a null associated page id, this function will return early, but it + * will be invoked again by an update job. + * + * @param SMW\Store $store semantic data store + * @param SMW\SemanticData $semanticData semantic data for page + * @return boolean true to continue + */ + public static function updateData( $store, $semanticData ) { + $subject = $semanticData->getSubject(); + if ( !is_null( $subject ) && !is_null( $subject->getTitle() ) && + $subject->getTitle()->getNamespace() === NS_COMMENTSTREAMS ) { + $page_id = $subject->getTitle()->getArticleID( Title::GAID_FOR_UPDATE ); + $wikipage = WikiPage::newFromId( $page_id ); + $comment = Comment::newFromWikiPage( $wikipage ); + + if ( is_null( $comment ) ) { + return true; + } + + $assoc_page_id = $comment->getAssociatedId(); + if ( !is_null( $assoc_page_id ) ) { + $assoc_wikipage = WikiPage::newFromId( $assoc_page_id ); + if ( !is_null( $assoc_wikipage ) ) { + $propertyDI = new SMW\DIProperty( '___CS_ASSOCPG' ); + $dataItem = + SMW\DIWikiPage::newFromTitle( $assoc_wikipage->getTitle() ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + } + } + + $parent_page_id = $comment->getParentId(); + if ( !is_null( $parent_page_id ) ) { + $parent_wikipage = WikiPage::newFromId( $parent_page_id ); + if ( !is_null( $parent_wikipage ) ) { + $propertyDI = new SMW\DIProperty( '___CS_REPLYTO' ); + $dataItem = + SMW\DIWikiPage::newFromTitle( $parent_wikipage->getTitle() ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + } + } + + $commentTitle = $comment->getCommentTitle(); + if ( !is_null( $commentTitle ) ) { + $propertyDI = new SMW\DIProperty( '___CS_TITLE' ); + $dataItem = new SMWDIBlob( $comment->getCommentTitle() ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + } + + if ( $GLOBALS['wgCommentStreamsEnableVoting'] === true ) { + $upvotes = $comment->getNumUpVotes(); + $propertyDI = new SMW\DIProperty( '___CS_UPVOTES' ); + $dataItem = new SMWDINumber( $upvotes ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + $downvotes = $comment->getNumDownVotes(); + $propertyDI = new SMW\DIProperty( '___CS_DOWNVOTES' ); + $dataItem = new SMWDINumber( $downvotes ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + $votediff = $upvotes - $downvotes; + $propertyDI = new SMW\DIProperty( '___CS_VOTEDIFF' ); + $dataItem = new SMWDINumber( $votediff ); + $semanticData->addPropertyObjectValue( $propertyDI, $dataItem ); + } + } + return true; + } + + /** + * @param array &$notifications notifications + * @param array &$notificationCategories notification categories + * @param array &$icons notification icons + */ + public static function onBeforeCreateEchoEvent( &$notifications, + &$notificationCategories, &$icons ) { + + $notificationCategories['commentstreams-notification-category'] = [ + 'priority' => 3 + ]; + + $notifications['commentstreams-comment-on-watched-page'] = [ + 'category' => 'commentstreams-notification-category', + 'group' => 'positive', + 'section' => 'alert', + 'presentation-model' => EchoCSPresentationModel::class, + 'user-locators' => [ 'EchoUserLocator::locateUsersWatchingTitle' ] + ]; + + $notifications['commentstreams-reply-on-watched-page'] = [ + 'category' => 'commentstreams-notification-category', + 'group' => 'positive', + 'section' => 'alert', + 'presentation-model' => EchoCSPresentationModel::class, + 'user-locators' => [ 'EchoUserLocator::locateUsersWatchingTitle' ], + 'user-filters' => [ 'Comment::locateUsersWatchingComment' ] + ]; + + $notifications['commentstreams-reply-to-watched-comment'] = [ + 'category' => 'commentstreams-notification-category', + 'group' => 'positive', + 'section' => 'alert', + 'presentation-model' => EchoCSPresentationModel::class, + 'user-locators' => [ 'Comment::locateUsersWatchingComment' ] + ]; + } +} diff --git a/CommentStreams/includes/EchoCSPresentationModel.php b/CommentStreams/includes/EchoCSPresentationModel.php new file mode 100644 index 00000000..c95657cb --- /dev/null +++ b/CommentStreams/includes/EchoCSPresentationModel.php @@ -0,0 +1,93 @@ +<?php +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +class EchoCSPresentationModel extends EchoEventPresentationModel { + + /** + * @return string The symbolic icon name as defined in $wgEchoNotificationIcons + */ + public function getIconType() { + return 'chat'; + } + + /** + * Array of primary link details, with possibly-relative URL & label. + * + * @return array|bool Array of link data, or false for no link: + * ['url' => (string) url, 'label' => (string) link text (non-escaped)] + */ + public function getPrimaryLink() { + $id = $this->event->getExtraParam( 'comment_id' ); + return [ + 'url' => $this->event->getTitle()->getFullURL() . '#cs-comment-' . $id, + 'label' => $this->msg( "notification-link-label-{$this->type}" ) + ]; + } + + /** + * Get a message object and add the performer's name as + * a parameter. It is expected that subclasses will override + * this. + * + * @return Message + */ + public function getHeaderMessage() { + $msg = wfMessage( "notification-header-{$this->type}" ); + $msg->params( $this->event->getExtraParam( + 'comment_author_display_name' ) ); + $msg->params( $this->event->getExtraParam( 'comment_title' ) ); + $msg->params( $this->event->getExtraParam( + 'associated_page_display_title' ) ); + $msg->params( $this->event->getExtraParam( + 'comment_author_username' ) ); + $msg->params( $this->event->getExtraParam( + 'comment_wikitext' ) ); + $msg->params( $this->getViewingUserForGender() ); + return $msg; + } + + public function getBodyMessage() { + $msg = wfMessage( "notification-body-{$this->type}" ); + $msg->params( $this->event->getExtraParam( + 'comment_author_display_name' ) ); + $msg->params( $this->event->getExtraParam( 'comment_title' ) ); + $msg->params( $this->event->getExtraParam( + 'associated_page_display_title' ) ); + $msg->params( $this->event->getExtraParam( + 'comment_author_username' ) ); + $msg->params( $this->event->getExtraParam( + 'comment_wikitext' ) ); + $msg->params( $this->getViewingUserForGender() ); + return $msg; + } + + /** + * If this function returns false, no other methods will be called + * on the object. + * + * @return bool + */ + public function canRender() { + return !is_null( $this->event->getTitle() ); + } +} diff --git a/CommentStreams/package.json b/CommentStreams/package.json new file mode 100644 index 00000000..bcf5b133 --- /dev/null +++ b/CommentStreams/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "scripts": { + "test": "grunt test" + }, + "devDependencies": { + "grunt": "1.0.1", + "grunt-banana-checker": "0.5.0", + "grunt-jsonlint": "1.1.0" + } +} diff --git a/CommentStreams/resources/CommentStreams.css b/CommentStreams/resources/CommentStreams.css new file mode 100644 index 00000000..818ef1c4 --- /dev/null +++ b/CommentStreams/resources/CommentStreams.css @@ -0,0 +1,191 @@ +.cs-hidden { + display: none; +} + +#cs-comments { + margin-top: 10px; + font-family: sans-serif; +} + +.cs-stream { + margin-top: 5px; + margin-bottom: 5px; + overflow: hidden; +} + +.cs-comment { + position: relative; +} + +.cs-reply-comment { + margin-top: 10px; + margin-left: 30px; +} + +.cs-head-comment > .cs-comment-header { + border-top: 1px solid #00a7d8; +} + +.cs-target-comment .cs-comment-header { + border: 2px solid green; +} + +.cs-expanded .cs-comment-header { + background-color: #e4f1ff; +} + +.cs-collapsed .cs-comment-header { + background-color: #eeeeee; + margin-bottom: 10px; +} + +.cs-reply-comment > .cs-comment-header { + background-color: #f5faff; +} + +.cs-comment-header { + padding-top: 2px; + padding-bottom: 2px; +} + +.cs-comment-header-left { + display: inline-block; + vertical-align: middle; +} + +.cs-comment-header-center { + display: inline-block; + vertical-align: middle; + padding-left: 5px; +} + +.cs-comment-header-right { + display: inline-block; + float: right; +} + +.cs-avatar { + height: 48px; + padding: 5px; +} + +.cs-comment-title { + font-size: 16px; + font-weight: bold; +} + +.cs-comment-author { + padding-right: 5px; + font-size: 12px; +} + +.cs-comment-author a { + color: #00a7d8; + font-weight: bold; +} + +.cs-comment-body { + margin-bottom: 5px; + font-size: 14px; +} + +.cs-comment-details { + color: #555555; + opacity: 0.8; + font-size: 12px; + padding-right: 5px; + padding-left: 5px; +} + +button:hover { + background-color: #8eddf5; +} + +.cs-button { + background-color: transparent; + border: none; + font-family: sans-serif; + font-weight: bold; + text-decoration: none; + padding-left: 5px; + padding-right: 5px; + box-shadow: none; +} + +.cs-button:enabled:active { + opacity: 0.5; +} + +.cs-button:enabled:hover { + color: #3ccefa; +} + +.cs-button:disabled { + color: #95a5a6; + opacity: 0.2; +} + +.cs-moderator-button { + color: #ff0000; +} + +.cs-moderator-button:enabled:hover { + color: #ff5555; +} + +.cs-toggle-button { + border: none; + border-style: none; +} + +.cs-vote-upcount, .cs-vote-downcount { + color: #555555; + padding-left: 3px; +} + +.cs-link-button { + border: none; + border-style: none; +} + +#cs-add-button, .cs-reply-button { + font-size: 14px; + padding-left: 0; +} + +#cs-edit-box { + position: relative; + background-color: #e4f1ff; + padding: 5px 5px 5px 5px; + margin-bottom: 7px; +} + +.cs-reply-edit-box { + margin-left: 30px; +} + +#cs-title-edit-field { + border-radius: 2px; + border-width: 1px; + border-color: #00a7d8; + box-style: content-box; + -webkit-box-sizing: content-box; + width: 80%; + min-width: 400px; + padding: 5px 5px 5px 5px; + margin-bottom: 5px; + font-family: sans-serif; + font-size: 16px; + font-weight: bold; +} + +#cs-body-edit-field { + border-radius: 2px; + border-width: 1px; + border-color: #00a7d8; + width: 100%; + padding: 5px 5px 5px 5px; + margin-bottom: 5px; + font-family: sans-serif; + font-size: 14px; +} diff --git a/CommentStreams/resources/CommentStreams.js b/CommentStreams/resources/CommentStreams.js new file mode 100644 index 00000000..bd2fb3d4 --- /dev/null +++ b/CommentStreams/resources/CommentStreams.js @@ -0,0 +1,1296 @@ +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +var commentstreams_controller = ( function( mw, $ ) { + 'use strict'; + + return { + baseUrl: null, + imagepath: null, + targetComment: null, + isLoggedIn: false, + moderatorEdit: false, + moderatorDelete: false, + moderatorFastDelete: false, + userDisplayName: null, + newestStreamsOnTop: false, + initiallyCollapsed: false, + enableVoting: false, + enableWatchlist: false, + comments: [], + spinnerOptions: { + lines: 11, // The number of lines to draw + length: 8, // The length of each line + width: 4, // The line thickness + radius: 8, // The radius of the inner circle + corners: 1, // Corner roundness (0..1) + rotate: 0, // The rotation offset + direction: 1, // 1: clockwise, -1: counterclockwise + color: '#000', // #rgb or #rrggbb or array of colors + speed: 1, // Rounds per second + trail: 60, // ƒfterglow percentage + shadow: false, // Whether to render a shadow + hwaccel: false, // Whether to use hardware acceleration + className: 'spinner', // The CSS class to assign to the spinner + zIndex: 2e9, // The z-index (defaults to 2000000000) + top: '50%', // Top position relative to parent + left: '50%' // Left position relative to parent + }, + initialize: function() { + var self = this; + this.baseUrl = window.location.href.split(/[?#]/)[0]; + this.imagepath = mw.config.get( 'wgExtensionAssetsPath' ) + + '/CommentStreams/images/'; + if ( window.location.hash ) { + var hash = window.location.hash.substring( 1 ); + var queryIndex = hash.indexOf( '?' ); + if ( queryIndex !== -1 ) { + hash = hash.substring( 0, queryIndex ); + } + this.targetComment = hash; + } + this.isLoggedIn = mw.config.get( 'wgUserName' ) !== null; + var config = mw.config.get( 'CommentStreams' ); + this.moderatorEdit = config.moderatorEdit; + this.moderatorDelete = config.moderatorDelete; + this.moderatorFastDelete = this.moderatorDelete ? + config.moderatorFastDelete : false; + this.userDisplayName = config.userDisplayName; + this.newestStreamsOnTop = config.newestStreamsOnTop; + this.initiallyCollapsed = config.initiallyCollapsed; + this.enableVoting = config.enableVoting; + this.enableWatchlist = config.enableWatchlist; + this.comments = config.comments; + this.setupDivs(); + this.addInitialComments(); + if ( this.targetComment ) { + this.scrollToAnchor( this.targetComment ); + } + }, + scrollToAnchor: function( id ){ + var element = $( '#' + id ); + if ( element.length ) { + $('html,body').animate( {scrollTop: element.offset().top},'slow'); + } + }, + setupDivs: function() { + var self = this; + + var mainDiv = $( '<div>' ).attr( 'id', 'cs-comments' ); + + var headerDiv = $( '<div> ').attr( 'id', 'cs-header'); + mainDiv.append( headerDiv ); + + var footerDiv = $( '<div> ').attr( 'id', 'cs-footer'); + mainDiv.append( footerDiv ); + + if ( this.isLoggedIn ) { + var addButton = $( '<button>' ) + .attr( { + type: 'button', + id: 'cs-add-button' + } ) + .addClass( 'cs-button' ); + var addimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-add' ), + src: this.imagepath + 'comment_add.png' + } ); + addButton.append( addimage ); + + if ( this.newestStreamsOnTop ) { + headerDiv.append( addButton ); + } else { + footerDiv.append( addButton ); + } + + addButton.click( function() { + self.showNewCommentStreamBox(); + } ); + } + + mainDiv.insertAfter( '#catlinks' ); + }, + addInitialComments: function() { + var self = this; + var parentIndex; + for ( parentIndex in this.comments ) { + var parentComment = this.comments[ parentIndex ]; + var commenthtml = this.formatComment( parentComment ); + var location = $( commenthtml ) + .insertBefore( '#cs-footer' ); + var childIndex; + for ( childIndex in parentComment.children ) { + var childComment = parentComment.children[ childIndex ]; + commenthtml = this.formatComment( childComment ); + $( commenthtml ).insertBefore( + $( location ).find( '.cs-stream-footer' ) ); + } + } + + if ( this.initiallyCollapsed ) { + $( '.cs-stream' ).each( function() { + self.collapseStream( $( this ), $( this ) + .find( '.cs-toggle-button' ) ); + } ); + } + }, + collapseStream: function( stream, button ) { + stream.find( '.cs-reply-comment' ).addClass( 'cs-hidden' ); + stream.find( '.cs-head-comment .cs-comment-body' ).addClass( 'cs-hidden' ); + stream.find( '.cs-stream-footer .cs-reply-button' ).addClass( 'cs-hidden' ); + $( stream ).addClass( 'cs-collapsed' ); + $( stream ).removeClass( 'cs-expanded' ); + $( button ).find( 'img' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-expand' ), + src: this.imagepath + 'expand.png' + } ); + }, + expandStream: function( stream, button ) { + stream.find( '.cs-reply-comment' ).removeClass( 'cs-hidden' ); + stream.find( '.cs-head-comment .cs-comment-body' ).removeClass( 'cs-hidden' ); + stream.find( '.cs-stream-footer .cs-reply-button' ).removeClass( 'cs-hidden' ); + $( stream ).addClass( 'cs-expanded' ); + $( stream ).removeClass( 'cs-collapsed' ); + $( button ).find( 'img' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-collapse' ), + src: this.imagepath + 'collapse.png' + } ); + }, + disableAllButtons: function() { + $( '.cs-edit-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-reply-button' ).attr( 'disabled', 'disabled' ); + $( '#cs-add-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-delete-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-toggle-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-link-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-vote-button' ).attr( 'disabled', 'disabled' ); + $( '.cs-watch-button' ).attr( 'disabled', 'disabled' ); + }, + enableAllButtons: function() { + $( '.cs-edit-button' ).attr( 'disabled', false ); + $( '.cs-reply-button' ).attr( 'disabled', false ); + $( '#cs-add-button' ).attr( 'disabled', false ); + $( '.cs-delete-button' ).attr( 'disabled', false ); + $( '.cs-toggle-button' ).attr( 'disabled', false ); + $( '.cs-link-button' ).attr( 'disabled', false ); + $( '.cs-vote-button' ).attr( 'disabled', false ); + $( '.cs-watch-button' ).attr( 'disabled', false ); + }, + formatComment: function( commentData ) { + var self = this; + var comment = this.formatCommentInner( commentData ); + + if ( commentData.parentid === null ) { + comment = $( '<div>' ) + .addClass( 'cs-stream' ) + .addClass( 'cs-expanded' ) + .attr( 'data-created-timestamp', commentData.created_timestamp ) + .append( comment ); + + var streamFooter = $( '<div>' ) + .addClass( 'cs-stream-footer' ); + comment.append( streamFooter ); + + if ( this.isLoggedIn ) { + var replyButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-reply-button' ) + .attr( { + type: 'button', + 'data-stream-id': commentData.pageid + } ); + var replyimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-reply' ), + src: this.imagepath + 'comment_reply.png' + } ); + replyButton.append( replyimage ); + streamFooter.append( replyButton ); + replyButton.click( function() { + var pageId = $( this ).attr( 'data-stream-id' ); + self.showNewReplyBox( $( this ), pageId ); + } ); + } + } + + return comment; + }, + formatCommentInner: function( commentData ) { + var self = this; + var commentHeader = $( '<div>' ) + .addClass( 'cs-comment-header' ); + + var leftDiv = $( '<div>' ) + .addClass( 'cs-comment-header-left' ); + if ( commentData.avatar !== null && commentData.avatar.length > 0 ) { + var avatar = $( '<img>' ) + .addClass( 'cs-avatar' ) + .attr( 'src', commentData.avatar ); + leftDiv.append( avatar ); + } + commentHeader.append( leftDiv ); + + var centerDiv = $( '<div>' ) + .addClass( 'cs-comment-header-center' ); + + if ( commentData.parentid === null ) { + var title = $( '<div>' ) + .addClass( 'cs-comment-title' ) + .text( commentData.commenttitle ); + centerDiv.append( title ); + } + + var author = $( '<span>' ) + .addClass( 'cs-comment-author' ) + .html( commentData.userdisplayname ); + centerDiv.append( author ); + + var created = $( '<span>' ) + .addClass( 'cs-comment-details' ) + .text( mw.message( 'commentstreams-datetext-postedon' ) + + ' ' + commentData.created ); + centerDiv.append( this.createDivider() ); + centerDiv.append( created ); + + if ( commentData.modified !== null ) { + var text = mw.message( 'commentstreams-datetext-lasteditedon' ) + + ' ' + commentData.modified; + if ( commentData.moderated ) { + text += ' (' + mw.message( 'commentstreams-datetext-moderated' ) + + ')'; + } + var modified = $( '<span>' ) + .addClass( 'cs-comment-details' ) + .text( text ); + centerDiv.append( this.createDivider() ); + centerDiv.append( modified ); + } + + var divider = this.createDivider(); + centerDiv.append( divider ); + + if ( this.canEdit( commentData ) ) { + centerDiv.append( this.createEditButton( commentData.username) ); + } + + if ( this.canDelete( commentData ) ) { + centerDiv.append( this.createDeleteButton( commentData.username) ); + } + + centerDiv.append( this.createPermalinkButton( commentData.pageid ) ); + + commentHeader.append( centerDiv ); + + var rightDiv = $( '<div>' ) + .addClass( 'cs-comment-header-right' ); + + if ( commentData.parentid === null && this.enableWatchlist && + !mw.user.isAnon() ) { + rightDiv.append( this.createWatchButton( commentData ) ); + } + + if ( commentData.parentid === null && this.enableVoting ) { + rightDiv.append( this.createVotingButtons( commentData ) ); + } + + if ( commentData.parentid === null ) { + var collapseButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-toggle-button' ) + .attr( 'type', 'button' ); + var collapseimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-collapse' ), + src: this.imagepath + 'collapse.png' + } ); + collapseButton.append( collapseimage ); + rightDiv.append( collapseButton ); + collapseButton.click( function() { + var stream = $( this ).closest( '.cs-stream' ); + if ( stream.hasClass( 'cs-expanded' ) ) { + self.collapseStream( stream, this ); + } else { + self.expandStream( stream, this ); + } + } ); + } + + commentHeader.append( rightDiv ); + + var commentBody = $( '<div>' ) + .addClass( 'cs-comment-body' ) + .html( commentData.html ); + var commentFooter = $( '<div>' ) + .addClass( 'cs-comment-footer' ); + + var commentClass; + if ( commentData.parentid !== null ) { + commentClass = 'cs-reply-comment'; + } else { + commentClass = 'cs-head-comment'; + } + var id = 'cs-comment-' + commentData.pageid; + var comment = $( '<div>' ) + .addClass( 'cs-comment' ) + .addClass( commentClass ) + .attr( { + 'id': id, + 'data-id': commentData.pageid + } ); + if ( this.targetComment === id ) { + comment + .addClass( 'cs-target-comment' ); + } + comment + .append( [ commentHeader, commentBody, commentFooter ] ); + + return comment; + }, + showUrlDialog: function ( id ) { + var instructions = + mw.message( 'commentstreams-urldialog-instructions' ).text(); + var textInput = new OO.ui.TextInputWidget( { + value: this.baseUrl + '#' + id + } ); + function UrlDialog( config ) { + UrlDialog.super.call( this, config ); + } + OO.inheritClass( UrlDialog, OO.ui.Dialog ); + UrlDialog.static.name = 'urlDialog'; + UrlDialog.static.title = 'Simple dialog'; + UrlDialog.prototype.initialize = function () { + UrlDialog.super.prototype.initialize.call( this ); + this.content = + new OO.ui.PanelLayout( { padded: true, expanded: false } ); + this.content.$element.append( '<p>' + instructions + '</p>' ); + this.content.$element.append( textInput.$element ); + this.$body.append( this.content.$element ); + }; + UrlDialog.prototype.getBodyHeight = function () { + return this.content.$element.outerHeight( true ); + }; + var urlDialog = new UrlDialog( { + size: 'medium' + } ); + var windowManager = new OO.ui.WindowManager(); + $( 'body' ).append( windowManager.$element ); + windowManager.addWindows( [ urlDialog ] ); + windowManager.openWindow( urlDialog ); + textInput.select(); + }, + createEditButton: function( username ) { + var self = this; + var editButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-edit-button' ) + .attr( 'type', 'button' ); + var editimage = $( '<img>' ); + if ( mw.user.getName() !== username ) { + editimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-moderator-edit' ), + src: this.imagepath + 'comment_moderator_edit.png' + } ); + editButton + .addClass( 'cs-moderator-button' ) + } else { + editimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-edit' ), + src: this.imagepath + 'comment_edit.png' + } ); + } + editButton.append( editimage ); + editButton.click( function() { + var comment = $( this ).closest( '.cs-comment' ); + var pageId = $( comment ).attr( 'data-id' ); + self.editComment( $( comment ), pageId ); + } ); + return editButton; + }, + createDeleteButton: function( username ) { + var self = this; + var deleteButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-delete-button' ) + .attr( 'type', 'button' ); + var deleteimage = $( '<img>' ); + if ( mw.user.getName() !== username ) { + deleteimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-moderator-delete' ), + src: this.imagepath + 'comment_moderator_delete.png' + } ); + deleteButton + .addClass( 'cs-moderator-button' ) + } else { + deleteimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-delete' ), + src: this.imagepath + 'comment_delete.png' + } ); + } + deleteButton.append( deleteimage ); + deleteButton.click( function() { + var comment = $( this ).closest( '.cs-comment' ); + var pageId = $( comment ).attr( 'data-id' ); + self.deleteComment( $( comment ), pageId ); + } ); + return deleteButton; + }, + createPermalinkButton( pageid ) { + var self = this; + var id = 'cs-comment-' + pageid; + var permalinkButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-link-button' ) + .click( function() { + $( '.cs-target-comment' ) + .removeClass( 'cs-target-comment' ); + self.scrollToAnchor( id ) + var comment = $( this ).closest( '.cs-comment' ); + comment + .addClass( 'cs-target-comment' ); + self.showUrlDialog( id ); + window.location.hash = '#' + id; + } ); + var permalinkimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-permalink' ), + src: this.imagepath + 'link.png' + } ); + permalinkButton.append( permalinkimage ); + return permalinkButton; + }, + createWatchButton( commentData ) { + var self = this; + var watchButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-watch-button' ) + .click( function() { + self.watch( $( this ), commentData.pageid ); + } ); + var watchimage = $( '<img>' ) + .addClass( 'cs-watch-image' ); + if ( commentData.watching ) { + watchimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-unwatch' ), + src: this.imagepath + 'watching.png' + } ) + .addClass( 'cs-watch-watching' ); + } else { + watchimage + .attr( { + title: mw.message( 'commentstreams-buttontooltip-watch' ), + src: this.imagepath + 'notwatching.png' + } ) + } + watchButton.append( watchimage ); + return watchButton; + }, + createVotingButtons( commentData ) { + var self = this; + + var upButton; + if ( mw.user.isAnon() ) { + upButton = $( '<span>' ) + .addClass( 'cs-button' ); + } else { + upButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-vote-button' ) + .click( function() { + self.vote( $( this ), commentData.pageid, true, + commentData.created_timestamp ); + } ); + } + var upimage = $( '<img>' ) + .attr( 'title', mw.message( 'commentstreams-buttontooltip-upvote' ) ) + .addClass( 'cs-vote-upimage' ); + if ( commentData.vote > 0 ) { + upimage.attr( 'src', this.imagepath + 'upvote-enabled.png' ); + upimage.addClass( 'cs-vote-enabled' ); + } else { + upimage.attr( 'src', this.imagepath + 'upvote-disabled.png' ); + } + var upcountspan = $( '<span>' ) + .addClass( 'cs-vote-upcount' ) + .text( commentData.numupvotes ); + upButton.append( upimage ); + upButton.append( upcountspan ); + + var downButton; + if ( mw.user.isAnon() ) { + downButton = $( '<span>' ) + .addClass( 'cs-button' ); + } else { + downButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-vote-button' ) + .click( function() { + self.vote( $( this ), commentData.pageid, false, + commentData.created_timestamp ); + } ); + } + var downimage = $( '<img>' ) + .attr( 'title', mw.message( 'commentstreams-buttontooltip-downvote' ) ) + .addClass( 'cs-vote-downimage' ); + if ( commentData.vote < 0 ) { + downimage.attr( 'src', this.imagepath + 'downvote-enabled.png' ); + downimage.addClass( 'cs-vote-enabled' ); + } else { + downimage.attr( 'src', this.imagepath + 'downvote-disabled.png' ); + } + var downcountspan = $( '<span>' ) + .addClass( 'cs-vote-downcount' ) + .text( commentData.numdownvotes ); + downButton.append( downimage ); + downButton.append( downcountspan ); + + var votingSpan = $( '<span>' ) + .addClass( 'cs-voting-span' ); + votingSpan.append( upButton ); + votingSpan.append( downButton ); + return votingSpan; + }, + vote: function( button, pageid, up, created_timestamp ) { + + var self = this; + var votespan = button.closest( '.cs-voting-span' ); + var upcountspan = votespan.find( '.cs-vote-upcount' ); + var upcount = parseInt(upcountspan.text()); + var upimage = votespan.find( '.cs-vote-upimage' ); + var downcountspan = votespan.find( '.cs-vote-downcount' ); + var downcount = parseInt(downcountspan.text()); + var downimage = votespan.find( '.cs-vote-downimage' ); + + var newvote; + var oldvote; + if ( up ) { + if ( upimage.hasClass( 'cs-vote-enabled' ) ) { + newvote = 0; + oldvote = 1; + } else { + newvote = 1; + if ( downimage.hasClass( 'cs-vote-enabled' ) ) { + oldvote = -1; + } else { + oldvote = 0; + } + } + } else { + if ( downimage.hasClass( 'cs-vote-enabled' ) ) { + newvote = 0; + oldvote = -1; + } else { + newvote = -1; + if ( upimage.hasClass( 'cs-vote-enabled' ) ) { + oldvote = 1; + } else { + oldvote = 0; + } + } + } + + var comment = button.closest( '.cs-comment' ); + this.disableAllButtons(); + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( comment.attr( 'id' ) ) ); + CommentStreamsQuerier.vote( pageid, newvote, function( result ) { + $( '.spinner' ).remove(); + if ( result.error === undefined ) { + if ( up ) { + if ( upimage.hasClass( 'cs-vote-enabled' ) ) { + upimage.attr( 'src', self.imagepath + 'upvote-disabled.png' ); + upimage.removeClass( 'cs-vote-enabled' ); + upcount = upcount - 1; + upcountspan.text( upcount ); + } else { + upimage.attr( 'src', self.imagepath + 'upvote-enabled.png' ); + upimage.addClass( 'cs-vote-enabled' ); + upcount = upcount + 1; + upcountspan.text( upcount ); + if ( downimage.hasClass( 'cs-vote-enabled' ) ) { + downimage.attr( 'src', self.imagepath + 'downvote-disabled.png' ); + downimage.removeClass( 'cs-vote-enabled' ); + downcount = downcount - 1; + downcountspan.text( downcount ); + } + } + } else { + if ( downimage.hasClass( 'cs-vote-enabled' ) ) { + downimage.attr( 'src', self.imagepath + 'downvote-disabled.png' ); + downimage.removeClass( 'cs-vote-enabled' ); + downcount = downcount - 1; + downcountspan.text( downcount ); + } else { + downimage.attr( 'src', self.imagepath + 'downvote-enabled.png' ); + downimage.addClass( 'cs-vote-enabled' ); + downcount = downcount + 1; + downcountspan.text( downcount ); + if ( upimage.hasClass( 'cs-vote-enabled' ) ) { + upimage.attr( 'src', self.imagepath + 'upvote-disabled.png' ); + upimage.removeClass( 'cs-vote-enabled' ); + upcount = upcount - 1; + upcountspan.text( upcount ); + } + } + } + var votediff = upcount - downcount; + var stream = comment.closest( '.cs-stream' ); + self.adjustCommentOrder( stream, votediff, upcount, + created_timestamp ); + } else { + self.reportError( result.error ); + self.enableAllButtons(); + } + } ); + }, + watch: function( button, pageid ) { + var self = this; + var image = button.find( '.cs-watch-image'); + var watchaction = !image.hasClass( 'cs-watch-watching' ); + var comment = button.closest( '.cs-comment' ); + this.disableAllButtons(); + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( comment.attr( 'id' ) ) ); + CommentStreamsQuerier.watch( pageid, watchaction, function( result ) { + $( '.spinner' ).remove(); + if ( result.error === undefined ) { + if ( watchaction ) { + image + .attr( { + title: mw.message( 'commentstreams-buttontooltip-unwatch' ), + src: self.imagepath + 'watching.png' + } ) + .addClass( 'cs-watch-watching' ); + } else { + image + .attr( { + title: mw.message( 'commentstreams-buttontooltip-watch' ), + src: self.imagepath + 'notwatching.png' + } ) + .removeClass( 'cs-watch-watching' ); + } + } else { + self.reportError( result.error ); + } + self.enableAllButtons(); + } ); + }, + adjustCommentOrder: function( stream, votediff, upcount, + created_timestamp ) { + var nextSiblings = stream.nextAll( '.cs-stream' ); + var first = true; + var index; + for ( index = 0; index < nextSiblings.length; index++ ) { + var sibling = nextSiblings[index]; + var nextupcountspan = + $( sibling ).find( '.cs-vote-upcount' ); + var nextupcount = parseInt(nextupcountspan.text()); + var nextdowncountspan = + $( sibling ).find( '.cs-vote-downcount' ); + var nextdowncount = parseInt(nextdowncountspan.text()); + var nextvotediff = nextupcount - nextdowncount; + if ( nextvotediff > votediff ) { + // keeping looking + } else if ( nextvotediff === votediff ) { + if ( nextupcount > upcount ) { + // keeping looking + } else if ( nextupcount === upcount ) { + var nextcreated_timestamp = + $( sibling ).attr( 'data-created-timestamp' ); + if ( this.newestStreamsOnTop ) { + if ( nextcreated_timestamp > created_timestamp ) { + // keeping looking + } else if ( first ) { + // check previous siblings + break; + } else { + this.moveComment( stream, true, $( sibling ) ); + return; + } + } else if ( nextcreated_timestamp < created_timestamp ) { + // keep looking + } else if ( first ) { + // check previous siblings + break; + } else { + this.moveComment( stream, true, $( sibling ) ); + return; + } + } else if ( first ) { + // check previous siblings + break; + } else { + this.moveComment( stream, true, $( sibling ) ); + return; + } + } else if ( first ) { + // check previous siblings + break; + } else { + this.moveComment( stream, true, $( sibling ) ); + return; + } + first = false; + } + if ( !first ) { + this.moveComment( stream, false, + $( nextSiblings[nextSiblings.length - 1] ) ); + return; + } + var prevSiblings = stream.prevAll( '.cs-stream' ); + first = true; + for ( index = 0; index < prevSiblings.length; index++ ) { + var sibling = prevSiblings[index]; + var prevupcountspan = + $( sibling ).find( '.cs-vote-upcount' ); + var prevupcount = parseInt(prevupcountspan.text()); + var prevdowncountspan = + $( sibling ).find( '.cs-vote-downcount' ); + var prevdowncount = parseInt(prevdowncountspan.text()); + var prevvotediff = prevupcount - prevdowncount; + if ( prevvotediff < votediff ) { + // keeping looking + } else if ( prevvotediff === votediff ) { + if ( prevupcount < upcount ) { + // keeping looking + } else if ( prevupcount === upcount ) { + var prevcreated_timestamp = + $( sibling ).attr( 'data-created-timestamp' ); + if ( this.newestStreamsOnTop ) { + if ( prevcreated_timestamp < created_timestamp ) { + // keeping looking + } else if ( first ) { + // done + break; + } else { + this.moveComment( stream, false, $( sibling ) ); + return; + } + } else if ( prevcreated_timestamp > created_timestamp ) { + // keeping looking + } else if ( first ) { + // done + break; + } else { + this.moveComment( stream, false, $( sibling ) ); + return; + } + } else if ( first ) { + // done + break; + } else { + this.moveComment( stream, false, $( sibling ) ); + return; + } + } else if ( first ) { + // done + break; + } else { + this.moveComment( stream, false, $( sibling ) ); + return; + } + first = false; + } + if ( !first ) { + this.moveComment( stream, true, + $( prevSiblings[prevSiblings.length - 1] ) ); + return; + } + // otherwise, the comment was in the correct place already + this.enableAllButtons(); + }, + moveComment: function( stream, before, location ) { + var self = this; + stream.slideUp( 1000, function() { + stream.detach(); + stream.hide(); + if ( before ) { + stream.insertBefore( location ); + } else { + stream.insertAfter( location ); + } + stream.slideDown( 1000, function() { + self.enableAllButtons(); + var id = $ (this ).find( '.cs-head-comment:first' ).attr( 'id' ); + self.scrollToAnchor( id ); + } ); + } ); + }, + createDivider: function() { + return $( '<span>' ) + .addClass( 'cs-comment-details' ) + .text('|'); + }, + formatEditBox: function( is_stream ) { + var commentBox = $( '<div>' ) + .addClass( 'cs-edit-box' ) + .attr( 'id', 'cs-edit-box' ); + + if ( is_stream ) { + var titleField = $( '<input>' ) + .attr( { + 'id': 'cs-title-edit-field', + 'type': 'text', + 'placeholder': mw.message( 'commentstreams-title-field-placeholder' ) + } ); + commentBox.append( titleField ); + } else { + commentBox.addClass( 'cs-reply-edit-box' ); + } + + var bodyField = $( '<textarea>' ) + .attr( { + 'id': 'cs-body-edit-field', + 'rows': 10, + 'placeholder': mw.message( 'commentstreams-body-field-placeholder' ) + } ); + commentBox.append( bodyField ); + + var submitButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-submit-button' ) + .attr( { + 'id': 'cs-submit-button', + 'type': 'button' + } ); + var submitimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-submit' ), + src: this.imagepath + 'submit.png' + } ); + submitButton.append( submitimage ); + + commentBox.append( submitButton ); + + var cancelButton = $( '<button>' ) + .addClass( 'cs-button' ) + .addClass( 'cs-cancel-button' ) + .attr( { + 'id': 'cs-cancel-button', + 'type': 'button' + } ); + var cancelimage = $( '<img>' ) + .attr( { + title: mw.message( 'commentstreams-buttontooltip-cancel' ), + src: this.imagepath + 'cancel.png' + } ); + cancelButton.append( cancelimage ); + + commentBox.append( cancelButton ); + + return commentBox; + }, + showNewCommentStreamBox: function() { + var self = this; + var editBox = this.formatEditBox( true ); + if ( this.newestStreamsOnTop ) { + $( '#cs-header' ).append( editBox ); + $( '#cs-edit-box' ) + .hide() + .slideDown(); + } else { + $( '#cs-footer' ).prepend( editBox ); + $( '#cs-edit-box' ) + .hide() + .slideDown(); + } + $( '#cs-submit-button' ).click( function() { + self.postComment( null ); + } ); + $( '#cs-cancel-button' ).click( function() { + self.hideEditBox( true ); + } ); + this.disableAllButtons(); + var titleField = $( '#cs-title-edit-field' ); + if ( titleField !== null ) { + titleField.focus(); + } + }, + showNewReplyBox: function( element, topCommentId ) { + var self = this; + var editBox = this.formatEditBox( false ); + $( editBox ) + .insertBefore( element.closest( '.cs-stream-footer' ) ) + .hide() + .slideDown(); + + $( '#cs-submit-button' ).click( function() { + self.postComment( topCommentId ); + } ); + $( '#cs-cancel-button' ).click( function() { + self.hideEditBox( true ); + } ); + this.disableAllButtons(); + var editField = $( '#cs-body-edit-field' ); + if ( editField !== null ) { + editField.focus(); + } + }, + hideEditBox: function( animated ) { + var self = this; + if ( animated ) { + $( '#cs-edit-box' ).slideUp( 'normal', function() { + $( '#cs-edit-box' ).remove(); + } ); + } else { + $( '#cs-edit-box' ).remove(); + } + this.enableAllButtons(); + }, + postComment: function( parentPageId ) { + var self = this; + + var commentTitle; + if ( parentPageId === null ) { + var titleField = $( '#cs-title-edit-field' ); + if ( titleField !== null ) { + commentTitle = titleField .val(); + if ( commentTitle === null || commentTitle.trim() === "" ) { + this.reportError( 'commentstreams-validation-error-nocommenttitle' ); + return; + } + } + } else { + commentTitle = null; + } + + var commentText = $( '#cs-body-edit-field' ).val(); + if ( commentText === null || commentText.trim() === "" ) { + this.reportError( 'commentstreams-validation-error-nocommenttext' ); + return; + } + + $( '#cs-submit-button' ).attr( 'disabled', 'disabled' ); + $( '#cs-cancel-button' ).attr( 'disabled', 'disabled' ); + + $( '#cs-edit-box' ).fadeTo( 100, 0.2, function() { + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( 'cs-edit-box' ) ); + + var associatedPageId = mw.config.get( 'wgArticleId' ); + CommentStreamsQuerier.postComment( commentTitle, commentText, + associatedPageId, parentPageId, function( result ) { + $( '.spinner' ).remove(); + if ( result.error === undefined ) { + var comment = self.formatComment( result ); + if ( parentPageId ) { + if ( !self.moderatorFastDelete ) { + var deleteSpan = $( '#cs-edit-box' ) + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .find( '.cs-comment-header' ) + .find( '.cs-delete-button' ); + deleteSpan.remove(); + } + var location = $( '#cs-edit-box' ) + .closest( '.cs-stream' ) + .find( '.cs-stream-footer' ); + self.hideEditBox( false ); + comment.insertBefore( $( location ) ) + .hide() + .slideDown(); + } else { + self.hideEditBox( false ); + if ( self.newestStreamsOnTop ) { + comment.insertAfter( '#cs-header' ) + .hide() + .slideDown(); + } else { + comment.insertBefore( '#cs-footer' ) + .hide() + .slideDown(); + } + self.adjustCommentOrder( comment, 0, 0, + result.created_timestamp ); + } + } else { + self.reportError( result.error ); + $( '#cs-edit-box').fadeTo( 0.2, 100, function() { + $( '#cs-submit-button' ).attr( 'disabled', false ); + $( '#cs-cancel-button' ).attr( 'disabled', false ); + } ); + } + } ); + } ); + }, + deleteComment: function( element, pageId ) { + var self = this; + var message_text = + mw.message( 'commentstreams-dialog-delete-message' ).text(); + var yes_text = + mw.message( 'commentstreams-dialog-buttontext-yes' ).text(); + var no_text = + mw.message( 'commentstreams-dialog-buttontext-no' ).text(); + var dialog = new OO.ui.MessageDialog(); + var window_manager = new OO.ui.WindowManager(); + $( '#cs-comments' ).append( window_manager.$element ); + window_manager.addWindows( [ dialog ] ); + window_manager.openWindow( dialog, { + message: message_text, + actions: [ + { label: yes_text, action: 'yes' }, + { label: no_text, flags: 'primary' } + ] + } ).then( function ( opened ) { + opened.then( function ( closing, data ) { + if ( data && data.action ) { + if ( data.action === 'yes' ) { + self.realDeleteComment( element, pageId ); + } + } + } ); + } ); + }, + realDeleteComment: function( element, pageId ) { + var self = this; + this.disableAllButtons(); + element.fadeTo( 100, 0.2, function() { + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( element.attr( 'id' ) ) ); + CommentStreamsQuerier.deleteComment( pageId, function( result ) { + $( '.spinner' ).remove(); + if ( result.error === undefined || + result.error === 'commentstreams-api-error-commentnotfound' ) { + if ( element.hasClass( 'cs-head-comment' ) ) { + element.closest( '.cs-stream' ) + .slideUp( 'normal', function() { + element.closest( '.cs-stream' ).remove(); + self.enableAllButtons(); + } ); + } else { + var parentId = element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .attr( 'data-id' ); + CommentStreamsQuerier.queryComment( parentId, function( result ) { + if ( result.error === undefined && self.canDelete( result ) && + !self.moderatorFastDelete ) { + self.createDeleteButton( result.username ) + .insertAfter ( element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .find( '.cs-comment-header' ) + .find( '.cs-edit-button' ) ); + } + element.slideUp( 'normal', function() { + element.remove(); + self.enableAllButtons(); + } ); + } ); + } + } else { + self.reportError( result.error ); + element.fadeTo( 0.2, 100, function() { + self.enableAllButtons(); + } ); + } + } ); + } ); + }, + editComment: function( element, pageId ) { + var self = this; + this.disableAllButtons(); + element.fadeTo( 100, 0.2, function() { + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( element.attr( 'id' ) ) ); + CommentStreamsQuerier.queryComment( pageId, function( result ) { + $( '.spinner' ).remove(); + + if ( result.error === undefined ) { + var is_stream = element.hasClass( 'cs-head-comment' ); + var commentBox = self.formatEditBox( is_stream ); + commentBox.insertAfter( element ); + element.hide(); + commentBox.slideDown(); + + var editField = $( '#cs-body-edit-field' ); + editField.val( result.wikitext ); + if ( is_stream ) { + var titleField = $( '#cs-title-edit-field' ); + titleField.val( result.commenttitle ); + titleField.focus(); + } else { + editField.focus(); + } + + $( '#cs-cancel-button' ).click( function() { + commentBox.slideUp( 'normal', function() { + element.fadeTo( 0.2, 100, function() { + commentBox.remove(); + self.enableAllButtons(); + } ); + } ); + } ); + + $( '#cs-submit-button' ).click( function() { + if ( element.hasClass( 'cs-head-comment' ) ) { + var commentTitle = $( '#cs-title-edit-field' ).val(); + if ( commentTitle === null || commentTitle.trim() === "" ) { + self.reportError( + 'commentstreams-validation-error-nocommenttitle' ); + return; + } + } + + var commentText = $( '#cs-body-edit-field' ).val(); + if ( commentText === null || commentText.trim() === "" ) { + self.reportError( + 'commentstreams-validation-error-nocommenttext' ); + return; + } + + $( '#cs-submit-button' ).attr( 'disabled', 'disabled' ); + $( '#cs-cancel-button' ).attr( 'disabled', 'disabled' ); + + commentBox.fadeTo( 100, 0.2, function() { + new Spinner( self.spinnerOptions ) + .spin( document.getElementById( 'cs-edit-box' ) ); + + CommentStreamsQuerier.editComment( commentTitle, commentText, + pageId, function( result ) { + $( '.spinner' ).remove(); + if ( result.error === undefined ) { + var comment = self.formatCommentInner( result ); + if ( element.closest( '.cs-stream' ).hasClass( 'cs-collapsed' ) ) { + comment.find( '.cs-comment-body' ).addClass( 'cs-hidden' ); + } + commentBox.slideUp( 'normal', function() { + comment.insertAfter( commentBox ); + commentBox.remove(); + element.remove(); + self.enableAllButtons(); + } ); + } else if ( result.error === 'commentstreams-api-error-commentnotfound' ) { + self.reportError( result.error ); + var parentId = element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .attr( 'data-id' ); + CommentStreamsQuerier.queryComment( parentId, function( result ) { + if ( result.error === undefined && + self.canDelete( result ) && + !self.moderatorFastDelete ) { + self.createDeleteButton( result.username ) + .insertAfter ( element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .find( '.cs-comment-header' ) + .find( '.cs-edit-button' ) ); + } + commentBox.slideUp( 'normal', function() { + commentBox.remove(); + element.remove(); + self.enableAllButtons(); + } ); + } ); + } else { + self.reportError( result.error ); + commentBox.fadeTo( 0.2, 100, function() { + $( '#cs-submit-button' ).attr( 'disabled', false ); + $( '#cs-cancel-button' ).attr( 'disabled', false ); + } ); + } + } ); + } ); + } ); + } else if ( result.error === 'commentstreams-api-error-commentnotfound' ) { + self.reportError( result.error ); + var parentId = element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .attr( 'data-id' ); + CommentStreamsQuerier.queryComment( parentId, function( result ) { + if ( result.error === undefined && + self.canDelete( result ) && + !self.moderatorFastDelete ) { + self.createDeleteButton( result.username ) + .insertAfter ( element + .closest( '.cs-stream' ) + .find( '.cs-head-comment' ) + .find( '.cs-comment-header' ) + .find( '.cs-edit-button' ) ); + } + element.remove(); + self.enableAllButtons(); + } ); + } else { + self.reportError( result.error ); + element.fadeTo( 0.2, 100, function() { + self.enableAllButtons(); + } ); + } + } ); + } ); + }, + canEdit: function( comment ) { + var username = comment.username; + if ( !mw.user.isAnon() && ( mw.user.getName() === username || + this.moderatorEdit ) ) { + return true; + } + return false; + }, + canDelete: function( comment ) { + var username = comment.username; + if ( !mw.user.isAnon() && + ( mw.user.getName() === username || this.moderatorDelete ) && + ( comment.numreplies === 0 || this.moderatorFastDelete ) ) { + return true; + } + return false; + }, + reportError: function( message ) { + var message_text = mw.message( message ).text(); + var ok_text = mw.message( 'commentstreams-dialog-buttontext-ok' ).text(); + var dialog = new OO.ui.MessageDialog(); + var window_manager = new OO.ui.WindowManager(); + $( '#cs-comments' ).append( window_manager.$element ); + window_manager.addWindows( [ dialog ] ); + window_manager.openWindow( dialog, { + message: message_text, + actions: [ { + action: 'accept', + label: ok_text, + flags: 'primary' + } ] + } ); + } + }; +}( mediaWiki, jQuery ) ); + +window.CommentStreamsController = commentstreams_controller; + +( function( mw, $ ) { + $( document ) + .ready( function() { + if ( mw.config.exists( 'CommentStreams' ) ) { + window.CommentStreamsController.initialize(); + } + } ); +}( mediaWiki, jQuery ) ); diff --git a/CommentStreams/resources/CommentStreamsAllComments.css b/CommentStreams/resources/CommentStreamsAllComments.css new file mode 100644 index 00000000..e39ea4bc --- /dev/null +++ b/CommentStreams/resources/CommentStreamsAllComments.css @@ -0,0 +1,30 @@ +.csall-message { + background-color: #ddd; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 10px; + padding-right: 10px; +} + +.csall-wikitable { + width: 100%; +} + +.csall-wikitable th { + text-align: center; +} + +.csall-navigationtable { + width: 100%; +} + +.csall-button { + font-size: 1em; + padding:5px 15px; + margin:5px 5px; + background:#ccc; + border:0 none; + cursor:pointer; + -webkit-border-radius: 5px; + border-radius: 5px; +} diff --git a/CommentStreams/resources/CommentStreamsQuerier.js b/CommentStreams/resources/CommentStreamsQuerier.js new file mode 100644 index 00000000..b124656a --- /dev/null +++ b/CommentStreams/resources/CommentStreamsQuerier.js @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2016 The MITRE Corporation + * + * 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. + */ + +var commentstreams_querier = ( function( mw ) { + return { + queryComment: function( pageid, reply ) { + var self = this; + var api = new mw.Api(); + api.get( { + action: 'csQueryComment', + pageid: pageid + } ) + .done( function( data ) { + reply( data.csQueryComment ); + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + deleteComment: function( pageid, reply ) { + var self = this; + var api = new mw.Api(); + api.post( { + action: 'csDeleteComment', + pageid: pageid, + token: mw.user.tokens.get( 'editToken' ) + } ) + .done( function( data ) { + reply( data ); + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + postComment: function( commenttitle, wikitext, associatedid, parentid, + reply ) { + var self = this; + var api = new mw.Api(); + var data = { + action: 'csPostComment', + wikitext: wikitext, + associatedid: associatedid, + token: mw.user.tokens.get( 'editToken' ) + }; + if ( commenttitle !== null ) { + data.commenttitle = commenttitle; + } + if ( parentid !== null ) { + data.parentid = parentid; + } + api.post( + data + ) + .done( function( data ) { + reply( data.csPostComment ); + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + editComment: function( commenttitle, wikitext, pageid, reply ) { + var self = this; + var api = new mw.Api(); + api.post( { + action: 'csEditComment', + pageid: pageid, + commenttitle: commenttitle, + wikitext: wikitext, + token: mw.user.tokens.get( 'editToken' ) + } ) + .done( function( data ) { + reply( data.csEditComment ); + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + vote: function( pageid, vote, reply ) { + var self = this; + var api = new mw.Api(); + api.post( { + action: 'csVote', + pageid: pageid, + vote: vote, + token: mw.user.tokens.get( 'editToken' ) + } ) + .done( function( data ) { + reply( data.csVote ); + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + watch: function( pageid, action, reply ) { + var self = this; + var api = new mw.Api(); + api.post( { + action: action ? 'csWatch' : 'csUnwatch', + pageid: pageid, + token: mw.user.tokens.get( 'editToken' ) + } ) + .done( function( data ) { + if ( action ) { + reply( data.csWatch ); + } else { + reply( data.csUnwatch ); + } + } ) + .fail( function( data ) { + self.reportError( data, reply ); + } ); + }, + reportError: function( data, reply ) { + if ( data === 'nosuchpageid' ) { + reply( { + 'error': 'commentstreams-api-error-commentnotfound' + } ); + } else if ( data === 'badtoken' ) { + reply( { + 'error': 'commentstreams-api-error-notloggedin' + } ); + } else { + reply( { + 'error': data + } ); + } + } + }; +}( mediaWiki ) ); + +window.CommentStreamsQuerier = commentstreams_querier; diff --git a/CommentStreams/resources/spin.min.js b/CommentStreams/resources/spin.min.js new file mode 100644 index 00000000..ebfbb1a2 --- /dev/null +++ b/CommentStreams/resources/spin.min.js @@ -0,0 +1,2 @@ +//fgnass.github.com/spin.js#v2.0.1 +!function(a,b){"object"==typeof exports?module.exports=b():"function"==typeof define&&define.amd?define(b):a.Spinner=b()}(this,function(){"use strict";function a(a,b){var c,d=document.createElement(a||"div");for(c in b)d[c]=b[c];return d}function b(a){for(var b=1,c=arguments.length;c>b;b++)a.appendChild(arguments[b]);return a}function c(a,b,c,d){var e=["opacity",b,~~(100*a),c,d].join("-"),f=.01+c/d*100,g=Math.max(1-(1-a)/b*(100-f),a),h=j.substring(0,j.indexOf("Animation")).toLowerCase(),i=h&&"-"+h+"-"||"";return l[e]||(m.insertRule("@"+i+"keyframes "+e+"{0%{opacity:"+g+"}"+f+"%{opacity:"+a+"}"+(f+.01)+"%{opacity:1}"+(f+b)%100+"%{opacity:"+a+"}100%{opacity:"+g+"}}",m.cssRules.length),l[e]=1),e}function d(a,b){var c,d,e=a.style;for(b=b.charAt(0).toUpperCase()+b.slice(1),d=0;d<k.length;d++)if(c=k[d]+b,void 0!==e[c])return c;return void 0!==e[b]?b:void 0}function e(a,b){for(var c in b)a.style[d(a,c)||c]=b[c];return a}function f(a){for(var b=1;b<arguments.length;b++){var c=arguments[b];for(var d in c)void 0===a[d]&&(a[d]=c[d])}return a}function g(a,b){return"string"==typeof a?a:a[b%a.length]}function h(a){this.opts=f(a||{},h.defaults,n)}function i(){function c(b,c){return a("<"+b+' xmlns="urn:schemas-microsoft.com:vml" class="spin-vml">',c)}m.addRule(".spin-vml","behavior:url(#default#VML)"),h.prototype.lines=function(a,d){function f(){return e(c("group",{coordsize:k+" "+k,coordorigin:-j+" "+-j}),{width:k,height:k})}function h(a,h,i){b(m,b(e(f(),{rotation:360/d.lines*a+"deg",left:~~h}),b(e(c("roundrect",{arcsize:d.corners}),{width:j,height:d.width,left:d.radius,top:-d.width>>1,filter:i}),c("fill",{color:g(d.color,a),opacity:d.opacity}),c("stroke",{opacity:0}))))}var i,j=d.length+d.width,k=2*j,l=2*-(d.width+d.length)+"px",m=e(f(),{position:"absolute",top:l,left:l});if(d.shadow)for(i=1;i<=d.lines;i++)h(i,-2,"progid:DXImageTransform.Microsoft.Blur(pixelradius=2,makeshadow=1,shadowopacity=.3)");for(i=1;i<=d.lines;i++)h(i);return b(a,m)},h.prototype.opacity=function(a,b,c,d){var e=a.firstChild;d=d.shadow&&d.lines||0,e&&b+d<e.childNodes.length&&(e=e.childNodes[b+d],e=e&&e.firstChild,e=e&&e.firstChild,e&&(e.opacity=c))}}var j,k=["webkit","Moz","ms","O"],l={},m=function(){var c=a("style",{type:"text/css"});return b(document.getElementsByTagName("head")[0],c),c.sheet||c.styleSheet}(),n={lines:12,length:7,width:5,radius:10,rotate:0,corners:1,color:"#000",direction:1,speed:1,trail:100,opacity:.25,fps:20,zIndex:2e9,className:"spinner",top:"50%",left:"50%",position:"absolute"};h.defaults={},f(h.prototype,{spin:function(b){this.stop();{var c=this,d=c.opts,f=c.el=e(a(0,{className:d.className}),{position:d.position,width:0,zIndex:d.zIndex});d.radius+d.length+d.width}if(e(f,{left:d.left,top:d.top}),b&&b.insertBefore(f,b.firstChild||null),f.setAttribute("role","progressbar"),c.lines(f,c.opts),!j){var g,h=0,i=(d.lines-1)*(1-d.direction)/2,k=d.fps,l=k/d.speed,m=(1-d.opacity)/(l*d.trail/100),n=l/d.lines;!function o(){h++;for(var a=0;a<d.lines;a++)g=Math.max(1-(h+(d.lines-a)*n)%l*m,d.opacity),c.opacity(f,a*d.direction+i,g,d);c.timeout=c.el&&setTimeout(o,~~(1e3/k))}()}return c},stop:function(){var a=this.el;return a&&(clearTimeout(this.timeout),a.parentNode&&a.parentNode.removeChild(a),this.el=void 0),this},lines:function(d,f){function h(b,c){return e(a(),{position:"absolute",width:f.length+f.width+"px",height:f.width+"px",background:b,boxShadow:c,transformOrigin:"left",transform:"rotate("+~~(360/f.lines*k+f.rotate)+"deg) translate("+f.radius+"px,0)",borderRadius:(f.corners*f.width>>1)+"px"})}for(var i,k=0,l=(f.lines-1)*(1-f.direction)/2;k<f.lines;k++)i=e(a(),{position:"absolute",top:1+~(f.width/2)+"px",transform:f.hwaccel?"translate3d(0,0,0)":"",opacity:f.opacity,animation:j&&c(f.opacity,f.trail,l+k*f.direction,f.lines)+" "+1/f.speed+"s linear infinite"}),f.shadow&&b(i,e(h("#000","0 0 4px #000"),{top:"2px"})),b(d,b(i,h(g(f.color,k),"0 0 1px rgba(0,0,0,.1)")));return d},opacity:function(a,b,c){b<a.childNodes.length&&(a.childNodes[b].style.opacity=c)}});var o=e(a("group"),{behavior:"url(#default#VML)"});return!d(o,"transform")&&o.adj?i():j=d(o,"animation"),h});
\ No newline at end of file diff --git a/CommentStreams/sql/commentData.sql b/CommentStreams/sql/commentData.sql new file mode 100644 index 00000000..00b8fead --- /dev/null +++ b/CommentStreams/sql/commentData.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS cs_comment_data +( +page_id int(10) unsigned, +assoc_page_id int(10) unsigned, +parent_page_id int(10) unsigned, +comment_title varbinary(255), +PRIMARY KEY (page_id), +FOREIGN KEY (assoc_page_id) REFERENCES page(page_id) +);
\ No newline at end of file diff --git a/CommentStreams/sql/votes.sql b/CommentStreams/sql/votes.sql new file mode 100644 index 00000000..4e232eb2 --- /dev/null +++ b/CommentStreams/sql/votes.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS cs_votes +( +page_id int(10) unsigned NOT NULL, +user_id int(10) unsigned NOT NULL, +vote tinyint NOT NULL, +INDEX (page_id, user_id) +); diff --git a/CommentStreams/sql/watch.sql b/CommentStreams/sql/watch.sql new file mode 100644 index 00000000..4e3dbf61 --- /dev/null +++ b/CommentStreams/sql/watch.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS cs_watchlist +( +page_id int(10) unsigned NOT NULL, +user_id int(10) unsigned NOT NULL, +INDEX (page_id, user_id) +); diff --git a/CommentStreams/version b/CommentStreams/version new file mode 100644 index 00000000..7342fdcf --- /dev/null +++ b/CommentStreams/version @@ -0,0 +1,4 @@ +CommentStreams: REL1_30 +2017-09-21T22:04:40 + +08aec40 |