diff --git a/lms/djangoapps/django_comment_client/helpers.py b/lms/djangoapps/django_comment_client/helpers.py index a168f53021..b3eff7026d 100644 --- a/lms/djangoapps/django_comment_client/helpers.py +++ b/lms/djangoapps/django_comment_client/helpers.py @@ -1,11 +1,15 @@ from django.core.urlresolvers import reverse +from django.template.defaultfilters import escapejs +from django.conf import settings from mitxmako.shortcuts import render_to_string -from utils import * from mustache_helpers import mustache_helpers from functools import partial +from utils import * + import pystache_custom as pystache import urllib +import os def pluralize(singular_term, count): if int(count) >= 2: @@ -18,6 +22,19 @@ def show_if(text, condition): else: return '' +# TODO there should be a better way to handle this +def include_mustache_templates(): + mustache_dir = settings.PROJECT_ROOT / 'templates' / 'discussion' + valid_file_name = lambda file_name: file_name.endswith('.mustache') + read_file = lambda file_name: (file_name, open(mustache_dir / file_name, "r").read()) + strip_file_name = lambda x: (x[0].rpartition('.')[0], x[1]) + wrap_in_tag = lambda x: "".format(x[0], escapejs(x[1])) + + file_contents = map(read_file, filter(valid_file_name, os.listdir(mustache_dir))) + return '\n'.join(map(wrap_in_tag, map(strip_file_name, file_contents))) + + + def render_content(content, additional_context={}): content_info = { 'displayed_title': content.get('highlighted_title') or content.get('title', ''), diff --git a/lms/static/coffee/src/backbone_discussion/content.coffee b/lms/static/coffee/src/backbone_discussion/content.coffee index afd7bd6ff0..6d67846346 100644 --- a/lms/static/coffee/src/backbone_discussion/content.coffee +++ b/lms/static/coffee/src/backbone_discussion/content.coffee @@ -1,7 +1,128 @@ -$ -> - class Content extends Backbone.Model +class @Content extends Backbone.Model - class Thread extends Content + template: -> DiscussionUtil.getTemplate('_content') - window.Content = Content - window.Thread = Thread + actions: + editable: '.admin-edit' + can_reply: '.discussion-reply' + can_endorse: '.admin-endorse' + can_delete: '.admin-delete' + can_openclose: '.admin-openclose' + + isUpvoted: -> + DiscussionUtil.isUpvoted @id + + isDownvoted: -> + DiscussionUtil.isDownvoted @id + + can: (action) -> + DiscussionUtil.getContentInfo @id, action + +class @ContentView extends Backbone.View + + $: (selector) -> + @$local.find(selector) + + showSingleThread: (event) -> + + unvote: (event) -> + url = DiscussionUtil.urlFor("undo_vote_for_#{@model.get('type')}", @model.id) + DiscussionUtil.safeAjax + $elem: @$(".discussion-vote") + url: url + type: "POST" + dataType: "json" + success: (response, textStatus) => + if textStatus == "success" + @$(".discussion-vote").removeClass("voted") + @$(".discussion-votes-point").html response.votes.point + + vote: (event) -> + $elem = $(event.target) + if $elem.hasClass("voted") + @unvote(event) + else + value = $elem.attr("value") + url = Discussion.urlFor("#{value}vote_#{@model.get('type')}", @model.id) + DiscussionUtil.safeAjax + $elem: @$(".discussion-vote") + url: url + type: "POST" + dataType: "json" + success: (response, textStatus) => + if textStatus == "success" + @$(".discussion-vote").removeClass("voted") + @$(".discussion-vote-#{value}").addClass("voted") + @$(".discussion-votes-point").html response.votes.point + + hideSingleThread: -> + + reply: -> + + cancelReply: -> + + endorse: -> + + close: -> + + edit: -> + + delete: -> + + events: + "click .thread-title": "showSingleThread" + "click .discussion-show-comments": "showSingleThread" + "click .discussion-hide-comments": "hideSingleThread" + "click .discussion-reply-thread": "reply" + "click .discussion-reply-comment": "reply" + "click .discussion-cancel-reply": "cancelReply" + "click .discussion-vote-up": "vote" + "click .discussion-vote-down": "vote" + "click .admin-endorse": "endorse" + "click .admin-openclose": "close" + "click .admin-edit": "edit" + "click .admin-delete": "delete" + + initLocal: -> + @$local = @$el.children(".local") + + initFollowThread: -> + $el.children(".discussion-content") + .find(".follow-wrapper") + .append(DiscussionUtil.subscriptionLink('thread', id)) + + initVote: -> + if @model.isUpvoted() + @$(".discussion-vote-up").addClass("voted") + else if @model.isDownvoted() + @$(".discussion-vote-down").addClass("voted") + + initBody: -> + $contentBody = @$(".content-body") + $contentBody.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight $contentBody.html() + MathJax.Hub.Queue ["Typeset", MathJax.Hub, $contentBody.attr("id")] + + initActions: -> + for action, elemSelector of @model.actions + if not @model.can action + @$(elemSelector).remove() + + initTimeago: -> + @$("span.timeago").timeago() + + initialize: -> + @model.view = @ + @initLocal() + @initVote() + @initTimeago() + @initBody() + @initActions() + +class @Thread extends @Content + +class @ThreadView extends @ContentView + +class @Comment extends @Content + +class @Comments extends Backbone.Collection + model: Comment diff --git a/lms/static/coffee/src/backbone_discussion/discussion.coffee b/lms/static/coffee/src/backbone_discussion/discussion.coffee index d67da04e0e..39ce545dae 100644 --- a/lms/static/coffee/src/backbone_discussion/discussion.coffee +++ b/lms/static/coffee/src/backbone_discussion/discussion.coffee @@ -1,31 +1,36 @@ -$ -> - - class Discussion extends Backbone.Collection - model: Thread - initialize: -> - this.bind "add", (item) => - item.collection = this +class @Discussion extends Backbone.Collection + model: Thread + initialize: -> + this.bind "add", (item) => + item.collection = this - class DiscussionModuleView extends Backbone.View + find: (id) -> + _.first @where(id: id) - class DiscussionView extends Backbone.View +class @DiscussionModuleView extends Backbone.View - $: (selector) -> - @$local.find(selector) +class @DiscussionView extends Backbone.View - initialize: -> - @$local = @$el.children(".local") + $: (selector) -> + @$local.find(selector) - events: - "submit .search-wrapper>.discussion-search-form": "search" - "click .discussion-search-link": "search" - "click .discussion-sort-link": "sort" - "click .discussion-paginator>.discussion-page-link": "page" - - $(".discussion-module").each (index, elem) -> - view = new DiscussionModuleView(el: elem) + initLocal: -> + @$local = @$el.children(".local") - $("section.discussion").each (index, elem) -> - discussionData = DiscussionUtil.getDiscussionData(elem) - discussion = new Discussion(discussionData) - view = new DiscussionView(el: elem, model: discussion) + initialize: -> + @initLocal() + @model.view = @ + @$el.children(".threads").children(".thread").each (index, elem) => + threadView = new ThreadView el: elem, model: @model.find $(elem).attr("_id") + + search: -> + + sort: -> + + page: -> + + events: + "submit .search-wrapper>.discussion-search-form": "search" + "click .discussion-search-link": "search" + "click .discussion-sort-link": "sort" + "click .discussion-paginator>.discussion-page-link": "page" diff --git a/lms/static/coffee/src/backbone_discussion/main.coffee b/lms/static/coffee/src/backbone_discussion/main.coffee index e69de29bb2..fe214e80a3 100644 --- a/lms/static/coffee/src/backbone_discussion/main.coffee +++ b/lms/static/coffee/src/backbone_discussion/main.coffee @@ -0,0 +1,9 @@ +$ -> + + $(".discussion-module").each (index, elem) -> + view = new DiscussionModuleView(el: elem) + + $("section.discussion").each (index, elem) -> + discussionData = DiscussionUtil.getDiscussionData(elem) + discussion = new Discussion(discussionData) + view = new DiscussionView(el: elem, model: discussion) diff --git a/lms/static/coffee/src/backbone_discussion/utils.coffee b/lms/static/coffee/src/backbone_discussion/utils.coffee index 09bac7a335..9820a63272 100644 --- a/lms/static/coffee/src/backbone_discussion/utils.coffee +++ b/lms/static/coffee/src/backbone_discussion/utils.coffee @@ -1,7 +1,262 @@ class @DiscussionUtil + + @wmdEditors: {} + + @getTemplate: (id) -> + $("script##{id}").html() + @getDiscussionData: (id) -> if id instanceof $ id = id.attr("_id") else if typeof id == "object" id = $(id).attr("_id") return $$discussion_data[id] + + @getContentInfo: (id, attr) -> + if not window.$$annotated_content_info? + window.$$annotated_content_info = {} + (window.$$annotated_content_info[id] || {})[attr] + + @setContentInfo: (id, attr, value) -> + if not window.$$annotated_content_info? + window.$$annotated_content_info = {} + window.$$annotated_content_info[id] ||= {} + window.$$annotated_content_info[id][attr] = value + + @extendContentInfo: (id, newInfo) -> + if not window.$$annotated_content_info? + window.$$annotated_content_info = {} + window.$$annotated_content_info[id] = newInfo + + @bulkExtendContentInfo: (newInfos) -> + if not window.$$annotated_content_info? + window.$$annotated_content_info = {} + window.$$annotated_content_info = $.extend window.$$annotated_content_info, newInfos + + @generateDiscussionLink: (cls, txt, handler) -> + $("").addClass("discussion-link") + .attr("href", "javascript:void(0)") + .addClass(cls).html(txt) + .click -> handler(this) + + @urlFor: (name, param, param1, param2) -> + { + follow_discussion : "/courses/#{$$course_id}/discussion/#{param}/follow" + unfollow_discussion : "/courses/#{$$course_id}/discussion/#{param}/unfollow" + create_thread : "/courses/#{$$course_id}/discussion/#{param}/threads/create" + search_similar_threads : "/courses/#{$$course_id}/discussion/#{param}/threads/search_similar" + update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update" + create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply" + delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" + upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" + downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" + undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote" + follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow" + unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow" + update_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/update" + endorse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/endorse" + create_sub_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/reply" + delete_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/delete" + upvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/upvote" + downvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/downvote" + undo_vote_for_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unvote" + upload : "/courses/#{$$course_id}/discussion/upload" + search : "/courses/#{$$course_id}/discussion/forum/search" + tags_autocomplete : "/courses/#{$$course_id}/discussion/threads/tags/autocomplete" + retrieve_discussion : "/courses/#{$$course_id}/discussion/forum/#{param}/inline" + retrieve_single_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}" + update_moderator_status : "/courses/#{$$course_id}/discussion/users/#{param}/update_moderator_status" + openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close" + permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}" + permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}" + }[name] + + @safeAjax: (params) -> + $elem = params.$elem + if $elem.attr("disabled") + return + $elem.attr("disabled", "disabled") + $.ajax(params).always -> + $elem.removeAttr("disabled") + + @handleAnchorAndReload: (response) -> + #window.location = window.location.pathname + "#" + response['id'] + window.location.reload() + + @bindLocalEvents: ($local, eventsHandler) -> + for eventSelector, handler of eventsHandler + [event, selector] = eventSelector.split(' ') + $local(selector).unbind(event)[event] handler + + @tagsInputOptions: -> + autocomplete_url: @urlFor('tags_autocomplete') + autocomplete: + remoteDataType: 'json' + interactive: true + height: '30px' + width: '100%' + defaultText: "Tag your post: press enter after each tag" + removeWithBackspace: true + + @isSubscribed: (id, type) -> + $$user_info? and ( + if type == "thread" + id in $$user_info.subscribed_thread_ids + else if type == "commentable" or type == "discussion" + id in $$user_info.subscribed_commentable_ids + else + id in $$user_info.subscribed_user_ids + ) + + @isUpvoted: (id) -> + $$user_info? and (id in $$user_info.upvoted_ids) + + @isDownvoted: (id) -> + $$user_info? and (id in $$user_info.downvoted_ids) + + @formErrorHandler: (errorsField) -> + (xhr, textStatus, error) -> + response = JSON.parse(xhr.responseText) + if response.errors? and response.errors.length > 0 + errorsField.empty() + for error in response.errors + errorsField.append($("
  • ").addClass("new-post-form-error").html(error)) + + @clearFormErrors: (errorsField) -> + errorsField.empty() + + @postMathJaxProcessor: (text) -> + RE_INLINEMATH = /^\$([^\$]*)\$/g + RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g + @processEachMathAndCode text, (s, type) -> + if type == 'display' + s.replace RE_DISPLAYMATH, ($0, $1) -> + "\\[" + $1 + "\\]" + else if type == 'inline' + s.replace RE_INLINEMATH, ($0, $1) -> + "\\(" + $1 + "\\)" + else + s + + @makeWmdEditor: ($content, $local, cls_identifier) -> + elem = $local(".#{cls_identifier}") + id = $content.attr("_id") + appended_id = "-#{cls_identifier}-#{id}" + imageUploadUrl = @urlFor('upload') + editor = Markdown.makeWmdEditor elem, appended_id, imageUploadUrl, @postMathJaxProcessor + @wmdEditors["#{cls_identifier}-#{id}"] = editor + editor + + @getWmdEditor: ($content, $local, cls_identifier) -> + id = $content.attr("_id") + @wmdEditors["#{cls_identifier}-#{id}"] + + @getWmdInput: ($content, $local, cls_identifier) -> + id = $content.attr("_id") + $local("#wmd-input-#{cls_identifier}-#{id}") + + @getWmdContent: ($content, $local, cls_identifier) -> + @getWmdInput($content, $local, cls_identifier).val() + + @setWmdContent: ($content, $local, cls_identifier, text) -> + @getWmdInput($content, $local, cls_identifier).val(text) + @getWmdEditor($content, $local, cls_identifier).refreshPreview() + + @subscriptionLink: (type, id) -> + followLink = -> + @generateDiscussionLink("discussion-follow-#{type}", "Follow", handleFollow) + + unfollowLink = -> + @generateDiscussionLink("discussion-unfollow-#{type}", "Unfollow", handleUnfollow) + + handleFollow = (elem) -> + @safeAjax + $elem: $(elem) + url: @urlFor("follow_#{type}", id) + type: "POST" + success: (response, textStatus) -> + if textStatus == "success" + $(elem).replaceWith unfollowLink() + dataType: 'json' + + handleUnfollow = (elem) -> + @safeAjax + $elem: $(elem) + url: @urlFor("unfollow_#{type}", id) + type: "POST" + success: (response, textStatus) -> + if textStatus == "success" + $(elem).replaceWith followLink() + dataType: 'json' + + if @isSubscribed(id, type) + unfollowLink() + else + followLink() + + @processEachMathAndCode: (text, processor) -> + + codeArchive = [] + + RE_DISPLAYMATH = /^([^\$]*?)\$\$([^\$]*?)\$\$(.*)$/m + RE_INLINEMATH = /^([^\$]*?)\$([^\$]+?)\$(.*)$/m + + ESCAPED_DOLLAR = '@@ESCAPED_D@@' + ESCAPED_BACKSLASH = '@@ESCAPED_B@@' + + processedText = "" + + $div = $("
    ").html(text) + + $div.find("code").each (index, code) -> + codeArchive.push $(code).html() + $(code).html(codeArchive.length - 1) + + text = $div.html() + text = text.replace /\\\$/g, ESCAPED_DOLLAR + + while true + if RE_INLINEMATH.test(text) + text = text.replace RE_INLINEMATH, ($0, $1, $2, $3) -> + processedText += $1 + processor("$" + $2 + "$", 'inline') + $3 + else if RE_DISPLAYMATH.test(text) + text = text.replace RE_DISPLAYMATH, ($0, $1, $2, $3) -> + processedText += $1 + processor("$$" + $2 + "$$", 'display') + $3 + else + processedText += text + break + + text = processedText + text = text.replace(new RegExp(ESCAPED_DOLLAR, 'g'), '\\$') + + text = text.replace /\\\\\\\\/g, ESCAPED_BACKSLASH + text = text.replace /\\begin\{([a-z]*\*?)\}([\s\S]*?)\\end\{\1\}/img, ($0, $1, $2) -> + processor("\\begin{#{$1}}" + $2 + "\\end{#{$1}}") + text = text.replace(new RegExp(ESCAPED_BACKSLASH, 'g'), '\\\\\\\\') + + $div = $("
    ").html(text) + cnt = 0 + $div.find("code").each (index, code) -> + $(code).html(processor(codeArchive[cnt], 'code')) + cnt += 1 + + text = $div.html() + + text + + @unescapeHighlightTag: (text) -> + text.replace(/\<\;highlight\>\;/g, "") + .replace(/\<\;\/highlight\>\;/g, "") + + @stripHighlight: (text) -> + text.replace(/\&(amp\;)?lt\;highlight\&(amp\;)?gt\;/g, "") + .replace(/\&(amp\;)?lt\;\/highlight\&(amp\;)?gt\;/g, "") + + @stripLatexHighlight: (text) -> + @processEachMathAndCode text, @stripHighlight + + @markdownWithHighlight: (text) -> + converter = Markdown.getMathCompatibleConverter() + @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index fdc590bf92..0e2428a1c5 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -5,6 +5,7 @@ <%block name="headextra"> <%static:css group='course'/> + <%include file="discussion/_js_head_dependencies.html" /> <%block name="js_extra"> @@ -21,7 +22,7 @@ <%static:js group='courseware'/> - <%include file="discussion/_js_dependencies.html" /> + <%include file="discussion/_js_body_dependencies.html" /> diff --git a/lms/templates/discussion/_content.mustache b/lms/templates/discussion/_content.mustache index 8f3a284510..95e95a8c4b 100644 --- a/lms/templates/discussion/_content.mustache +++ b/lms/templates/discussion/_content.mustache @@ -1,9 +1,9 @@
    - +
    {{content.votes.point}}
    - +
      diff --git a/lms/templates/discussion/_content_renderer.html b/lms/templates/discussion/_content_renderer.html index 88b87da4a2..9734165944 100644 --- a/lms/templates/discussion/_content_renderer.html +++ b/lms/templates/discussion/_content_renderer.html @@ -6,7 +6,7 @@ <%def name="render_content_with_comments(content)">
      - ${render_content(content)} +
      ${render_content(content)}
      % if content.get('children') is not None: ${render_comments(content['children'])} % endif diff --git a/lms/templates/discussion/_js_body_dependencies.html b/lms/templates/discussion/_js_body_dependencies.html new file mode 100644 index 0000000000..86265c7cb4 --- /dev/null +++ b/lms/templates/discussion/_js_body_dependencies.html @@ -0,0 +1,4 @@ +<%! from django_comment_client.helpers import include_mustache_templates %> + +<%include file="/mathjax_include.html" /> +${include_mustache_templates()} diff --git a/lms/templates/discussion/_js_dependencies.html b/lms/templates/discussion/_js_dependencies.html index b6e65f43c6..bde873fee1 100644 --- a/lms/templates/discussion/_js_dependencies.html +++ b/lms/templates/discussion/_js_dependencies.html @@ -1,7 +1,5 @@ <%namespace name='static' file='../static_content.html'/> -<%include file="/mathjax_include.html" /> - @@ -17,4 +15,3 @@ - diff --git a/lms/templates/discussion/_js_head_dependencies.html b/lms/templates/discussion/_js_head_dependencies.html new file mode 100644 index 0000000000..bde873fee1 --- /dev/null +++ b/lms/templates/discussion/_js_head_dependencies.html @@ -0,0 +1,17 @@ +<%namespace name='static' file='../static_content.html'/> + + + + + + + + + + + + + + + + diff --git a/lms/templates/discussion/index.html b/lms/templates/discussion/index.html index 46bc35f34b..b0ca1a2ffa 100644 --- a/lms/templates/discussion/index.html +++ b/lms/templates/discussion/index.html @@ -5,10 +5,11 @@ <%block name="headextra"> <%static:css group='course'/> +<%include file="_js_head_dependencies.html" /> <%block name="js_extra"> -<%include file="_js_dependencies.html" /> +<%include file="_js_body_dependencies.html" /> <%include file="../courseware/course_navigation.html" args="active_page='discussion'" />