Commit d26c7b1a by David Ormsbee

Merge pull request #458 from MITx/feature/bk_forum_int

Berkeley Forum Merge
parents 2a25e9c5 943cd5bc
...@@ -25,3 +25,4 @@ Gemfile.lock ...@@ -25,3 +25,4 @@ Gemfile.lock
.env/ .env/
lms/static/sass/*.css lms/static/sass/*.css
cms/static/sass/*.css cms/static/sass/*.css
lms/lib/comment_client/python
##
## One-off script to sync all user information to the discussion service (later info will be synced automatically)
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
import comment_client as cc
class Command(BaseCommand):
help = \
'''
Sync all user ids, usernames, and emails to the discussion
service'''
def handle(self, *args, **options):
for user in User.objects.all().iterator():
cc_user = cc.User.from_django_user(user)
cc_user.save()
...@@ -45,6 +45,15 @@ from django.db.models.signals import post_delete, post_save ...@@ -45,6 +45,15 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django_countries import CountryField from django_countries import CountryField
from django.db.models.signals import post_save
from django.dispatch import receiver
from functools import partial
import comment_client as cc
import logging
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
#from cache_toolbox import cache_model, cache_relation #from cache_toolbox import cache_model, cache_relation
...@@ -254,8 +263,18 @@ def add_user_to_default_group(user, group): ...@@ -254,8 +263,18 @@ def add_user_to_default_group(user, group):
utg.users.add(User.objects.get(username=user)) utg.users.add(User.objects.get(username=user))
utg.save() utg.save()
# @receiver(post_save, sender=User)
def update_user_information(sender, instance, created, **kwargs):
try:
cc_user = cc.User.from_django_user(instance)
cc_user.save()
except Exception as e:
log = logging.getLogger("mitx.discussion")
log.error(unicode(e))
log.error("update user info to discussion failed for user with id: " + str(instance.id))
########################## REPLICATION SIGNALS ################################# ########################## REPLICATION SIGNALS #################################
@receiver(post_save, sender=User) # @receiver(post_save, sender=User)
def replicate_user_save(sender, **kwargs): def replicate_user_save(sender, **kwargs):
user_obj = kwargs['instance'] user_obj = kwargs['instance']
if not should_replicate(user_obj): if not should_replicate(user_obj):
...@@ -263,7 +282,7 @@ def replicate_user_save(sender, **kwargs): ...@@ -263,7 +282,7 @@ def replicate_user_save(sender, **kwargs):
for course_db_name in db_names_to_replicate_to(user_obj.id): for course_db_name in db_names_to_replicate_to(user_obj.id):
replicate_user(user_obj, course_db_name) replicate_user(user_obj, course_db_name)
@receiver(post_save, sender=CourseEnrollment) # @receiver(post_save, sender=CourseEnrollment)
def replicate_enrollment_save(sender, **kwargs): def replicate_enrollment_save(sender, **kwargs):
"""This is called when a Student enrolls in a course. It has to do the """This is called when a Student enrolls in a course. It has to do the
following: following:
...@@ -289,12 +308,12 @@ def replicate_enrollment_save(sender, **kwargs): ...@@ -289,12 +308,12 @@ def replicate_enrollment_save(sender, **kwargs):
user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id) user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id)
replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id) replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id)
@receiver(post_delete, sender=CourseEnrollment) # @receiver(post_delete, sender=CourseEnrollment)
def replicate_enrollment_delete(sender, **kwargs): def replicate_enrollment_delete(sender, **kwargs):
enrollment_obj = kwargs['instance'] enrollment_obj = kwargs['instance']
return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id)
@receiver(post_save, sender=UserProfile) # @receiver(post_save, sender=UserProfile)
def replicate_userprofile_save(sender, **kwargs): def replicate_userprofile_save(sender, **kwargs):
"""We just updated the UserProfile (say an update to the name), so push that """We just updated the UserProfile (say an update to the name), so push that
change to all Course DBs that we're enrolled in.""" change to all Course DBs that we're enrolled in."""
...@@ -404,4 +423,3 @@ def should_replicate(instance): ...@@ -404,4 +423,3 @@ def should_replicate(instance):
.format(instance)) .format(instance))
return False return False
return True return True
...@@ -8,6 +8,7 @@ import logging ...@@ -8,6 +8,7 @@ import logging
from datetime import datetime from datetime import datetime
from django.test import TestCase from django.test import TestCase
from nose.plugins.skip import SkipTest
from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY
...@@ -22,6 +23,7 @@ class ReplicationTest(TestCase): ...@@ -22,6 +23,7 @@ class ReplicationTest(TestCase):
def test_user_replication(self): def test_user_replication(self):
"""Test basic user replication.""" """Test basic user replication."""
raise SkipTest()
portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass') portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass')
portal_user.first_name='Rusty' portal_user.first_name='Rusty'
portal_user.last_name='Skids' portal_user.last_name='Skids'
...@@ -80,6 +82,7 @@ class ReplicationTest(TestCase): ...@@ -80,6 +82,7 @@ class ReplicationTest(TestCase):
def test_enrollment_for_existing_user_info(self): def test_enrollment_for_existing_user_info(self):
"""Test the effect of Enrolling in a class if you've already got user """Test the effect of Enrolling in a class if you've already got user
data to be copied over.""" data to be copied over."""
raise SkipTest()
# Create our User # Create our User
portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass') portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass')
portal_user.first_name = "Jack" portal_user.first_name = "Jack"
...@@ -143,6 +146,8 @@ class ReplicationTest(TestCase): ...@@ -143,6 +146,8 @@ class ReplicationTest(TestCase):
def test_enrollment_for_user_info_after_enrollment(self): def test_enrollment_for_user_info_after_enrollment(self):
"""Test the effect of modifying User data after you've enrolled.""" """Test the effect of modifying User data after you've enrolled."""
raise SkipTest()
# Create our User # Create our User
portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass') portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass')
portal_user.first_name = "Patty" portal_user.first_name = "Patty"
......
...@@ -34,6 +34,7 @@ setup( ...@@ -34,6 +34,7 @@ setup(
"video = xmodule.video_module:VideoDescriptor", "video = xmodule.video_module:VideoDescriptor",
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"videosequence = xmodule.seq_module:SequenceDescriptor", "videosequence = xmodule.seq_module:SequenceDescriptor",
"discussion = xmodule.discussion_module:DiscussionDescriptor",
] ]
} }
) )
from lxml import etree
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
import json
class DiscussionModule(XModule):
def get_html(self):
context = {
'discussion_id': self.discussion_id,
}
return self.system.render_template('discussion/_discussion_module.html', context)
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
if isinstance(instance_state, str):
instance_state = json.loads(instance_state)
xml_data = etree.fromstring(definition['data'])
self.discussion_id = xml_data.attrib['id']
self.title = xml_data.attrib['for']
self.discussion_category = xml_data.attrib['discussion_category']
class DiscussionDescriptor(RawDescriptor):
module_class = DiscussionModule
# Running the discussion service
## Instruction for Mac
## Installing Mongodb
If you haven't done so already:
brew install mongodb
Make sure that you have mongodb running. You can simply open a new terminal tab and type:
mongod
## Installing elasticsearch
brew install elasticsearch
For debugging, it's often more convenient to have elasticsearch running in a terminal tab instead of in background. To do so, simply open a new terminal tab and then type:
elasticsearch -f
## Setting up the discussion service
First, make sure that you have access to the [github repository](https://github.com/rll/cs_comments_service). If this were not the case, send an email to dementrock@gmail.com.
First go into the mitx_all directory. Then type
git clone git@github.com:rll/cs_comments_service.git
cd cs_comments_service/
If you see a prompt asking "Do you wish to trust this .rvmrc file?", type "y"
Now if you see this error "Gemset 'cs_comments_service' does not exist," run the following command to create the gemset and then use the rvm environment manually:
rvm gemset create 'cs_comments_service'
rvm use 1.9.3@cs_comments_service
Now use the following command to install required packages:
bundle install
The following command creates database indexes:
bundle exec rake db:init
Now use the following command to generate seeds (basically some random comments in Latin):
bundle exec rake db:seed
It's done! Launch the app now:
ruby app.rb
## Running the delayed job worker
In the discussion service, notifications are handled asynchronously using a third party gem called delayed_job. If you want to test this functionality, run the following command in a separate tab:
bundle exec rake jobs:work
## Initialize roles and permissions
To fully test the discussion forum, you might want to act as a moderator or an administrator. Currently, moderators can manage everything in the forum, and administrator can manage everything plus assigning and revoking moderator status of other users.
First make sure that the database is up-to-date:
rake django-admin[syncdb]
rake django-admin[migrate]
For convenience, add the following environment variables to the terminal (assuming that you're using configuration set lms.envs.dev):
export DJANGO_SETTINGS_MODULE=lms.envs.dev
export PYTHONPATH=.
Now initialzie roles and permissions:
django-admin.py seed_permissions_roles
To assign yourself as a moderator, use the following command (assuming your username is "test", and the course id is "MITx/6.002x/2012_Fall"):
django-admin.py assign_role test Moderator "MITx/6.002x/2012_Fall"
To assign yourself as an administrator, use the following command
django-admin.py assign_role test Administrator "MITx/6.002x/2012_Fall"
## Some other useful commands
### generate seeds for a specific forum
The seed generating command above assumes that you have the following discussion tags somewhere in the course data:
<discussion for="Welcome Video" id="video_1" discussion_category="Video"/>
<discussion for="Lab 0: Using the Tools" id="lab_1" discussion_category="Lab"/>
<discussion for="Lab Circuit Sandbox" id="lab_2" discussion_category="Lab"/>
For example, you can insert them into overview section as following:
<chapter name="Overview">
<section format="Video" name="Welcome">
<vertical>
<video youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"/>
<discussion for="Welcome Video" id="video_1" discussion_category="Video"/>
</vertical>
</section>
<section format="Lecture Sequence" name="System Usage Sequence">
<%include file="sections/introseq.xml"/>
</section>
<section format="Lab" name="Lab0: Using the tools">
<vertical>
<html> See the <a href="/section/labintro"> Lab Introduction </a> or <a href="/static/handouts/schematic_tutorial.pdf">Interactive Lab Usage Handout </a> for information on how to do the lab </html>
<problem name="Lab 0: Using the Tools" filename="Lab0" rerandomize="false"/>
<discussion for="Lab 0: Using the Tools" id="lab_1" discussion_category="Lab"/>
</vertical>
</section>
<section format="Lab" name="Circuit Sandbox">
<vertical>
<problem name="Circuit Sandbox" filename="Lab_sandbox" rerandomize="false"/>
<discussion for="Lab Circuit Sandbox" id="lab_2" discussion_category="Lab"/>
</vertical>
</section>
</chapter>
Currently, only the attribute "id" is actually used, which identifies discussion forum. In the code for the data generator, the corresponding lines are:
generate_comments_for("video_1")
generate_comments_for("lab_1")
generate_comments_for("lab_2")
We also have a command for generating comments within a forum with the specified id:
bundle exec rake db:generate_comments[type_the_discussion_id_here]
For instance, if you want to generate comments for a new discussion tab named "lab_3", then use the following command
bundle exec rake db:generate_comments[lab_3]
### Running tests for the service
bundle exec rspec
Warning: the development and test environments share the same elasticsearch index. After running tests, search may not work in the development environment. You simply need to reindex:
bundle exec rake db:reindex_search
### debugging the service
You can use the following command to launch a console within the service environment:
bundle exec rake console
### show user roles and permissions
Use the following command to see the roles and permissions of a user in a given course (assuming, again, that the username is "test"):
django-admin.py show_permissions moderator
You need to make sure that the environment variables are exported. Otherwise you would need to do
django-admin.py show_permissions moderator --settings=lms.envs.dev --pythonpath=.
...@@ -74,5 +74,4 @@ There is also a script "create-dev-env.sh" that automates these steps. ...@@ -74,5 +74,4 @@ There is also a script "create-dev-env.sh" that automates these steps.
$ django-admin.py syncdb --settings=envs.dev --pythonpath=. $ django-admin.py syncdb --settings=envs.dev --pythonpath=.
$ django-admin.py migrate --settings=envs.dev --pythonpath=. $ django-admin.py migrate --settings=envs.dev --pythonpath=.
$ django-admin.py runserver --settings=envs.dev --pythonpath=. $ django-admin.py runserver --settings=envs.dev --pythonpath=.
...@@ -167,6 +167,7 @@ def get_module(user, request, location, student_module_cache, course_id, positio ...@@ -167,6 +167,7 @@ def get_module(user, request, location, student_module_cache, course_id, positio
shared_module = student_module_cache.lookup(descriptor.category, shared_module = student_module_cache.lookup(descriptor.category,
shared_state_key) shared_state_key)
instance_state = instance_module.state if instance_module is not None else None instance_state = instance_module.state if instance_module is not None else None
shared_state = shared_module.state if shared_module is not None else None shared_state = shared_module.state if shared_module is not None else None
......
...@@ -3,6 +3,10 @@ import logging ...@@ -3,6 +3,10 @@ import logging
import urllib import urllib
import itertools import itertools
from functools import partial
from functools import partial
from django.conf import settings from django.conf import settings
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -21,6 +25,11 @@ from courseware.courses import (get_course_with_access, get_courses_by_universit ...@@ -21,6 +25,11 @@ from courseware.courses import (get_course_with_access, get_courses_by_universit
from models import StudentModuleCache from models import StudentModuleCache
from module_render import toc_for_course, get_module, get_section from module_render import toc_for_course, get_module, get_section
from student.models import UserProfile from student.models import UserProfile
from multicourse import multicourse_settings
from django_comment_client.utils import get_discussion_title
from student.models import UserTestGroup, CourseEnrollment from student.models import UserTestGroup, CourseEnrollment
from util.cache import cache, cache_if_anonymous from util.cache import cache, cache_if_anonymous
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -29,6 +38,11 @@ from xmodule.modulestore.django import modulestore ...@@ -29,6 +38,11 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location from xmodule.modulestore.search import path_to_location
import comment_client
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib} template_imports = {'urllib': urllib}
...@@ -256,6 +270,26 @@ def university_profile(request, org_id): ...@@ -256,6 +270,26 @@ def university_profile(request, org_id):
return render_to_response(template_file, context) return render_to_response(template_file, context)
def render_notifications(request, course, notifications):
context = {
'notifications': notifications,
'get_discussion_title': partial(get_discussion_title, request=request, course=course),
'course': course,
}
return render_to_string('notifications.html', context)
@login_required
def news(request, course_id):
course = get_course_with_access(request.user, course_id, 'load')
notifications = comment_client.get_notifications(request.user.id)
context = {
'course': course,
'content': render_notifications(request, course, notifications),
}
return render_to_response('news.html', context)
@login_required @login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
...@@ -346,4 +380,3 @@ def instructor_dashboard(request, course_id): ...@@ -346,4 +380,3 @@ def instructor_dashboard(request, course_id):
context = {'course': course, context = {'course': course,
'staff_access': True,} 'staff_access': True,}
return render_to_response('courseware/instructor_dashboard.html', context) return render_to_response('courseware/instructor_dashboard.html', context)
# call some function from permissions so that the post_save hook is imported
from permissions import assign_default_role
from django.conf.urls.defaults import url, patterns
import django_comment_client.base.views
urlpatterns = patterns('django_comment_client.base.views',
url(r'upload$', 'upload', name='upload'),
url(r'users/(?P<user_id>\w+)/update_moderator_status$', 'update_moderator_status', name='update_moderator_status'),
url(r'threads/tags/autocomplete$', 'tags_autocomplete', name='tags_autocomplete'),
url(r'threads/(?P<thread_id>[\w\-]+)/update$', 'update_thread', name='update_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/reply$', 'create_comment', name='create_comment'),
url(r'threads/(?P<thread_id>[\w\-]+)/delete', 'delete_thread', name='delete_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/follow$', 'follow_thread', name='follow_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'),
url(r'comments/(?P<comment_id>[\w\-]+)/update$', 'update_comment', name='update_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/endorse$', 'endorse_comment', name='endorse_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/reply$', 'create_sub_comment', name='create_sub_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/delete$', 'delete_comment', name='delete_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/upvote$', 'vote_for_comment', {'value': 'up'}, name='upvote_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'),
url(r'(?P<commentable_id>[\w\-]+)/threads/create$', 'create_thread', name='create_thread'),
# TODO should we search within the board?
url(r'(?P<commentable_id>[\w\-]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'),
url(r'(?P<commentable_id>[\w\-]+)/follow$', 'follow_commentable', name='follow_commentable'),
url(r'(?P<commentable_id>[\w\-]+)/unfollow$', 'unfollow_commentable', name='unfollow_commentable'),
)
import time
import random
import os
import os.path
import logging
import urlparse
import functools
import comment_client as cc
import django_comment_client.utils as utils
from django.core import exceptions
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST, require_GET
from django.views.decorators import csrf
from django.core.files.storage import get_storage_class
from django.utils.translation import ugettext as _
from django.conf import settings
from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access
from django_comment_client.utils import JsonResponse, JsonError, extract
from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.models import Role
def permitted(fn):
@functools.wraps(fn)
def wrapper(request, *args, **kwargs):
def fetch_content():
if "thread_id" in kwargs:
content = cc.Thread.find(kwargs["thread_id"]).to_dict()
elif "comment_id" in kwargs:
content = cc.Comment.find(kwargs["comment_id"]).to_dict()
else:
content = None
return content
if check_permissions_by_view(request.user, kwargs['course_id'], fetch_content(), request.view_name):
return fn(request, *args, **kwargs)
else:
return JsonError("unauthorized")
return wrapper
def ajax_content_response(request, course_id, content, template_name):
context = {
'course_id': course_id,
'content': content,
}
html = render_to_string(template_name, context)
annotated_content_info = utils.get_annotated_content_info(course_id, content, request.user)
return JsonResponse({
'html': html,
'content': content,
'annotated_content_info': annotated_content_info,
})
@require_POST
@login_required
@permitted
def create_thread(request, course_id, commentable_id):
post = request.POST
thread = cc.Thread(**extract(post, ['body', 'title', 'tags']))
thread.update_attributes(**{
'anonymous' : post.get('anonymous', 'false').lower() == 'true',
'commentable_id' : commentable_id,
'course_id' : course_id,
'user_id' : request.user.id,
})
thread.save()
if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user)
user.follow(thread)
if request.is_ajax():
return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_create_thread.html')
else:
return JsonResponse(thread.to_dict())
@require_POST
@login_required
@permitted
def update_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags']))
thread.save()
if request.is_ajax():
return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_update_thread.html')
else:
return JsonResponse(thread.to_dict())
def _create_comment(request, course_id, thread_id=None, parent_id=None):
post = request.POST
comment = cc.Comment(**extract(post, ['body']))
comment.update_attributes(**{
'anonymous' : post.get('anonymous', 'false').lower() == 'true',
'user_id' : request.user.id,
'course_id' : course_id,
'thread_id' : thread_id,
'parent_id' : parent_id,
})
comment.save()
if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user)
user.follow(comment.thread)
if request.is_ajax():
return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_create_comment.html')
else:
return JsonResponse(comment.to_dict())
@require_POST
@login_required
@permitted
def create_comment(request, course_id, thread_id):
return _create_comment(request, course_id, thread_id=thread_id)
@require_POST
@login_required
@permitted
def delete_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
thread.delete()
return JsonResponse(thread.to_dict())
@require_POST
@login_required
@permitted
def update_comment(request, course_id, comment_id):
comment = cc.Comment.find(comment_id)
comment.update_attributes(**extract(request.POST, ['body']))
comment.save()
if request.is_ajax():
return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_update_comment.html')
else:
return JsonResponse(comment.to_dict()),
@require_POST
@login_required
@permitted
def endorse_comment(request, course_id, comment_id):
comment = cc.Comment.find(comment_id)
comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true'
comment.save()
return JsonResponse(comment.to_dict())
@require_POST
@login_required
@permitted
def openclose_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
thread.closed = request.POST.get('closed', 'false').lower() == 'true'
thread.save()
return JsonResponse(thread.to_dict())
@require_POST
@login_required
@permitted
def create_sub_comment(request, course_id, comment_id):
return _create_comment(request, course_id, parent_id=comment_id)
@require_POST
@login_required
@permitted
def delete_comment(request, course_id, comment_id):
comment = cc.Comment.find(comment_id)
comment.delete()
return JsonResponse(comment.to_dict())
@require_POST
@login_required
@permitted
def vote_for_comment(request, course_id, comment_id, value):
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.vote(comment, value)
return JsonResponse(comment.to_dict())
@require_POST
@login_required
@permitted
def undo_vote_for_comment(request, course_id, comment_id):
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.unvote(comment)
return JsonResponse(comment.to_dict())
@require_POST
@login_required
@permitted
def vote_for_thread(request, course_id, thread_id, value):
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.vote(thread, value)
return JsonResponse(thread.to_dict())
@require_POST
@login_required
@permitted
def undo_vote_for_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unvote(thread)
return JsonResponse(thread.to_dict())
@require_POST
@login_required
@permitted
def follow_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.follow(thread)
return JsonResponse({})
@require_POST
@login_required
@permitted
def follow_commentable(request, course_id, commentable_id):
user = cc.User.from_django_user(request.user)
commentable = cc.Commentable.find(commentable_id)
user.follow(commentable)
return JsonResponse({})
@require_POST
@login_required
@permitted
def follow_user(request, course_id, followed_user_id):
user = cc.User.from_django_user(request.user)
followed_user = cc.User.find(followed_user_id)
user.follow(followed_user)
return JsonResponse({})
@require_POST
@login_required
@permitted
def unfollow_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unfollow(thread)
return JsonResponse({})
@require_POST
@login_required
@permitted
def unfollow_commentable(request, course_id, commentable_id):
user = cc.User.from_django_user(request.user)
commentable = cc.Commentable.find(commentable_id)
user.unfollow(commentable)
return JsonResponse({})
@require_POST
@login_required
@permitted
def unfollow_user(request, course_id, followed_user_id):
user = cc.User.from_django_user(request.user)
followed_user = cc.User.find(followed_user_id)
user.unfollow(followed_user)
return JsonResponse({})
@require_POST
@login_required
@permitted
def update_moderator_status(request, course_id, user_id):
is_moderator = request.POST.get('is_moderator', '').lower()
if is_moderator not in ["true", "false"]:
return JsonError("Must provide is_moderator as boolean value")
is_moderator = is_moderator == "true"
user = User.objects.get(id=user_id)
role = Role.objects.get(course_id=course_id, name="Moderator")
if is_moderator:
user.roles.add(role)
else:
user.roles.remove(role)
if request.is_ajax():
course = get_course_with_access(request.user, course_id, 'load')
discussion_user = cc.User(id=user_id, course_id=course_id)
context = {
'course': course,
'course_id': course_id,
'user': request.user,
'django_user': user,
'discussion_user': discussion_user.to_dict(),
}
return JsonResponse({
'html': render_to_string('discussion/ajax_user_profile.html', context)
})
else:
return JsonResponse({})
@require_GET
def search_similar_threads(request, course_id, commentable_id):
text = request.GET.get('text', None)
if text:
query_params = {
'text': text,
'commentable_id': commentable_id,
}
result = cc.search_similar_threads(course_id, recursive=False, query_params=query_params)
return JsonResponse(result)
else:
return JsonResponse([])
@require_GET
def tags_autocomplete(request, course_id):
value = request.GET.get('q', None)
results = []
if value:
results = cc.tags_autocomplete(value)
return JsonResponse(results)
@require_POST
@login_required
@csrf.csrf_exempt
def upload(request, course_id):#ajax upload file to a question or answer
"""view that handles file upload via Ajax
"""
# check upload permission
result = ''
error = ''
new_file_name = ''
try:
# TODO authorization
#may raise exceptions.PermissionDenied
#if request.user.is_anonymous():
# msg = _('Sorry, anonymous users cannot upload files')
# raise exceptions.PermissionDenied(msg)
#request.user.assert_can_upload_file()
# check file type
f = request.FILES['file-upload']
file_extension = os.path.splitext(f.name)[1].lower()
if not file_extension in settings.DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES:
file_types = "', '".join(settings.DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES)
msg = _("allowed file types are '%(file_types)s'") % \
{'file_types': file_types}
raise exceptions.PermissionDenied(msg)
# generate new file name
new_file_name = str(
time.time()
).replace(
'.',
str(random.randint(0,100000))
) + file_extension
file_storage = get_storage_class()()
# use default storage to store file
file_storage.save(new_file_name, f)
# check file size
# byte
size = file_storage.size(new_file_name)
if size > settings.ASKBOT_MAX_UPLOAD_FILE_SIZE:
file_storage.delete(new_file_name)
msg = _("maximum upload file size is %(file_size)sK") % \
{'file_size': settings.ASKBOT_MAX_UPLOAD_FILE_SIZE}
raise exceptions.PermissionDenied(msg)
except exceptions.PermissionDenied, e:
error = unicode(e)
except Exception, e:
logging.critical(unicode(e))
error = _('Error uploading file. Please contact the site administrator. Thank you.')
if error == '':
result = 'Good'
file_url = file_storage.url(new_file_name)
parsed_url = urlparse.urlparse(file_url)
file_url = urlparse.urlunparse(
urlparse.ParseResult(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
'', '', ''
)
)
else:
result = ''
file_url = ''
return JsonResponse({
'result': {
'msg': result,
'error': error,
'file_url': file_url,
}
})
from django.conf.urls.defaults import url, patterns
import django_comment_client.forum.views
urlpatterns = patterns('django_comment_client.forum.views',
url(r'users/(?P<user_id>\w+)$', 'user_profile', name='user_profile'),
url(r'(?P<discussion_id>\w+)/threads/(?P<thread_id>\w+)$', 'single_thread', name='single_thread'),
url(r'(?P<discussion_id>\w+)/inline$', 'inline_discussion', name='inline_discussion'),
url(r'', 'forum_form_discussion', name='forum_form_discussion'),
)
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.http import HttpResponse
from django.utils import simplejson
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access
from urllib import urlencode
from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.utils import merge_dict, extract, strip_none
import json
import dateutil
import django_comment_client.utils as utils
import comment_client as cc
THREADS_PER_PAGE = 5
PAGES_NEARBY_DELTA = 2
def _general_discussion_id(course_id):
return course_id.replace('/', '_').replace('.', '_')
def _should_perform_search(request):
return bool(request.GET.get('text', False) or \
request.GET.get('tags', False))
def render_accordion(request, course, discussion_id):
discussion_info = utils.get_categorized_discussion_info(request, course)
context = {
'course': course,
'discussion_info': discussion_info,
'active': discussion_id,
'csrf': csrf(request)['csrf_token'],
}
return render_to_string('discussion/_accordion.html', context)
def render_discussion(request, course_id, threads, *args, **kwargs):
discussion_id = kwargs.get('discussion_id')
user_id = kwargs.get('user_id')
discussion_type = kwargs.get('discussion_type', 'inline')
query_params = kwargs.get('query_params', {})
template = {
'inline': 'discussion/_inline.html',
'forum': 'discussion/_forum.html',
'user': 'discussion/_user_active_threads.html',
}[discussion_type]
base_url = {
'inline': (lambda: reverse('django_comment_client.forum.views.inline_discussion', args=[course_id, discussion_id])),
'forum': (lambda: reverse('django_comment_client.forum.views.forum_form_discussion', args=[course_id])),
'user': (lambda: reverse('django_comment_client.forum.views.user_profile', args=[course_id, user_id])),
}[discussion_type]()
annotated_content_infos = map(lambda x: utils.get_annotated_content_infos(course_id, x, request.user), threads)
annotated_content_info = reduce(merge_dict, annotated_content_infos, {})
context = {
'threads': threads,
'discussion_id': discussion_id,
'user_id': user_id,
'user_info': json.dumps(cc.User.from_django_user(request.user).to_dict()),
'course_id': course_id,
'request': request,
'performed_search': _should_perform_search(request),
'pages_nearby_delta': PAGES_NEARBY_DELTA,
'discussion_type': discussion_type,
'base_url': base_url,
'query_params': strip_none(extract(query_params, ['page', 'sort_key', 'sort_order', 'tags', 'text'])),
'annotated_content_info': json.dumps(annotated_content_info),
}
context = dict(context.items() + query_params.items())
return render_to_string(template, context)
def render_inline_discussion(*args, **kwargs):
return render_discussion(discussion_type='inline', *args, **kwargs)
def render_forum_discussion(*args, **kwargs):
return render_discussion(discussion_type='forum', *args, **kwargs)
def render_user_discussion(*args, **kwargs):
return render_discussion(discussion_type='user', *args, **kwargs)
def get_threads(request, course_id, discussion_id=None):
default_query_params = {
'page': 1,
'per_page': THREADS_PER_PAGE,
'sort_key': 'activity',
'sort_order': 'desc',
'text': '',
'tags': '',
'commentable_id': discussion_id,
'course_id': course_id,
}
query_params = merge_dict(default_query_params,
strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags'])))
threads, page, num_pages = cc.Thread.search(query_params)
query_params['page'] = page
query_params['num_pages'] = num_pages
return threads, query_params
# discussion per page is fixed for now
def inline_discussion(request, course_id, discussion_id):
threads, query_params = get_threads(request, course_id, discussion_id)
html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, \
query_params=query_params)
return utils.HtmlResponse(html)
def render_search_bar(request, course_id, discussion_id=None, text=''):
if not discussion_id:
return ''
context = {
'discussion_id': discussion_id,
'text': text,
'course_id': course_id,
}
return render_to_string('discussion/_search_bar.html', context)
def forum_form_discussion(request, course_id):
course = get_course_with_access(request.user, course_id, 'load')
threads, query_params = get_threads(request, course_id)
content = render_forum_discussion(request, course_id, threads, discussion_id=_general_discussion_id(course_id), query_params=query_params)
recent_active_threads = cc.search_recent_active_threads(
course_id,
recursive=False,
query_params={'follower_id': request.user.id},
)
trending_tags = cc.search_trending_tags(
course_id,
)
if request.is_ajax():
return utils.HtmlResponse(content)
else:
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'content': content,
'recent_active_threads': recent_active_threads,
'trending_tags': trending_tags,
}
print "start rendering.."
return render_to_response('discussion/index.html', context)
def render_single_thread(request, discussion_id, course_id, thread_id):
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread=thread.to_dict(), user=request.user)
context = {
'discussion_id': discussion_id,
'thread': thread,
'user_info': json.dumps(cc.User.from_django_user(request.user).to_dict()),
'annotated_content_info': json.dumps(annotated_content_info),
'course_id': course_id,
'request': request,
}
return render_to_string('discussion/_single_thread.html', context)
def single_thread(request, course_id, discussion_id, thread_id):
if request.is_ajax():
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user)
context = {'thread': thread.to_dict(), 'course_id': course_id}
html = render_to_string('discussion/_ajax_single_thread.html', context)
return utils.JsonResponse({
'html': html,
'annotated_content_info': annotated_content_info,
})
else:
course = get_course_with_access(request.user, course_id, 'load')
context = {
'discussion_id': discussion_id,
'csrf': csrf(request)['csrf_token'],
'init': '',
'content': render_single_thread(request, discussion_id, course_id, thread_id),
'accordion': render_accordion(request, course, discussion_id),
'course': course,
'course_id': course.id,
}
return render_to_response('discussion/index.html', context)
def user_profile(request, course_id, user_id):
course = get_course_with_access(request.user, course_id, 'load')
profiled_user = cc.User(id=user_id, course_id=course_id)
query_params = {
'page': request.GET.get('page', 1),
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
}
threads, page, num_pages = profiled_user.active_threads(query_params)
query_params['page'] = page
query_params['num_pages'] = num_pages
content = render_user_discussion(request, course_id, threads, user_id=user_id, query_params=query_params)
if request.is_ajax():
return utils.HtmlResponse(content)
else:
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'profiled_user': profiled_user.to_dict(),
'content': content,
}
return render_to_response('discussion/user_profile.html', context)
from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_string
from utils import *
from mustache_helpers import mustache_helpers
from functools import partial
import pystache_custom as pystache
import urllib
def pluralize(singular_term, count):
if int(count) >= 2:
return singular_term + 's'
return singular_term
def show_if(text, condition):
if condition:
return text
else:
return ''
def render_content(content, additional_context={}):
content_info = {
'displayed_title': content.get('highlighted_title') or content.get('title', ''),
'displayed_body': content.get('highlighted_body') or content.get('body', ''),
'raw_tags': ','.join(content.get('tags', [])),
}
context = {
'content': merge_dict(content, content_info),
content['type']: True,
}
context = merge_dict(context, additional_context)
partial_mustache_helpers = {k: partial(v, content) for k, v in mustache_helpers.items()}
context = merge_dict(context, partial_mustache_helpers)
return render_mustache('discussion/_content.mustache', context)
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Permission, Role
from django.contrib.auth.models import User
class Command(BaseCommand):
args = 'user role course_id'
help = 'Assign a role to a user'
def handle(self, *args, **options):
role = Role.objects.get(name=args[1], course_id=args[2])
if '@' in args[0]:
user = User.objects.get(email=args[0])
else:
user = User.objects.get(username=args[0])
user.roles.add(role)
\ No newline at end of file
"""
This must be run only after seed_permissions_roles.py!
Creates default roles for all users currently in the database. Just runs through
Enrollments.
"""
from django.core.management.base import BaseCommand, CommandError
from student.models import CourseEnrollment
from django_comment_client.permissions import assign_default_role
class Command(BaseCommand):
args = 'course_id'
help = 'Seed default permisssions and roles'
def handle(self, *args, **options):
if len(args) != 0:
raise CommandError("This Command takes no arguments")
print "Updated roles for ",
for i, enrollment in enumerate(CourseEnrollment.objects.all(), start=1):
assign_default_role(None, enrollment)
if i % 1000 == 0:
print "{0}...".format(i),
print
\ No newline at end of file
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Permission, Role
class Command(BaseCommand):
args = 'course_id'
help = 'Seed default permisssions and roles'
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("The number of arguments does not match. ")
course_id = args[0]
administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0]
moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0]
student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0]
for per in ["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote" , "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ]:
student_role.add_permission(per)
for per in ["edit_content", "delete_thread", "openclose_thread",
"endorse_comment", "delete_comment"]:
moderator_role.add_permission(per)
for per in ["manage_moderator"]:
administrator_role.add_permission(per)
moderator_role.inherit_permissions(student_role)
administrator_role.inherit_permissions(moderator_role)
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Permission, Role
from django.contrib.auth.models import User
class Command(BaseCommand):
args = 'user'
help = "Show a user's roles and permissions"
def handle(self, *args, **options):
print args
if len(args) != 1:
raise CommandError("The number of arguments does not match. ")
try:
if '@' in args[0]:
user = User.objects.get(email=args[0])
else:
user = User.objects.get(username=args[0])
except User.DoesNotExist:
print "User %s does not exist. " % args[0]
print "Available users: "
print User.objects.all()
return
roles = user.roles.all()
print "%s has %d roles:" % (user, len(roles))
for role in roles:
print "\t%s" % role
for role in roles:
print "%s has permissions: " % role
print role.permissions.all()
from comment_client import CommentClientError
from django_comment_client.utils import JsonError
import json
class AjaxExceptionMiddleware(object):
def process_exception(self, request, exception):
if isinstance(exception, CommentClientError) and request.is_ajax():
return JsonError(json.loads(exception.message))
return None
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Role'
db.create_table('django_comment_client_role', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('name', self.gf('django.db.models.fields.CharField')(max_length=30)),
('course_id', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)),
))
db.send_create_signal('django_comment_client', ['Role'])
# Adding M2M table for field users on 'Role'
db.create_table('django_comment_client_role_users', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('role', models.ForeignKey(orm['django_comment_client.role'], null=False)),
('user', models.ForeignKey(orm['auth.user'], null=False))
))
db.create_unique('django_comment_client_role_users', ['role_id', 'user_id'])
# Adding model 'Permission'
db.create_table('django_comment_client_permission', (
('name', self.gf('django.db.models.fields.CharField')(max_length=30, primary_key=True)),
))
db.send_create_signal('django_comment_client', ['Permission'])
# Adding M2M table for field roles on 'Permission'
db.create_table('django_comment_client_permission_roles', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('permission', models.ForeignKey(orm['django_comment_client.permission'], null=False)),
('role', models.ForeignKey(orm['django_comment_client.role'], null=False))
))
db.create_unique('django_comment_client_permission_roles', ['permission_id', 'role_id'])
def backwards(self, orm):
# Deleting model 'Role'
db.delete_table('django_comment_client_role')
# Removing M2M table for field users on 'Role'
db.delete_table('django_comment_client_role_users')
# Deleting model 'Permission'
db.delete_table('django_comment_client_permission')
# Removing M2M table for field roles on 'Permission'
db.delete_table('django_comment_client_permission_roles')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}),
'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}),
'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}),
'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}),
'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'django_comment_client.permission': {
'Meta': {'object_name': 'Permission'},
'name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'primary_key': 'True'}),
'roles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'permissions'", 'symmetrical': 'False', 'to': "orm['django_comment_client.Role']"})
},
'django_comment_client.role': {
'Meta': {'object_name': 'Role'},
'course_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'roles'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
}
}
complete_apps = ['django_comment_client']
\ No newline at end of file
from django.db import models
from django.contrib.auth.models import User
import logging
class Role(models.Model):
name = models.CharField(max_length=30, null=False, blank=False)
users = models.ManyToManyField(User, related_name="roles")
course_id = models.CharField(max_length=255, blank=True, db_index=True)
def __unicode__(self):
return self.name + " for " + (self.course_id if self.course_id else "all courses")
def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing,
# since it's one-off and doesn't handle inheritance later
if role.course_id and role.course_id != self.course_id:
logging.warning("%s cannot inheret permissions from %s due to course_id inconsistency" %
(self, role))
for per in role.permissions.all():
self.add_permission(per)
def add_permission(self, permission):
self.permissions.add(Permission.objects.get_or_create(name=permission)[0])
def has_permission(self, permission):
return self.permissions.filter(name=permission).exists()
class Permission(models.Model):
name = models.CharField(max_length=30, null=False, blank=False, primary_key=True)
roles = models.ManyToManyField(Role, related_name="permissions")
def __unicode__(self):
return self.name
from django.core.urlresolvers import reverse
import urllib
def pluralize(content, text):
num, word = text.split(' ')
if int(num or '0') >= 2:
return num + ' ' + word + 's'
else:
return num + ' ' + word
def url_for_user(content, user_id):
return reverse('django_comment_client.forum.views.user_profile', args=[content['course_id'], user_id])
def url_for_tags(content, tags): # assume that tags is in the format u'a, b, c'
return reverse('django_comment_client.forum.views.forum_form_discussion', args=[content['course_id']]) + '?' + urllib.urlencode({'tags': tags})
def close_thread_text(content):
if content.get('closed'):
return 'Re-open thread'
else:
return 'Close thread'
mustache_helpers = {
'pluralize': pluralize,
'url_for_tags': url_for_tags,
'url_for_user': url_for_user,
'close_thread_text': close_thread_text,
}
from .models import Role, Permission
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
from student.models import CourseEnrollment
import logging
from util.cache import cache
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
else:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0]
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
instance.user.roles.add(role)
def cached_has_permission(user, permission, course_id=None):
"""
Call has_permission if it's not cached. A change in a user's role or
a role's permissions will only become effective after CACHE_LIFESPAN seconds.
"""
CACHE_LIFESPAN = 60
key = "permission_%d_%s_%s" % (user.id, str(course_id), permission)
val = cache.get(key, None)
if val not in [True, False]:
val = has_permission(user, permission, course_id=course_id)
cache.set(key, val, CACHE_LIFESPAN)
return val
def has_permission(user, permission, course_id=None):
for role in user.roles.filter(course_id=course_id):
if role.has_permission(permission):
return True
return False
CONDITIONS = ['is_open', 'is_author']
def check_condition(user, condition, course_id, data):
def check_open(user, condition, course_id, data):
try:
return data and not data['content']['closed']
except KeyError:
return False
def check_author(user, condition, course_id, data):
try:
return data and data['content']['user_id'] == str(user.id)
except KeyError:
return False
handlers = {
'is_open' : check_open,
'is_author' : check_author,
}
return handlers[condition](user, condition, course_id, data)
def check_conditions_permissions(user, permissions, course_id, **kwargs):
"""
Accepts a list of permissions and proceed if any of the permission is valid.
Note that ["can_view", "can_edit"] will proceed if the user has either
"can_view" or "can_edit" permission. To use AND operator in between, wrap them in
a list.
"""
def test(user, per, operator="or"):
if isinstance(per, basestring):
if per in CONDITIONS:
return check_condition(user, per, course_id, kwargs)
return cached_has_permission(user, per, course_id=course_id)
elif isinstance(per, list) and operator in ["and", "or"]:
results = [test(user, x, operator="and") for x in per]
if operator == "or":
return True in results
elif operator == "and":
return not False in results
return test(user, permissions, operator="or")
VIEW_PERMISSIONS = {
'update_thread' : ['edit_content', ['update_thread', 'is_open', 'is_author']],
'create_comment' : [["create_comment", "is_open"]],
'delete_thread' : ['delete_thread'],
'update_comment' : ['edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment' : ['endorse_comment'],
'openclose_thread' : ['openclose_thread'],
'create_sub_comment': [['create_sub_comment', 'is_open']],
'delete_comment' : ['delete_comment'],
'vote_for_comment' : [['vote', 'is_open']],
'undo_vote_for_comment': [['unvote', 'is_open']],
'vote_for_thread' : [['vote', 'is_open']],
'undo_vote_for_thread': [['unvote', 'is_open']],
'follow_thread' : ['follow_thread'],
'follow_commentable': ['follow_commentable'],
'follow_user' : ['follow_user'],
'unfollow_thread' : ['unfollow_thread'],
'unfollow_commentable': ['unfollow_commentable'],
'unfollow_user' : ['unfollow_user'],
'create_thread' : ['create_thread'],
'update_moderator_status' : ['manage_moderator'],
}
def check_permissions_by_view(user, course_id, content, name):
try:
p = VIEW_PERMISSIONS[name]
except KeyError:
logging.warning("Permission for view named %s does not exist in permissions.py" % name)
return check_conditions_permissions(user, p, course_id, content=content)
from django.contrib.auth.models import User
from django.test import TestCase
from student.models import CourseEnrollment, \
replicate_enrollment_save, \
replicate_enrollment_delete, \
update_user_information, \
replicate_user_save
from django.db.models.signals import m2m_changed, pre_delete, pre_save, post_delete, post_save
from django.dispatch.dispatcher import _make_id
import string
import random
from .permissions import has_permission
from .models import Role, Permission
class PermissionsTestCase(TestCase):
def random_str(self, length=15, chars=string.ascii_uppercase + string.digits):
return ''.join(random.choice(chars) for x in range(length))
def setUp(self):
self.course_id = "MITx/6.002x/2012_Fall"
self.moderator_role = Role.objects.get_or_create(name="Moderator", course_id=self.course_id)[0]
self.student_role = Role.objects.get_or_create(name="Student", course_id=self.course_id)[0]
self.student = User.objects.create(username=self.random_str(),
password="123456", email="john@yahoo.com")
self.moderator = User.objects.create(username=self.random_str(),
password="123456", email="staff@edx.org")
self.moderator.is_staff = True
self.moderator.save()
self.student_enrollment = CourseEnrollment.objects.create(user=self.student, course_id=self.course_id)
self.moderator_enrollment = CourseEnrollment.objects.create(user=self.moderator, course_id=self.course_id)
def tearDown(self):
self.student_enrollment.delete()
self.moderator_enrollment.delete()
# Do we need to have this? We shouldn't be deleting students, ever
# self.student.delete()
# self.moderator.delete()
def testDefaultRoles(self):
self.assertTrue(self.student_role in self.student.roles.all())
self.assertTrue(self.moderator_role in self.moderator.roles.all())
def testPermission(self):
name = self.random_str()
self.moderator_role.add_permission(name)
self.assertTrue(has_permission(self.moderator, name, self.course_id))
self.student_role.add_permission(name)
self.assertTrue(has_permission(self.student, name, self.course_id))
from django.conf.urls.defaults import url, patterns, include
urlpatterns = patterns('',
url(r'forum/', include('django_comment_client.forum.urls')),
url(r'', include('django_comment_client.base.urls')),
)
from importlib import import_module
from courseware.models import StudentModuleCache
from courseware.module_render import get_module
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from django.http import HttpResponse
from django.utils import simplejson
from django.db import connection
from django.conf import settings
from django_comment_client.permissions import check_permissions_by_view
from mitxmako import middleware
import logging
import operator
import itertools
import pystache_custom as pystache
_FULLMODULES = None
_DISCUSSIONINFO = None
def extract(dic, keys):
return {k: dic.get(k) for k in keys}
def strip_none(dic):
return dict([(k, v) for k, v in dic.iteritems() if v is not None])
def strip_blank(dic):
def _is_blank(v):
return isinstance(v, str) and len(v.strip()) == 0
return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)])
def merge_dict(dic1, dic2):
return dict(dic1.items() + dic2.items())
def get_full_modules():
global _FULLMODULES
if not _FULLMODULES:
class_path = settings.MODULESTORE['default']['ENGINE']
module_path, _, class_name = class_path.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
modulestore = class_(**dict(settings.MODULESTORE['default']['OPTIONS'].items() + [('eager', True)]))
_FULLMODULES = modulestore.modules
return _FULLMODULES
def get_categorized_discussion_info(request, course):
"""
return a dict of the form {category: modules}
"""
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
initialize_discussion_info(request, course)
return _DISCUSSIONINFO['categorized']
def get_discussion_title(request, course, discussion_id):
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
initialize_discussion_info(request, course)
title = _DISCUSSIONINFO['by_id'].get(discussion_id, {}).get('title', '(no title)')
return title
def initialize_discussion_info(request, course):
global _DISCUSSIONINFO
if _DISCUSSIONINFO:
return
course_id = course.id
_, course_name, _ = course_id.split('/')
user = request.user
url_course_id = course_id.replace('/', '_').replace('.', '_')
_is_course_discussion = lambda x: x[0].dict()['category'] == 'discussion' \
and x[0].dict()['course'] == course_name
_get_module_descriptor = operator.itemgetter(1)
def _get_module(module_descriptor):
print module_descriptor
module = get_module(user, request, module_descriptor.location, student_module_cache)
return module
def _extract_info(module):
return {
'title': module.title,
'discussion_id': module.discussion_id,
'category': module.discussion_category,
}
def _pack_with_id(info):
return (info['discussion_id'], info)
discussion_module_descriptors = map(_get_module_descriptor,
filter(_is_course_discussion,
get_full_modules().items()))
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course)
discussion_info = map(_extract_info, map(_get_module, discussion_module_descriptors))
_DISCUSSIONINFO = {}
_DISCUSSIONINFO['by_id'] = dict(map(_pack_with_id, discussion_info))
_DISCUSSIONINFO['categorized'] = dict((category, list(l)) \
for category, l in itertools.groupby(discussion_info, operator.itemgetter('category')))
_DISCUSSIONINFO['categorized']['General'] = [{
'title': 'General discussion',
'discussion_id': url_course_id,
'category': 'General',
}]
class JsonResponse(HttpResponse):
def __init__(self, data=None):
content = simplejson.dumps(data)
super(JsonResponse, self).__init__(content,
mimetype='application/json; charset=utf8')
class JsonError(HttpResponse):
def __init__(self, error_messages=[]):
if isinstance(error_messages, str):
error_messages = [error_messages]
content = simplejson.dumps({'errors': error_messages},
indent=2,
ensure_ascii=False)
super(JsonError, self).__init__(content,
mimetype='application/json; charset=utf8', status=400)
class HtmlResponse(HttpResponse):
def __init__(self, html=''):
super(HtmlResponse, self).__init__(html, content_type='text/plain')
class ViewNameMiddleware(object):
def process_view(self, request, view_func, view_args, view_kwargs):
request.view_name = view_func.__name__
class QueryCountDebugMiddleware(object):
"""
This middleware will log the number of queries run
and the total time taken for each request (with a
status code of 200). It does not currently support
multi-db setups.
"""
def process_response(self, request, response):
if response.status_code == 200:
total_time = 0
for query in connection.queries:
query_time = query.get('time')
if query_time is None:
# django-debug-toolbar monkeypatches the connection
# cursor wrapper and adds extra information in each
# item in connection.queries. The query time is stored
# under the key "duration" rather than "time" and is
# in milliseconds, not seconds.
query_time = query.get('duration', 0) / 1000
total_time += float(query_time)
logging.info('%s queries run, total %s seconds' % (len(connection.queries), total_time))
return response
def get_annotated_content_info(course_id, content, user):
return {
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
}
def get_annotated_content_infos(course_id, thread, user):
infos = {}
def _annotate(content):
infos[str(content['id'])] = get_annotated_content_info(course_id, content, user)
for child in content.get('children', []):
_annotate(child)
_annotate(thread)
return infos
def render_mustache(template_name, dictionary, *args, **kwargs):
template = middleware.lookup['main'].get_template(template_name).source
return pystache.render(template, dictionary)
...@@ -19,6 +19,11 @@ EMAIL_BACKEND = 'django_ses.SESBackend' ...@@ -19,6 +19,11 @@ EMAIL_BACKEND = 'django_ses.SESBackend'
SESSION_ENGINE = 'django.contrib.sessions.backends.cache' SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
# Disable askbot, enable Berkeley forums
MITX_FEATURES['ENABLE_DISCUSSION'] = False
MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
########################### NON-SECURE ENV CONFIG ############################## ########################### NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc. # Things like server locations, ports, etc.
with open(ENV_ROOT / "env.json") as env_file: with open(ENV_ROOT / "env.json") as env_file:
...@@ -60,3 +65,5 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE'] ...@@ -60,3 +65,5 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
if 'COURSE_ID' in ENV_TOKENS: if 'COURSE_ID' in ENV_TOKENS:
ASKBOT_URL = "courses/{0}/discussions/".format(ENV_TOKENS['COURSE_ID']) ASKBOT_URL = "courses/{0}/discussions/".format(ENV_TOKENS['COURSE_ID'])
COMMENTS_SERVICE_URL = ENV_TOKENS["COMMENTS_SERVICE_URL"]
...@@ -30,10 +30,11 @@ import djcelery ...@@ -30,10 +30,11 @@ import djcelery
from path import path from path import path
from .askbotsettings import * # this is where LIVESETTINGS_OPTIONS comes from from .askbotsettings import * # this is where LIVESETTINGS_OPTIONS comes from
from .discussionsettings import *
################################### FEATURES ################################### ################################### FEATURES ###################################
COURSEWARE_ENABLED = True COURSEWARE_ENABLED = True
ASKBOT_ENABLED = True ASKBOT_ENABLED = False
GENERATE_RANDOM_USER_CREDENTIALS = False GENERATE_RANDOM_USER_CREDENTIALS = False
PERFSTATS = False PERFSTATS = False
...@@ -55,7 +56,8 @@ MITX_FEATURES = { ...@@ -55,7 +56,8 @@ MITX_FEATURES = {
'SUBDOMAIN_COURSE_LISTINGS' : False, 'SUBDOMAIN_COURSE_LISTINGS' : False,
'ENABLE_TEXTBOOK' : True, 'ENABLE_TEXTBOOK' : True,
'ENABLE_DISCUSSION' : True, 'ENABLE_DISCUSSION' : False,
'ENABLE_DISCUSSION_SERVICE': True,
'ENABLE_SQL_TRACKING_LOGS': False, 'ENABLE_SQL_TRACKING_LOGS': False,
'ENABLE_LMS_MIGRATION': False, 'ENABLE_LMS_MIGRATION': False,
...@@ -328,6 +330,7 @@ TEMPLATE_LOADERS = ( ...@@ -328,6 +330,7 @@ TEMPLATE_LOADERS = (
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE_CLASSES = (
'django_comment_client.middleware.AjaxExceptionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
...@@ -350,6 +353,9 @@ MIDDLEWARE_CLASSES = ( ...@@ -350,6 +353,9 @@ MIDDLEWARE_CLASSES = (
'askbot.middleware.spaceless.SpacelessMiddleware', 'askbot.middleware.spaceless.SpacelessMiddleware',
# 'askbot.middleware.pagesize.QuestionsPageSizeMiddleware', # 'askbot.middleware.pagesize.QuestionsPageSizeMiddleware',
# 'debug_toolbar.middleware.DebugToolbarMiddleware', # 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django_comment_client.utils.ViewNameMiddleware',
'django_comment_client.utils.QueryCountDebugMiddleware',
) )
############################### Pipeline ####################################### ############################### Pipeline #######################################
...@@ -571,6 +577,9 @@ INSTALLED_APPS = ( ...@@ -571,6 +577,9 @@ INSTALLED_APPS = (
# For testing # For testing
'django_jasmine', 'django_jasmine',
# Discussion
'django_comment_client',
# For Askbot # For Askbot
'django.contrib.sitemaps', 'django.contrib.sitemaps',
'django.contrib.admin', 'django.contrib.admin',
......
DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES = ('.jpg', '.jpeg', '.gif', '.bmp', '.png', '.tiff')
from comment_client import *
from utils import CommentClientError, CommentClientUnknownError
from utils import *
import models
import settings
class Comment(models.Model):
accessible_fields = [
'id', 'body', 'anonymous', 'course_id',
'endorsed', 'parent_id', 'thread_id',
'username', 'votes', 'user_id', 'closed',
'created_at', 'updated_at', 'depth',
'at_position_list', 'type',
]
updatable_fields = [
'body', 'anonymous', 'course_id', 'closed',
'user_id', 'endorsed',
]
initializable_fields = updatable_fields
base_url = "{prefix}/comments".format(prefix=settings.PREFIX)
type = 'comment'
@classmethod
def url_for_comments(cls, params={}):
if params.get('thread_id'):
return _url_for_thread_comments(params['thread_id'])
else:
return _url_for_comment(params['parent_id'])
@classmethod
def url(cls, action, params={}):
if action in ['post']:
return cls.url_for_comments(params)
else:
return super(Comment, cls).url(action, params)
def _url_for_thread_comments(thread_id):
return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_comment(comment_id):
return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id)
from comment import Comment
from thread import Thread
from user import User
from commentable import Commentable
from utils import *
import settings
def search_similar_threads(course_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
return perform_request('get', _url_for_search_similar_threads(), attributes, *args, **kwargs)
def search_recent_active_threads(course_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
return perform_request('get', _url_for_search_recent_active_threads(), attributes, *args, **kwargs)
def search_trending_tags(course_id, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id}
attributes = dict(default_params.items() + query_params.items())
return perform_request('get', _url_for_search_trending_tags(), attributes, *args, **kwargs)
def tags_autocomplete(value, *args, **kwargs):
return perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs)
def _url_for_search_similar_threads():
return "{prefix}/search/threads/more_like_this".format(prefix=settings.PREFIX)
def _url_for_search_recent_active_threads():
return "{prefix}/search/threads/recent_active".format(prefix=settings.PREFIX)
def _url_for_search_trending_tags():
return "{prefix}/search/tags/trending".format(prefix=settings.PREFIX)
def _url_for_threads_tags_autocomplete():
return "{prefix}/threads/tags/autocomplete".format(prefix=settings.PREFIX)
from utils import *
import models
import settings
class Commentable(models.Model):
base_url = "{prefix}/commentables".format(prefix=settings.PREFIX)
type = 'commentable'
def delete_threads(commentable_id, *args, **kwargs):
return _perform_request('delete', _url_for_commentable_threads(commentable_id), *args, **kwargs)
def get_threads(commentable_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'page': 1, 'per_page': 20, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
response = _perform_request('get', _url_for_threads(commentable_id), \
attributes, *args, **kwargs)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def search_threads(course_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'page': 1, 'per_page': 20, 'course_id': course_id, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
response = _perform_request('get', _url_for_search_threads(), \
attributes, *args, **kwargs)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def search_similar_threads(course_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
return _perform_request('get', _url_for_search_similar_threads(), attributes, *args, **kwargs)
def search_recent_active_threads(course_id, recursive=False, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id, 'recursive': recursive}
attributes = dict(default_params.items() + query_params.items())
return _perform_request('get', _url_for_search_recent_active_threads(), attributes, *args, **kwargs)
def search_trending_tags(course_id, query_params={}, *args, **kwargs):
default_params = {'course_id': course_id}
attributes = dict(default_params.items() + query_params.items())
return _perform_request('get', _url_for_search_trending_tags(), attributes, *args, **kwargs)
def create_user(attributes, *args, **kwargs):
return _perform_request('post', _url_for_users(), attributes, *args, **kwargs)
def update_user(user_id, attributes, *args, **kwargs):
return _perform_request('put', _url_for_user(user_id), attributes, *args, **kwargs)
def get_threads_tags(*args, **kwargs):
return _perform_request('get', _url_for_threads_tags(), {}, *args, **kwargs)
def tags_autocomplete(value, *args, **kwargs):
return _perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs)
def create_thread(commentable_id, attributes, *args, **kwargs):
return _perform_request('post', _url_for_threads(commentable_id), attributes, *args, **kwargs)
def get_thread(thread_id, recursive=False, *args, **kwargs):
return _perform_request('get', _url_for_thread(thread_id), {'recursive': recursive}, *args, **kwargs)
def update_thread(thread_id, attributes, *args, **kwargs):
return _perform_request('put', _url_for_thread(thread_id), attributes, *args, **kwargs)
def create_comment(thread_id, attributes, *args, **kwargs):
return _perform_request('post', _url_for_thread_comments(thread_id), attributes, *args, **kwargs)
def delete_thread(thread_id, *args, **kwargs):
return _perform_request('delete', _url_for_thread(thread_id), *args, **kwargs)
def get_comment(comment_id, recursive=False, *args, **kwargs):
return _perform_request('get', _url_for_comment(comment_id), {'recursive': recursive}, *args, **kwargs)
def update_comment(comment_id, attributes, *args, **kwargs):
return _perform_request('put', _url_for_comment(comment_id), attributes, *args, **kwargs)
def create_sub_comment(comment_id, attributes, *args, **kwargs):
return _perform_request('post', _url_for_comment(comment_id), attributes, *args, **kwargs)
def delete_comment(comment_id, *args, **kwargs):
return _perform_request('delete', _url_for_comment(comment_id), *args, **kwargs)
def vote_for_comment(comment_id, user_id, value, *args, **kwargs):
return _perform_request('put', _url_for_vote_comment(comment_id), {'user_id': user_id, 'value': value}, *args, **kwargs)
def undo_vote_for_comment(comment_id, user_id, *args, **kwargs):
return _perform_request('delete', _url_for_vote_comment(comment_id), {'user_id': user_id}, *args, **kwargs)
def vote_for_thread(thread_id, user_id, value, *args, **kwargs):
return _perform_request('put', _url_for_vote_thread(thread_id), {'user_id': user_id, 'value': value}, *args, **kwargs)
def undo_vote_for_thread(thread_id, user_id, *args, **kwargs):
return _perform_request('delete', _url_for_vote_thread(thread_id), {'user_id': user_id}, *args, **kwargs)
def get_notifications(user_id, *args, **kwargs):
return _perform_request('get', _url_for_notifications(user_id), *args, **kwargs)
def get_user_info(user_id, complete=True, *args, **kwargs):
return _perform_request('get', _url_for_user(user_id), {'complete': complete}, *args, **kwargs)
def subscribe(user_id, subscription_detail, *args, **kwargs):
return _perform_request('post', _url_for_subscription(user_id), subscription_detail, *args, **kwargs)
def subscribe_user(user_id, followed_user_id, *args, **kwargs):
return subscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id})
follow = subscribe_user
def subscribe_thread(user_id, thread_id, *args, **kwargs):
return subscribe(user_id, {'source_type': 'thread', 'source_id': thread_id})
def subscribe_commentable(user_id, commentable_id, *args, **kwargs):
return subscribe(user_id, {'source_type': 'other', 'source_id': commentable_id})
def unsubscribe(user_id, subscription_detail, *args, **kwargs):
return _perform_request('delete', _url_for_subscription(user_id), subscription_detail, *args, **kwargs)
def unsubscribe_user(user_id, followed_user_id, *args, **kwargs):
return unsubscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id})
unfollow = unsubscribe_user
def unsubscribe_thread(user_id, thread_id, *args, **kwargs):
return unsubscribe(user_id, {'source_type': 'thread', 'source_id': thread_id})
def unsubscribe_commentable(user_id, commentable_id, *args, **kwargs):
return unsubscribe(user_id, {'source_type': 'other', 'source_id': commentable_id})
def _perform_request(method, url, data_or_params=None, *args, **kwargs):
if method in ['post', 'put', 'patch']:
response = requests.request(method, url, data=data_or_params)
else:
response = requests.request(method, url, params=data_or_params)
if 200 < response.status_code < 500:
raise CommentClientError(response.text)
elif response.status_code == 500:
raise CommentClientUnknownError(response.text)
else:
if kwargs.get("raw", False):
return response.text
else:
return json.loads(response.text)
def _url_for_threads(commentable_id):
return "{prefix}/{commentable_id}/threads".format(prefix=PREFIX, commentable_id=commentable_id)
def _url_for_thread(thread_id):
return "{prefix}/threads/{thread_id}".format(prefix=PREFIX, thread_id=thread_id)
def _url_for_thread_comments(thread_id):
return "{prefix}/threads/{thread_id}/comments".format(prefix=PREFIX, thread_id=thread_id)
def _url_for_comment(comment_id):
return "{prefix}/comments/{comment_id}".format(prefix=PREFIX, comment_id=comment_id)
def _url_for_vote_comment(comment_id):
return "{prefix}/comments/{comment_id}/votes".format(prefix=PREFIX, comment_id=comment_id)
def _url_for_vote_thread(thread_id):
return "{prefix}/threads/{thread_id}/votes".format(prefix=PREFIX, thread_id=thread_id)
def _url_for_notifications(user_id):
return "{prefix}/users/{user_id}/notifications".format(prefix=PREFIX, user_id=user_id)
def _url_for_subscription(user_id):
return "{prefix}/users/{user_id}/subscriptions".format(prefix=PREFIX, user_id=user_id)
def _url_for_user(user_id):
return "{prefix}/users/{user_id}".format(prefix=PREFIX, user_id=user_id)
def _url_for_search_threads():
return "{prefix}/search/threads".format(prefix=PREFIX)
def _url_for_search_similar_threads():
return "{prefix}/search/threads/more_like_this".format(prefix=PREFIX)
def _url_for_search_recent_active_threads():
return "{prefix}/search/threads/recent_active".format(prefix=PREFIX)
def _url_for_search_trending_tags():
return "{prefix}/search/tags/trending".format(prefix=PREFIX)
def _url_for_threads_tags():
return "{prefix}/threads/tags".format(prefix=PREFIX)
def _url_for_threads_tags_autocomplete():
return "{prefix}/threads/tags/autocomplete".format(prefix=PREFIX)
def _url_for_users():
return "{prefix}/users".format(prefix=PREFIX)
from utils import *
class Model(object):
accessible_fields = ['id']
updatable_fields = ['id']
initializable_fields = ['id']
base_url = None
default_retrieve_params = {}
DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete']
DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post']
DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID
def __init__(self, *args, **kwargs):
self.attributes = extract(kwargs, self.accessible_fields)
self.retrieved = False
def __getattr__(self, name):
if name == 'id':
return self.attributes.get('id', None)
try:
return self.attributes[name]
except KeyError:
if self.retrieved or self.id == None:
raise AttributeError("Field {0} does not exist".format(name))
self.retrieve()
return self.__getattr__(name)
def __setattr__(self, name, value):
if name == 'attributes' or name not in self.accessible_fields:
super(Model, self).__setattr__(name, value)
else:
self.attributes[name] = value
def __getitem__(self, key):
if key not in self.accessible_fields:
raise KeyError("Field {0} does not exist".format(key))
return self.attributes.get(key)
def __setitem__(self, key, value):
if key not in self.accessible_fields:
raise KeyError("Field {0} does not exist".format(key))
self.attributes.__setitem__(key, value)
def get(self, *args, **kwargs):
return self.attributes.get(*args, **kwargs)
def to_dict(self):
self.retrieve()
return self.attributes
def retrieve(self, *args, **kwargs):
if not self.retrieved:
self._retrieve(*args, **kwargs)
self.retrieved = True
return self
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
response = perform_request('get', url, self.default_retrieve_params)
self.update_attributes(**response)
@classmethod
def find(cls, id):
return cls(id=id)
def update_attributes(self, *args, **kwargs):
for k, v in kwargs.items():
if k in self.accessible_fields:
self.__setattr__(k, v)
else:
raise AttributeError("Field {0} does not exist".format(k))
def updatable_attributes(self):
return extract(self.attributes, self.updatable_fields)
def initializable_attributes(self):
return extract(self.attributes, self.initializable_fields)
@classmethod
def before_save(cls, instance):
pass
@classmethod
def after_save(cls, instance):
pass
def save(self):
self.__class__.before_save(self)
if self.id: # if we have id already, treat this as an update
url = self.url(action='put', params=self.attributes)
response = perform_request('put', url, self.updatable_attributes())
else: # otherwise, treat this as an insert
url = self.url(action='post', params=self.attributes)
response = perform_request('post', url, self.initializable_attributes())
self.retrieved = True
self.update_attributes(**response)
self.__class__.after_save(self)
def delete(self):
url = self.url(action='delete', params=self.attributes)
response = perform_request('delete', url)
self.retrieved = True
self.update_attributes(**response)
@classmethod
def url_with_id(cls, params={}):
return cls.base_url + '/' + str(params['id'])
@classmethod
def url_without_id(cls, params={}):
return cls.base_url
@classmethod
def url(cls, action, params={}):
if cls.base_url is None:
raise CommentClientError("Must provide base_url when using default url function")
if action not in cls.DEFAULT_ACTIONS:
raise ValueError("Invalid action {0}. The supported action must be in {1}".format(action, str(cls.DEFAULT_ACTIONS)))
elif action in cls.DEFAULT_ACTIONS_WITH_ID:
try:
return cls.url_with_id(params)
except KeyError:
raise CommentClientError("Cannot perform action {0} without id".format(action))
else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now
return cls.url_without_id()
from django.conf import settings
if hasattr(settings, "COMMENTS_SERVICE_URL"):
SERVICE_HOST = settings.COMMENTS_SERVICE_URL
else:
SERVICE_HOST = 'http://localhost:4567'
PREFIX = SERVICE_HOST + '/api/v1'
from utils import *
import models
import settings
class Thread(models.Model):
accessible_fields = [
'id', 'title', 'body', 'anonymous',
'course_id', 'closed', 'tags', 'votes',
'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count',
'at_position_list', 'children', 'type',
]
updatable_fields = [
'title', 'body', 'anonymous', 'course_id',
'closed', 'tags', 'user_id', 'commentable_id',
]
initializable_fields = updatable_fields
base_url = "{prefix}/threads".format(prefix=settings.PREFIX)
default_retrieve_params = {'recursive': False}
type = 'thread'
@classmethod
def search(cls, query_params, *args, **kwargs):
default_params = {'page': 1,
'per_page': 20,
'course_id': query_params['course_id'],
'recursive': False}
params = merge_dict(default_params, strip_blank(strip_none(query_params)))
if query_params.get('text') or query_params.get('tags'):
url = cls.url(action='search')
else:
url = cls.url(action='get_all', params=extract(params, 'commentable_id'))
if params.get('commentable_id'):
del params['commentable_id']
response = perform_request('get', url, params, *args, **kwargs)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
@classmethod
def url_for_threads(cls, params={}):
if params.get('commentable_id'):
return "{prefix}/{commentable_id}/threads".format(prefix=settings.PREFIX, commentable_id=params['commentable_id'])
else:
return "{prefix}/threads".format(prefix=settings.PREFIX)
@classmethod
def url_for_search_threads(cls, params={}):
return "{prefix}/search/threads".format(prefix=settings.PREFIX)
@classmethod
def url(cls, action, params={}):
if action in ['get_all', 'post']:
return cls.url_for_threads(params)
elif action == 'search':
return cls.url_for_search_threads(params)
else:
return super(Thread, cls).url(action, params)
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
response = perform_request('get', url, {'recursive': kwargs.get('recursive')})
self.update_attributes(**response)
from utils import *
import models
import settings
class User(models.Model):
accessible_fields = ['username', 'email', 'follower_ids', 'upvoted_ids', 'downvoted_ids',
'id', 'external_id', 'subscribed_user_ids', 'children', 'course_id',
'subscribed_thread_ids', 'subscribed_commentable_ids',
'threads_count', 'comments_count',
]
updatable_fields = ['username', 'external_id', 'email']
initializable_fields = updatable_fields
base_url = "{prefix}/users".format(prefix=settings.PREFIX)
default_retrieve_params = {'complete': True}
type = 'user'
@classmethod
def from_django_user(cls, user):
return cls(id=str(user.id),
external_id=str(user.id),
username=user.username,
email=user.email)
def follow(self, source):
params = {'source_type': source.type, 'source_id': source.id}
response = perform_request('post', _url_for_subscription(self.id), params)
def unfollow(self, source):
params = {'source_type': source.type, 'source_id': source.id}
response = perform_request('delete', _url_for_subscription(self.id), params)
def vote(self, voteable, value):
if voteable.type == 'thread':
url = _url_for_vote_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_vote_comment(voteable.id)
else:
raise CommentClientError("Can only vote / unvote for threads or comments")
params = {'user_id': self.id, 'value': value}
request = perform_request('put', url, params)
voteable.update_attributes(request)
def unvote(self, voteable):
if voteable.type == 'thread':
url = _url_for_vote_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_vote_comment(voteable.id)
else:
raise CommentClientError("Can only vote / unvote for threads or comments")
params = {'user_id': self.id}
request = perform_request('delete', url, params)
voteable.update_attributes(request)
def active_threads(self, query_params={}):
if not self.course_id:
raise CommentClientError("Must provide course_id when retrieving active threads for the user")
url = _url_for_user_active_threads(self.id)
params = {'course_id': self.course_id}
params = merge_dict(params, query_params)
response = perform_request('get', url, params)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
retrieve_params = self.default_retrieve_params
if self.attributes.get('course_id'):
retrieve_params['course_id'] = self.course_id
response = perform_request('get', url, retrieve_params)
self.update_attributes(**response)
def _url_for_vote_comment(comment_id):
return "{prefix}/comments/{comment_id}/votes".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_vote_thread(thread_id):
return "{prefix}/threads/{thread_id}/votes".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_subscription(user_id):
return "{prefix}/users/{user_id}/subscriptions".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_user_active_threads(user_id):
return "{prefix}/users/{user_id}/active_threads".format(prefix=settings.PREFIX, user_id=user_id)
import requests
import json
def strip_none(dic):
return dict([(k, v) for k, v in dic.iteritems() if v is not None])
def strip_blank(dic):
def _is_blank(v):
return isinstance(v, str) and len(v.strip()) == 0
return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)])
def extract(dic, keys):
if isinstance(keys, str):
return strip_none({keys: dic.get(keys)})
else:
return strip_none({k: dic.get(k) for k in keys})
def merge_dict(dic1, dic2):
return dict(dic1.items() + dic2.items())
def perform_request(method, url, data_or_params=None, *args, **kwargs):
if method in ['post', 'put', 'patch']:
response = requests.request(method, url, data=data_or_params)
else:
response = requests.request(method, url, params=data_or_params)
if 200 < response.status_code < 500:
raise CommentClientError(response.text)
elif response.status_code == 500:
raise CommentClientUnknownError(response.text)
else:
if kwargs.get("raw", False):
return response.text
else:
return json.loads(response.text)
class CommentClientError(Exception):
def __init__(self, msg):
self.message = msg
def __str__(self):
return repr(self.message)
class CommentClientUnknownError(CommentClientError):
pass
...@@ -24,7 +24,6 @@ from mitxmako.shortcuts import render_to_response, render_to_string ...@@ -24,7 +24,6 @@ from mitxmako.shortcuts import render_to_response, render_to_string
import track.views import track.views
from lxml import etree from lxml import etree
from courseware.module_render import make_track_function, ModuleSystem, get_module from courseware.module_render import make_track_function, ModuleSystem, get_module
from courseware.models import StudentModule from courseware.models import StudentModule
from multicourse import multicourse_settings from multicourse import multicourse_settings
......
# Mostly adapted from math.stackexchange.com: http://cdn.sstatic.net/js/mathjax-editing-new.js
$ ->
if not MathJax?
return
HUB = MathJax.Hub
class MathJaxProcessor
MATHSPLIT = /// (
\$\$? # normal inline or display delimiter
| \\(?:begin|end)\{[a-z]*\*?\} # \begin{} \end{} style
| \\[\\{}$]
| [{}]
| (?:\n\s*)+ # only treat as math when there's single new line
| @@\d+@@ # delimiter similar to the one used internally
) ///i
CODESPAN = ///
(^|[^\\]) # match beginning or any previous character other than escape delimiter ('/')
(`+) # code span starts
([^\n]*?[^`\n]) # code content
\2 # code span ends
(?!`)
///gm
constructor: (inlineMark, displayMark) ->
@inlineMark = inlineMark || "$"
@displayMark = displayMark || "$$"
@math = null
@blocks = null
processMath: (start, last, preProcess) ->
block = @blocks.slice(start, last + 1).join("").replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
if HUB.Browser.isMSIE
block = block.replace /(%[^\n]*)\n/g, "$1<br/>\n"
@blocks[i] = "" for i in [start+1..last]
@blocks[start] = "@@#{@math.length}@@"
block = preProcess(block) if preProcess
@math.push block
removeMath: (text) ->
@math = []
start = end = last = null
braces = 0
hasCodeSpans = /`/.test text
if hasCodeSpans
text = text.replace(/~/g, "~T").replace CODESPAN, ($0) -> # replace dollar sign in code span temporarily
$0.replace /\$/g, "~D"
deTilde = (text) ->
text.replace /~([TD])/g, ($0, $1) ->
{T: "~", D: "$"}[$1]
else
deTilde = (text) -> text
@blocks = _split(text.replace(/\r\n?/g, "\n"), MATHSPLIT)
for current in [1...@blocks.length] by 2
block = @blocks[current]
if block.charAt(0) == "@"
@blocks[current] = "@@#{@math.length}@@"
@math.push block
else if start
if block == end
if braces
last = current
else
@processMath(start, current, deTilde)
start = end = last = null
else if block.match /\n.*\n/
if last
current = last
@processMath(start, current, deTilde)
start = end = last = null
braces = 0
else if block == "{"
++braces
else if block == "}" and braces
--braces
else
if block == @inlineMark or block == @displayMark
start = current
end = block
braces = 0
else if block.substr(1, 5) == "begin"
start = current
end = "\\end" + block.substr(6)
braces = 0
if last
@processMath(start, last, deTilde)
start = end = last = null
deTilde(@blocks.join(""))
@removeMathWrapper: (_this) ->
(text) -> _this.removeMath(text)
replaceMath: (text) ->
text = text.replace /@@(\d+)@@/g, ($0, $1) => @math[$1]
@math = null
text
@replaceMathWrapper: (_this) ->
(text) -> _this.replaceMath(text)
if Markdown?
Markdown.getMathCompatibleConverter = (postProcessor) ->
postProcessor ||= ((text) -> text)
converter = Markdown.getSanitizingConverter()
processor = new MathJaxProcessor()
converter.hooks.chain "preConversion", MathJaxProcessor.removeMathWrapper(processor)
converter.hooks.chain "postConversion", (text) ->
postProcessor(MathJaxProcessor.replaceMathWrapper(processor)(text))
converter
Markdown.makeWmdEditor = (elem, appended_id, imageUploadUrl, postProcessor) ->
$elem = $(elem)
if not $elem.length
console.log "warning: elem for makeWmdEditor doesn't exist"
return
if not $elem.find(".wmd-panel").length
initialText = $elem.html()
$elem.empty()
_append = appended_id || ""
$wmdPanel = $("<div>").addClass("wmd-panel")
.append($("<div>").attr("id", "wmd-button-bar#{_append}"))
.append($("<textarea>").addClass("wmd-input").attr("id", "wmd-input#{_append}").html(initialText))
.append($("<div>").attr("id", "wmd-preview#{_append}").addClass("wmd-panel wmd-preview"))
$elem.append($wmdPanel)
converter = Markdown.getMathCompatibleConverter(postProcessor)
ajaxFileUpload = (imageUploadUrl, input, startUploadHandler) ->
$("#loading").ajaxStart(-> $(this).show()).ajaxComplete(-> $(this).hide())
$("#upload").ajaxStart(-> $(this).hide()).ajaxComplete(-> $(this).show())
$.ajaxFileUpload
url: imageUploadUrl
secureuri: false
fileElementId: 'file-upload'
dataType: 'json'
success: (data, status) ->
fileURL = data['result']['file_url']
error = data['result']['error']
if error != ''
alert error
if startUploadHandler
$('#file-upload').unbind('change').change(startUploadHandler)
console.log error
else
$(input).attr('value', fileURL)
error: (data, status, e) ->
alert(e)
if startUploadHandler
$('#file-upload').unbind('change').change(startUploadHandler)
imageUploadHandler = (elem, input) ->
ajaxFileUpload(imageUploadUrl, input, imageUploadHandler)
editor = new Markdown.Editor(
converter,
appended_id, # idPostfix
null, # help handler
imageUploadHandler
)
delayRenderer = new MathJaxDelayRenderer()
editor.hooks.chain "onPreviewPush", (text, previewSet) ->
delayRenderer.render
text: text
previewSetter: previewSet
editor.run()
editor
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
initializeVote = (content) ->
$content = $(content)
$local = Discussion.generateLocal($content.children(".discussion-content"))
id = $content.attr("_id")
if Discussion.isUpvoted id
$local(".discussion-vote-up").addClass("voted")
else if Discussion.isDownvoted id
$local(".discussion-vote-down").addClass("voted")
initializeFollowThread = (thread) ->
$thread = $(thread)
id = $thread.attr("_id")
$thread.children(".discussion-content")
.find(".follow-wrapper")
.append(Discussion.subscriptionLink('thread', id))
@Discussion = $.extend @Discussion,
bindContentEvents: (content) ->
$content = $(content)
$discussionContent = $content.children(".discussion-content")
$local = Discussion.generateLocal($discussionContent)
id = $content.attr("_id")
handleReply = (elem) ->
$replyView = $local(".discussion-reply-new")
if $replyView.length
$replyView.show()
else
thread_id = $discussionContent.parents(".thread").attr("_id")
view =
id: id
showWatchCheckbox: not Discussion.isSubscribed(thread_id, "thread")
$discussionContent.append Mustache.render Discussion.replyTemplate, view
Discussion.makeWmdEditor $content, $local, "reply-body"
$local(".discussion-submit-post").click -> handleSubmitReply(this)
$local(".discussion-cancel-post").click -> handleCancelReply(this)
$local(".discussion-reply").hide()
$local(".discussion-edit").hide()
handleCancelReply = (elem) ->
$replyView = $local(".discussion-reply-new")
if $replyView.length
$replyView.hide()
$local(".discussion-reply").show()
$local(".discussion-edit").show()
handleSubmitReply = (elem) ->
if $content.hasClass("thread")
url = Discussion.urlFor('create_comment', id)
else if $content.hasClass("comment")
url = Discussion.urlFor('create_sub_comment', id)
else
return
body = Discussion.getWmdContent $content, $local, "reply-body"
anonymous = false || $local(".discussion-post-anonymously").is(":checked")
autowatch = false || $local(".discussion-auto-watch").is(":checked")
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: 'json'
data:
body: body
anonymous: anonymous
autowatch: autowatch
error: Discussion.formErrorHandler($local(".discussion-errors"))
success: (response, textStatus) ->
Discussion.clearFormErrors($local(".discussion-errors"))
$comment = $(response.html)
$content.children(".comments").prepend($comment)
Discussion.setWmdContent $content, $local, "reply-body", ""
Discussion.setContentInfo response.content['id'], 'can_reply', true
Discussion.setContentInfo response.content['id'], 'editable', true
Discussion.extendContentInfo response.content['id'], response['annotated_content_info']
Discussion.initializeContent($comment)
Discussion.bindContentEvents($comment)
$local(".discussion-reply-new").hide()
$local(".discussion-reply").show()
$local(".discussion-edit").show()
$discussionContent.attr("status", "normal")
handleVote = (elem, value) ->
contentType = if $content.hasClass("thread") then "thread" else "comment"
url = Discussion.urlFor("#{value}vote_#{contentType}", id)
Discussion.safeAjax
$elem: $local(".discussion-vote")
url: url
type: "POST"
dataType: "json"
success: (response, textStatus) ->
if textStatus == "success"
$local(".discussion-vote").removeClass("voted")
$local(".discussion-vote-#{value}").addClass("voted")
$local(".discussion-votes-point").html response.votes.point
handleUnvote = (elem, value) ->
contentType = if $content.hasClass("thread") then "thread" else "comment"
url = Discussion.urlFor("undo_vote_for_#{contentType}", id)
Discussion.safeAjax
$elem: $local(".discussion-vote")
url: url
type: "POST"
dataType: "json"
success: (response, textStatus) ->
if textStatus == "success"
$local(".discussion-vote").removeClass("voted")
$local(".discussion-votes-point").html response.votes.point
handleCancelEdit = (elem) ->
$local(".discussion-content-edit").hide()
$local(".discussion-content-wrapper").show()
handleEditThread = (elem) ->
$local(".discussion-content-wrapper").hide()
$editView = $local(".discussion-content-edit")
if $editView.length
$editView.show()
else
view = {
id: id
title: $local(".thread-raw-title").html()
body: $local(".thread-raw-body").html()
tags: $local(".thread-raw-tags").html()
}
$discussionContent.append Mustache.render Discussion.editThreadTemplate, view
Discussion.makeWmdEditor $content, $local, "thread-body-edit"
$local(".thread-tags-edit").tagsInput Discussion.tagsInputOptions()
$local(".discussion-submit-update").unbind("click").click -> handleSubmitEditThread(this)
$local(".discussion-cancel-update").unbind("click").click -> handleCancelEdit(this)
handleSubmitEditThread = (elem) ->
url = Discussion.urlFor('update_thread', id)
title = $local(".thread-title-edit").val()
body = Discussion.getWmdContent $content, $local, "thread-body-edit"
tags = $local(".thread-tags-edit").val()
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: 'json'
data: {title: title, body: body, tags: tags},
error: Discussion.formErrorHandler($local(".discussion-update-errors"))
success: (response, textStatus) ->
Discussion.clearFormErrors($local(".discussion-update-errors"))
$discussionContent.replaceWith(response.html)
Discussion.extendContentInfo response.content['id'], response['annotated_content_info']
Discussion.initializeContent($content)
Discussion.bindContentEvents($content)
handleEditComment = (elem) ->
$local(".discussion-content-wrapper").hide()
$editView = $local(".discussion-content-edit")
if $editView.length
$editView.show()
else
view = { id: id, body: $local(".comment-raw-body").html() }
$discussionContent.append Mustache.render Discussion.editCommentTemplate, view
Discussion.makeWmdEditor $content, $local, "comment-body-edit"
$local(".discussion-submit-update").unbind("click").click -> handleSubmitEditComment(this)
$local(".discussion-cancel-update").unbind("click").click -> handleCancelEdit(this)
handleSubmitEditComment= (elem) ->
url = Discussion.urlFor('update_comment', id)
body = Discussion.getWmdContent $content, $local, "comment-body-edit"
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: "json"
data: {body: body}
error: Discussion.formErrorHandler($local(".discussion-update-errors"))
success: (response, textStatus) ->
Discussion.clearFormErrors($local(".discussion-update-errors"))
$discussionContent.replaceWith(response.html)
Discussion.extendContentInfo response.content['id'], response['annotated_content_info']
Discussion.initializeContent($content)
Discussion.bindContentEvents($content)
handleEndorse = (elem, endorsed) ->
url = Discussion.urlFor('endorse_comment', id)
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: "json"
data: {endorsed: endorsed}
success: (response, textStatus) ->
if textStatus == "success"
if endorsed
$(content).addClass("endorsed")
else
$(content).removeClass("endorsed")
$(elem).unbind('click').click ->
handleEndorse(elem, !endorsed)
handleOpenClose = (elem, text) ->
url = Discussion.urlFor('openclose_thread', id)
closed = undefined
if text.match(/Close/)
closed = true
else if text.match(/[Oo]pen/)
closed = false
else
console.log "Unexpected text " + text + "for open/close thread."
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: "json"
data: {closed: closed}
success: (response, textStatus) =>
if textStatus == "success"
if closed
$(content).addClass("closed")
$(elem).text "Re-open Thread"
else
$(content).removeClass("closed")
$(elem).text "Close Thread"
error: (response, textStatus, e) ->
console.log e
handleDelete = (elem) ->
if $content.hasClass("thread")
url = Discussion.urlFor('delete_thread', id)
c = confirm "Are you sure to delete thread \"" + $content.find("a.thread-title").text() + "\"?"
else
url = Discussion.urlFor('delete_comment', id)
c = confirm "Are you sure to delete this comment? "
if c != true
return
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: "json"
data: {}
success: (response, textStatus) =>
if textStatus == "success"
$(content).remove()
error: (response, textStatus, e) ->
console.log e
handleHideSingleThread = (elem) ->
$threadTitle = $local(".thread-title")
$hideComments = $local(".discussion-hide-comments")
$hideComments.removeClass("discussion-hide-comments")
.addClass("discussion-show-comments")
$content.children(".comments").hide()
$threadTitle.unbind('click').click handleShowSingleThread
$hideComments.unbind('click').click handleShowSingleThread
prevHtml = $hideComments.html()
$hideComments.html prevHtml.replace "Hide", "Show"
handleShowSingleThread = ->
$threadTitle = $local(".thread-title")
$showComments = $local(".discussion-show-comments")
if not $showComments.hasClass("first-time") and (not $showComments.length or not $threadTitle.length)
return
rebindHideEvents = ->
$threadTitle.unbind('click').click handleHideSingleThread
$showComments.unbind('click').click handleHideSingleThread
$showComments.removeClass("discussion-show-comments")
.addClass("discussion-hide-comments")
prevHtml = $showComments.html()
$showComments.html prevHtml.replace "Show", "Hide"
if not $showComments.hasClass("first-time") and $content.children(".comments").length
$content.children(".comments").show()
rebindHideEvents()
else
discussion_id = $threadTitle.parents(".discussion").attr("_id")
url = Discussion.urlFor('retrieve_single_thread', discussion_id, id)
Discussion.safeAjax
$elem: $.merge($threadTitle, $showComments)
url: url
type: "GET"
dataType: 'json'
success: (response, textStatus) ->
Discussion.bulkExtendContentInfo response['annotated_content_info']
$content.append(response['html'])
$content.find(".comment").each (index, comment) ->
Discussion.initializeContent(comment)
Discussion.bindContentEvents(comment)
$showComments.removeClass("first-time")
rebindHideEvents()
Discussion.bindLocalEvents $local,
"click .thread-title": ->
handleShowSingleThread(this)
"click .discussion-show-comments": ->
handleShowSingleThread(this)
"click .discussion-hide-comments": ->
handleHideSingleThread(this)
"click .discussion-reply-thread": ->
handleShowSingleThread($local(".thread-title"))
handleReply(this)
"click .discussion-reply-comment": ->
handleReply(this)
"click .discussion-cancel-reply": ->
handleCancelReply(this)
"click .discussion-vote-up": ->
$elem = $(this)
if $elem.hasClass("voted")
handleUnvote($elem)
else
handleVote($elem, "up")
"click .discussion-vote-down": ->
$elem = $(this)
if $elem.hasClass("voted")
handleUnvote($elem)
else
handleVote($elem, "down")
"click .admin-endorse": ->
handleEndorse(this, not $content.hasClass("endorsed"))
"click .admin-openclose": ->
handleOpenClose(this, $(this).text())
"click .admin-edit": ->
if $content.hasClass("thread")
handleEditThread(this)
else
handleEditComment(this)
"click .admin-delete": ->
handleDelete(this)
initializeContent: (content) ->
unescapeHighlightTag = (text) ->
text.replace(/\&lt\;highlight\&gt\;/g, "<span class='search-highlight'>")
.replace(/\&lt\;\/highlight\&gt\;/g, "</span>")
stripHighlight = (text, type) ->
text.replace(/\&(amp\;)?lt\;highlight\&(amp\;)?gt\;/g, "")
.replace(/\&(amp\;)?lt\;\/highlight\&(amp\;)?gt\;/g, "")
stripLatexHighlight = (text) ->
Discussion.processEachMathAndCode text, stripHighlight
markdownWithHighlight = (text) ->
converter = Markdown.getMathCompatibleConverter()
unescapeHighlightTag stripLatexHighlight converter.makeHtml text
$content = $(content)
initializeVote $content
if $content.hasClass("thread")
initializeFollowThread $content
$local = Discussion.generateLocal($content.children(".discussion-content"))
$local("span.timeago").timeago()
$contentTitle = $local(".thread-title")
if $contentTitle.length
$contentTitle.html unescapeHighlightTag stripLatexHighlight $contentTitle.html()
$contentBody = $local(".content-body")
$contentBody.html Discussion.postMathJaxProcessor markdownWithHighlight $contentBody.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, $contentBody.attr("id")]
id = $content.attr("_id")
if $content.hasClass("thread")
discussion_id = $content.attr("_discussion_id")
permalink = Discussion.urlFor("permanent_link_thread", discussion_id, id)
else
thread_id = $content.parents(".thread").attr("_id")
discussion_id = $content.parents(".thread").attr("_discussion_id")
permalink = Discussion.urlFor("permanent_link_comment", discussion_id, thread_id, id)
$local(".discussion-permanent-link").attr "href", permalink
if not Discussion.getContentInfo id, 'editable'
$local(".admin-edit").remove()
if not Discussion.getContentInfo id, 'can_reply'
$local(".discussion-reply").remove()
if not Discussion.getContentInfo id, 'can_endorse'
$local(".admin-endorse").remove()
if not Discussion.getContentInfo id, 'can_delete'
$local(".admin-delete").remove()
if not Discussion.getContentInfo id, 'can_openclose'
$local(".admin-openclose").remove()
#if not Discussion.getContentInfo id, 'can_vote'
# $local(".discussion-vote").css "visibility", "hidden"
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
initializeFollowDiscussion = (discussion) ->
$discussion = $(discussion)
id = $following.attr("_id")
$local = Discussion.generateLocal()
$discussion.children(".discussion-non-content")
.find(".discussion-title-wrapper")
.append(Discussion.subscriptionLink('discussion', id))
@Discussion = $.extend @Discussion,
initializeDiscussion: (discussion) ->
$discussion = $(discussion)
$discussion.find(".thread").each (index, thread) ->
Discussion.initializeContent(thread)
Discussion.bindContentEvents(thread)
$discussion.find(".comment").each (index, comment) ->
Discussion.initializeContent(comment)
Discussion.bindContentEvents(comment)
#initializeFollowDiscussion(discussion) TODO move this somewhere else
bindDiscussionEvents: (discussion) ->
$discussion = $(discussion)
$discussionNonContent = $discussion.children(".discussion-non-content")
$local = Discussion.generateLocal($discussion.children(".discussion-local"))
id = $discussion.attr("_id")
handleSubmitNewPost = (elem) ->
title = $local(".new-post-title").val()
body = Discussion.getWmdContent $discussion, $local, "new-post-body"
tags = $local(".new-post-tags").val()
url = Discussion.urlFor('create_thread', id)
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: 'json'
data:
title: title
body: body
tags: tags
error: Discussion.formErrorHandler($local(".new-post-form-errors"))
success: (response, textStatus) ->
Discussion.clearFormErrors($local(".new-post-form-errors"))
$thread = $(response.html)
$discussion.children(".threads").prepend($thread)
$local(".new-post-title").val("")
Discussion.setWmdContent $discussion, $local, "new-post-body", ""
$local(".new-post-tags").val("")
if $discussion.hasClass("inline-discussion")
$local(".new-post-form").addClass("collapsed")
else if $discussion.hasClass("forum-discussion")
$local(".new-post-form").hide()
handleCancelNewPost = (elem) ->
if $discussion.hasClass("inline-discussion")
$local(".new-post-form").addClass("collapsed")
else if $discussion.hasClass("forum-discussion")
$local(".new-post-form").hide()
handleSimilarPost = (elem) ->
$title = $local(".new-post-title")
$wrapper = $local(".new-post-similar-posts-wrapper")
$similarPosts = $local(".new-post-similar-posts")
prevText = $title.attr("prev-text")
text = $title.val()
if text == prevText
if $local(".similar-post").length
$wrapper.show()
else if $.trim(text).length
Discussion.safeAjax
$elem: $(elem)
url: Discussion.urlFor 'search_similar_threads', id
type: "GET"
dateType: 'json'
data:
text: $local(".new-post-title").val()
success: (response, textStatus) ->
$similarPosts.empty()
console.log response
if $.type(response) == "array" and response.length
$wrapper.show()
for thread in response
#singleThreadUrl = Discussion.urlFor 'retrieve_single_thread
$similarPost = $("<a>").addClass("similar-post")
.html(thread["title"])
.attr("href", "javascript:void(0)") #TODO
.appendTo($similarPosts)
else
$wrapper.hide()
else
$wrapper.hide()
$title.attr("prev-text", text)
initializeNewPost = ->
view = { discussion_id: id }
$discussionNonContent = $discussion.children(".discussion-non-content")
if not $local(".wmd-panel").length
$discussionNonContent.append Mustache.render Discussion.newPostTemplate, view
$newPostBody = $local(".new-post-body")
Discussion.makeWmdEditor $discussion, $local, "new-post-body"
$input = Discussion.getWmdInput($discussion, $local, "new-post-body")
$input.attr("placeholder", "post a new topic...")
if $discussion.hasClass("inline-discussion")
$input.bind 'focus', (e) ->
$local(".new-post-form").removeClass('collapsed')
else if $discussion.hasClass("forum-discussion")
$local(".new-post-form").removeClass('collapsed')
$local(".new-post-tags").tagsInput Discussion.tagsInputOptions()
$local(".new-post-title").blur ->
handleSimilarPost(this)
$local(".hide-similar-posts").click ->
$local(".new-post-similar-posts-wrapper").hide()
$local(".discussion-submit-post").click ->
handleSubmitNewPost(this)
$local(".discussion-cancel-post").click ->
handleCancelNewPost(this)
$local(".new-post-form").show()
handleAjaxReloadDiscussion = (elem, url) ->
if not url then return
$elem = $(elem)
$discussion = $elem.parents("section.discussion")
Discussion.safeAjax
$elem: $elem
url: url
type: "GET"
dataType: 'html'
success: (data, textStatus) ->
$data = $(data)
$parent = $discussion.parent()
$discussion.replaceWith($data)
$discussion = $parent.children(".discussion")
Discussion.initializeDiscussion($discussion)
Discussion.bindDiscussionEvents($discussion)
handleAjaxSearch = (elem) ->
$elem = $(elem)
url = URI($elem.attr("action")).addSearch({text: $local(".search-input").val()})
handleAjaxReloadDiscussion($elem, url)
handleAjaxSort = (elem) ->
$elem = $(elem)
url = $elem.attr("sort-url")
handleAjaxReloadDiscussion($elem, url)
handleAjaxPage = (elem) ->
$elem = $(elem)
url = $elem.attr("page-url")
handleAjaxReloadDiscussion($elem, url)
if $discussion.hasClass("inline-discussion")
initializeNewPost()
if $discussion.hasClass("forum-discussion")
$discussionSidebar = $(".discussion-sidebar")
if $discussionSidebar.length
$sidebarLocal = Discussion.generateLocal($discussionSidebar)
Discussion.bindLocalEvents $sidebarLocal,
"click .sidebar-new-post-button": (event) ->
initializeNewPost()
Discussion.bindLocalEvents $local,
"submit .search-wrapper>.discussion-search-form": (event) ->
event.preventDefault()
handleAjaxSearch(this)
"click .discussion-search-link": ->
handleAjaxSearch($local(".search-wrapper>.discussion-search-form"))
"click .discussion-sort-link": ->
handleAjaxSort(this)
$discussion.children(".discussion-paginator").find(".discussion-page-link").unbind('click').click ->
handleAjaxPage(this)
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
@Discussion = $.extend @Discussion,
initializeDiscussionModule: (elem) ->
$discussionModule = $(elem)
$local = Discussion.generateLocal($discussionModule)
handleShowDiscussion = (elem) ->
$elem = $(elem)
if not $local("section.discussion").length
discussion_id = $elem.attr("discussion_id")
url = Discussion.urlFor 'retrieve_discussion', discussion_id
Discussion.safeAjax
$elem: $elem
url: url
type: "GET"
success: (data, textStatus, xhr) ->
$discussionModule.append(data)
discussion = $local("section.discussion")
Discussion.initializeDiscussion(discussion)
Discussion.bindDiscussionEvents(discussion)
$elem.html("Hide Discussion")
$elem.unbind('click').click ->
handleHideDiscussion(this)
dataType: 'html'
else
$local("section.discussion").show()
$elem.html("Hide Discussion")
$elem.unbind('click').click ->
handleHideDiscussion(this)
handleHideDiscussion = (elem) ->
$local("section.discussion").hide()
$elem = $(elem)
$elem.html("Show Discussion")
$elem.unbind('click').click ->
handleShowDiscussion(this)
$local(".discussion-show").click ->
handleShowDiscussion(this)
$ ->
toggle = ->
$('.course-wrapper').toggleClass('closed')
Discussion = window.Discussion
if $('#accordion').length
active = $('#accordion ul:has(li.active)').index('#accordion ul')
$('#accordion').bind('accordionchange', @log).accordion
active: if active >= 0 then active else 1
header: 'h3'
autoHeight: false
$('#open_close_accordion a').click toggle
$('#accordion').show()
$(".discussion-module").each (index, elem) ->
Discussion.initializeDiscussionModule(elem)
$("section.discussion").each (index, discussion) ->
Discussion.initializeDiscussion(discussion)
Discussion.bindDiscussionEvents(discussion)
Discussion.initializeUserProfile($(".discussion-sidebar>.user-profile"))
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
@Discussion = $.extend @Discussion,
newPostTemplate: """
<form class="new-post-form collapsed" id="new-post-form" style="display: block; ">
<ul class="new-post-form-errors discussion-errors"></ul>
<input type="text" class="new-post-title title-input" placeholder="Title" />
<div class="new-post-similar-posts-wrapper" style="display: none">
Similar Posts:
<a class="hide-similar-posts" href="javascript:void(0)">Hide</a>
<div class="new-post-similar-posts"></div>
</div>
<div class="new-post-body reply-body"></div>
<input class="new-post-tags" placeholder="Tags" />
<div class="post-options">
<input type="checkbox" class="discussion-post-anonymously" id="discussion-post-anonymously-${discussion_id}">
<label for="discussion-post-anonymously-${discussion_id}">post anonymously</label>
<input type="checkbox" class="discussion-auto-watch" id="discussion-autowatch-${discussion_id}" checked="">
<label for="discussion-auto-watch-${discussion_id}">follow this thread</label>
</div>
<div class="new-post-control post-control">
<a class="discussion-cancel-post" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-post control-button" href="javascript:void(0)">Submit</a>
</div>
</form>
"""
replyTemplate: """
<form class="discussion-reply-new">
<ul class="discussion-errors"></ul>
<div class="reply-body"></div>
<input type="checkbox" class="discussion-post-anonymously" id="discussion-post-anonymously-{{id}}" />
<label for="discussion-post-anonymously-{{id}}">post anonymously</label>
{{#showWatchCheckbox}}
<input type="checkbox" class="discussion-auto-watch" id="discussion-autowatch-{{id}}" checked />
<label for="discussion-auto-watch-{{id}}">follow this thread</label>
{{/showWatchCheckbox}}
<br />
<div class = "reply-post-control">
<a class="discussion-cancel-post" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-post control-button" href="javascript:void(0)">Submit</a>
</div>
</form>
"""
editThreadTemplate: """
<form class="discussion-content-edit discussion-thread-edit" _id="{{id}}">
<ul class="discussion-errors discussion-update-errors"></ul>
<input type="text" class="thread-title-edit title-input" placeholder="Title" value="{{title}}"/>
<div class="thread-body-edit body-input">{{body}}</div>
<input class="thread-tags-edit" placeholder="Tags" value="{{tags}}" />
<div class = "edit-post-control">
<a class="discussion-cancel-update" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-update control-button" href="javascript:void(0)">Update</a>
</div>
</form>
"""
editCommentTemplate: """
<form class="discussion-content-edit discussion-comment-edit" _id="{{id}}">
<ul class="discussion-errors discussion-update-errors"></ul>
<div class="comment-body-edit body-input">{{body}}</div>
<div class = "edit-post-control">
<a class="discussion-cancel-update" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-update control-button" href="javascript:void(0)">Update</a>
</div>
</form>
"""
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
@Discussion = $.extend @Discussion,
initializeUserProfile: ($userProfile) ->
$local = Discussion.generateLocal $userProfile
handleUpdateModeratorStatus = (elem, isModerator) ->
confirmValue = confirm("Are you sure?")
if not confirmValue then return
url = Discussion.urlFor('update_moderator_status', $$profiled_user_id)
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: 'json'
data:
is_moderator: isModerator
error: (response, textStatus, e) ->
console.log e
success: (response, textStatus) ->
parent = $userProfile.parent()
$userProfile.replaceWith(response.html)
Discussion.initializeUserProfile parent.children(".user-profile")
Discussion.bindLocalEvents $local,
"click .sidebar-revoke-moderator-button": (event) ->
handleUpdateModeratorStatus(this, false)
"click .sidebar-promote-moderator-button": (event) ->
handleUpdateModeratorStatus(this, true)
initializeUserActiveDiscussion: ($discussion) ->
if not @Discussion?
@Discussion = {}
Discussion = @Discussion
wmdEditors = {}
@Discussion = $.extend @Discussion,
generateLocal: (elem) ->
(selector) -> $(elem).find(selector)
generateDiscussionLink: (cls, txt, handler) ->
$("<a>").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: Discussion.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($("<li>").addClass("new-post-form-error").html(error))
clearFormErrors: (errorsField) ->
errorsField.empty()
postMathJaxProcessor: (text) ->
RE_INLINEMATH = /^\$([^\$]*)\$/g
RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g
Discussion.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 = Discussion.urlFor('upload')
editor = Markdown.makeWmdEditor elem, appended_id, imageUploadUrl, Discussion.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) ->
Discussion.getWmdInput($content, $local, cls_identifier).val()
setWmdContent: ($content, $local, cls_identifier, text) ->
Discussion.getWmdInput($content, $local, cls_identifier).val(text)
Discussion.getWmdEditor($content, $local, cls_identifier).refreshPreview()
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
subscriptionLink: (type, id) ->
followLink = ->
Discussion.generateDiscussionLink("discussion-follow-#{type}", "Follow", handleFollow)
unfollowLink = ->
Discussion.generateDiscussionLink("discussion-unfollow-#{type}", "Unfollow", handleUnfollow)
handleFollow = (elem) ->
Discussion.safeAjax
$elem: $(elem)
url: Discussion.urlFor("follow_#{type}", id)
type: "POST"
success: (response, textStatus) ->
if textStatus == "success"
$(elem).replaceWith unfollowLink()
dataType: 'json'
handleUnfollow = (elem) ->
Discussion.safeAjax
$elem: $(elem)
url: Discussion.urlFor("unfollow_#{type}", id)
type: "POST"
success: (response, textStatus) ->
if textStatus == "success"
$(elem).replaceWith followLink()
dataType: 'json'
if Discussion.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 = $("<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 = $("<div>").html(text)
cnt = 0
$div.find("code").each (index, code) ->
$(code).html(processor(codeArchive[cnt], 'code'))
cnt += 1
text = $div.html()
text
getTime = ->
new Date().getTime()
class @MathJaxDelayRenderer
maxDelay: 3000
mathjaxRunning: false
elapsedTime: 0
mathjaxDelay: 0
mathjaxTimeout: undefined
bufferId = "mathjax_delay_buffer"
numBuffers = 0
constructor: (params) ->
params = params || {}
@maxDelay = params["maxDelay"] || @maxDelay
@bufferId = params["bufferId"] || (bufferId + numBuffers)
numBuffers += 1
@$buffer = $("<div>").attr("id", @bufferId).css("display", "none").appendTo($("body"))
# render: (params) ->
# params:
# elem: jquery element to be rendered
# text: text to be rendered & put into the element;
# if blank, then just render the current text in the element
# preprocessor: pre-process the text before rendering using MathJax
# if text is blank, it will pre-process the html in the element
# previewSetter: if provided, will pass text back to it instead of
# directly setting the element
render: (params) ->
elem = params["element"]
previewSetter = params["previewSetter"]
text = params["text"]
if not text?
text = $(elem).html()
preprocessor = params["preprocessor"]
if params["delay"] == false
if preprocessor?
text = preprocessor(text)
$(elem).html(text)
MathJax.Hub.Queue ["Typeset", MathJax.Hub, $(elem).attr("id")]
else
if @mathjaxTimeout
window.clearTimeout(@mathjaxTimeout)
@mathjaxTimeout = undefined
delay = Math.min @elapsedTime + @mathjaxDelay, @maxDelay
renderer = =>
if @mathjaxRunning
return
prevTime = getTime()
if preprocessor?
text = preprocessor(text)
@$buffer.html(text)
curTime = getTime()
@elapsedTime = curTime - prevTime
if MathJax
prevTime = getTime()
@mathjaxRunning = true
MathJax.Hub.Queue ["Typeset", MathJax.Hub, @$buffer.attr("id")], =>
@mathjaxRunning = false
curTime = getTime()
@mathjaxDelay = curTime - prevTime
if previewSetter
previewSetter($(@$buffer).html())
else
$(elem).html($(@$buffer).html())
else
@mathjaxDelay = 0
@mathjaxTimeout = window.setTimeout(renderer, delay)
.acInput {
width: 200px;
}
.acResults {
padding: 0px;
border: 1px solid WindowFrame;
background-color: Window;
overflow: hidden;
}
.acResults ul {
margin: 0px;
padding: 0px;
list-style-position: outside;
list-style: none;
}
.acResults ul li {
margin: 0px;
padding: 2px 5px;
cursor: pointer;
display: block;
font: menu;
font-size: 12px;
overflow: hidden;
}
.acLoading {
background : url('indicator.gif') right center no-repeat;
}
.acSelect {
background-color: Highlight;
color: HighlightText;
}
div.tagsinput { border:1px solid #CCC; background: #FFF; padding:5px; width:300px; height:100px; overflow-y: auto;}
div.tagsinput span.tag { border: 1px solid #a5d24a; -moz-border-radius:2px; -webkit-border-radius:2px; display: block; float: left; padding: 5px; text-decoration:none; background: #cde69c; color: #638421; margin-right: 5px; margin-bottom:5px;font-family: helvetica; font-size:13px;}
div.tagsinput span.tag a { font-weight: bold; color: #82ad2b; text-decoration:none; font-size: 11px; }
div.tagsinput input { width:80px; margin:0px; font-family: helvetica; font-size: 13px; border:1px solid transparent; padding:5px; background: transparent; color: #000; outline:0px; margin-right:5px; margin-bottom:5px; }
div.tagsinput div { display:block; float: left; }
.tags_clear { clear: both; width: 100%; height: 0px; }
.not_valid {background: #FBD8DB !important; color: #90111A !important;}
var Markdown;
if (typeof exports === "object" && typeof require === "function") // we're in a CommonJS (e.g. Node.js) module
Markdown = exports;
else
Markdown = {};
// The following text is included for historical reasons, but should
// be taken with a pinch of salt; it's not all true anymore.
//
// Wherever possible, Showdown is a straight, line-by-line port
// of the Perl version of Markdown.
//
// This is not a normal parser design; it's basically just a
// series of string substitutions. It's hard to read and
// maintain this way, but keeping Showdown close to the original
// design makes it easier to port new features.
//
// More importantly, Showdown behaves like markdown.pl in most
// edge cases. So web applications can do client-side preview
// in Javascript, and then build identical HTML on the server.
//
// This port needs the new RegExp functionality of ECMA 262,
// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers
// should do fine. Even with the new regular expression features,
// We do a lot of work to emulate Perl's regex functionality.
// The tricky changes in this file mostly have the "attacklab:"
// label. Major or self-explanatory changes don't.
//
// Smart diff tools like Araxis Merge will be able to match up
// this file with markdown.pl in a useful way. A little tweaking
// helps: in a copy of markdown.pl, replace "#" with "//" and
// replace "$text" with "text". Be sure to ignore whitespace
// and line endings.
//
//
// Usage:
//
// var text = "Markdown *rocks*.";
//
// var converter = new Markdown.Converter();
// var html = converter.makeHtml(text);
//
// alert(html);
//
// Note: move the sample code to the bottom of this
// file before uncommenting it.
//
(function () {
function identity(x) { return x; }
function returnFalse(x) { return false; }
function HookCollection() { }
HookCollection.prototype = {
chain: function (hookname, func) {
var original = this[hookname];
if (!original)
throw new Error("unknown hook " + hookname);
if (original === identity)
this[hookname] = func;
else
this[hookname] = function (x) { return func(original(x)); }
},
set: function (hookname, func) {
if (!this[hookname])
throw new Error("unknown hook " + hookname);
this[hookname] = func;
},
addNoop: function (hookname) {
this[hookname] = identity;
},
addFalse: function (hookname) {
this[hookname] = returnFalse;
}
};
Markdown.HookCollection = HookCollection;
// g_urls and g_titles allow arbitrary user-entered strings as keys. This
// caused an exception (and hence stopped the rendering) when the user entered
// e.g. [push] or [__proto__]. Adding a prefix to the actual key prevents this
// (since no builtin property starts with "s_"). See
// http://meta.stackoverflow.com/questions/64655/strange-wmd-bug
// (granted, switching from Array() to Object() alone would have left only __proto__
// to be a problem)
function SaveHash() { }
SaveHash.prototype = {
set: function (key, value) {
this["s_" + key] = value;
},
get: function (key) {
return this["s_" + key];
}
};
Markdown.Converter = function () {
var pluginHooks = this.hooks = new HookCollection();
pluginHooks.addNoop("plainLinkText"); // given a URL that was encountered by itself (without markup), should return the link text that's to be given to this link
pluginHooks.addNoop("preConversion"); // called with the orignal text as given to makeHtml. The result of this plugin hook is the actual markdown source that will be cooked
pluginHooks.addNoop("postConversion"); // called with the final cooked HTML code. The result of this plugin hook is the actual output of makeHtml
//
// Private state of the converter instance:
//
// Global hashes, used by various utility routines
var g_urls;
var g_titles;
var g_html_blocks;
// Used to track when we're inside an ordered or unordered list
// (see _ProcessListItems() for details):
var g_list_level;
this.makeHtml = function (text) {
//
// Main function. The order in which other subs are called here is
// essential. Link and image substitutions need to happen before
// _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the <a>
// and <img> tags get encoded.
//
// This will only happen if makeHtml on the same converter instance is called from a plugin hook.
// Don't do that.
if (g_urls)
throw new Error("Recursive call to converter.makeHtml");
// Create the private state objects.
g_urls = new SaveHash();
g_titles = new SaveHash();
g_html_blocks = [];
g_list_level = 0;
text = pluginHooks.preConversion(text);
// attacklab: Replace ~ with ~T
// This lets us use tilde as an escape char to avoid md5 hashes
// The choice of character is arbitray; anything that isn't
// magic in Markdown will work.
text = text.replace(/~/g, "~T");
// attacklab: Replace $ with ~D
// RegExp interprets $ as a special character
// when it's in a replacement string
text = text.replace(/\$/g, "~D");
// Standardize line endings
text = text.replace(/\r\n/g, "\n"); // DOS to Unix
text = text.replace(/\r/g, "\n"); // Mac to Unix
// Make sure text begins and ends with a couple of newlines:
text = "\n\n" + text + "\n\n";
// Convert all tabs to spaces.
text = _Detab(text);
// Strip any lines consisting only of spaces and tabs.
// This makes subsequent regexen easier to write, because we can
// match consecutive blank lines with /\n+/ instead of something
// contorted like /[ \t]*\n+/ .
text = text.replace(/^[ \t]+$/mg, "");
// Turn block-level HTML blocks into hash entries
text = _HashHTMLBlocks(text);
// Strip link definitions, store in hashes.
text = _StripLinkDefinitions(text);
text = _RunBlockGamut(text);
text = _UnescapeSpecialChars(text);
// attacklab: Restore dollar signs
text = text.replace(/~D/g, "$$");
// attacklab: Restore tildes
text = text.replace(/~T/g, "~");
text = pluginHooks.postConversion(text);
g_html_blocks = g_titles = g_urls = null;
return text;
};
function _StripLinkDefinitions(text) {
//
// Strips link definitions from text, stores the URLs and titles in
// hash references.
//
// Link defs are in the form: ^[id]: url "optional title"
/*
text = text.replace(/
^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1
[ \t]*
\n? // maybe *one* newline
[ \t]*
<?(\S+?)>? // url = $2
(?=\s|$) // lookahead for whitespace instead of the lookbehind removed below
[ \t]*
\n? // maybe one newline
[ \t]*
( // (potential) title = $3
(\n*) // any lines skipped = $4 attacklab: lookbehind removed
[ \t]+
["(]
(.+?) // title = $5
[")]
[ \t]*
)? // title is optional
(?:\n+|$)
/gm, function(){...});
*/
text = text.replace(/^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*<?(\S+?)>?(?=\s|$)[ \t]*\n?[ \t]*((\n*)["(](.+?)[")][ \t]*)?(?:\n+)/gm,
function (wholeMatch, m1, m2, m3, m4, m5) {
m1 = m1.toLowerCase();
g_urls.set(m1, _EncodeAmpsAndAngles(m2)); // Link IDs are case-insensitive
if (m4) {
// Oops, found blank lines, so it's not a title.
// Put back the parenthetical statement we stole.
return m3;
} else if (m5) {
g_titles.set(m1, m5.replace(/"/g, "&quot;"));
}
// Completely remove the definition from the text
return "";
}
);
return text;
}
function _HashHTMLBlocks(text) {
// Hashify HTML blocks:
// We only want to do this for block-level HTML tags, such as headers,
// lists, and tables. That's because we still want to wrap <p>s around
// "paragraphs" that are wrapped in non-block-level tags, such as anchors,
// phrase emphasis, and spans. The list of tags we're looking for is
// hard-coded:
var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del"
var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math"
// First, look for nested blocks, e.g.:
// <div>
// <div>
// tags for inner block must be indented.
// </div>
// </div>
//
// The outermost tags must start at the left margin for this to match, and
// the inner nested divs must be indented.
// We need to do this before the next, more liberal match, because the next
// match will start at the first `<div>` and stop at the first `</div>`.
// attacklab: This regex can be expensive when it fails.
/*
text = text.replace(/
( // save in $1
^ // start of line (with /m)
<($block_tags_a) // start tag = $2
\b // word break
// attacklab: hack around khtml/pcre bug...
[^\r]*?\n // any number of lines, minimally matching
</\2> // the matching end tag
[ \t]* // trailing spaces/tabs
(?=\n+) // followed by a newline
) // attacklab: there are sentinel newlines at end of document
/gm,function(){...}};
*/
text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, hashElement);
//
// Now match more liberally, simply from `\n<tag>` to `</tag>\n`
//
/*
text = text.replace(/
( // save in $1
^ // start of line (with /m)
<($block_tags_b) // start tag = $2
\b // word break
// attacklab: hack around khtml/pcre bug...
[^\r]*? // any number of lines, minimally matching
.*</\2> // the matching end tag
[ \t]* // trailing spaces/tabs
(?=\n+) // followed by a newline
) // attacklab: there are sentinel newlines at end of document
/gm,function(){...}};
*/
text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math)\b[^\r]*?.*<\/\2>[ \t]*(?=\n+)\n)/gm, hashElement);
// Special case just for <hr />. It was easier to make a special case than
// to make the other regex more complicated.
/*
text = text.replace(/
\n // Starting after a blank line
[ ]{0,3}
( // save in $1
(<(hr) // start tag = $2
\b // word break
([^<>])*?
\/?>) // the matching end tag
[ \t]*
(?=\n{2,}) // followed by a blank line
)
/g,hashElement);
*/
text = text.replace(/\n[ ]{0,3}((<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, hashElement);
// Special case for standalone HTML comments:
/*
text = text.replace(/
\n\n // Starting after a blank line
[ ]{0,3} // attacklab: g_tab_width - 1
( // save in $1
<!
(--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--) // see http://www.w3.org/TR/html-markup/syntax.html#comments and http://meta.stackoverflow.com/q/95256
>
[ \t]*
(?=\n{2,}) // followed by a blank line
)
/g,hashElement);
*/
text = text.replace(/\n\n[ ]{0,3}(<!(--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--)>[ \t]*(?=\n{2,}))/g, hashElement);
// PHP and ASP-style processor instructions (<?...?> and <%...%>)
/*
text = text.replace(/
(?:
\n\n // Starting after a blank line
)
( // save in $1
[ ]{0,3} // attacklab: g_tab_width - 1
(?:
<([?%]) // $2
[^\r]*?
\2>
)
[ \t]*
(?=\n{2,}) // followed by a blank line
)
/g,hashElement);
*/
text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, hashElement);
return text;
}
function hashElement(wholeMatch, m1) {
var blockText = m1;
// Undo double lines
blockText = blockText.replace(/^\n+/, "");
// strip trailing blank lines
blockText = blockText.replace(/\n+$/g, "");
// Replace the element text with a marker ("~KxK" where x is its key)
blockText = "\n\n~K" + (g_html_blocks.push(blockText) - 1) + "K\n\n";
return blockText;
}
function _RunBlockGamut(text, doNotUnhash) {
//
// These are all the transformations that form block-level
// tags like paragraphs, headers, and list items.
//
text = _DoHeaders(text);
// Do Horizontal Rules:
var replacement = "<hr />\n";
text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, replacement);
text = text.replace(/^[ ]{0,2}([ ]?-[ ]?){3,}[ \t]*$/gm, replacement);
text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, replacement);
text = _DoLists(text);
text = _DoCodeBlocks(text);
text = _DoBlockQuotes(text);
// We already ran _HashHTMLBlocks() before, in Markdown(), but that
// was to escape raw HTML in the original Markdown source. This time,
// we're escaping the markup we've just created, so that we don't wrap
// <p> tags around block-level tags.
text = _HashHTMLBlocks(text);
text = _FormParagraphs(text, doNotUnhash);
return text;
}
function _RunSpanGamut(text) {
//
// These are all the transformations that occur *within* block-level
// tags like paragraphs, headers, and list items.
//
text = _DoCodeSpans(text);
text = _EscapeSpecialCharsWithinTagAttributes(text);
text = _EncodeBackslashEscapes(text);
// Process anchor and image tags. Images must come first,
// because ![foo][f] looks like an anchor.
text = _DoImages(text);
text = _DoAnchors(text);
// Make links out of things like `<http://example.com/>`
// Must come after _DoAnchors(), because you can use < and >
// delimiters in inline links like [this](<url>).
text = _DoAutoLinks(text);
text = text.replace(/~P/g, "://"); // put in place to prevent autolinking; reset now
text = _EncodeAmpsAndAngles(text);
text = _DoItalicsAndBold(text);
// Do hard breaks:
text = text.replace(/ +\n/g, " <br>\n");
return text;
}
function _EscapeSpecialCharsWithinTagAttributes(text) {
//
// Within tags -- meaning between < and > -- encode [\ ` * _] so they
// don't conflict with their use in Markdown for code, italics and strong.
//
// Build a regex to find HTML tags and comments. See Friedl's
// "Mastering Regular Expressions", 2nd Ed., pp. 200-201.
// SE: changed the comment part of the regex
var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|<!(--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--)>)/gi;
text = text.replace(regex, function (wholeMatch) {
var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, "$1`");
tag = escapeCharacters(tag, wholeMatch.charAt(1) == "!" ? "\\`*_/" : "\\`*_"); // also escape slashes in comments to prevent autolinking there -- http://meta.stackoverflow.com/questions/95987
return tag;
});
return text;
}
function _DoAnchors(text) {
//
// Turn Markdown link shortcuts into XHTML <a> tags.
//
//
// First, handle reference-style links: [link text] [id]
//
/*
text = text.replace(/
( // wrap whole match in $1
\[
(
(?:
\[[^\]]*\] // allow brackets nested one level
|
[^\[] // or anything else
)*
)
\]
[ ]? // one optional space
(?:\n[ ]*)? // one optional newline followed by spaces
\[
(.*?) // id = $3
\]
)
()()()() // pad remaining backreferences
/g, writeAnchorTag);
*/
text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeAnchorTag);
//
// Next, inline-style links: [link text](url "optional title")
//
/*
text = text.replace(/
( // wrap whole match in $1
\[
(
(?:
\[[^\]]*\] // allow brackets nested one level
|
[^\[\]] // or anything else
)*
)
\]
\( // literal paren
[ \t]*
() // no id, so leave $3 empty
<?( // href = $4
(?:
\([^)]*\) // allow one level of (correctly nested) parens (think MSDN)
|
[^()\s]
)*?
)>?
[ \t]*
( // $5
(['"]) // quote char = $6
(.*?) // Title = $7
\6 // matching quote
[ \t]* // ignore any spaces/tabs between closing quote and )
)? // title is optional
\)
)
/g, writeAnchorTag);
*/
text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()<?((?:\([^)]*\)|[^()\s])*?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeAnchorTag);
//
// Last, handle reference-style shortcuts: [link text]
// These must come last in case you've also got [link test][1]
// or [link test](/foo)
//
/*
text = text.replace(/
( // wrap whole match in $1
\[
([^\[\]]+) // link text = $2; can't contain '[' or ']'
\]
)
()()()()() // pad rest of backreferences
/g, writeAnchorTag);
*/
text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag);
return text;
}
function writeAnchorTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) {
if (m7 == undefined) m7 = "";
var whole_match = m1;
var link_text = m2.replace(/:\/\//g, "~P"); // to prevent auto-linking withing the link. will be converted back after the auto-linker runs
var link_id = m3.toLowerCase();
var url = m4;
var title = m7;
if (url == "") {
if (link_id == "") {
// lower-case and turn embedded newlines into spaces
link_id = link_text.toLowerCase().replace(/ ?\n/g, " ");
}
url = "#" + link_id;
if (g_urls.get(link_id) != undefined) {
url = g_urls.get(link_id);
if (g_titles.get(link_id) != undefined) {
title = g_titles.get(link_id);
}
}
else {
if (whole_match.search(/\(\s*\)$/m) > -1) {
// Special case for explicit empty url
url = "";
} else {
return whole_match;
}
}
}
url = encodeProblemUrlChars(url);
url = escapeCharacters(url, "*_");
var result = "<a href=\"" + url + "\"";
if (title != "") {
title = attributeEncode(title);
title = escapeCharacters(title, "*_");
result += " title=\"" + title + "\"";
}
result += ">" + link_text + "</a>";
return result;
}
function _DoImages(text) {
//
// Turn Markdown image shortcuts into <img> tags.
//
//
// First, handle reference-style labeled images: ![alt text][id]
//
/*
text = text.replace(/
( // wrap whole match in $1
!\[
(.*?) // alt text = $2
\]
[ ]? // one optional space
(?:\n[ ]*)? // one optional newline followed by spaces
\[
(.*?) // id = $3
\]
)
()()()() // pad rest of backreferences
/g, writeImageTag);
*/
text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeImageTag);
//
// Next, handle inline images: ![alt text](url "optional title")
// Don't forget: encode * and _
/*
text = text.replace(/
( // wrap whole match in $1
!\[
(.*?) // alt text = $2
\]
\s? // One optional whitespace character
\( // literal paren
[ \t]*
() // no id, so leave $3 empty
<?(\S+?)>? // src url = $4
[ \t]*
( // $5
(['"]) // quote char = $6
(.*?) // title = $7
\6 // matching quote
[ \t]*
)? // title is optional
\)
)
/g, writeImageTag);
*/
text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()<?(\S+?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeImageTag);
return text;
}
function attributeEncode(text) {
// unconditionally replace angle brackets here -- what ends up in an attribute (e.g. alt or title)
// never makes sense to have verbatim HTML in it (and the sanitizer would totally break it)
return text.replace(/>/g, "&gt;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
}
function writeImageTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) {
var whole_match = m1;
var alt_text = m2;
var link_id = m3.toLowerCase();
var url = m4;
var title = m7;
if (!title) title = "";
if (url == "") {
if (link_id == "") {
// lower-case and turn embedded newlines into spaces
link_id = alt_text.toLowerCase().replace(/ ?\n/g, " ");
}
url = "#" + link_id;
if (g_urls.get(link_id) != undefined) {
url = g_urls.get(link_id);
if (g_titles.get(link_id) != undefined) {
title = g_titles.get(link_id);
}
}
else {
return whole_match;
}
}
alt_text = escapeCharacters(attributeEncode(alt_text), "*_[]()");
url = escapeCharacters(url, "*_");
var result = "<img src=\"" + url + "\" alt=\"" + alt_text + "\"";
// attacklab: Markdown.pl adds empty title attributes to images.
// Replicate this bug.
//if (title != "") {
title = attributeEncode(title);
title = escapeCharacters(title, "*_");
result += " title=\"" + title + "\"";
//}
result += " />";
return result;
}
function _DoHeaders(text) {
// Setext-style headers:
// Header 1
// ========
//
// Header 2
// --------
//
text = text.replace(/^(.+)[ \t]*\n=+[ \t]*\n+/gm,
function (wholeMatch, m1) { return "<h1>" + _RunSpanGamut(m1) + "</h1>\n\n"; }
);
text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm,
function (matchFound, m1) { return "<h2>" + _RunSpanGamut(m1) + "</h2>\n\n"; }
);
// atx-style headers:
// # Header 1
// ## Header 2
// ## Header 2 with closing hashes ##
// ...
// ###### Header 6
//
/*
text = text.replace(/
^(\#{1,6}) // $1 = string of #'s
[ \t]*
(.+?) // $2 = Header text
[ \t]*
\#* // optional closing #'s (not counted)
\n+
/gm, function() {...});
*/
text = text.replace(/^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm,
function (wholeMatch, m1, m2) {
var h_level = m1.length;
return "<h" + h_level + ">" + _RunSpanGamut(m2) + "</h" + h_level + ">\n\n";
}
);
return text;
}
function _DoLists(text) {
//
// Form HTML ordered (numbered) and unordered (bulleted) lists.
//
// attacklab: add sentinel to hack around khtml/safari bug:
// http://bugs.webkit.org/show_bug.cgi?id=11231
text += "~0";
// Re-usable pattern to match any entirel ul or ol list:
/*
var whole_list = /
( // $1 = whole list
( // $2
[ ]{0,3} // attacklab: g_tab_width - 1
([*+-]|\d+[.]) // $3 = first list item marker
[ \t]+
)
[^\r]+?
( // $4
~0 // sentinel for workaround; should be $
|
\n{2,}
(?=\S)
(?! // Negative lookahead for another list item marker
[ \t]*
(?:[*+-]|\d+[.])[ \t]+
)
)
)
/g
*/
var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm;
if (g_list_level) {
text = text.replace(whole_list, function (wholeMatch, m1, m2) {
var list = m1;
var list_type = (m2.search(/[*+-]/g) > -1) ? "ul" : "ol";
var result = _ProcessListItems(list, list_type);
// Trim any trailing whitespace, to put the closing `</$list_type>`
// up on the preceding line, to get it past the current stupid
// HTML block parser. This is a hack to work around the terrible
// hack that is the HTML block parser.
result = result.replace(/\s+$/, "");
result = "<" + list_type + ">" + result + "</" + list_type + ">\n";
return result;
});
} else {
whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g;
text = text.replace(whole_list, function (wholeMatch, m1, m2, m3) {
var runup = m1;
var list = m2;
var list_type = (m3.search(/[*+-]/g) > -1) ? "ul" : "ol";
var result = _ProcessListItems(list, list_type);
result = runup + "<" + list_type + ">\n" + result + "</" + list_type + ">\n";
return result;
});
}
// attacklab: strip sentinel
text = text.replace(/~0/, "");
return text;
}
var _listItemMarkers = { ol: "\\d+[.]", ul: "[*+-]" };
function _ProcessListItems(list_str, list_type) {
//
// Process the contents of a single ordered or unordered list, splitting it
// into individual list items.
//
// list_type is either "ul" or "ol".
// The $g_list_level global keeps track of when we're inside a list.
// Each time we enter a list, we increment it; when we leave a list,
// we decrement. If it's zero, we're not in a list anymore.
//
// We do this because when we're not inside a list, we want to treat
// something like this:
//
// I recommend upgrading to version
// 8. Oops, now this line is treated
// as a sub-list.
//
// As a single paragraph, despite the fact that the second line starts
// with a digit-period-space sequence.
//
// Whereas when we're inside a list (or sub-list), that line will be
// treated as the start of a sub-list. What a kludge, huh? This is
// an aspect of Markdown's syntax that's hard to parse perfectly
// without resorting to mind-reading. Perhaps the solution is to
// change the syntax rules such that sub-lists must start with a
// starting cardinal number; e.g. "1." or "a.".
g_list_level++;
// trim trailing blank lines:
list_str = list_str.replace(/\n{2,}$/, "\n");
// attacklab: add sentinel to emulate \z
list_str += "~0";
// In the original attacklab showdown, list_type was not given to this function, and anything
// that matched /[*+-]|\d+[.]/ would just create the next <li>, causing this mismatch:
//
// Markdown rendered by WMD rendered by MarkdownSharp
// ------------------------------------------------------------------
// 1. first 1. first 1. first
// 2. second 2. second 2. second
// - third 3. third * third
//
// We changed this to behave identical to MarkdownSharp. This is the constructed RegEx,
// with {MARKER} being one of \d+[.] or [*+-], depending on list_type:
/*
list_str = list_str.replace(/
(^[ \t]*) // leading whitespace = $1
({MARKER}) [ \t]+ // list marker = $2
([^\r]+? // list item text = $3
(\n+)
)
(?=
(~0 | \2 ({MARKER}) [ \t]+)
)
/gm, function(){...});
*/
var marker = _listItemMarkers[list_type];
var re = new RegExp("(^[ \\t]*)(" + marker + ")[ \\t]+([^\\r]+?(\\n+))(?=(~0|\\1(" + marker + ")[ \\t]+))", "gm");
var last_item_had_a_double_newline = false;
list_str = list_str.replace(re,
function (wholeMatch, m1, m2, m3) {
var item = m3;
var leading_space = m1;
var ends_with_double_newline = /\n\n$/.test(item);
var contains_double_newline = ends_with_double_newline || item.search(/\n{2,}/) > -1;
if (contains_double_newline || last_item_had_a_double_newline) {
item = _RunBlockGamut(_Outdent(item), /* doNotUnhash = */true);
}
else {
// Recursion for sub-lists:
item = _DoLists(_Outdent(item));
item = item.replace(/\n$/, ""); // chomp(item)
item = _RunSpanGamut(item);
}
last_item_had_a_double_newline = ends_with_double_newline;
return "<li>" + item + "</li>\n";
}
);
// attacklab: strip sentinel
list_str = list_str.replace(/~0/g, "");
g_list_level--;
return list_str;
}
function _DoCodeBlocks(text) {
//
// Process Markdown `<pre><code>` blocks.
//
/*
text = text.replace(/
(?:\n\n|^)
( // $1 = the code block -- one or more lines, starting with a space/tab
(?:
(?:[ ]{4}|\t) // Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
.*\n+
)+
)
(\n*[ ]{0,3}[^ \t\n]|(?=~0)) // attacklab: g_tab_width
/g ,function(){...});
*/
// attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
text += "~0";
text = text.replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
function (wholeMatch, m1, m2) {
var codeblock = m1;
var nextChar = m2;
codeblock = _EncodeCode(_Outdent(codeblock));
codeblock = _Detab(codeblock);
codeblock = codeblock.replace(/^\n+/g, ""); // trim leading newlines
codeblock = codeblock.replace(/\n+$/g, ""); // trim trailing whitespace
codeblock = "<pre><code>" + codeblock + "\n</code></pre>";
return "\n\n" + codeblock + "\n\n" + nextChar;
}
);
// attacklab: strip sentinel
text = text.replace(/~0/, "");
return text;
}
function hashBlock(text) {
text = text.replace(/(^\n+|\n+$)/g, "");
return "\n\n~K" + (g_html_blocks.push(text) - 1) + "K\n\n";
}
function _DoCodeSpans(text) {
//
// * Backtick quotes are used for <code></code> spans.
//
// * You can use multiple backticks as the delimiters if you want to
// include literal backticks in the code span. So, this input:
//
// Just type ``foo `bar` baz`` at the prompt.
//
// Will translate to:
//
// <p>Just type <code>foo `bar` baz</code> at the prompt.</p>
//
// There's no arbitrary limit to the number of backticks you
// can use as delimters. If you need three consecutive backticks
// in your code, use four for delimiters, etc.
//
// * You can use spaces to get literal backticks at the edges:
//
// ... type `` `bar` `` ...
//
// Turns to:
//
// ... type <code>`bar`</code> ...
//
/*
text = text.replace(/
(^|[^\\]) // Character before opening ` can't be a backslash
(`+) // $2 = Opening run of `
( // $3 = The code block
[^\r]*?
[^`] // attacklab: work around lack of lookbehind
)
\2 // Matching closer
(?!`)
/gm, function(){...});
*/
text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,
function (wholeMatch, m1, m2, m3, m4) {
var c = m3;
c = c.replace(/^([ \t]*)/g, ""); // leading whitespace
c = c.replace(/[ \t]*$/g, ""); // trailing whitespace
c = _EncodeCode(c);
c = c.replace(/:\/\//g, "~P"); // to prevent auto-linking. Not necessary in code *blocks*, but in code spans. Will be converted back after the auto-linker runs.
return m1 + "<code>" + c + "</code>";
}
);
return text;
}
function _EncodeCode(text) {
//
// Encode/escape certain characters inside Markdown code runs.
// The point is that in code, these characters are literals,
// and lose their special Markdown meanings.
//
// Encode all ampersands; HTML entities are not
// entities within a Markdown code span.
text = text.replace(/&/g, "&amp;");
// Do the angle bracket song and dance:
text = text.replace(/</g, "&lt;");
text = text.replace(/>/g, "&gt;");
// Now, escape characters that are magic in Markdown:
text = escapeCharacters(text, "\*_{}[]\\", false);
// jj the line above breaks this:
//---
//* Item
// 1. Subitem
// special char: *
//---
return text;
}
function _DoItalicsAndBold(text) {
// <strong> must go first:
text = text.replace(/([\W_]|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2([\W_]|$)/g,
"$1<strong>$3</strong>$4");
text = text.replace(/([\W_]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2([\W_]|$)/g,
"$1<em>$3</em>$4");
return text;
}
function _DoBlockQuotes(text) {
/*
text = text.replace(/
( // Wrap whole match in $1
(
^[ \t]*>[ \t]? // '>' at the start of a line
.+\n // rest of the first line
(.+\n)* // subsequent consecutive lines
\n* // blanks
)+
)
/gm, function(){...});
*/
text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm,
function (wholeMatch, m1) {
var bq = m1;
// attacklab: hack around Konqueror 3.5.4 bug:
// "----------bug".replace(/^-/g,"") == "bug"
bq = bq.replace(/^[ \t]*>[ \t]?/gm, "~0"); // trim one level of quoting
// attacklab: clean up hack
bq = bq.replace(/~0/g, "");
bq = bq.replace(/^[ \t]+$/gm, ""); // trim whitespace-only lines
bq = _RunBlockGamut(bq); // recurse
bq = bq.replace(/(^|\n)/g, "$1 ");
// These leading spaces screw with <pre> content, so we need to fix that:
bq = bq.replace(
/(\s*<pre>[^\r]+?<\/pre>)/gm,
function (wholeMatch, m1) {
var pre = m1;
// attacklab: hack around Konqueror 3.5.4 bug:
pre = pre.replace(/^ /mg, "~0");
pre = pre.replace(/~0/g, "");
return pre;
});
return hashBlock("<blockquote>\n" + bq + "\n</blockquote>");
}
);
return text;
}
function _FormParagraphs(text, doNotUnhash) {
//
// Params:
// $text - string to process with html <p> tags
//
// Strip leading and trailing lines:
text = text.replace(/^\n+/g, "");
text = text.replace(/\n+$/g, "");
var grafs = text.split(/\n{2,}/g);
var grafsOut = [];
var markerRe = /~K(\d+)K/;
//
// Wrap <p> tags.
//
var end = grafs.length;
for (var i = 0; i < end; i++) {
var str = grafs[i];
// if this is an HTML marker, copy it
if (markerRe.test(str)) {
grafsOut.push(str);
}
else if (/\S/.test(str)) {
str = _RunSpanGamut(str);
str = str.replace(/^([ \t]*)/g, "<p>");
str += "</p>"
grafsOut.push(str);
}
}
//
// Unhashify HTML blocks
//
if (!doNotUnhash) {
end = grafsOut.length;
for (var i = 0; i < end; i++) {
var foundAny = true;
while (foundAny) { // we may need several runs, since the data may be nested
foundAny = false;
grafsOut[i] = grafsOut[i].replace(/~K(\d+)K/g, function (wholeMatch, id) {
foundAny = true;
return g_html_blocks[id];
});
}
}
}
return grafsOut.join("\n\n");
}
function _EncodeAmpsAndAngles(text) {
// Smart processing for ampersands and angle brackets that need to be encoded.
// Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin:
// http://bumppo.net/projects/amputator/
text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, "&amp;");
// Encode naked <'s
text = text.replace(/<(?![a-z\/?\$!])/gi, "&lt;");
return text;
}
function _EncodeBackslashEscapes(text) {
//
// Parameter: String.
// Returns: The string, with after processing the following backslash
// escape sequences.
//
// attacklab: The polite way to do this is with the new
// escapeCharacters() function:
//
// text = escapeCharacters(text,"\\",true);
// text = escapeCharacters(text,"`*_{}[]()>#+-.!",true);
//
// ...but we're sidestepping its use of the (slow) RegExp constructor
// as an optimization for Firefox. This function gets called a LOT.
text = text.replace(/\\(\\)/g, escapeCharacters_callback);
text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g, escapeCharacters_callback);
return text;
}
function _DoAutoLinks(text) {
// note that at this point, all other URL in the text are already hyperlinked as <a href=""></a>
// *except* for the <http://www.foo.com> case
// automatically add < and > around unadorned raw hyperlinks
// must be preceded by space/BOF and followed by non-word/EOF character
text = text.replace(/(^|\s)(https?|ftp)(:\/\/[-A-Z0-9+&@#\/%?=~_|\[\]\(\)!:,\.;]*[-A-Z0-9+&@#\/%=~_|\[\]])($|\W)/gi, "$1<$2$3>$4");
// autolink anything like <http://example.com>
var replacer = function (wholematch, m1) { return "<a href=\"" + m1 + "\">" + pluginHooks.plainLinkText(m1) + "</a>"; }
text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer);
// Email addresses: <address@domain.foo>
/*
text = text.replace(/
<
(?:mailto:)?
(
[-.\w]+
\@
[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+
)
>
/gi, _DoAutoLinks_callback());
*/
/* disabling email autolinking, since we don't do that on the server, either
text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi,
function(wholeMatch,m1) {
return _EncodeEmailAddress( _UnescapeSpecialChars(m1) );
}
);
*/
return text;
}
function _UnescapeSpecialChars(text) {
//
// Swap back in all the special characters we've hidden.
//
text = text.replace(/~E(\d+)E/g,
function (wholeMatch, m1) {
var charCodeToReplace = parseInt(m1);
return String.fromCharCode(charCodeToReplace);
}
);
return text;
}
function _Outdent(text) {
//
// Remove one level of line-leading tabs or spaces
//
// attacklab: hack around Konqueror 3.5.4 bug:
// "----------bug".replace(/^-/g,"") == "bug"
text = text.replace(/^(\t|[ ]{1,4})/gm, "~0"); // attacklab: g_tab_width
// attacklab: clean up hack
text = text.replace(/~0/g, "")
return text;
}
function _Detab(text) {
if (!/\t/.test(text))
return text;
var spaces = [" ", " ", " ", " "],
skew = 0,
v;
return text.replace(/[\n\t]/g, function (match, offset) {
if (match === "\n") {
skew = offset + 1;
return match;
}
v = (offset - skew) % 4;
skew = offset + 1;
return spaces[v];
});
}
//
// attacklab: Utility functions
//
var _problemUrlChars = /(?:["'*()[\]:]|~D)/g;
// hex-encodes some unusual "problem" chars in URLs to avoid URL detection problems
function encodeProblemUrlChars(url) {
if (!url)
return "";
var len = url.length;
return url.replace(_problemUrlChars, function (match, offset) {
if (match == "~D") // escape for dollar
return "%24";
if (match == ":") {
if (offset == len - 1 || /[0-9\/]/.test(url.charAt(offset + 1)))
return ":"
}
return "%" + match.charCodeAt(0).toString(16);
});
}
function escapeCharacters(text, charsToEscape, afterBackslash) {
// First we have to escape the escape characters so that
// we can build a character class out of them
var regexString = "([" + charsToEscape.replace(/([\[\]\\])/g, "\\$1") + "])";
if (afterBackslash) {
regexString = "\\\\" + regexString;
}
var regex = new RegExp(regexString, "g");
text = text.replace(regex, escapeCharacters_callback);
return text;
}
function escapeCharacters_callback(wholeMatch, m1) {
var charCodeToEscape = m1.charCodeAt(0);
return "~E" + charCodeToEscape + "E";
}
}; // end of the Markdown.Converter constructor
})();
// needs Markdown.Converter.js at the moment
(function () {
var util = {},
position = {},
ui = {},
doc = window.document,
re = window.RegExp,
nav = window.navigator,
SETTINGS = { lineLength: 72 },
// Used to work around some browser bugs where we can't use feature testing.
uaSniffed = {
isIE: /msie/.test(nav.userAgent.toLowerCase()),
isIE_5or6: /msie 6/.test(nav.userAgent.toLowerCase()) || /msie 5/.test(nav.userAgent.toLowerCase()),
isOpera: /opera/.test(nav.userAgent.toLowerCase())
};
// -------------------------------------------------------------------
// YOUR CHANGES GO HERE
//
// I've tried to localize the things you are likely to change to
// this area.
// -------------------------------------------------------------------
// The text that appears on the upper part of the dialog box when
// entering links.
var linkDialogText = "<p><b>Insert Hyperlink</b></p><p>http://example.com/ \"optional title\"</p>";
var imageDialogText = "<p><b>Insert Image (upload file or type url)</b></p><p>http://example.com/images/diagram.jpg \"optional title\"<br><br></p>";
// The default text that appears in the dialog input box when entering
// links.
var imageDefaultText = "http://";
var linkDefaultText = "http://";
var defaultHelpHoverTitle = "Markdown Editing Help";
// -------------------------------------------------------------------
// END OF YOUR CHANGES
// -------------------------------------------------------------------
// help, if given, should have a property "handler", the click handler for the help button,
// and can have an optional property "title" for the button's tooltip (defaults to "Markdown Editing Help").
// If help isn't given, not help button is created.
//
// The constructed editor object has the methods:
// - getConverter() returns the markdown converter object that was passed to the constructor
// - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op.
// - refreshPreview() forces the preview to be updated. This method is only available after run() was called.
Markdown.Editor = function (markdownConverter, idPostfix, help, imageUploadHandler) {
idPostfix = idPostfix || "";
var hooks = this.hooks = new Markdown.HookCollection();
hooks.addNoop("onPreviewPush"); // called with no arguments after the preview has been refreshed
hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text
hooks.addFalse("insertImageDialog"); /* called with one parameter: a callback to be called with the URL of the image. If the application creates
* its own image insertion dialog, this hook should return true, and the callback should be called with the chosen
* image url (or null if the user cancelled). If this hook returns false, the default dialog will be used.
*/
this.getConverter = function () { return markdownConverter; }
var that = this,
panels;
this.run = function () {
if (panels)
return; // already initialized
panels = new PanelCollection(idPostfix);
var commandManager = new CommandManager(hooks);
var previewManager = new PreviewManager(markdownConverter, panels, function (text, previewSet) { hooks.onPreviewPush(text, previewSet); });
var undoManager, uiManager;
if (!/\?noundo/.test(doc.location.href)) {
undoManager = new UndoManager(function () {
previewManager.refresh();
if (uiManager) // not available on the first call
uiManager.setUndoRedoButtonStates();
}, panels);
this.textOperation = function (f) {
undoManager.setCommandMode();
f();
that.refreshPreview();
}
}
uiManager = new UIManager(idPostfix, panels, undoManager, previewManager, commandManager, help, imageUploadHandler);
uiManager.setUndoRedoButtonStates();
var forceRefresh = that.refreshPreview = function () { previewManager.refresh(true); };
forceRefresh();
};
}
// before: contains all the text in the input box BEFORE the selection.
// after: contains all the text in the input box AFTER the selection.
function Chunks() { }
// startRegex: a regular expression to find the start tag
// endRegex: a regular expresssion to find the end tag
Chunks.prototype.findTags = function (startRegex, endRegex) {
var chunkObj = this;
var regex;
if (startRegex) {
regex = util.extendRegExp(startRegex, "", "$");
this.before = this.before.replace(regex,
function (match) {
chunkObj.startTag = chunkObj.startTag + match;
return "";
});
regex = util.extendRegExp(startRegex, "^", "");
this.selection = this.selection.replace(regex,
function (match) {
chunkObj.startTag = chunkObj.startTag + match;
return "";
});
}
if (endRegex) {
regex = util.extendRegExp(endRegex, "", "$");
this.selection = this.selection.replace(regex,
function (match) {
chunkObj.endTag = match + chunkObj.endTag;
return "";
});
regex = util.extendRegExp(endRegex, "^", "");
this.after = this.after.replace(regex,
function (match) {
chunkObj.endTag = match + chunkObj.endTag;
return "";
});
}
};
// If remove is false, the whitespace is transferred
// to the before/after regions.
//
// If remove is true, the whitespace disappears.
Chunks.prototype.trimWhitespace = function (remove) {
var beforeReplacer, afterReplacer, that = this;
if (remove) {
beforeReplacer = afterReplacer = "";
} else {
beforeReplacer = function (s) { that.before += s; return ""; }
afterReplacer = function (s) { that.after = s + that.after; return ""; }
}
this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer);
};
Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) {
if (nLinesBefore === undefined) {
nLinesBefore = 1;
}
if (nLinesAfter === undefined) {
nLinesAfter = 1;
}
nLinesBefore++;
nLinesAfter++;
var regexText;
var replacementText;
// chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985
if (navigator.userAgent.match(/Chrome/)) {
"X".match(/()./);
}
this.selection = this.selection.replace(/(^\n*)/, "");
this.startTag = this.startTag + re.$1;
this.selection = this.selection.replace(/(\n*$)/, "");
this.endTag = this.endTag + re.$1;
this.startTag = this.startTag.replace(/(^\n*)/, "");
this.before = this.before + re.$1;
this.endTag = this.endTag.replace(/(\n*$)/, "");
this.after = this.after + re.$1;
if (this.before) {
regexText = replacementText = "";
while (nLinesBefore--) {
regexText += "\\n?";
replacementText += "\n";
}
if (findExtraNewlines) {
regexText = "\\n*";
}
this.before = this.before.replace(new re(regexText + "$", ""), replacementText);
}
if (this.after) {
regexText = replacementText = "";
while (nLinesAfter--) {
regexText += "\\n?";
replacementText += "\n";
}
if (findExtraNewlines) {
regexText = "\\n*";
}
this.after = this.after.replace(new re(regexText, ""), replacementText);
}
};
// end of Chunks
// A collection of the important regions on the page.
// Cached so we don't have to keep traversing the DOM.
// Also holds ieCachedRange and ieCachedScrollTop, where necessary; working around
// this issue:
// Internet explorer has problems with CSS sprite buttons that use HTML
// lists. When you click on the background image "button", IE will
// select the non-existent link text and discard the selection in the
// textarea. The solution to this is to cache the textarea selection
// on the button's mousedown event and set a flag. In the part of the
// code where we need to grab the selection, we check for the flag
// and, if it's set, use the cached area instead of querying the
// textarea.
//
// This ONLY affects Internet Explorer (tested on versions 6, 7
// and 8) and ONLY on button clicks. Keyboard shortcuts work
// normally since the focus never leaves the textarea.
function PanelCollection(postfix) {
this.buttonBar = doc.getElementById("wmd-button-bar" + postfix);
this.preview = doc.getElementById("wmd-preview" + postfix);
this.input = doc.getElementById("wmd-input" + postfix);
};
// Returns true if the DOM element is visible, false if it's hidden.
// Checks if display is anything other than none.
util.isVisible = function (elem) {
if (window.getComputedStyle) {
// Most browsers
return window.getComputedStyle(elem, null).getPropertyValue("display") !== "none";
}
else if (elem.currentStyle) {
// IE
return elem.currentStyle["display"] !== "none";
}
};
// Adds a listener callback to a DOM element which is fired on a specified
// event.
util.addEvent = function (elem, event, listener) {
if (elem.attachEvent) {
// IE only. The "on" is mandatory.
elem.attachEvent("on" + event, listener);
}
else {
// Other browsers.
elem.addEventListener(event, listener, false);
}
};
// Removes a listener callback from a DOM element which is fired on a specified
// event.
util.removeEvent = function (elem, event, listener) {
if (elem.detachEvent) {
// IE only. The "on" is mandatory.
elem.detachEvent("on" + event, listener);
}
else {
// Other browsers.
elem.removeEventListener(event, listener, false);
}
};
// Converts \r\n and \r to \n.
util.fixEolChars = function (text) {
text = text.replace(/\r\n/g, "\n");
text = text.replace(/\r/g, "\n");
return text;
};
// Extends a regular expression. Returns a new RegExp
// using pre + regex + post as the expression.
// Used in a few functions where we have a base
// expression and we want to pre- or append some
// conditions to it (e.g. adding "$" to the end).
// The flags are unchanged.
//
// regex is a RegExp, pre and post are strings.
util.extendRegExp = function (regex, pre, post) {
if (pre === null || pre === undefined) {
pre = "";
}
if (post === null || post === undefined) {
post = "";
}
var pattern = regex.toString();
var flags;
// Replace the flags with empty space and store them.
pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) {
flags = flagsPart;
return "";
});
// Remove the slash delimiters on the regular expression.
pattern = pattern.replace(/(^\/|\/$)/g, "");
pattern = pre + pattern + post;
return new re(pattern, flags);
}
// UNFINISHED
// The assignment in the while loop makes jslint cranky.
// I'll change it to a better loop later.
position.getTop = function (elem, isInner) {
var result = elem.offsetTop;
if (!isInner) {
while (elem = elem.offsetParent) {
result += elem.offsetTop;
}
}
return result;
};
position.getHeight = function (elem) {
return elem.offsetHeight || elem.scrollHeight;
};
position.getWidth = function (elem) {
return elem.offsetWidth || elem.scrollWidth;
};
position.getPageSize = function () {
var scrollWidth, scrollHeight;
var innerWidth, innerHeight;
// It's not very clear which blocks work with which browsers.
if (self.innerHeight && self.scrollMaxY) {
scrollWidth = doc.body.scrollWidth;
scrollHeight = self.innerHeight + self.scrollMaxY;
}
else if (doc.body.scrollHeight > doc.body.offsetHeight) {
scrollWidth = doc.body.scrollWidth;
scrollHeight = doc.body.scrollHeight;
}
else {
scrollWidth = doc.body.offsetWidth;
scrollHeight = doc.body.offsetHeight;
}
if (self.innerHeight) {
// Non-IE browser
innerWidth = self.innerWidth;
innerHeight = self.innerHeight;
}
else if (doc.documentElement && doc.documentElement.clientHeight) {
// Some versions of IE (IE 6 w/ a DOCTYPE declaration)
innerWidth = doc.documentElement.clientWidth;
innerHeight = doc.documentElement.clientHeight;
}
else if (doc.body) {
// Other versions of IE
innerWidth = doc.body.clientWidth;
innerHeight = doc.body.clientHeight;
}
var maxWidth = Math.max(scrollWidth, innerWidth);
var maxHeight = Math.max(scrollHeight, innerHeight);
return [maxWidth, maxHeight, innerWidth, innerHeight];
};
// Handles pushing and popping TextareaStates for undo/redo commands.
// I should rename the stack variables to list.
function UndoManager(callback, panels) {
var undoObj = this;
var undoStack = []; // A stack of undo states
var stackPtr = 0; // The index of the current state
var mode = "none";
var lastState; // The last state
var timer; // The setTimeout handle for cancelling the timer
var inputStateObj;
// Set the mode for later logic steps.
var setMode = function (newMode, noSave) {
if (mode != newMode) {
mode = newMode;
if (!noSave) {
saveState();
}
}
if (!uaSniffed.isIE || mode != "moving") {
timer = setTimeout(refreshState, 1);
}
else {
inputStateObj = null;
}
};
var refreshState = function (isInitialState) {
inputStateObj = new TextareaState(panels, isInitialState);
timer = undefined;
};
this.setCommandMode = function () {
mode = "command";
saveState();
timer = setTimeout(refreshState, 0);
};
this.canUndo = function () {
return stackPtr > 1;
};
this.canRedo = function () {
if (undoStack[stackPtr + 1]) {
return true;
}
return false;
};
// Removes the last state and restores it.
this.undo = function () {
if (undoObj.canUndo()) {
if (lastState) {
// What about setting state -1 to null or checking for undefined?
lastState.restore();
lastState = null;
}
else {
undoStack[stackPtr] = new TextareaState(panels);
undoStack[--stackPtr].restore();
if (callback) {
callback();
}
}
}
mode = "none";
panels.input.focus();
refreshState();
};
// Redo an action.
this.redo = function () {
if (undoObj.canRedo()) {
undoStack[++stackPtr].restore();
if (callback) {
callback();
}
}
mode = "none";
panels.input.focus();
refreshState();
};
// Push the input area state to the stack.
var saveState = function () {
var currState = inputStateObj || new TextareaState(panels);
if (!currState) {
return false;
}
if (mode == "moving") {
if (!lastState) {
lastState = currState;
}
return;
}
if (lastState) {
if (undoStack[stackPtr - 1].text != lastState.text) {
undoStack[stackPtr++] = lastState;
}
lastState = null;
}
undoStack[stackPtr++] = currState;
undoStack[stackPtr + 1] = null;
if (callback) {
callback();
}
};
var handleCtrlYZ = function (event) {
var handled = false;
if (event.ctrlKey || event.metaKey) {
// IE and Opera do not support charCode.
var keyCode = event.charCode || event.keyCode;
var keyCodeChar = String.fromCharCode(keyCode);
switch (keyCodeChar) {
case "y":
undoObj.redo();
handled = true;
break;
case "z":
if (!event.shiftKey) {
undoObj.undo();
}
else {
undoObj.redo();
}
handled = true;
break;
}
}
if (handled) {
if (event.preventDefault) {
event.preventDefault();
}
if (window.event) {
window.event.returnValue = false;
}
return;
}
};
// Set the mode depending on what is going on in the input area.
var handleModeChange = function (event) {
if (!event.ctrlKey && !event.metaKey) {
var keyCode = event.keyCode;
if ((keyCode >= 33 && keyCode <= 40) || (keyCode >= 63232 && keyCode <= 63235)) {
// 33 - 40: page up/dn and arrow keys
// 63232 - 63235: page up/dn and arrow keys on safari
setMode("moving");
}
else if (keyCode == 8 || keyCode == 46 || keyCode == 127) {
// 8: backspace
// 46: delete
// 127: delete
setMode("deleting");
}
else if (keyCode == 13) {
// 13: Enter
setMode("newlines");
}
else if (keyCode == 27) {
// 27: escape
setMode("escape");
}
else if ((keyCode < 16 || keyCode > 20) && keyCode != 91) {
// 16-20 are shift, etc.
// 91: left window key
// I think this might be a little messed up since there are
// a lot of nonprinting keys above 20.
setMode("typing");
}
}
};
var setEventHandlers = function () {
util.addEvent(panels.input, "keypress", function (event) {
// keyCode 89: y
// keyCode 90: z
if ((event.ctrlKey || event.metaKey) && (event.keyCode == 89 || event.keyCode == 90)) {
event.preventDefault();
}
});
var handlePaste = function () {
if (uaSniffed.isIE || (inputStateObj && inputStateObj.text != panels.input.value)) {
if (timer == undefined) {
mode = "paste";
saveState();
refreshState();
}
}
};
util.addEvent(panels.input, "keydown", handleCtrlYZ);
util.addEvent(panels.input, "keydown", handleModeChange);
util.addEvent(panels.input, "mousedown", function () {
setMode("moving");
});
panels.input.onpaste = handlePaste;
panels.input.ondrop = handlePaste;
};
var init = function () {
setEventHandlers();
refreshState(true);
saveState();
};
init();
}
// end of UndoManager
// The input textarea state/contents.
// This is used to implement undo/redo by the undo manager.
function TextareaState(panels, isInitialState) {
// Aliases
var stateObj = this;
var inputArea = panels.input;
this.init = function () {
if (!util.isVisible(inputArea)) {
return;
}
if (!isInitialState && doc.activeElement && doc.activeElement !== inputArea) { // this happens when tabbing out of the input box
return;
}
this.setInputAreaSelectionStartEnd();
this.scrollTop = inputArea.scrollTop;
if (!this.text && inputArea.selectionStart || inputArea.selectionStart === 0) {
this.text = inputArea.value;
}
}
// Sets the selected text in the input box after we've performed an
// operation.
this.setInputAreaSelection = function () {
if (!util.isVisible(inputArea)) {
return;
}
if (inputArea.selectionStart !== undefined && !uaSniffed.isOpera) {
inputArea.focus();
inputArea.selectionStart = stateObj.start;
inputArea.selectionEnd = stateObj.end;
inputArea.scrollTop = stateObj.scrollTop;
}
else if (doc.selection) {
if (doc.activeElement && doc.activeElement !== inputArea) {
return;
}
inputArea.focus();
var range = inputArea.createTextRange();
range.moveStart("character", -inputArea.value.length);
range.moveEnd("character", -inputArea.value.length);
range.moveEnd("character", stateObj.end);
range.moveStart("character", stateObj.start);
range.select();
}
};
this.setInputAreaSelectionStartEnd = function () {
if (!panels.ieCachedRange && (inputArea.selectionStart || inputArea.selectionStart === 0)) {
stateObj.start = inputArea.selectionStart;
stateObj.end = inputArea.selectionEnd;
}
else if (doc.selection) {
stateObj.text = util.fixEolChars(inputArea.value);
// IE loses the selection in the textarea when buttons are
// clicked. On IE we cache the selection. Here, if something is cached,
// we take it.
var range = panels.ieCachedRange || doc.selection.createRange();
var fixedRange = util.fixEolChars(range.text);
var marker = "\x07";
var markedRange = marker + fixedRange + marker;
range.text = markedRange;
var inputText = util.fixEolChars(inputArea.value);
range.moveStart("character", -markedRange.length);
range.text = fixedRange;
stateObj.start = inputText.indexOf(marker);
stateObj.end = inputText.lastIndexOf(marker) - marker.length;
var len = stateObj.text.length - util.fixEolChars(inputArea.value).length;
if (len) {
range.moveStart("character", -fixedRange.length);
while (len--) {
fixedRange += "\n";
stateObj.end += 1;
}
range.text = fixedRange;
}
if (panels.ieCachedRange)
stateObj.scrollTop = panels.ieCachedScrollTop; // this is set alongside with ieCachedRange
panels.ieCachedRange = null;
this.setInputAreaSelection();
}
};
// Restore this state into the input area.
this.restore = function () {
if (stateObj.text != undefined && stateObj.text != inputArea.value) {
inputArea.value = stateObj.text;
}
this.setInputAreaSelection();
inputArea.scrollTop = stateObj.scrollTop;
};
// Gets a collection of HTML chunks from the inptut textarea.
this.getChunks = function () {
var chunk = new Chunks();
chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start));
chunk.startTag = "";
chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end));
chunk.endTag = "";
chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end));
chunk.scrollTop = stateObj.scrollTop;
return chunk;
};
// Sets the TextareaState properties given a chunk of markdown.
this.setChunks = function (chunk) {
chunk.before = chunk.before + chunk.startTag;
chunk.after = chunk.endTag + chunk.after;
this.start = chunk.before.length;
this.end = chunk.before.length + chunk.selection.length;
this.text = chunk.before + chunk.selection + chunk.after;
this.scrollTop = chunk.scrollTop;
};
this.init();
};
function PreviewManager(converter, panels, previewPushCallback) {
var managerObj = this;
var timeout;
var elapsedTime;
var oldInputText;
var maxDelay = 3000;
var startType = "delayed"; // The other legal value is "manual"
// Adds event listeners to elements
var setupEvents = function (inputElem, listener) {
util.addEvent(inputElem, "input", listener);
inputElem.onpaste = listener;
inputElem.ondrop = listener;
util.addEvent(inputElem, "keypress", listener);
util.addEvent(inputElem, "keydown", listener);
};
var getDocScrollTop = function () {
var result = 0;
if (window.innerHeight) {
result = window.pageYOffset;
}
else
if (doc.documentElement && doc.documentElement.scrollTop) {
result = doc.documentElement.scrollTop;
}
else
if (doc.body) {
result = doc.body.scrollTop;
}
return result;
};
var makePreviewHtml = function () {
// If there is no registered preview panel
// there is nothing to do.
if (!panels.preview)
return;
var text = panels.input.value;
if (text && text == oldInputText) {
return; // Input text hasn't changed.
}
else {
oldInputText = text;
}
var prevTime = new Date().getTime();
text = converter.makeHtml(text);
// Calculate the processing time of the HTML creation.
// It's used as the delay time in the event listener.
var currTime = new Date().getTime();
elapsedTime = currTime - prevTime;
pushPreviewHtml(text);
};
// setTimeout is already used. Used as an event listener.
var applyTimeout = function () {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if (startType !== "manual") {
var delay = 0;
if (startType === "delayed") {
delay = elapsedTime;
}
if (delay > maxDelay) {
delay = maxDelay;
}
timeout = setTimeout(makePreviewHtml, delay);
}
};
var getScaleFactor = function (panel) {
if (panel.scrollHeight <= panel.clientHeight) {
return 1;
}
return panel.scrollTop / (panel.scrollHeight - panel.clientHeight);
};
var setPanelScrollTops = function () {
if (panels.preview) {
panels.preview.scrollTop = (panels.preview.scrollHeight - panels.preview.clientHeight) * getScaleFactor(panels.preview);
}
};
this.refresh = function (requiresRefresh) {
if (requiresRefresh) {
oldInputText = "";
makePreviewHtml();
}
else {
applyTimeout();
}
};
this.processingTime = function () {
return elapsedTime;
};
var isFirstTimeFilled = true;
// IE doesn't let you use innerHTML if the element is contained somewhere in a table
// (which is the case for inline editing) -- in that case, detach the element, set the
// value, and reattach. Yes, that *is* ridiculous.
var ieSafePreviewSet = function (text) {
var preview = panels.preview;
var parent = preview.parentNode;
var sibling = preview.nextSibling;
parent.removeChild(preview);
preview.innerHTML = text;
if (!sibling)
parent.appendChild(preview);
else
parent.insertBefore(preview, sibling);
}
var nonSuckyBrowserPreviewSet = function (text) {
panels.preview.innerHTML = text;
}
var previewSetter;
var previewSet = function (text) {
if (previewSetter)
return previewSetter(text);
try {
nonSuckyBrowserPreviewSet(text);
previewSetter = nonSuckyBrowserPreviewSet;
} catch (e) {
previewSetter = ieSafePreviewSet;
previewSetter(text);
}
};
var pushPreviewHtml = function (text) {
var emptyTop = position.getTop(panels.input) - getDocScrollTop();
if (panels.preview) {
previewPushCallback(text, previewSet);
}
setPanelScrollTops();
if (isFirstTimeFilled) {
isFirstTimeFilled = false;
return;
}
var fullTop = position.getTop(panels.input) - getDocScrollTop();
if (uaSniffed.isIE) {
setTimeout(function () {
window.scrollBy(0, fullTop - emptyTop);
}, 0);
}
else {
window.scrollBy(0, fullTop - emptyTop);
}
};
var init = function () {
setupEvents(panels.input, applyTimeout);
makePreviewHtml();
if (panels.preview) {
panels.preview.scrollTop = 0;
}
};
init();
};
// Creates the background behind the hyperlink text entry box.
// And download dialog
// Most of this has been moved to CSS but the div creation and
// browser-specific hacks remain here.
ui.createBackground = function () {
var background = doc.createElement("div"),
style = background.style;
background.className = "wmd-prompt-background";
style.position = "absolute";
style.top = "0";
style.zIndex = "1000";
if (uaSniffed.isIE) {
style.filter = "alpha(opacity=50)";
}
else {
style.opacity = "0.5";
}
var pageSize = position.getPageSize();
style.height = pageSize[1] + "px";
if (uaSniffed.isIE) {
style.left = doc.documentElement.scrollLeft;
style.width = doc.documentElement.clientWidth;
}
else {
style.left = "0";
style.width = "100%";
}
doc.body.appendChild(background);
return background;
};
// This simulates a modal dialog box and asks for the URL when you
// click the hyperlink or image buttons.
//
// text: The html for the input box.
// defaultInputText: The default value that appears in the input box.
// callback: The function which is executed when the prompt is dismissed, either via OK or Cancel.
// It receives a single argument; either the entered text (if OK was chosen) or null (if Cancel
// was chosen).
ui.prompt = function (text, defaultInputText, callback, imageUploadHandler) {
// These variables need to be declared at this level since they are used
// in multiple functions.
var dialog; // The dialog box.
var input; // The text box where you enter the hyperlink.
if (defaultInputText === undefined) {
defaultInputText = "";
}
// Used as a keydown event handler. Esc dismisses the prompt.
// Key code 27 is ESC.
var checkEscape = function (key) {
var code = (key.charCode || key.keyCode);
if (code === 27) {
close(true);
}
};
// Dismisses the hyperlink input box.
// isCancel is true if we don't care about the input text.
// isCancel is false if we are going to keep the text.
var close = function (isCancel) {
util.removeEvent(doc.body, "keydown", checkEscape);
var text = input.value;
if (isCancel) {
text = null;
}
else {
// Fixes common pasting errors.
text = text.replace(/^http:\/\/(https?|ftp):\/\//, '$1://');
// doesn't change url if started with '/' (local)
if (!/^(?:https?|ftp):\/\//.test(text) && text.charAt(0) != '/') {
text = 'http://' + text;
}
}
dialog.parentNode.removeChild(dialog);
callback(text);
return false;
};
// Create the text input box form/window.
var createDialog = function () {
// The main dialog box.
dialog = doc.createElement("div");
dialog.className = "wmd-prompt-dialog";
dialog.style.padding = "10px;";
dialog.style.position = "fixed";
dialog.style.width = "400px";
dialog.style.zIndex = "1001";
// The dialog text.
var question = doc.createElement("div");
question.innerHTML = text;
question.style.padding = "5px";
dialog.appendChild(question);
// The web form container for the text box and buttons.
var form = doc.createElement("form"),
style = form.style;
form.onsubmit = function () { return close(false); };
style.padding = "0";
style.margin = "0";
style.cssFloat = "left";
style.width = "100%";
style.textAlign = "center";
style.position = "relative";
dialog.appendChild(form);
// The input text box
input = doc.createElement("input");
input.type = "text";
input.value = defaultInputText;
style = input.style;
style.display = "block";
style.width = "80%";
style.marginLeft = style.marginRight = "auto";
form.appendChild(input);
// The choose file button if prompt type is 'image'
if (imageUploadHandler) {
var chooseFile = doc.createElement("input");
chooseFile.type = "file";
chooseFile.name = "file-upload";
chooseFile.id = "file-upload";
chooseFile.onchange = function() {
imageUploadHandler(this, input);
};
form.appendChild(doc.createElement("br"));
form.appendChild(chooseFile);
}
// The ok button
var okButton = doc.createElement("input");
okButton.type = "button";
okButton.onclick = function () { return close(false); };
okButton.value = "OK";
style = okButton.style;
style.margin = "10px";
style.display = "inline";
style.width = "7em";
// The cancel button
var cancelButton = doc.createElement("input");
cancelButton.type = "button";
cancelButton.onclick = function () { return close(true); };
cancelButton.value = "Cancel";
style = cancelButton.style;
style.margin = "10px";
style.display = "inline";
style.width = "7em";
form.appendChild(okButton);
form.appendChild(cancelButton);
util.addEvent(doc.body, "keydown", checkEscape);
dialog.style.top = "50%";
dialog.style.left = "50%";
dialog.style.display = "block";
if (uaSniffed.isIE_5or6) {
dialog.style.position = "absolute";
dialog.style.top = doc.documentElement.scrollTop + 200 + "px";
dialog.style.left = "50%";
}
doc.body.appendChild(dialog);
// This has to be done AFTER adding the dialog to the form if you
// want it to be centered.
dialog.style.marginTop = -(position.getHeight(dialog) / 2) + "px";
dialog.style.marginLeft = -(position.getWidth(dialog) / 2) + "px";
};
// Why is this in a zero-length timeout?
// Is it working around a browser bug?
setTimeout(function () {
createDialog();
var defTextLen = defaultInputText.length;
if (input.selectionStart !== undefined) {
input.selectionStart = 0;
input.selectionEnd = defTextLen;
}
else if (input.createTextRange) {
var range = input.createTextRange();
range.collapse(false);
range.moveStart("character", -defTextLen);
range.moveEnd("character", defTextLen);
range.select();
}
input.focus();
}, 0);
};
function UIManager(postfix, panels, undoManager, previewManager, commandManager, helpOptions, imageUploadHandler) {
var inputBox = panels.input,
buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements.
makeSpritedButtonRow();
var keyEvent = "keydown";
if (uaSniffed.isOpera) {
keyEvent = "keypress";
}
util.addEvent(inputBox, keyEvent, function (key) {
// Check to see if we have a button key and, if so execute the callback.
if ((key.ctrlKey || key.metaKey) && !key.altKey && !key.shiftKey) {
var keyCode = key.charCode || key.keyCode;
var keyCodeStr = String.fromCharCode(keyCode).toLowerCase();
switch (keyCodeStr) {
case "b":
doClick(buttons.bold);
break;
case "i":
doClick(buttons.italic);
break;
case "l":
doClick(buttons.link);
break;
case "q":
doClick(buttons.quote);
break;
case "k":
doClick(buttons.code);
break;
case "g":
doClick(buttons.image);
break;
case "o":
doClick(buttons.olist);
break;
case "u":
doClick(buttons.ulist);
break;
case "h":
doClick(buttons.heading);
break;
case "r":
doClick(buttons.hr);
break;
case "y":
doClick(buttons.redo);
break;
case "z":
if (key.shiftKey) {
doClick(buttons.redo);
}
else {
doClick(buttons.undo);
}
break;
default:
return;
}
if (key.preventDefault) {
key.preventDefault();
}
if (window.event) {
window.event.returnValue = false;
}
}
});
// Auto-indent on shift-enter
util.addEvent(inputBox, "keyup", function (key) {
if (key.shiftKey && !key.ctrlKey && !key.metaKey) {
var keyCode = key.charCode || key.keyCode;
// Character 13 is Enter
if (keyCode === 13) {
var fakeButton = {};
fakeButton.textOp = bindCommand("doAutoindent");
doClick(fakeButton);
}
}
});
// special handler because IE clears the context of the textbox on ESC
if (uaSniffed.isIE) {
util.addEvent(inputBox, "keydown", function (key) {
var code = key.keyCode;
if (code === 27) {
return false;
}
});
}
// Perform the button's action.
function doClick(button) {
inputBox.focus();
if (button.textOp) {
if (undoManager) {
undoManager.setCommandMode();
}
var state = new TextareaState(panels);
if (!state) {
return;
}
var chunks = state.getChunks();
// Some commands launch a "modal" prompt dialog. Javascript
// can't really make a modal dialog box and the WMD code
// will continue to execute while the dialog is displayed.
// This prevents the dialog pattern I'm used to and means
// I can't do something like this:
//
// var link = CreateLinkDialog();
// makeMarkdownLink(link);
//
// Instead of this straightforward method of handling a
// dialog I have to pass any code which would execute
// after the dialog is dismissed (e.g. link creation)
// in a function parameter.
//
// Yes this is awkward and I think it sucks, but there's
// no real workaround. Only the image and link code
// create dialogs and require the function pointers.
var fixupInputArea = function () {
inputBox.focus();
if (chunks) {
state.setChunks(chunks);
}
state.restore();
previewManager.refresh();
};
var noCleanup = button.textOp(chunks, fixupInputArea);
if (!noCleanup) {
fixupInputArea();
}
}
if (button.execute) {
button.execute(undoManager);
}
};
function setupButton(button, isEnabled) {
var normalYShift = "0px";
var disabledYShift = "-20px";
var highlightYShift = "-40px";
var image = button.getElementsByTagName("span")[0];
if (isEnabled) {
image.style.backgroundPosition = button.XShift + " " + normalYShift;
button.onmouseover = function () {
image.style.backgroundPosition = this.XShift + " " + highlightYShift;
};
button.onmouseout = function () {
image.style.backgroundPosition = this.XShift + " " + normalYShift;
};
// IE tries to select the background image "button" text (it's
// implemented in a list item) so we have to cache the selection
// on mousedown.
if (uaSniffed.isIE) {
button.onmousedown = function () {
if (doc.activeElement && doc.activeElement !== panels.input) { // we're not even in the input box, so there's no selection
return;
}
panels.ieCachedRange = document.selection.createRange();
panels.ieCachedScrollTop = panels.input.scrollTop;
};
}
if (!button.isHelp) {
button.onclick = function () {
if (this.onmouseout) {
this.onmouseout();
}
doClick(this);
return false;
}
}
}
else {
image.style.backgroundPosition = button.XShift + " " + disabledYShift;
button.onmouseover = button.onmouseout = button.onclick = function () { };
}
}
function bindCommand(method) {
if (typeof method === "string")
method = commandManager[method];
return function () { method.apply(commandManager, arguments); }
}
function makeSpritedButtonRow() {
var buttonBar = panels.buttonBar;
var normalYShift = "0px";
var disabledYShift = "-20px";
var highlightYShift = "-40px";
var buttonRow = document.createElement("ul");
buttonRow.id = "wmd-button-row" + postfix;
buttonRow.className = 'wmd-button-row';
buttonRow = buttonBar.appendChild(buttonRow);
var xPosition = 0;
var makeButton = function (id, title, XShift, textOp) {
var button = document.createElement("li");
button.className = "wmd-button";
button.style.left = xPosition + "px";
xPosition += 25;
var buttonImage = document.createElement("span");
button.id = id + postfix;
button.appendChild(buttonImage);
button.title = title;
button.XShift = XShift;
if (textOp)
button.textOp = textOp;
setupButton(button, true);
buttonRow.appendChild(button);
return button;
};
var makeSpacer = function (num) {
var spacer = document.createElement("li");
spacer.className = "wmd-spacer wmd-spacer" + num;
spacer.id = "wmd-spacer" + num + postfix;
buttonRow.appendChild(spacer);
xPosition += 25;
}
buttons.bold = makeButton("wmd-bold-button", "Strong <strong> Ctrl+B", "0px", bindCommand("doBold"));
buttons.italic = makeButton("wmd-italic-button", "Emphasis <em> Ctrl+I", "-20px", bindCommand("doItalic"));
makeSpacer(1);
buttons.link = makeButton("wmd-link-button", "Hyperlink <a> Ctrl+L", "-40px", bindCommand(function (chunk, postProcessing) {
return this.doLinkOrImage(chunk, postProcessing, false);
}));
buttons.quote = makeButton("wmd-quote-button", "Blockquote <blockquote> Ctrl+Q", "-60px", bindCommand("doBlockquote"));
buttons.code = makeButton("wmd-code-button", "Code Sample <pre><code> Ctrl+K", "-80px", bindCommand("doCode"));
buttons.image = makeButton("wmd-image-button", "Image <img> Ctrl+G", "-100px", bindCommand(function (chunk, postProcessing) {
return this.doLinkOrImage(chunk, postProcessing, true, imageUploadHandler);
}));
makeSpacer(2);
buttons.olist = makeButton("wmd-olist-button", "Numbered List <ol> Ctrl+O", "-120px", bindCommand(function (chunk, postProcessing) {
this.doList(chunk, postProcessing, true);
}));
buttons.ulist = makeButton("wmd-ulist-button", "Bulleted List <ul> Ctrl+U", "-140px", bindCommand(function (chunk, postProcessing) {
this.doList(chunk, postProcessing, false);
}));
buttons.heading = makeButton("wmd-heading-button", "Heading <h1>/<h2> Ctrl+H", "-160px", bindCommand("doHeading"));
buttons.hr = makeButton("wmd-hr-button", "Horizontal Rule <hr> Ctrl+R", "-180px", bindCommand("doHorizontalRule"));
makeSpacer(3);
buttons.undo = makeButton("wmd-undo-button", "Undo - Ctrl+Z", "-200px", null);
buttons.undo.execute = function (manager) { if (manager) manager.undo(); };
var redoTitle = /win/.test(nav.platform.toLowerCase()) ?
"Redo - Ctrl+Y" :
"Redo - Ctrl+Shift+Z"; // mac and other non-Windows platforms
buttons.redo = makeButton("wmd-redo-button", redoTitle, "-220px", null);
buttons.redo.execute = function (manager) { if (manager) manager.redo(); };
if (helpOptions) {
var helpButton = document.createElement("li");
var helpButtonImage = document.createElement("span");
helpButton.appendChild(helpButtonImage);
helpButton.className = "wmd-button wmd-help-button";
helpButton.id = "wmd-help-button" + postfix;
helpButton.XShift = "-240px";
helpButton.isHelp = true;
helpButton.style.right = "0px";
helpButton.title = helpOptions.title || defaultHelpHoverTitle;
helpButton.onclick = helpOptions.handler;
setupButton(helpButton, true);
buttonRow.appendChild(helpButton);
buttons.help = helpButton;
}
setUndoRedoButtonStates();
}
function setUndoRedoButtonStates() {
if (undoManager) {
setupButton(buttons.undo, undoManager.canUndo());
setupButton(buttons.redo, undoManager.canRedo());
}
};
this.setUndoRedoButtonStates = setUndoRedoButtonStates;
}
function CommandManager(pluginHooks) {
this.hooks = pluginHooks;
}
var commandProto = CommandManager.prototype;
// The markdown symbols - 4 spaces = code, > = blockquote, etc.
commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)";
// Remove markdown symbols from the chunk selection.
commandProto.unwrap = function (chunk) {
var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g");
chunk.selection = chunk.selection.replace(txt, "$1 $2");
};
commandProto.wrap = function (chunk, len) {
this.unwrap(chunk);
var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"),
that = this;
chunk.selection = chunk.selection.replace(regex, function (line, marked) {
if (new re("^" + that.prefixes, "").test(line)) {
return line;
}
return marked + "\n";
});
chunk.selection = chunk.selection.replace(/\s+$/, "");
};
commandProto.doBold = function (chunk, postProcessing) {
return this.doBorI(chunk, postProcessing, 2, "strong text");
};
commandProto.doItalic = function (chunk, postProcessing) {
return this.doBorI(chunk, postProcessing, 1, "emphasized text");
};
// chunk: The selected region that will be enclosed with */**
// nStars: 1 for italics, 2 for bold
// insertText: If you just click the button without highlighting text, this gets inserted
commandProto.doBorI = function (chunk, postProcessing, nStars, insertText) {
// Get rid of whitespace and fixup newlines.
chunk.trimWhitespace();
chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n");
// Look for stars before and after. Is the chunk already marked up?
// note that these regex matches cannot fail
var starsBefore = /(\**$)/.exec(chunk.before)[0];
var starsAfter = /(^\**)/.exec(chunk.after)[0];
var prevStars = Math.min(starsBefore.length, starsAfter.length);
// Remove stars if we have to since the button acts as a toggle.
if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) {
chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), "");
chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), "");
}
else if (!chunk.selection && starsAfter) {
// It's not really clear why this code is necessary. It just moves
// some arbitrary stuff around.
chunk.after = chunk.after.replace(/^([*_]*)/, "");
chunk.before = chunk.before.replace(/(\s?)$/, "");
var whitespace = re.$1;
chunk.before = chunk.before + starsAfter + whitespace;
}
else {
// In most cases, if you don't have any selected text and click the button
// you'll get a selected, marked up region with the default text inserted.
if (!chunk.selection && !starsAfter) {
chunk.selection = insertText;
}
// Add the true markup.
var markup = nStars <= 1 ? "*" : "**"; // shouldn't the test be = ?
chunk.before = chunk.before + markup;
chunk.after = markup + chunk.after;
}
return;
};
commandProto.stripLinkDefs = function (text, defsToAdd) {
text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*<?(\S+?)>?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm,
function (totalMatch, id, link, newlines, title) {
defsToAdd[id] = totalMatch.replace(/\s*$/, "");
if (newlines) {
// Strip the title and return that separately.
defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, "");
return newlines + title;
}
return "";
});
return text;
};
commandProto.addLinkDef = function (chunk, linkDef) {
var refNumber = 0; // The current reference number
var defsToAdd = {}; //
// Start with a clean slate by removing all previous link definitions.
chunk.before = this.stripLinkDefs(chunk.before, defsToAdd);
chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd);
chunk.after = this.stripLinkDefs(chunk.after, defsToAdd);
var defs = "";
var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g;
var addDefNumber = function (def) {
refNumber++;
def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:");
defs += "\n" + def;
};
// note that
// a) the recursive call to getLink cannot go infinite, because by definition
// of regex, inner is always a proper substring of wholeMatch, and
// b) more than one level of nesting is neither supported by the regex
// nor making a lot of sense (the only use case for nesting is a linked image)
var getLink = function (wholeMatch, before, inner, afterInner, id, end) {
inner = inner.replace(regex, getLink);
if (defsToAdd[id]) {
addDefNumber(defsToAdd[id]);
return before + inner + afterInner + refNumber + end;
}
return wholeMatch;
};
chunk.before = chunk.before.replace(regex, getLink);
if (linkDef) {
addDefNumber(linkDef);
}
else {
chunk.selection = chunk.selection.replace(regex, getLink);
}
var refOut = refNumber;
chunk.after = chunk.after.replace(regex, getLink);
if (chunk.after) {
chunk.after = chunk.after.replace(/\n*$/, "");
}
if (!chunk.after) {
chunk.selection = chunk.selection.replace(/\n*$/, "");
}
chunk.after += "\n\n" + defs;
return refOut;
};
// takes the line as entered into the add link/as image dialog and makes
// sure the URL and the optinal title are "nice".
function properlyEncoded(linkdef) {
return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) {
link = link.replace(/\?.*$/, function (querypart) {
return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical
});
link = decodeURIComponent(link); // unencode first, to prevent double encoding
link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29');
link = link.replace(/\?.*$/, function (querypart) {
return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded
});
if (title) {
title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, "");
title = $.trim(title).replace(/"/g, "quot;").replace(/\(/g, "&#40;").replace(/\)/g, "&#41;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return title ? link + ' "' + title + '"' : link;
});
}
commandProto.doLinkOrImage = function (chunk, postProcessing, isImage, imageUploadHandler) {
chunk.trimWhitespace();
chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/);
var background;
if (chunk.endTag.length > 1 && chunk.startTag.length > 0) {
chunk.startTag = chunk.startTag.replace(/!?\[/, "");
chunk.endTag = "";
this.addLinkDef(chunk, null);
}
else {
// We're moving start and end tag back into the selection, since (as we're in the else block) we're not
// *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the
// link text. linkEnteredCallback takes care of escaping any brackets.
chunk.selection = chunk.startTag + chunk.selection + chunk.endTag;
chunk.startTag = chunk.endTag = "";
if (/\n\n/.test(chunk.selection)) {
this.addLinkDef(chunk, null);
return;
}
var that = this;
// The function to be executed when you enter a link and press OK or Cancel.
// Marks up the link and adds the ref.
var linkEnteredCallback = function (link) {
background.parentNode.removeChild(background);
if (link !== null) {
// ( $1
// [^\\] anything that's not a backslash
// (?:\\\\)* an even number (this includes zero) of backslashes
// )
// (?= followed by
// [[\]] an opening or closing bracket
// )
//
// In other words, a non-escaped bracket. These have to be escaped now to make sure they
// don't count as the end of the link or similar.
// Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets),
// the bracket in one match may be the "not a backslash" character in the next match, so it
// should not be consumed by the first match.
// The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the
// start of the string, so this also works if the selection begins with a bracket. We cannot solve
// this by anchoring with ^, because in the case that the selection starts with two brackets, this
// would mean a zero-width match at the start. Since zero-width matches advance the string position,
// the first bracket could then not act as the "not a backslash" for the second.
chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1);
var linkDef = " [999]: " + properlyEncoded(link);
var num = that.addLinkDef(chunk, linkDef);
chunk.startTag = isImage ? "![" : "[";
chunk.endTag = "][" + num + "]";
if (!chunk.selection) {
if (isImage) {
chunk.selection = "enter image description here";
}
else {
chunk.selection = "enter link description here";
}
}
}
postProcessing();
};
background = ui.createBackground();
if (isImage) {
if (!this.hooks.insertImageDialog(linkEnteredCallback))
ui.prompt(imageDialogText, imageDefaultText, linkEnteredCallback, imageUploadHandler);
}
else {
ui.prompt(linkDialogText, linkDefaultText, linkEnteredCallback);
}
return true;
}
};
// When making a list, hitting shift-enter will put your cursor on the next line
// at the current indent level.
commandProto.doAutoindent = function (chunk, postProcessing) {
var commandMgr = this,
fakeSelection = false;
chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]*\n$/, "\n\n");
chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}>[ \t]*\n$/, "\n\n");
chunk.before = chunk.before.replace(/(\n|^)[ \t]+\n$/, "\n\n");
// There's no selection, end the cursor wasn't at the end of the line:
// The user wants to split the current list item / code line / blockquote line
// (for the latter it doesn't really matter) in two. Temporarily select the
// (rest of the) line to achieve this.
if (!chunk.selection && !/^[ \t]*(?:\n|$)/.test(chunk.after)) {
chunk.after = chunk.after.replace(/^[^\n]*/, function (wholeMatch) {
chunk.selection = wholeMatch;
return "";
});
fakeSelection = true;
}
if (/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]+.*\n$/.test(chunk.before)) {
if (commandMgr.doList) {
commandMgr.doList(chunk);
}
}
if (/(\n|^)[ ]{0,3}>[ \t]+.*\n$/.test(chunk.before)) {
if (commandMgr.doBlockquote) {
commandMgr.doBlockquote(chunk);
}
}
if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) {
if (commandMgr.doCode) {
commandMgr.doCode(chunk);
}
}
if (fakeSelection) {
chunk.after = chunk.selection + chunk.after;
chunk.selection = "";
}
};
commandProto.doBlockquote = function (chunk, postProcessing) {
chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/,
function (totalMatch, newlinesBefore, text, newlinesAfter) {
chunk.before += newlinesBefore;
chunk.after = newlinesAfter + chunk.after;
return text;
});
chunk.before = chunk.before.replace(/(>[ \t]*)$/,
function (totalMatch, blankLine) {
chunk.selection = blankLine + chunk.selection;
return "";
});
chunk.selection = chunk.selection.replace(/^(\s|>)+$/, "");
chunk.selection = chunk.selection || "Blockquote";
// The original code uses a regular expression to find out how much of the
// text *directly before* the selection already was a blockquote:
/*
if (chunk.before) {
chunk.before = chunk.before.replace(/\n?$/, "\n");
}
chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/,
function (totalMatch) {
chunk.startTag = totalMatch;
return "";
});
*/
// This comes down to:
// Go backwards as many lines a possible, such that each line
// a) starts with ">", or
// b) is almost empty, except for whitespace, or
// c) is preceeded by an unbroken chain of non-empty lines
// leading up to a line that starts with ">" and at least one more character
// and in addition
// d) at least one line fulfills a)
//
// Since this is essentially a backwards-moving regex, it's susceptible to
// catstrophic backtracking and can cause the browser to hang;
// see e.g. http://meta.stackoverflow.com/questions/9807.
//
// Hence we replaced this by a simple state machine that just goes through the
// lines and checks for a), b), and c).
var match = "",
leftOver = "",
line;
if (chunk.before) {
var lines = chunk.before.replace(/\n$/, "").split("\n");
var inChain = false;
for (var i = 0; i < lines.length; i++) {
var good = false;
line = lines[i];
inChain = inChain && line.length > 0; // c) any non-empty line continues the chain
if (/^>/.test(line)) { // a)
good = true;
if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain
inChain = true;
} else if (/^[ \t]*$/.test(line)) { // b)
good = true;
} else {
good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain
}
if (good) {
match += line + "\n";
} else {
leftOver += match + line;
match = "\n";
}
}
if (!/(^|\n)>/.test(match)) { // d)
leftOver += match;
match = "";
}
}
chunk.startTag = match;
chunk.before = leftOver;
// end of change
if (chunk.after) {
chunk.after = chunk.after.replace(/^\n?/, "\n");
}
chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/,
function (totalMatch) {
chunk.endTag = totalMatch;
return "";
}
);
var replaceBlanksInTags = function (useBracket) {
var replacement = useBracket ? "> " : "";
if (chunk.startTag) {
chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/,
function (totalMatch, markdown) {
return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n";
});
}
if (chunk.endTag) {
chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/,
function (totalMatch, markdown) {
return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n";
});
}
};
if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) {
this.wrap(chunk, SETTINGS.lineLength - 2);
chunk.selection = chunk.selection.replace(/^/gm, "> ");
replaceBlanksInTags(true);
chunk.skipLines();
} else {
chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, "");
this.unwrap(chunk);
replaceBlanksInTags(false);
if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) {
chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n");
}
if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) {
chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n");
}
}
chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection);
if (!/\n/.test(chunk.selection)) {
chunk.selection = chunk.selection.replace(/^(> *)/,
function (wholeMatch, blanks) {
chunk.startTag += blanks;
return "";
});
}
};
commandProto.doCode = function (chunk, postProcessing) {
var hasTextBefore = /\S[ ]*$/.test(chunk.before);
var hasTextAfter = /^[ ]*\S/.test(chunk.after);
// Use 'four space' markdown if the selection is on its own
// line or is multiline.
if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) {
chunk.before = chunk.before.replace(/[ ]{4}$/,
function (totalMatch) {
chunk.selection = totalMatch + chunk.selection;
return "";
});
var nLinesBack = 1;
var nLinesForward = 1;
if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) {
nLinesBack = 0;
}
if (/^\n(\t|[ ]{4,})/.test(chunk.after)) {
nLinesForward = 0;
}
chunk.skipLines(nLinesBack, nLinesForward);
if (!chunk.selection) {
chunk.startTag = " ";
chunk.selection = "enter code here";
}
else {
if (/^[ ]{0,3}\S/m.test(chunk.selection)) {
if (/\n/.test(chunk.selection))
chunk.selection = chunk.selection.replace(/^/gm, " ");
else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior
chunk.before += " ";
}
else {
chunk.selection = chunk.selection.replace(/^[ ]{4}/gm, "");
}
}
}
else {
// Use backticks (`) to delimit the code block.
chunk.trimWhitespace();
chunk.findTags(/`/, /`/);
if (!chunk.startTag && !chunk.endTag) {
chunk.startTag = chunk.endTag = "`";
if (!chunk.selection) {
chunk.selection = "enter code here";
}
}
else if (chunk.endTag && !chunk.startTag) {
chunk.before += chunk.endTag;
chunk.endTag = "";
}
else {
chunk.startTag = chunk.endTag = "";
}
}
};
commandProto.doList = function (chunk, postProcessing, isNumberedList) {
// These are identical except at the very beginning and end.
// Should probably use the regex extension function to make this clearer.
var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/;
var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/;
// The default bullet is a dash but others are possible.
// This has nothing to do with the particular HTML bullet,
// it's just a markdown bullet.
var bullet = "-";
// The number in a numbered list.
var num = 1;
// Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list.
var getItemPrefix = function () {
var prefix;
if (isNumberedList) {
prefix = " " + num + ". ";
num++;
}
else {
prefix = " " + bullet + " ";
}
return prefix;
};
// Fixes the prefixes of the other list items.
var getPrefixedItem = function (itemText) {
// The numbering flag is unset when called by autoindent.
if (isNumberedList === undefined) {
isNumberedList = /^\s*\d/.test(itemText);
}
// Renumber/bullet the list element.
itemText = itemText.replace(/^[ ]{0,3}([*+-]|\d+[.])\s/gm,
function (_) {
return getItemPrefix();
});
return itemText;
};
chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null);
if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) {
chunk.before += chunk.startTag;
chunk.startTag = "";
}
if (chunk.startTag) {
var hasDigits = /\d+[.]/.test(chunk.startTag);
chunk.startTag = "";
chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n");
this.unwrap(chunk);
chunk.skipLines();
if (hasDigits) {
// Have to renumber the bullet points if this is a numbered list.
chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem);
}
if (isNumberedList == hasDigits) {
return;
}
}
var nLinesUp = 1;
chunk.before = chunk.before.replace(previousItemsRegex,
function (itemText) {
if (/^\s*([*+-])/.test(itemText)) {
bullet = re.$1;
}
nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0;
return getPrefixedItem(itemText);
});
if (!chunk.selection) {
chunk.selection = "List item";
}
var prefix = getItemPrefix();
var nLinesDown = 1;
chunk.after = chunk.after.replace(nextItemsRegex,
function (itemText) {
nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0;
return getPrefixedItem(itemText);
});
chunk.trimWhitespace(true);
chunk.skipLines(nLinesUp, nLinesDown, true);
chunk.startTag = prefix;
var spaces = prefix.replace(/./g, " ");
this.wrap(chunk, SETTINGS.lineLength - spaces.length);
chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces);
};
commandProto.doHeading = function (chunk, postProcessing) {
// Remove leading/trailing whitespace and reduce internal spaces to single spaces.
chunk.selection = chunk.selection.replace(/\s+/g, " ");
chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, "");
// If we clicked the button with no selected text, we just
// make a level 2 hash header around some default text.
if (!chunk.selection) {
chunk.startTag = "## ";
chunk.selection = "Heading";
chunk.endTag = " ##";
return;
}
var headerLevel = 0; // The existing header level of the selected text.
// Remove any existing hash heading markdown and save the header level.
chunk.findTags(/#+[ ]*/, /[ ]*#+/);
if (/#+/.test(chunk.startTag)) {
headerLevel = re.lastMatch.length;
}
chunk.startTag = chunk.endTag = "";
// Try to get the current header level by looking for - and = in the line
// below the selection.
chunk.findTags(null, /\s?(-+|=+)/);
if (/=+/.test(chunk.endTag)) {
headerLevel = 1;
}
if (/-+/.test(chunk.endTag)) {
headerLevel = 2;
}
// Skip to the next line so we can create the header markdown.
chunk.startTag = chunk.endTag = "";
chunk.skipLines(1, 1);
// We make a level 2 header if there is no current header.
// If there is a header level, we substract one from the header level.
// If it's already a level 1 header, it's removed.
var headerLevelToCreate = headerLevel == 0 ? 2 : headerLevel - 1;
if (headerLevelToCreate > 0) {
// The button only creates level 1 and 2 underline headers.
// Why not have it iterate over hash header levels? Wouldn't that be easier and cleaner?
var headerChar = headerLevelToCreate >= 2 ? "-" : "=";
var len = chunk.selection.length;
if (len > SETTINGS.lineLength) {
len = SETTINGS.lineLength;
}
chunk.endTag = "\n";
while (len--) {
chunk.endTag += headerChar;
}
}
};
commandProto.doHorizontalRule = function (chunk, postProcessing) {
chunk.startTag = "----------\n";
chunk.selection = "";
chunk.skipLines(2, 1, true);
}
})();
(function () {
var output, Converter;
if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module
output = exports;
Converter = require("./Markdown.Converter").Converter;
} else {
output = window.Markdown;
Converter = output.Converter;
}
output.getSanitizingConverter = function () {
var converter = new Converter();
converter.hooks.chain("postConversion", sanitizeHtml);
converter.hooks.chain("postConversion", balanceTags);
return converter;
}
function sanitizeHtml(html) {
return html.replace(/<[^>]*>?/gi, sanitizeTag);
}
// (tags that can be opened/closed) | (tags that stand alone)
var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|kbd|li|ol|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i;
// <a href="url..." optional title>|</a>
var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\stitle="[^"<>]+")?\s?>|<\/a>)$/i;
// <img src="url..." optional width optional height optional alt optional title
var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i;
function sanitizeTag(tag) {
if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white))
return tag;
else
return "";
}
/// <summary>
/// attempt to balance HTML tags in the html string
/// by removing any unmatched opening or closing tags
/// IMPORTANT: we *assume* HTML has *already* been
/// sanitized and is safe/sane before balancing!
///
/// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593
/// </summary>
function balanceTags(html) {
if (html == "")
return "";
var re = /<\/?\w+[^>]*(\s|$|>)/g;
// convert everything to lower case; this makes
// our case insensitive comparisons easier
var tags = html.toLowerCase().match(re);
// no HTML tags present? nothing to do; exit now
var tagcount = (tags || []).length;
if (tagcount == 0)
return html;
var tagname, tag;
var ignoredtags = "<p><img><br><li><hr>";
var match;
var tagpaired = [];
var tagremove = [];
var needsRemoval = false;
// loop through matched tags in forward order
for (var ctag = 0; ctag < tagcount; ctag++) {
tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1");
// skip any already paired tags
// and skip tags in our ignore list; assume they're self-closed
if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1)
continue;
tag = tags[ctag];
match = -1;
if (!/^<\//.test(tag)) {
// this is an opening tag
// search forwards (next tags), look for closing tags
for (var ntag = ctag + 1; ntag < tagcount; ntag++) {
if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") {
match = ntag;
break;
}
}
}
if (match == -1)
needsRemoval = tagremove[ctag] = true; // mark for removal
else
tagpaired[match] = true; // mark paired
}
if (!needsRemoval)
return html;
// delete all orphaned tags from the string
var ctag = 0;
html = html.replace(re, function (match) {
var res = tagremove[ctag] ? "" : match;
ctag++;
return res;
});
return html;
}
})();
/*! URI.js v1.6.3 http://medialize.github.com/URI.js/ */
(function(){("undefined"!==typeof module&&module.exports?module.exports:window).IPv6={best:function(e){var e=e.toLowerCase().split(":"),h=e.length,j=8;""===e[0]&&""===e[1]&&""===e[2]?(e.shift(),e.shift()):""===e[0]&&""===e[1]?e.shift():""===e[h-1]&&""===e[h-2]&&e.pop();h=e.length;-1!==e[h-1].indexOf(".")&&(j=7);var f;for(f=0;f<h&&""!==e[f];f++);if(f<j)for(e.splice(f,1,"0000");e.length<j;)e.splice(f,0,"0000");for(f=0;f<j;f++){for(var h=e[f].split(""),l=0;3>l;l++)if("0"===h[0]&&1<h.length)h.splice(0,
1);else break;e[f]=h.join("")}var h=-1,r=l=0,p=-1,d=!1;for(f=0;f<j;f++)d?"0"===e[f]?r+=1:(d=!1,r>l&&(h=p,l=r)):"0"==e[f]&&(d=!0,p=f,r=1);r>l&&(h=p,l=r);1<l&&e.splice(h,l,"");h=e.length;j="";""===e[0]&&(beststr=":");for(f=0;f<h;f++){j+=e[f];if(f===h-1)break;j+=":"}""===e[h-1]&&(j+=":");return j}}})();
(function(e){function h(a){throw RangeError(E[a]);}function j(a,b){for(var c=a.length;c--;)a[c]=b(a[c]);return a}function f(a){for(var b=[],c=0,d=a.length,g,e;c<d;)g=a.charCodeAt(c++),55296==(g&63488)&&(e=a.charCodeAt(c++),(55296!=(g&64512)||56320!=(e&64512))&&h("ucs2decode"),g=((g&1023)<<10)+(e&1023)+65536),b.push(g);return b}function l(a){return j(a,function(a){var b="";55296==(a&63488)&&h("ucs2encode");65535<a&&(a-=65536,b+=x(a>>>10&1023|55296),a=56320|a&1023);return b+=x(a)}).join("")}function r(g,
d,e){for(var f=0,g=e?t(g/c):g>>1,g=g+t(g/d);g>B*a>>1;f+=s)g=t(g/B);return t(f+(B+1)*g/(g+b))}function p(b){var c=[],d=b.length,e,f=0,j=C,k=g,m,u,n,o,i;m=b.lastIndexOf(D);0>m&&(m=0);for(u=0;u<m;++u)128<=b.charCodeAt(u)&&h("not-basic"),c.push(b.charCodeAt(u));for(m=0<m?m+1:0;m<d;){u=f;e=1;for(n=s;;n+=s){m>=d&&h("invalid-input");o=b.charCodeAt(m++);o=10>o-48?o-22:26>o-65?o-65:26>o-97?o-97:s;(o>=s||o>t((v-f)/e))&&h("overflow");f+=o*e;i=n<=k?y:n>=k+a?a:n-k;if(o<i)break;o=s-i;e>t(v/o)&&h("overflow");e*=
o}e=c.length+1;k=r(f-u,e,0==u);t(f/e)>v-j&&h("overflow");j+=t(f/e);f%=e;c.splice(f++,0,j)}return l(c)}function d(b){var c,d,e,j,k,i,m,l,n,o=[],w,p,q,b=f(b);w=b.length;c=C;d=0;k=g;for(i=0;i<w;++i)n=b[i],128>n&&o.push(x(n));for((e=j=o.length)&&o.push(D);e<w;){m=v;for(i=0;i<w;++i)n=b[i],n>=c&&n<m&&(m=n);p=e+1;m-c>t((v-d)/p)&&h("overflow");d+=(m-c)*p;c=m;for(i=0;i<w;++i)if(n=b[i],n<c&&++d>v&&h("overflow"),n==c){l=d;for(m=s;;m+=s){n=m<=k?y:m>=k+a?a:m-k;if(l<n)break;q=l-n;l=s-n;o.push(x(n+q%l+22+75*(26>
n+q%l)-0));l=t(q/l)}o.push(x(l+22+75*(26>l)-0));k=r(d,p,e==j);d=0;++e}++d;++c}return o.join("")}var k,i="function"==typeof define&&"object"==typeof define.amd&&define.amd&&define,q="object"==typeof exports&&exports,z="object"==typeof module&&module,v=2147483647,s=36,y=1,a=26,b=38,c=700,g=72,C=128,D="-",w=/[^ -~]/,F=/^xn--/,E={overflow:"Overflow: input needs wider integers to process.",ucs2decode:"UCS-2(decode): illegal sequence",ucs2encode:"UCS-2(encode): illegal value","not-basic":"Illegal input >= 0x80 (not a basic code point)",
"invalid-input":"Invalid input"},B=s-y,t=Math.floor,x=String.fromCharCode,A;k={version:"0.3.0",ucs2:{decode:f,encode:l},decode:p,encode:d,toASCII:function(a){return j(a.split("."),function(a){return w.test(a)?"xn--"+d(a):a}).join(".")},toUnicode:function(a){return j(a.split("."),function(a){return F.test(a)?p(a.slice(4).toLowerCase()):a}).join(".")}};if(q)if(z&&z.exports==q)z.exports=k;else for(A in k)k.hasOwnProperty(A)&&(q[A]=k[A]);else i?define("punycode",k):e.punycode=k})(this);
(function(){var e={list:{ac:"com|gov|mil|net|org",ae:"ac|co|gov|mil|name|net|org|pro|sch",af:"com|edu|gov|net|org",al:"com|edu|gov|mil|net|org",ao:"co|ed|gv|it|og|pb",ar:"com|edu|gob|gov|int|mil|net|org|tur",at:"ac|co|gv|or",au:"asn|com|csiro|edu|gov|id|net|org",ba:"co|com|edu|gov|mil|net|org|rs|unbi|unmo|unsa|untz|unze",bb:"biz|co|com|edu|gov|info|net|org|store|tv",bh:"biz|cc|com|edu|gov|info|net|org",bn:"com|edu|gov|net|org",bo:"com|edu|gob|gov|int|mil|net|org|tv",br:"adm|adv|agr|am|arq|art|ato|b|bio|blog|bmd|cim|cng|cnt|com|coop|ecn|edu|eng|esp|etc|eti|far|flog|fm|fnd|fot|fst|g12|ggf|gov|imb|ind|inf|jor|jus|lel|mat|med|mil|mus|net|nom|not|ntr|odo|org|ppg|pro|psc|psi|qsl|rec|slg|srv|tmp|trd|tur|tv|vet|vlog|wiki|zlg",
bs:"com|edu|gov|net|org",bz:"du|et|om|ov|rg",ca:"ab|bc|mb|nb|nf|nl|ns|nt|nu|on|pe|qc|sk|yk",ck:"biz|co|edu|gen|gov|info|net|org",cn:"ac|ah|bj|com|cq|edu|fj|gd|gov|gs|gx|gz|ha|hb|he|hi|hl|hn|jl|js|jx|ln|mil|net|nm|nx|org|qh|sc|sd|sh|sn|sx|tj|tw|xj|xz|yn|zj",co:"com|edu|gov|mil|net|nom|org",cr:"ac|c|co|ed|fi|go|or|sa",cy:"ac|biz|com|ekloges|gov|ltd|name|net|org|parliament|press|pro|tm","do":"art|com|edu|gob|gov|mil|net|org|sld|web",dz:"art|asso|com|edu|gov|net|org|pol",ec:"com|edu|fin|gov|info|med|mil|net|org|pro",
eg:"com|edu|eun|gov|mil|name|net|org|sci",er:"com|edu|gov|ind|mil|net|org|rochest|w",es:"com|edu|gob|nom|org",et:"biz|com|edu|gov|info|name|net|org",fj:"ac|biz|com|info|mil|name|net|org|pro",fk:"ac|co|gov|net|nom|org",fr:"asso|com|f|gouv|nom|prd|presse|tm",gg:"co|net|org",gh:"com|edu|gov|mil|org",gn:"ac|com|gov|net|org",gr:"com|edu|gov|mil|net|org",gt:"com|edu|gob|ind|mil|net|org",gu:"com|edu|gov|net|org",hk:"com|edu|gov|idv|net|org",id:"ac|co|go|mil|net|or|sch|web",il:"ac|co|gov|idf|k12|muni|net|org",
"in":"ac|co|edu|ernet|firm|gen|gov|i|ind|mil|net|nic|org|res",iq:"com|edu|gov|i|mil|net|org",ir:"ac|co|dnssec|gov|i|id|net|org|sch",it:"edu|gov",je:"co|net|org",jo:"com|edu|gov|mil|name|net|org|sch",jp:"ac|ad|co|ed|go|gr|lg|ne|or",ke:"ac|co|go|info|me|mobi|ne|or|sc",kh:"com|edu|gov|mil|net|org|per",ki:"biz|com|de|edu|gov|info|mob|net|org|tel",km:"asso|com|coop|edu|gouv|k|medecin|mil|nom|notaires|pharmaciens|presse|tm|veterinaire",kn:"edu|gov|net|org",kr:"ac|busan|chungbuk|chungnam|co|daegu|daejeon|es|gangwon|go|gwangju|gyeongbuk|gyeonggi|gyeongnam|hs|incheon|jeju|jeonbuk|jeonnam|k|kg|mil|ms|ne|or|pe|re|sc|seoul|ulsan",
kw:"com|edu|gov|net|org",ky:"com|edu|gov|net|org",kz:"com|edu|gov|mil|net|org",lb:"com|edu|gov|net|org",lk:"assn|com|edu|gov|grp|hotel|int|ltd|net|ngo|org|sch|soc|web",lr:"com|edu|gov|net|org",lv:"asn|com|conf|edu|gov|id|mil|net|org",ly:"com|edu|gov|id|med|net|org|plc|sch",ma:"ac|co|gov|m|net|org|press",mc:"asso|tm",me:"ac|co|edu|gov|its|net|org|priv",mg:"com|edu|gov|mil|nom|org|prd|tm",mk:"com|edu|gov|inf|name|net|org|pro",ml:"com|edu|gov|net|org|presse",mn:"edu|gov|org",mo:"com|edu|gov|net|org",
mt:"com|edu|gov|net|org",mv:"aero|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro",mw:"ac|co|com|coop|edu|gov|int|museum|net|org",mx:"com|edu|gob|net|org",my:"com|edu|gov|mil|name|net|org|sch",nf:"arts|com|firm|info|net|other|per|rec|store|web",ng:"biz|com|edu|gov|mil|mobi|name|net|org|sch",ni:"ac|co|com|edu|gob|mil|net|nom|org",np:"com|edu|gov|mil|net|org",nr:"biz|com|edu|gov|info|net|org",om:"ac|biz|co|com|edu|gov|med|mil|museum|net|org|pro|sch",pe:"com|edu|gob|mil|net|nom|org|sld",ph:"com|edu|gov|i|mil|net|ngo|org",
pk:"biz|com|edu|fam|gob|gok|gon|gop|gos|gov|net|org|web",pl:"art|bialystok|biz|com|edu|gda|gdansk|gorzow|gov|info|katowice|krakow|lodz|lublin|mil|net|ngo|olsztyn|org|poznan|pwr|radom|slupsk|szczecin|torun|warszawa|waw|wroc|wroclaw|zgora",pr:"ac|biz|com|edu|est|gov|info|isla|name|net|org|pro|prof",ps:"com|edu|gov|net|org|plo|sec",pw:"belau|co|ed|go|ne|or",ro:"arts|com|firm|info|nom|nt|org|rec|store|tm|www",rs:"ac|co|edu|gov|in|org",sb:"com|edu|gov|net|org",sc:"com|edu|gov|net|org",sh:"co|com|edu|gov|net|nom|org",
sl:"com|edu|gov|net|org",st:"co|com|consulado|edu|embaixada|gov|mil|net|org|principe|saotome|store",sv:"com|edu|gob|org|red",sz:"ac|co|org",tr:"av|bbs|bel|biz|com|dr|edu|gen|gov|info|k12|name|net|org|pol|tel|tsk|tv|web",tt:"aero|biz|cat|co|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel",tw:"club|com|ebiz|edu|game|gov|idv|mil|net|org",mu:"ac|co|com|gov|net|or|org",mz:"ac|co|edu|gov|org",na:"co|com",nz:"ac|co|cri|geek|gen|govt|health|iwi|maori|mil|net|org|parliament|school",
pa:"abo|ac|com|edu|gob|ing|med|net|nom|org|sld",pt:"com|edu|gov|int|net|nome|org|publ",py:"com|edu|gov|mil|net|org",qa:"com|edu|gov|mil|net|org",re:"asso|com|nom",ru:"ac|adygeya|altai|amur|arkhangelsk|astrakhan|bashkiria|belgorod|bir|bryansk|buryatia|cbg|chel|chelyabinsk|chita|chukotka|chuvashia|com|dagestan|e-burg|edu|gov|grozny|int|irkutsk|ivanovo|izhevsk|jar|joshkar-ola|kalmykia|kaluga|kamchatka|karelia|kazan|kchr|kemerovo|khabarovsk|khakassia|khv|kirov|koenig|komi|kostroma|kranoyarsk|kuban|kurgan|kursk|lipetsk|magadan|mari|mari-el|marine|mil|mordovia|mosreg|msk|murmansk|nalchik|net|nnov|nov|novosibirsk|nsk|omsk|orenburg|org|oryol|penza|perm|pp|pskov|ptz|rnd|ryazan|sakhalin|samara|saratov|simbirsk|smolensk|spb|stavropol|stv|surgut|tambov|tatarstan|tom|tomsk|tsaritsyn|tsk|tula|tuva|tver|tyumen|udm|udmurtia|ulan-ude|vladikavkaz|vladimir|vladivostok|volgograd|vologda|voronezh|vrn|vyatka|yakutia|yamal|yekaterinburg|yuzhno-sakhalinsk",
rw:"ac|co|com|edu|gouv|gov|int|mil|net",sa:"com|edu|gov|med|net|org|pub|sch",sd:"com|edu|gov|info|med|net|org|tv",se:"a|ac|b|bd|c|d|e|f|g|h|i|k|l|m|n|o|org|p|parti|pp|press|r|s|t|tm|u|w|x|y|z",sg:"com|edu|gov|idn|net|org|per",sn:"art|com|edu|gouv|org|perso|univ",sy:"com|edu|gov|mil|net|news|org",th:"ac|co|go|in|mi|net|or",tj:"ac|biz|co|com|edu|go|gov|info|int|mil|name|net|nic|org|test|web",tn:"agrinet|com|defense|edunet|ens|fin|gov|ind|info|intl|mincom|nat|net|org|perso|rnrt|rns|rnu|tourism",tz:"ac|co|go|ne|or",
ua:"biz|cherkassy|chernigov|chernovtsy|ck|cn|co|com|crimea|cv|dn|dnepropetrovsk|donetsk|dp|edu|gov|if|in|ivano-frankivsk|kh|kharkov|kherson|khmelnitskiy|kiev|kirovograd|km|kr|ks|kv|lg|lugansk|lutsk|lviv|me|mk|net|nikolaev|od|odessa|org|pl|poltava|pp|rovno|rv|sebastopol|sumy|te|ternopil|uzhgorod|vinnica|vn|zaporizhzhe|zhitomir|zp|zt",ug:"ac|co|go|ne|or|org|sc",uk:"ac|bl|british-library|co|cym|gov|govt|icnet|jet|lea|ltd|me|mil|mod|national-library-scotland|nel|net|nhs|nic|nls|org|orgn|parliament|plc|police|sch|scot|soc",
us:"dni|fed|isa|kids|nsn",uy:"com|edu|gub|mil|net|org",ve:"co|com|edu|gob|info|mil|net|org|web",vi:"co|com|k12|net|org",vn:"ac|biz|com|edu|gov|health|info|int|name|net|org|pro",ye:"co|com|gov|ltd|me|net|org|plc",yu:"ac|co|edu|gov|org",za:"ac|agric|alt|bourse|city|co|cybernet|db|edu|gov|grondar|iaccess|imt|inca|landesign|law|mil|net|ngo|nis|nom|olivetti|org|pix|school|tm|web",zm:"ac|co|com|edu|gov|net|org|sch"},has_expression:null,is_expression:null,has:function(h){return!!h.match(e.has_expression)},
is:function(h){return!!h.match(e.is_expression)},get:function(h){return(h=h.match(e.has_expression))&&h[1]||null},init:function(){var h="",j;for(j in e.list)Object.prototype.hasOwnProperty.call(e.list,j)&&(h+="|("+("("+e.list[j]+")."+j)+")");e.has_expression=RegExp(".("+h.substr(1)+")$","i");e.is_expression=RegExp("^("+h.substr(1)+")$","i")}};e.init();"undefined"!==typeof module&&module.exports?module.exports=e:window.SecondLevelDomains=e})();
(function(e){function h(a){return a.replace(/([.*+?^=!:${}()|[\]\/\\])/g,"\\$1")}function j(a){return"[object Array]"===""+Object.prototype.toString.call(a)}var f="undefined"!==typeof module&&module.exports,l=f?require("./punycode"):window.punycode,r=f?require("./IPv6"):window.IPv6,p=f?require("./SecondLevelDomains"):window.SecondLevelDomains,d=function(a,b){if(!(this instanceof d))return new d(a);a===e&&(a=location.href+"");this.href(a);return b!==e?this.absoluteTo(b):this},f=d.prototype;d.idn_expression=
/[^a-z0-9\.-]/i;d.punycode_expression=/(xn--)/i;d.ip4_expression=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;d.ip6_expression=/^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/;
d.find_uri_expression=/\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019]))/ig;d.defaultPorts={http:"80",https:"443",ftp:"21"};d.invalid_hostname_characters=/[^a-zA-Z0-9\.-]/;d.encode=encodeURIComponent;d.decode=decodeURIComponent;d.iso8859=function(){d.encode=escape;d.decode=unescape};d.unicode=function(){d.encode=encodeURIComponent;
d.decode=decodeURIComponent};d.characters={pathname:{encode:{expression:/%(24|26|2B|2C|3B|3D|3A|40)/ig,map:{"%24":"$","%26":"&","%2B":"+","%2C":",","%3B":";","%3D":"=","%3A":":","%40":"@"}},decode:{expression:/[\/\?#]/g,map:{"/":"%2F","?":"%3F","#":"%23"}}}};d.encodeQuery=function(a){return d.encode(a+"").replace(/%20/g,"+")};d.decodeQuery=function(a){return d.decode((a+"").replace(/\+/g,"%20"))};d.recodePath=function(a){for(var a=(a+"").split("/"),b=0,c=a.length;b<c;b++)a[b]=d.encodePathSegment(d.decode(a[b]));
return a.join("/")};d.decodePath=function(a){for(var a=(a+"").split("/"),b=0,c=a.length;b<c;b++)a[b]=d.decodePathSegment(a[b]);return a.join("/")};var k={encode:"encode",decode:"decode"},i,q=function(a){return function(b){return d[a](b+"").replace(d.characters.pathname[a].expression,function(b){return d.characters.pathname[a].map[b]})}};for(i in k)d[i+"PathSegment"]=q(k[i]);d.parse=function(a){var b,c={};b=a.indexOf("#");-1<b&&(c.fragment=a.substring(b+1)||null,a=a.substring(0,b));b=a.indexOf("?");
-1<b&&(c.query=a.substring(b+1)||null,a=a.substring(0,b));"//"===a.substring(0,2)?(c.protocol="",a=a.substring(2),a=d.parseAuthority(a,c)):(b=a.indexOf(":"),-1<b&&(c.protocol=a.substring(0,b),"//"===a.substring(b+1,b+3)?(a=a.substring(b+3),a=d.parseAuthority(a,c)):(a=a.substring(b+1),c.urn=!0)));c.path=a;return c};d.parseHost=function(a,b){var c=a.indexOf("/"),d;-1===c&&(c=a.length);"["===a[0]?(d=a.indexOf("]"),b.hostname=a.substring(1,d)||null,b.port=a.substring(d+2,c)||null):a.indexOf(":")!==a.lastIndexOf(":")?
(b.hostname=a.substring(0,c)||null,b.port=null):(d=a.substring(0,c).split(":"),b.hostname=d[0]||null,b.port=d[1]||null);b.hostname&&"/"!==a.substring(c)[0]&&(c++,a="/"+a);return a.substring(c)||"/"};d.parseAuthority=function(a,b){a=d.parseUserinfo(a,b);return d.parseHost(a,b)};d.parseUserinfo=function(a,b){var c=a.indexOf("@"),g=a.indexOf("/");-1<c&&(-1===g||c<g)?(g=a.substring(0,c).split(":"),b.username=g[0]?d.decode(g[0]):null,b.password=g[1]?d.decode(g[1]):null,a=a.substring(c+1)):(b.username=
null,b.password=null);return a};d.parseQuery=function(a){if(!a)return{};a=a.replace(/&+/g,"&").replace(/^\?*&*|&+$/g,"");if(!a)return{};for(var b={},a=a.split("&"),c=a.length,g=0;g<c;g++){var e=a[g].split("="),f=d.decodeQuery(e.shift()),e=e.length?d.decodeQuery(e.join("=")):null;b[f]?("string"===typeof b[f]&&(b[f]=[b[f]]),b[f].push(e)):b[f]=e}return b};d.build=function(a){var b="";a.protocol&&(b+=a.protocol+":");if(!a.urn&&(b||a.hostname))b+="//";b+=d.buildAuthority(a)||"";"string"===typeof a.path&&
("/"!==a.path[0]&&"string"===typeof a.hostname&&(b+="/"),b+=a.path);"string"==typeof a.query&&(b+="?"+a.query);"string"===typeof a.fragment&&(b+="#"+a.fragment);return b};d.buildHost=function(a){var b="";if(a.hostname)d.ip6_expression.test(a.hostname)?b=a.port?b+("["+a.hostname+"]:"+a.port):b+a.hostname:(b+=a.hostname,a.port&&(b+=":"+a.port));else return"";return b};d.buildAuthority=function(a){return d.buildUserinfo(a)+d.buildHost(a)};d.buildUserinfo=function(a){var b="";a.username&&(b+=d.encode(a.username),
a.password&&(b+=":"+d.encode(a.password)),b+="@");return b};d.buildQuery=function(a,b){var c="",g;for(g in a)if(Object.hasOwnProperty.call(a,g)&&g)if(j(a[g]))for(var f={},h=0,i=a[g].length;h<i;h++)a[g][h]!==e&&f[a[g][h]+""]===e&&(c+="&"+d.buildQueryParameter(g,a[g][h]),!0!==b&&(f[a[g][h]+""]=!0));else a[g]!==e&&(c+="&"+d.buildQueryParameter(g,a[g]));return c.substring(1)};d.buildQueryParameter=function(a,b){return d.encodeQuery(a)+(null!==b?"="+d.encodeQuery(b):"")};d.addQuery=function(a,b,c){if("object"===
typeof b)for(var g in b)Object.prototype.hasOwnProperty.call(b,g)&&d.addQuery(a,g,b[g]);else if("string"===typeof b)a[b]===e?a[b]=c:("string"===typeof a[b]&&(a[b]=[a[b]]),j(c)||(c=[c]),a[b]=a[b].concat(c));else throw new TypeError("URI.addQuery() accepts an object, string as the name parameter");};d.removeQuery=function(a,b,c){if(j(b))for(var c=0,g=b.length;c<g;c++)a[b[c]]=e;else if("object"===typeof b)for(g in b)Object.prototype.hasOwnProperty.call(b,g)&&d.removeQuery(a,g,b[g]);else if("string"===
typeof b)if(c!==e)if(a[b]===c)a[b]=e;else{if(j(a[b])){var g=a[b],f={},h,i;if(j(c)){h=0;for(i=c.length;h<i;h++)f[c[h]]=!0}else f[c]=!0;h=0;for(i=g.length;h<i;h++)f[g[h]]!==e&&(g.splice(h,1),i--,h--);a[b]=g}}else a[b]=e;else throw new TypeError("URI.addQuery() accepts an object, string as the first parameter");};d.commonPath=function(a,b){var c=Math.min(a.length,b.length),d;for(d=0;d<c;d++)if(a[d]!==b[d]){d--;break}if(1>d)return a[0]===b[0]&&"/"===a[0]?"/":"";"/"!==a[d]&&(d=a.substring(0,d).lastIndexOf("/"));
return a.substring(0,d+1)};d.withinString=function(a,b){return a.replace(d.find_uri_expression,b)};d.ensureValidHostname=function(a){if(a.match(d.invalid_hostname_characters)){if(!l)throw new TypeError("Hostname '"+a+"' contains characters other than [A-Z0-9.-] and Punycode.js is not available");if(l.toASCII(a).match(d.invalid_hostname_characters))throw new TypeError("Hostname '"+a+"' contains characters other than [A-Z0-9.-]");}};f.build=function(a){if(!0===a)this._deferred_build=!0;else if(a===
e||this._deferred_build)this._string=d.build(this._parts),this._deferred_build=!1;return this};f.clone=function(){return new d(this)};f.toString=function(){return this.build(!1)._string};f.valueOf=function(){return this.toString()};k={protocol:"protocol",username:"username",password:"password",hostname:"hostname",port:"port"};q=function(a){return function(b,c){if(b===e)return this._parts[a]||"";this._parts[a]=b;this.build(!c);return this}};for(i in k)f[i]=q(k[i]);k={query:"?",fragment:"#"};q=function(a,
b){return function(c,d){if(c===e)return this._parts[a]||"";null!==c&&(c+="",c[0]===b&&(c=c.substring(1)));this._parts[a]=c;this.build(!d);return this}};for(i in k)f[i]=q(i,k[i]);k={search:["?","query"],hash:["#","fragment"]};q=function(a,b){return function(c,d){var e=this[a](c,d);return"string"===typeof e&&e.length?b+e:e}};for(i in k)f[i]=q(k[i][1],k[i][0]);f.pathname=function(a,b){if(a===e||!0===a){var c=this._parts.path||(this._parts.urn?"":"/");return a?d.decodePath(c):c}this._parts.path=a?d.recodePath(a):
"/";this.build(!b);return this};f.path=f.pathname;f.href=function(a,b){if(a===e)return this.toString();this._string="";this._parts={protocol:null,username:null,password:null,hostname:null,urn:null,port:null,path:null,query:null,fragment:null};var c=a instanceof d,g="object"===typeof a&&(a.hostname||a.path),f;if("string"===typeof a)this._parts=d.parse(a);else if(c||g)for(f in c=c?a._parts:a,c)Object.hasOwnProperty.call(this._parts,f)&&(this._parts[f]=c[f]);else throw new TypeError("invalid input");
this.build(!b);return this};f.is=function(a){var b=!1,c=!1,g=!1,e=!1,f=!1,h=!1,i=!1,j=!this._parts.urn;this._parts.hostname&&(j=!1,c=d.ip4_expression.test(this._parts.hostname),g=d.ip6_expression.test(this._parts.hostname),b=c||g,f=(e=!b)&&p&&p.has(this._parts.hostname),h=e&&d.idn_expression.test(this._parts.hostname),i=e&&d.punycode_expression.test(this._parts.hostname));switch(a.toLowerCase()){case "relative":return j;case "absolute":return!j;case "domain":case "name":return e;case "sld":return f;
case "ip":return b;case "ip4":case "ipv4":case "inet4":return c;case "ip6":case "ipv6":case "inet6":return g;case "idn":return h;case "url":return!this._parts.urn;case "urn":return!!this._parts.urn;case "punycode":return i}return null};var z=f.protocol,v=f.port,s=f.hostname;f.protocol=function(a,b){if(a!==e&&a&&(a=a.replace(/:(\/\/)?$/,""),a.match(/[^a-zA-z0-9\.+-]/)))throw new TypeError("Protocol '"+a+"' contains characters other than [A-Z0-9.+-]");return z.call(this,a,b)};f.scheme=f.protocol;f.port=
function(a,b){if(this._parts.urn)return a===e?"":this;if(a!==e&&(0===a&&(a=null),a&&(a+="",":"===a[0]&&(a=a.substring(1)),a.match(/[^0-9]/))))throw new TypeError("Port '"+a+"' contains characters other than [0-9]");return v.call(this,a,b)};f.hostname=function(a,b){if(this._parts.urn)return a===e?"":this;if(a!==e){var c={};d.parseHost(a,c);a=c.hostname}return s.call(this,a,b)};f.host=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e)return this._parts.hostname?d.buildHost(this._parts):
"";d.parseHost(a,this._parts);this.build(!b);return this};f.authority=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e)return this._parts.hostname?d.buildAuthority(this._parts):"";d.parseAuthority(a,this._parts);this.build(!b);return this};f.userinfo=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e){if(!this._parts.username)return"";var c=d.buildUserinfo(this._parts);return c.substring(0,c.length-1)}"@"!==a[a.length-1]&&(a+="@");d.parseUserinfo(a,this._parts);this.build(!b);
return this};f.subdomain=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e)return!this._parts.hostname||this.is("IP")?"":this._parts.hostname.substring(0,this._parts.hostname.length-this.domain().length-1)||"";var c=this._parts.hostname.substring(0,this._parts.hostname.length-this.domain().length),c=RegExp("^"+h(c));a&&"."!==a[a.length-1]&&(a+=".");a&&d.ensureValidHostname(a);this._parts.hostname=this._parts.hostname.replace(c,a);this.build(!b);return this};f.domain=function(a,b){if(this._parts.urn)return a===
e?"":this;"boolean"==typeof a&&(b=a,a=e);if(a===e){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.match(/\./g);if(c&&2>c.length)return this._parts.hostname;c=this._parts.hostname.length-this.tld(b).length-1;c=this._parts.hostname.lastIndexOf(".",c-1)+1;return this._parts.hostname.substring(c)||""}if(!a)throw new TypeError("cannot set domain empty");d.ensureValidHostname(a);this._parts.hostname=!this._parts.hostname||this.is("IP")?a:this._parts.hostname.replace(RegExp(h(this.domain())+
"$"),a);this.build(!b);return this};f.tld=function(a,b){if(this._parts.urn)return a===e?"":this;"boolean"==typeof a&&(b=a,a=e);if(a===e){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.substring(this._parts.hostname.lastIndexOf(".")+1);return!0!==b&&p&&p.list[c.toLowerCase()]?p.get(this._parts.hostname)||c:c}if(a)if(a.match(/[^a-zA-Z0-9-]/))if(p&&p.is(a))c=RegExp(h(this.tld())+"$"),this._parts.hostname=this._parts.hostname.replace(c,a);else throw new TypeError("TLD '"+
a+"' contains characters other than [A-Z0-9]");else{if(!this._parts.hostname||this.is("IP"))throw new ReferenceError("cannot set TLD on non-domain host");c=RegExp(h(this.tld())+"$");this._parts.hostname=this._parts.hostname.replace(c,a)}else throw new TypeError("cannot set TLD empty");this.build(!b);return this};f.directory=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e||!0===a){if(!this._parts.path&&!this._parts.hostname)return"";if("/"===this._parts.path)return"/";var c=this._parts.path.substring(0,
this._parts.path.length-this.filename().length-1)||(this._parts.hostname?"/":"");return a?d.decodePath(c):c}c=this._parts.path.substring(0,this._parts.path.length-this.filename().length);c=RegExp("^"+h(c));this.is("relative")||(a||(a="/"),"/"!==a[0]&&(a="/"+a));a&&"/"!==a[a.length-1]&&(a+="/");a=d.recodePath(a);this._parts.path=this._parts.path.replace(c,a);this.build(!b);return this};f.filename=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";
var c=this._parts.path.substring(this._parts.path.lastIndexOf("/")+1);return a?d.decodePathSegment(c):c}c=!1;"/"===a[0]&&(a=a.substring(1));a.match(/\.?\//)&&(c=!0);var g=RegExp(h(this.filename())+"$"),a=d.recodePath(a);this._parts.path=this._parts.path.replace(g,a);c?this.normalizePath(b):this.build(!b);return this};f.suffix=function(a,b){if(this._parts.urn)return a===e?"":this;if(a===e||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var c=this.filename(),g=c.lastIndexOf(".");if(-1===
g)return"";c=c.substring(g+1);c=/^[a-z0-9%]+$/i.test(c)?c:"";return a?d.decodePathSegment(c):c}"."===a[0]&&(a=a.substring(1));if(c=this.suffix())g=a?RegExp(h(c)+"$"):RegExp(h("."+c)+"$");else{if(!a)return this;this._parts.path+="."+d.recodePath(a)}g&&(a=d.recodePath(a),this._parts.path=this._parts.path.replace(g,a));this.build(!b);return this};var y=f.query;f.query=function(a,b){return!0===a?d.parseQuery(this._parts.query):a!==e&&"string"!==typeof a?(this._parts.query=d.buildQuery(a),this.build(!b),
this):y.call(this,a,b)};f.addQuery=function(a,b,c){var g=d.parseQuery(this._parts.query);d.addQuery(g,a,b);this._parts.query=d.buildQuery(g);"string"!==typeof a&&(c=b);this.build(!c);return this};f.removeQuery=function(a,b,c){var g=d.parseQuery(this._parts.query);d.removeQuery(g,a,b);this._parts.query=d.buildQuery(g);"string"!==typeof a&&(c=b);this.build(!c);return this};f.addSearch=f.addQuery;f.removeSearch=f.removeQuery;f.normalize=function(){return this._parts.urn?this.normalizeProtocol(!1).normalizeQuery(!1).normalizeFragment(!1).build():
this.normalizeProtocol(!1).normalizeHostname(!1).normalizePort(!1).normalizePath(!1).normalizeQuery(!1).normalizeFragment(!1).build()};f.normalizeProtocol=function(a){"string"===typeof this._parts.protocol&&(this._parts.protocol=this._parts.protocol.toLowerCase(),this.build(!a));return this};f.normalizeHostname=function(a){this._parts.hostname&&(this.is("IDN")&&l?this._parts.hostname=l.toASCII(this._parts.hostname):this.is("IPv6")&&r&&(this._parts.hostname=r.best(this._parts.hostname)),this._parts.hostname=
this._parts.hostname.toLowerCase(),this.build(!a));return this};f.normalizePort=function(a){"string"===typeof this._parts.protocol&&this._parts.port===d.defaultPorts[this._parts.protocol]&&(this._parts.port=null,this.build(!a));return this};f.normalizePath=function(a){if(this._parts.urn||!this._parts.path||"/"===this._parts.path)return this;var b,c,g=this._parts.path,e,f;"/"!==g[0]&&("."===g[0]&&(c=g.substring(0,g.indexOf("/"))),b=!0,g="/"+g);for(g=g.replace(/(\/(\.\/)+)|\/{2,}/g,"/");;){e=g.indexOf("/../");
if(-1===e)break;else if(0===e){g=g.substring(3);break}f=g.substring(0,e).lastIndexOf("/");-1===f&&(f=e);g=g.substring(0,f)+g.substring(e+3)}b&&this.is("relative")&&(g=c?c+g:g.substring(1));g=d.recodePath(g);this._parts.path=g;this.build(!a);return this};f.normalizePathname=f.normalizePath;f.normalizeQuery=function(a){"string"===typeof this._parts.query&&(this._parts.query.length?this.query(d.parseQuery(this._parts.query)):this._parts.query=null,this.build(!a));return this};f.normalizeFragment=function(a){this._parts.fragment||
(this._parts.fragment=null,this.build(!a));return this};f.normalizeSearch=f.normalizeQuery;f.normalizeHash=f.normalizeFragment;f.iso8859=function(){var a=d.encode,b=d.decode;d.encode=escape;d.decode=decodeURIComponent;this.normalize();d.encode=a;d.decode=b;return this};f.unicode=function(){var a=d.encode,b=d.decode;d.encode=encodeURIComponent;d.decode=unescape;this.normalize();d.encode=a;d.decode=b;return this};f.readable=function(){var a=this.clone();a.username("").password("").normalize();var b=
"";a._parts.protocol&&(b+=a._parts.protocol+"://");a._parts.hostname&&(a.is("punycode")&&l?(b+=l.toUnicode(a._parts.hostname),a._parts.port&&(b+=":"+a._parts.port)):b+=a.host());a._parts.hostname&&(a._parts.path&&"/"!==a._parts.path[0])&&(b+="/");b+=a.path(!0);if(a._parts.query){for(var c="",g=0,f=a._parts.query.split("&"),h=f.length;g<h;g++){var i=(f[g]||"").split("="),c=c+("&"+d.decodeQuery(i[0]).replace(/&/g,"%26"));i[1]!==e&&(c+="="+d.decodeQuery(i[1]).replace(/&/g,"%26"))}b+="?"+c.substring(1)}return b+=
a.hash()};f.absoluteTo=function(a){var b=this.clone(),c=["protocol","username","password","hostname","port"];if(this._parts.urn)throw Error("URNs do not have any generally defined hierachical components");if(this._parts.hostname)return b;a instanceof d||(a=new d(a));for(var e=0,f;f=c[e];e++)b._parts[f]=a._parts[f];"/"!==b.path()[0]&&(a=a.directory(),b._parts.path=(a?a+"/":"")+b._parts.path,b.normalizePath());b.build();return b};f.relativeTo=function(a){var b=this.clone(),c=["protocol","username",
"password","hostname","port"],e;if(this._parts.urn)throw Error("URNs do not have any generally defined hierachical components");a instanceof d||(a=new d(a));if("/"!==this.path()[0]||"/"!==a.path()[0])throw Error("Cannot calculate common path from non-relative URLs");e=d.commonPath(b.path(),a.path());for(var a=a.directory(),f=0,i;i=c[f];f++)b._parts[i]=null;if(!e||"/"===e)return b;if(a+"/"===e)b._parts.path="./"+b.filename();else{c="../";e=RegExp("^"+h(e));for(a=a.replace(e,"/").match(/\//g).length-
1;a--;)c+="../";b._parts.path=b._parts.path.replace(e,c)}b.build();return b};f.equals=function(a){var b=this.clone(),c=new d(a),e={},f={},a={},h;b.normalize();c.normalize();if(b.toString()===c.toString())return!0;e=b.query();f=c.query();b.query("");c.query("");if(b.toString()!==c.toString()||e.length!==f.length)return!1;e=d.parseQuery(e);f=d.parseQuery(f);for(h in e)if(Object.prototype.hasOwnProperty.call(e,h)){if(j(e[h])){if(!j(f[h])||e[h].length!==f[h].length)return!1;e[h].sort();f[h].sort();b=
0;for(c=e[h].length;b<c;b++)if(e[h][b]!==f[h][b])return!1}else if(e[h]!==f[h])return!1;a[h]=!0}for(h in f)if(Object.prototype.hasOwnProperty.call(f,h)&&!a[h])return!1;return!0};"undefined"!==typeof module&&module.exports?module.exports=d:window.URI=d})();
\ No newline at end of file
jQuery.extend({
handleError: function( s, xhr, status, e ) {
// If a local callback was specified, fire it
if ( s.error ) {
s.error.call( s.context || s, xhr, status, e );
}
// Fire the global callback
if ( s.global ) {
(s.context ? jQuery(s.context) : jQuery.event).trigger( "ajaxError", [xhr, s, e] );
}
},
createUploadIframe: function(id, uri){
//create frame
var frameId = 'jUploadFrame' + id;
if(window.ActiveXObject) {
var io = document.createElement('<iframe id="' + frameId + '" name="' + frameId + '" />');
if(typeof uri== 'boolean'){
io.src = 'javascript:false';
}
else if(typeof uri== 'string'){
io.src = uri;
}
}
else {
var io = document.createElement('iframe');
io.id = frameId;
io.name = frameId;
}
io.style.position = 'absolute';
io.style.top = '-1000px';
io.style.left = '-1000px';
document.body.appendChild(io);
return io;
},
createUploadForm: function(id, fileElementId)
{
//create form
var formId = 'jUploadForm' + id;
var fileId = 'jUploadFile' + id;
var form = $('<form action="" method="POST" name="' + formId + '" id="' + formId
+ '" enctype="multipart/form-data"></form>');
var oldElement = $('#' + fileElementId);
var newElement = $(oldElement).clone();
$(oldElement).attr('id', fileId);
$(oldElement).before(newElement);
$(oldElement).appendTo(form);
//set attributes
$(form).css('position', 'absolute');
$(form).css('top', '-1200px');
$(form).css('left', '-1200px');
$(form).appendTo('body');
return form;
},
ajaxFileUpload: function(s) {
// TODO introduce global settings, allowing the client to modify them for all requests, not only timeout
s = jQuery.extend({}, jQuery.ajaxSettings, s);
var id = new Date().getTime()
var form = jQuery.createUploadForm(id, s.fileElementId);
var io = jQuery.createUploadIframe(id, s.secureuri);
var frameId = 'jUploadFrame' + id;
var formId = 'jUploadForm' + id;
// Watch for a new set of requests
if ( s.global && ! jQuery.active++ )
{
jQuery.event.trigger( "ajaxStart" );
}
var requestDone = false;
// Create the request object
var xml = {}
if ( s.global )
jQuery.event.trigger("ajaxSend", [xml, s]);
// Wait for a response to come back
var uploadCallback = function(isTimeout)
{
var io = document.getElementById(frameId);
try {
if(io.contentWindow){
xml.responseText = io.contentWindow.document.body ?
io.contentWindow.document.body.innerText : null;
xml.responseXML = io.contentWindow.document.XMLDocument ?
io.contentWindow.document.XMLDocument : io.contentWindow.document;
}
else if(io.contentDocument)
{
xml.responseText = io.contentDocument.document.body ?
io.contentDocument.document.body.textContent || document.body.innerText : null;
xml.responseXML = io.contentDocument.document.XMLDocument ?
io.contentDocument.document.XMLDocument : io.contentDocument.document;
}
}
catch(e)
{
jQuery.handleError(s, xml, null, e);
}
if ( xml || isTimeout == "timeout")
{
requestDone = true;
var status;
try {
status = isTimeout != "timeout" ? "success" : "error";
// Make sure that the request was successful or notmodified
if ( status != "error" )
{
// process the data (runs the xml through httpData regardless of callback)
var data = jQuery.uploadHttpData( xml, s.dataType );
// If a local callback was specified, fire it and pass it the data
if ( s.success )
s.success( data, status );
// Fire the global callback
if( s.global )
jQuery.event.trigger( "ajaxSuccess", [xml, s] );
} else
jQuery.handleError(s, xml, status);
} catch(e)
{
status = "error";
jQuery.handleError(s, xml, status, e);
}
// The request was completed
if( s.global )
jQuery.event.trigger( "ajaxComplete", [xml, s] );
// Handle the global AJAX counter
if ( s.global && ! --jQuery.active )
jQuery.event.trigger( "ajaxStop" );
// Process result
if ( s.complete )
s.complete(xml, status);
jQuery(io).unbind();
setTimeout(function()
{ try
{
$(io).remove();
$(form).remove();
} catch(e) {
jQuery.handleError(s, xml, null, e);
}
}, 100)
xml = null;
}
}
// Timeout checker
if ( s.timeout > 0 ) {
setTimeout(function(){
// Check to see if the request is still happening
if( !requestDone ) uploadCallback( "timeout" );
}, s.timeout);
}
try
{
// var io = $('#' + frameId);
var form = $('#' + formId);
$(form).attr('action', s.url);
$(form).attr('method', 'POST');
$(form).attr('target', frameId);
if(form.encoding)
{
form.encoding = 'multipart/form-data';
}
else
{
form.enctype = 'multipart/form-data';
}
$(form).submit();
} catch(e)
{
jQuery.handleError(s, xml, null, e);
}
if(window.attachEvent){
document.getElementById(frameId).attachEvent('onload', uploadCallback);
}
else{
document.getElementById(frameId).addEventListener('load', uploadCallback, false);
}
return {abort: function () {}};
},
uploadHttpData: function( r, type ) {
var data = !type;
data = type == "xml" || data ? r.responseXML : r.responseText;
// If the type is "script", eval it in global context
if ( type == "script" )
jQuery.globalEval( data );
// Get the JavaScript object, if JSON is used.
if ( type == "json" )
eval( "data = " + data );
// evaluate scripts within html
if ( type == "html" )
jQuery("<div>").html(data).evalScripts();
//alert($('param', data).each(function(){alert($(this).attr('value'));}));
return data;
}
})
/**
* @fileOverview jquery-autocomplete, the jQuery Autocompleter
* @author <a href="mailto:dylan@dyve.net">Dylan Verheul</a>
* @requires jQuery 1.6+
*
* Copyright 2005-2012, Dylan Verheul
*
* Use under either MIT, GPL or Apache 2.0. See LICENSE.txt
*
* Project home: https://github.com/dyve/jquery-autocomplete
*/
(function($) {
"use strict";
/**
* jQuery autocomplete plugin
* @param {object|string} options
* @returns (object} jQuery object
*/
$.fn.autocomplete = function(options) {
var url;
if (arguments.length > 1) {
url = options;
options = arguments[1];
options.url = url;
} else if (typeof options === 'string') {
url = options;
options = { url: url };
}
var opts = $.extend({}, $.fn.autocomplete.defaults, options);
return this.each(function() {
var $this = $(this);
$this.data('autocompleter', new $.Autocompleter(
$this,
$.meta ? $.extend({}, opts, $this.data()) : opts
));
});
};
/**
* Store default options
* @type {object}
*/
$.fn.autocomplete.defaults = {
inputClass: 'acInput',
loadingClass: 'acLoading',
resultsClass: 'acResults',
selectClass: 'acSelect',
queryParamName: 'q',
extraParams: {},
remoteDataType: false,
lineSeparator: '\n',
cellSeparator: '|',
minChars: 2,
maxItemsToShow: 10,
delay: 400,
useCache: true,
maxCacheLength: 10,
matchSubset: true,
matchCase: false,
matchInside: true,
mustMatch: false,
selectFirst: false,
selectOnly: false,
showResult: null,
preventDefaultReturn: true,
preventDefaultTab: false,
autoFill: false,
filterResults: true,
sortResults: true,
sortFunction: null,
onItemSelect: null,
onNoMatch: null,
onFinish: null,
matchStringConverter: null,
beforeUseConverter: null,
autoWidth: 'min-width',
useDelimiter: false,
delimiterChar: ',',
delimiterKeyCode: 188,
processData: null,
onError: null
};
/**
* Sanitize result
* @param {Object} result
* @returns {Object} object with members value (String) and data (Object)
* @private
*/
var sanitizeResult = function(result) {
var value, data;
var type = typeof result;
if (type === 'string') {
value = result;
data = {};
} else if ($.isArray(result)) {
value = result[0];
data = result.slice(1);
} else if (type === 'object') {
value = result.value;
data = result.data;
}
value = String(value);
if (typeof data !== 'object') {
data = {};
}
return {
value: value,
data: data
};
};
/**
* Sanitize integer
* @param {mixed} value
* @param {Object} options
* @returns {Number} integer
* @private
*/
var sanitizeInteger = function(value, stdValue, options) {
var num = parseInt(value, 10);
options = options || {};
if (isNaN(num) || (options.min && num < options.min)) {
num = stdValue;
}
return num;
};
/**
* Create partial url for a name/value pair
*/
var makeUrlParam = function(name, value) {
return [name, encodeURIComponent(value)].join('=');
};
/**
* Build an url
* @param {string} url Base url
* @param {object} [params] Dictionary of parameters
*/
var makeUrl = function(url, params) {
var urlAppend = [];
$.each(params, function(index, value) {
urlAppend.push(makeUrlParam(index, value));
});
if (urlAppend.length) {
url += url.indexOf('?') === -1 ? '?' : '&';
url += urlAppend.join('&');
}
return url;
};
/**
* Default sort filter
* @param {object} a
* @param {object} b
* @param {boolean} matchCase
* @returns {number}
*/
var sortValueAlpha = function(a, b, matchCase) {
a = String(a.value);
b = String(b.value);
if (!matchCase) {
a = a.toLowerCase();
b = b.toLowerCase();
}
if (a > b) {
return 1;
}
if (a < b) {
return -1;
}
return 0;
};
/**
* Parse data received in text format
* @param {string} text Plain text input
* @param {string} lineSeparator String that separates lines
* @param {string} cellSeparator String that separates cells
* @returns {array} Array of autocomplete data objects
*/
var plainTextParser = function(text, lineSeparator, cellSeparator) {
var results = [];
var i, j, data, line, value, lines;
// Be nice, fix linebreaks before splitting on lineSeparator
lines = String(text).replace('\r\n', '\n').split(lineSeparator);
for (i = 0; i < lines.length; i++) {
line = lines[i].split(cellSeparator);
data = [];
for (j = 0; j < line.length; j++) {
data.push(decodeURIComponent(line[j]));
}
value = data.shift();
results.push({ value: value, data: data });
}
return results;
};
/**
* Autocompleter class
* @param {object} $elem jQuery object with one input tag
* @param {object} options Settings
* @constructor
*/
$.Autocompleter = function($elem, options) {
/**
* Assert parameters
*/
if (!$elem || !($elem instanceof $) || $elem.length !== 1 || $elem.get(0).tagName.toUpperCase() !== 'INPUT') {
throw new Error('Invalid parameter for jquery.Autocompleter, jQuery object with one element with INPUT tag expected.');
}
/**
* @constant Link to this instance
* @type object
* @private
*/
var self = this;
/**
* @property {object} Options for this instance
* @public
*/
this.options = options;
/**
* @property object Cached data for this instance
* @private
*/
this.cacheData_ = {};
/**
* @property {number} Number of cached data items
* @private
*/
this.cacheLength_ = 0;
/**
* @property {string} Class name to mark selected item
* @private
*/
this.selectClass_ = 'jquery-autocomplete-selected-item';
/**
* @property {number} Handler to activation timeout
* @private
*/
this.keyTimeout_ = null;
/**
* @property {number} Handler to finish timeout
* @private
*/
this.finishTimeout_ = null;
/**
* @property {number} Last key pressed in the input field (store for behavior)
* @private
*/
this.lastKeyPressed_ = null;
/**
* @property {string} Last value processed by the autocompleter
* @private
*/
this.lastProcessedValue_ = null;
/**
* @property {string} Last value selected by the user
* @private
*/
this.lastSelectedValue_ = null;
/**
* @property {boolean} Is this autocompleter active (showing results)?
* @see showResults
* @private
*/
this.active_ = false;
/**
* @property {boolean} Is this autocompleter allowed to finish on blur?
* @private
*/
this.finishOnBlur_ = true;
/**
* Sanitize options
*/
this.options.minChars = sanitizeInteger(this.options.minChars, $.fn.autocomplete.defaults.minChars, { min: 1 });
this.options.maxItemsToShow = sanitizeInteger(this.options.maxItemsToShow, $.fn.autocomplete.defaults.maxItemsToShow, { min: 0 });
this.options.maxCacheLength = sanitizeInteger(this.options.maxCacheLength, $.fn.autocomplete.defaults.maxCacheLength, { min: 1 });
this.options.delay = sanitizeInteger(this.options.delay, $.fn.autocomplete.defaults.delay, { min: 0 });
/**
* Init DOM elements repository
*/
this.dom = {};
/**
* Store the input element we're attached to in the repository
*/
this.dom.$elem = $elem;
/**
* Switch off the native autocomplete and add the input class
*/
this.dom.$elem.attr('autocomplete', 'off').addClass(this.options.inputClass);
/**
* Create DOM element to hold results, and force absolute position
*/
this.dom.$results = $('<div></div>').hide().addClass(this.options.resultsClass).css({
position: 'absolute'
});
$('body').append(this.dom.$results);
/**
* Attach keyboard monitoring to $elem
*/
$elem.keydown(function(e) {
self.lastKeyPressed_ = e.keyCode;
switch(self.lastKeyPressed_) {
case self.options.delimiterKeyCode: // comma = 188
if (self.options.useDelimiter && self.active_) {
self.selectCurrent();
}
break;
// ignore navigational & special keys
case 35: // end
case 36: // home
case 16: // shift
case 17: // ctrl
case 18: // alt
case 37: // left
case 39: // right
break;
case 38: // up
e.preventDefault();
if (self.active_) {
self.focusPrev();
} else {
self.activate();
}
return false;
case 40: // down
e.preventDefault();
if (self.active_) {
self.focusNext();
} else {
self.activate();
}
return false;
case 9: // tab
if (self.active_) {
self.selectCurrent();
if (self.options.preventDefaultTab) {
e.preventDefault();
return false;
}
}
break;
case 13: // return
if (self.active_) {
self.selectCurrent();
if (self.options.preventDefaultReturn) {
e.preventDefault();
return false;
}
}
break;
case 27: // escape
if (self.active_) {
e.preventDefault();
self.deactivate(true);
return false;
}
break;
default:
self.activate();
}
});
/**
* Finish on blur event
* Use a timeout because instant blur gives race conditions
*/
$elem.blur(function() {
if (self.finishOnBlur_) {
self.finishTimeout_ = setTimeout(function() { self.deactivate(true); }, 200);
}
});
};
/**
* Position output DOM elements
* @private
*/
$.Autocompleter.prototype.position = function() {
var offset = this.dom.$elem.offset();
this.dom.$results.css({
top: offset.top + this.dom.$elem.outerHeight(),
left: offset.left
});
};
/**
* Read from cache
* @private
*/
$.Autocompleter.prototype.cacheRead = function(filter) {
var filterLength, searchLength, search, maxPos, pos;
if (this.options.useCache) {
filter = String(filter);
filterLength = filter.length;
if (this.options.matchSubset) {
searchLength = 1;
} else {
searchLength = filterLength;
}
while (searchLength <= filterLength) {
if (this.options.matchInside) {
maxPos = filterLength - searchLength;
} else {
maxPos = 0;
}
pos = 0;
while (pos <= maxPos) {
search = filter.substr(0, searchLength);
if (this.cacheData_[search] !== undefined) {
return this.cacheData_[search];
}
pos++;
}
searchLength++;
}
}
return false;
};
/**
* Write to cache
* @private
*/
$.Autocompleter.prototype.cacheWrite = function(filter, data) {
if (this.options.useCache) {
if (this.cacheLength_ >= this.options.maxCacheLength) {
this.cacheFlush();
}
filter = String(filter);
if (this.cacheData_[filter] !== undefined) {
this.cacheLength_++;
}
this.cacheData_[filter] = data;
return this.cacheData_[filter];
}
return false;
};
/**
* Flush cache
* @public
*/
$.Autocompleter.prototype.cacheFlush = function() {
this.cacheData_ = {};
this.cacheLength_ = 0;
};
/**
* Call hook
* Note that all called hooks are passed the autocompleter object
* @param {string} hook
* @param data
* @returns Result of called hook, false if hook is undefined
*/
$.Autocompleter.prototype.callHook = function(hook, data) {
var f = this.options[hook];
if (f && $.isFunction(f)) {
return f(data, this);
}
return false;
};
/**
* Set timeout to activate autocompleter
*/
$.Autocompleter.prototype.activate = function() {
var self = this;
if (this.keyTimeout_) {
clearTimeout(this.keyTimeout_);
}
this.keyTimeout_ = setTimeout(function() {
self.activateNow();
}, this.options.delay);
};
/**
* Activate autocompleter immediately
*/
$.Autocompleter.prototype.activateNow = function() {
var value = this.beforeUseConverter(this.dom.$elem.val());
if (value !== this.lastProcessedValue_ && value !== this.lastSelectedValue_) {
this.fetchData(value);
}
};
/**
* Get autocomplete data for a given value
* @param {string} value Value to base autocompletion on
* @private
*/
$.Autocompleter.prototype.fetchData = function(value) {
var self = this;
var processResults = function(results, filter) {
if (self.options.processData) {
results = self.options.processData(results);
}
self.showResults(self.filterResults(results, filter), filter);
};
this.lastProcessedValue_ = value;
if (value.length < this.options.minChars) {
processResults([], value);
} else if (this.options.data) {
processResults(this.options.data, value);
} else {
this.fetchRemoteData(value, function(remoteData) {
processResults(remoteData, value);
});
}
};
/**
* Get remote autocomplete data for a given value
* @param {string} filter The filter to base remote data on
* @param {function} callback The function to call after data retrieval
* @private
*/
$.Autocompleter.prototype.fetchRemoteData = function(filter, callback) {
var data = this.cacheRead(filter);
if (data) {
callback(data);
} else {
var self = this;
var dataType = self.options.remoteDataType === 'json' ? 'json' : 'text';
var ajaxCallback = function(data) {
var parsed = false;
if (data !== false) {
parsed = self.parseRemoteData(data);
self.cacheWrite(filter, parsed);
}
self.dom.$elem.removeClass(self.options.loadingClass);
callback(parsed);
};
this.dom.$elem.addClass(this.options.loadingClass);
$.ajax({
url: this.makeUrl(filter),
success: ajaxCallback,
error: function(jqXHR, textStatus, errorThrown) {
if($.isFunction(self.options.onError)) {
self.options.onError(jqXHR, textStatus, errorThrown);
} else {
ajaxCallback(false);
}
},
dataType: dataType
});
}
};
/**
* Create or update an extra parameter for the remote request
* @param {string} name Parameter name
* @param {string} value Parameter value
* @public
*/
$.Autocompleter.prototype.setExtraParam = function(name, value) {
var index = $.trim(String(name));
if (index) {
if (!this.options.extraParams) {
this.options.extraParams = {};
}
if (this.options.extraParams[index] !== value) {
this.options.extraParams[index] = value;
this.cacheFlush();
}
}
};
/**
* Build the url for a remote request
* If options.queryParamName === false, append query to url instead of using a GET parameter
* @param {string} param The value parameter to pass to the backend
* @returns {string} The finished url with parameters
*/
$.Autocompleter.prototype.makeUrl = function(param) {
var self = this;
var url = this.options.url;
var params = $.extend({}, this.options.extraParams);
if (this.options.queryParamName === false) {
url += encodeURIComponent(param);
} else {
params[this.options.queryParamName] = param;
}
return makeUrl(url, params);
};
/**
* Parse data received from server
* @param remoteData Data received from remote server
* @returns {array} Parsed data
*/
$.Autocompleter.prototype.parseRemoteData = function(remoteData) {
var remoteDataType;
var data = remoteData;
if (this.options.remoteDataType === 'json') {
remoteDataType = typeof(remoteData);
switch (remoteDataType) {
case 'object':
data = remoteData;
break;
case 'string':
data = $.parseJSON(remoteData);
break;
default:
throw new Error("Unexpected remote data type: " + remoteDataType);
}
return data;
}
return plainTextParser(data, this.options.lineSeparator, this.options.cellSeparator);
};
/**
* Filter result
* @param {Object} result
* @param {String} filter
* @returns {boolean} Include this result
* @private
*/
$.Autocompleter.prototype.filterResult = function(result, filter) {
if (!result.value) {
return false;
}
if (this.options.filterResults) {
var pattern = this.matchStringConverter(filter);
var testValue = this.matchStringConverter(result.value);
if (!this.options.matchCase) {
pattern = pattern.toLowerCase();
testValue = testValue.toLowerCase();
}
var patternIndex = testValue.indexOf(pattern);
if (this.options.matchInside) {
return patternIndex > -1;
} else {
return patternIndex === 0;
}
}
return true;
};
/**
* Filter results
* @param results
* @param filter
*/
$.Autocompleter.prototype.filterResults = function(results, filter) {
var filtered = [];
var i, result;
for (i = 0; i < results.length; i++) {
result = sanitizeResult(results[i]);
if (this.filterResult(result, filter)) {
filtered.push(result);
}
}
if (this.options.sortResults) {
filtered = this.sortResults(filtered, filter);
}
if (this.options.maxItemsToShow > 0 && this.options.maxItemsToShow < filtered.length) {
filtered.length = this.options.maxItemsToShow;
}
return filtered;
};
/**
* Sort results
* @param results
* @param filter
*/
$.Autocompleter.prototype.sortResults = function(results, filter) {
var self = this;
var sortFunction = this.options.sortFunction;
if (!$.isFunction(sortFunction)) {
sortFunction = function(a, b, f) {
return sortValueAlpha(a, b, self.options.matchCase);
};
}
results.sort(function(a, b) {
return sortFunction(a, b, filter, self.options);
});
return results;
};
/**
* Convert string before matching
* @param s
* @param a
* @param b
*/
$.Autocompleter.prototype.matchStringConverter = function(s, a, b) {
var converter = this.options.matchStringConverter;
if ($.isFunction(converter)) {
s = converter(s, a, b);
}
return s;
};
/**
* Convert string before use
* @param s
* @param a
* @param b
*/
$.Autocompleter.prototype.beforeUseConverter = function(s, a, b) {
s = this.getValue();
var converter = this.options.beforeUseConverter;
if ($.isFunction(converter)) {
s = converter(s, a, b);
}
return s;
};
/**
* Enable finish on blur event
*/
$.Autocompleter.prototype.enableFinishOnBlur = function() {
this.finishOnBlur_ = true;
};
/**
* Disable finish on blur event
*/
$.Autocompleter.prototype.disableFinishOnBlur = function() {
this.finishOnBlur_ = false;
};
/**
* Create a results item (LI element) from a result
* @param result
*/
$.Autocompleter.prototype.createItemFromResult = function(result) {
var self = this;
var $li = $('<li>' + this.showResult(result.value, result.data) + '</li>');
$li.data({value: result.value, data: result.data})
.click(function() {
self.selectItem($li);
})
.mousedown(self.disableFinishOnBlur)
.mouseup(self.enableFinishOnBlur)
;
return $li;
};
/**
* Get all items from the results list
* @param result
*/
$.Autocompleter.prototype.getItems = function() {
return $('>ul>li', this.dom.$results);
};
/**
* Show all results
* @param results
* @param filter
*/
$.Autocompleter.prototype.showResults = function(results, filter) {
var numResults = results.length;
var self = this;
var $ul = $('<ul></ul>');
var i, result, $li, autoWidth, first = false, $first = false;
if (numResults) {
for (i = 0; i < numResults; i++) {
result = results[i];
$li = this.createItemFromResult(result);
$ul.append($li);
if (first === false) {
first = String(result.value);
$first = $li;
$li.addClass(this.options.firstItemClass);
}
if (i === numResults - 1) {
$li.addClass(this.options.lastItemClass);
}
}
// Always recalculate position before showing since window size or
// input element location may have changed.
this.position();
this.dom.$results.html($ul).show();
if (this.options.autoWidth) {
autoWidth = this.dom.$elem.outerWidth() - this.dom.$results.outerWidth() + this.dom.$results.width();
this.dom.$results.css(this.options.autoWidth, autoWidth);
}
this.getItems().hover(
function() { self.focusItem(this); },
function() { /* void */ }
);
if (this.autoFill(first, filter) || this.options.selectFirst || (this.options.selectOnly && numResults === 1)) {
this.focusItem($first);
}
this.active_ = true;
} else {
this.hideResults();
this.active_ = false;
}
};
$.Autocompleter.prototype.showResult = function(value, data) {
if ($.isFunction(this.options.showResult)) {
return this.options.showResult(value, data);
} else {
return value;
}
};
$.Autocompleter.prototype.autoFill = function(value, filter) {
var lcValue, lcFilter, valueLength, filterLength;
if (this.options.autoFill && this.lastKeyPressed_ !== 8) {
lcValue = String(value).toLowerCase();
lcFilter = String(filter).toLowerCase();
valueLength = value.length;
filterLength = filter.length;
if (lcValue.substr(0, filterLength) === lcFilter) {
var d = this.getDelimiterOffsets();
var pad = d.start ? ' ' : ''; // if there is a preceding delimiter
this.setValue( pad + value );
var start = filterLength + d.start + pad.length;
var end = valueLength + d.start + pad.length;
this.selectRange(start, end);
return true;
}
}
return false;
};
$.Autocompleter.prototype.focusNext = function() {
this.focusMove(+1);
};
$.Autocompleter.prototype.focusPrev = function() {
this.focusMove(-1);
};
$.Autocompleter.prototype.focusMove = function(modifier) {
var $items = this.getItems();
modifier = sanitizeInteger(modifier, 0);
if (modifier) {
for (var i = 0; i < $items.length; i++) {
if ($($items[i]).hasClass(this.selectClass_)) {
this.focusItem(i + modifier);
return;
}
}
}
this.focusItem(0);
};
$.Autocompleter.prototype.focusItem = function(item) {
var $item, $items = this.getItems();
if ($items.length) {
$items.removeClass(this.selectClass_).removeClass(this.options.selectClass);
if (typeof item === 'number') {
if (item < 0) {
item = 0;
} else if (item >= $items.length) {
item = $items.length - 1;
}
$item = $($items[item]);
} else {
$item = $(item);
}
if ($item) {
$item.addClass(this.selectClass_).addClass(this.options.selectClass);
}
}
};
$.Autocompleter.prototype.selectCurrent = function() {
var $item = $('li.' + this.selectClass_, this.dom.$results);
if ($item.length === 1) {
this.selectItem($item);
} else {
this.deactivate(false);
}
};
$.Autocompleter.prototype.selectItem = function($li) {
var value = $li.data('value');
var data = $li.data('data');
var displayValue = this.displayValue(value, data);
var processedDisplayValue = this.beforeUseConverter(displayValue);
this.lastProcessedValue_ = processedDisplayValue;
this.lastSelectedValue_ = processedDisplayValue;
var d = this.getDelimiterOffsets();
var delimiter = this.options.delimiterChar;
var elem = this.dom.$elem;
var extraCaretPos = 0;
if ( this.options.useDelimiter ) {
// if there is a preceding delimiter, add a space after the delimiter
if ( elem.val().substring(d.start-1, d.start) == delimiter && delimiter != ' ' ) {
displayValue = ' ' + displayValue;
}
// if there is not already a delimiter trailing this value, add it
if ( elem.val().substring(d.end, d.end+1) != delimiter && this.lastKeyPressed_ != this.options.delimiterKeyCode ) {
displayValue = displayValue + delimiter;
} else {
// move the cursor after the existing trailing delimiter
extraCaretPos = 1;
}
}
this.setValue(displayValue);
this.setCaret(d.start + displayValue.length + extraCaretPos);
this.callHook('onItemSelect', { value: value, data: data });
this.deactivate(true);
elem.focus();
};
$.Autocompleter.prototype.displayValue = function(value, data) {
if ($.isFunction(this.options.displayValue)) {
return this.options.displayValue(value, data);
}
return value;
};
$.Autocompleter.prototype.hideResults = function() {
this.dom.$results.hide();
};
$.Autocompleter.prototype.deactivate = function(finish) {
if (this.finishTimeout_) {
clearTimeout(this.finishTimeout_);
}
if (this.keyTimeout_) {
clearTimeout(this.keyTimeout_);
}
if (finish) {
if (this.lastProcessedValue_ !== this.lastSelectedValue_) {
if (this.options.mustMatch) {
this.setValue('');
}
this.callHook('onNoMatch');
}
if (this.active_) {
this.callHook('onFinish');
}
this.lastKeyPressed_ = null;
this.lastProcessedValue_ = null;
this.lastSelectedValue_ = null;
this.active_ = false;
}
this.hideResults();
};
$.Autocompleter.prototype.selectRange = function(start, end) {
var input = this.dom.$elem.get(0);
if (input.setSelectionRange) {
input.focus();
input.setSelectionRange(start, end);
} else if (input.createTextRange) {
var range = input.createTextRange();
range.collapse(true);
range.moveEnd('character', end);
range.moveStart('character', start);
range.select();
}
};
/**
* Move caret to position
* @param {Number} pos
*/
$.Autocompleter.prototype.setCaret = function(pos) {
this.selectRange(pos, pos);
};
/**
* Get caret position
*/
$.Autocompleter.prototype.getCaret = function() {
var elem = this.dom.$elem;
if ($.browser.msie) {
// ie
var selection = document.selection;
if (elem[0].tagName.toLowerCase() != 'textarea') {
var val = elem.val();
var range = selection.createRange().duplicate();
range.moveEnd('character', val.length);
var s = ( range.text == '' ? val.length : val.lastIndexOf(range.text) );
range = selection.createRange().duplicate();
range.moveStart('character', -val.length);
var e = range.text.length;
} else {
var range = selection.createRange();
var stored_range = range.duplicate();
stored_range.moveToElementText(elem[0]);
stored_range.setEndPoint('EndToEnd', range);
var s = stored_range.text.length - range.text.length;
var e = s + range.text.length;
}
} else {
// ff, chrome, safari
var s = elem[0].selectionStart;
var e = elem[0].selectionEnd;
}
return {
start: s,
end: e
};
};
/**
* Set the value that is currently being autocompleted
* @param {String} value
*/
$.Autocompleter.prototype.setValue = function(value) {
if ( this.options.useDelimiter ) {
// set the substring between the current delimiters
var val = this.dom.$elem.val();
var d = this.getDelimiterOffsets();
var preVal = val.substring(0, d.start);
var postVal = val.substring(d.end);
value = preVal + value + postVal;
}
this.dom.$elem.val(value);
};
/**
* Get the value currently being autocompleted
* @param {String} value
*/
$.Autocompleter.prototype.getValue = function() {
var val = this.dom.$elem.val();
if ( this.options.useDelimiter ) {
var d = this.getDelimiterOffsets();
return val.substring(d.start, d.end).trim();
} else {
return val;
}
};
/**
* Get the offsets of the value currently being autocompleted
*/
$.Autocompleter.prototype.getDelimiterOffsets = function() {
var val = this.dom.$elem.val();
if ( this.options.useDelimiter ) {
var preCaretVal = val.substring(0, this.getCaret().start);
var start = preCaretVal.lastIndexOf(this.options.delimiterChar) + 1;
var postCaretVal = val.substring(this.getCaret().start);
var end = postCaretVal.indexOf(this.options.delimiterChar);
if ( end == -1 ) end = val.length;
end += this.getCaret().start;
} else {
start = 0;
end = val.length;
}
return {
start: start,
end: end
};
};
})(jQuery);
/*
jQuery Tags Input Plugin 1.3.3
Copyright (c) 2011 XOXCO, Inc
Documentation for this plugin lives here:
http://xoxco.com/clickable/jquery-tags-input
Licensed under the MIT license:
http://www.opensource.org/licenses/mit-license.php
ben@xoxco.com
*/
(function($) {
var delimiter = new Array();
var tags_callbacks = new Array();
$.fn.doAutosize = function(o){
var minWidth = $(this).data('minwidth'),
maxWidth = $(this).data('maxwidth'),
val = '',
input = $(this),
testSubject = $('#'+$(this).data('tester_id'));
if (val === (val = input.val())) {return;}
// Enter new content into testSubject
var escaped = val.replace(/&/g, '&amp;').replace(/\s/g,' ').replace(/</g, '&lt;').replace(/>/g, '&gt;');
testSubject.html(escaped);
// Calculate new width + whether to change
var testerWidth = testSubject.width(),
newWidth = (testerWidth + o.comfortZone) >= minWidth ? testerWidth + o.comfortZone : minWidth,
currentWidth = input.width(),
isValidWidthChange = (newWidth < currentWidth && newWidth >= minWidth)
|| (newWidth > minWidth && newWidth < maxWidth);
// Animate width
if (isValidWidthChange) {
input.width(newWidth);
}
};
$.fn.resetAutosize = function(options){
// alert(JSON.stringify(options));
var minWidth = $(this).data('minwidth') || options.minInputWidth || $(this).width(),
maxWidth = $(this).data('maxwidth') || options.maxInputWidth || ($(this).closest('.tagsinput').width() - options.inputPadding),
val = '',
input = $(this),
testSubject = $('<tester/>').css({
position: 'absolute',
top: -9999,
left: -9999,
width: 'auto',
fontSize: input.css('fontSize'),
fontFamily: input.css('fontFamily'),
fontWeight: input.css('fontWeight'),
letterSpacing: input.css('letterSpacing'),
whiteSpace: 'nowrap'
}),
testerId = $(this).attr('id')+'_autosize_tester';
if(! $('#'+testerId).length > 0){
testSubject.attr('id', testerId);
testSubject.appendTo('body');
}
input.data('minwidth', minWidth);
input.data('maxwidth', maxWidth);
input.data('tester_id', testerId);
input.css('width', minWidth);
};
$.fn.addTag = function(value,options) {
options = jQuery.extend({focus:false,callback:true},options);
this.each(function() {
var id = $(this).attr('id');
var tagslist = $(this).val().split(delimiter[id]);
if (tagslist[0] == '') {
tagslist = new Array();
}
value = jQuery.trim(value);
if (options.unique) {
var skipTag = $(this).tagExist(value);
if(skipTag == true) {
//Marks fake input as not_valid to let styling it
$('#'+id+'_tag').addClass('not_valid');
}
} else {
var skipTag = false;
}
if (value !='' && skipTag != true) {
$('<span>').addClass('tag').append(
$('<span>').text(value).append('&nbsp;&nbsp;'),
$('<a>', {
href : '#',
title : 'Removing tag',
text : 'x'
}).click(function () {
return $('#' + id).removeTag(escape(value));
})
).insertBefore('#' + id + '_addTag');
tagslist.push(value);
$('#'+id+'_tag').val('');
if (options.focus) {
$('#'+id+'_tag').focus();
} else {
$('#'+id+'_tag').blur();
}
$.fn.tagsInput.updateTagsField(this,tagslist);
if (options.callback && tags_callbacks[id] && tags_callbacks[id]['onAddTag']) {
var f = tags_callbacks[id]['onAddTag'];
f.call(this, value);
}
if(tags_callbacks[id] && tags_callbacks[id]['onChange'])
{
var i = tagslist.length;
var f = tags_callbacks[id]['onChange'];
f.call(this, $(this), tagslist[i-1]);
}
}
});
return false;
};
$.fn.removeTag = function(value) {
value = unescape(value);
this.each(function() {
var id = $(this).attr('id');
var old = $(this).val().split(delimiter[id]);
$('#'+id+'_tagsinput .tag').remove();
str = '';
for (i=0; i< old.length; i++) {
if (old[i]!=value) {
str = str + delimiter[id] +old[i];
}
}
$.fn.tagsInput.importTags(this,str);
if (tags_callbacks[id] && tags_callbacks[id]['onRemoveTag']) {
var f = tags_callbacks[id]['onRemoveTag'];
f.call(this, value);
}
});
return false;
};
$.fn.tagExist = function(val) {
var id = $(this).attr('id');
var tagslist = $(this).val().split(delimiter[id]);
return (jQuery.inArray(val, tagslist) >= 0); //true when tag exists, false when not
};
// clear all existing tags and import new ones from a string
$.fn.importTags = function(str) {
id = $(this).attr('id');
$('#'+id+'_tagsinput .tag').remove();
$.fn.tagsInput.importTags(this,str);
}
$.fn.tagsInput = function(options) {
var settings = jQuery.extend({
interactive:true,
defaultText:'add a tag',
minChars:0,
width:'300px',
height:'100px',
autocomplete: {selectFirst: false },
'hide':true,
'delimiter':',',
'unique':true,
removeWithBackspace:true,
placeholderColor:'#666666',
autosize: true,
comfortZone: 20,
inputPadding: 6*2
},options);
this.each(function() {
if (settings.hide) {
$(this).hide();
}
var id = $(this).attr('id');
if (!id || delimiter[$(this).attr('id')]) {
id = $(this).attr('id', 'tags' + new Date().getTime()).attr('id');
}
var data = jQuery.extend({
pid:id,
real_input: '#'+id,
holder: '#'+id+'_tagsinput',
input_wrapper: '#'+id+'_addTag',
fake_input: '#'+id+'_tag'
},settings);
delimiter[id] = data.delimiter;
if (settings.onAddTag || settings.onRemoveTag || settings.onChange) {
tags_callbacks[id] = new Array();
tags_callbacks[id]['onAddTag'] = settings.onAddTag;
tags_callbacks[id]['onRemoveTag'] = settings.onRemoveTag;
tags_callbacks[id]['onChange'] = settings.onChange;
}
var markup = '<div id="'+id+'_tagsinput" class="tagsinput"><div id="'+id+'_addTag">';
if (settings.interactive) {
markup = markup + '<input id="'+id+'_tag" value="" data-default="'+settings.defaultText+'" />';
}
markup = markup + '</div><div class="tags_clear"></div></div>';
$(markup).insertAfter(this);
$(data.holder).css('width',settings.width);
$(data.holder).css('min-height',settings.height);
$(data.holder).css('height','100%');
if ($(data.real_input).val()!='') {
$.fn.tagsInput.importTags($(data.real_input),$(data.real_input).val());
}
if (settings.interactive) {
$(data.fake_input).val($(data.fake_input).attr('data-default'));
$(data.fake_input).css('color',settings.placeholderColor);
$(data.fake_input).resetAutosize(settings);
$(data.fake_input).doAutosize(settings);
$(data.holder).bind('click',data,function(event) {
$(event.data.fake_input).focus();
});
$(data.fake_input).bind('focus',data,function(event) {
if ($(event.data.fake_input).val()==$(event.data.fake_input).attr('data-default')) {
$(event.data.fake_input).val('');
}
$(event.data.fake_input).css('color','#000000');
});
if (settings.autocomplete_url != undefined) {
autocomplete_options = {source: settings.autocomplete_url};
for (attrname in settings.autocomplete) {
autocomplete_options[attrname] = settings.autocomplete[attrname];
}
if (jQuery.Autocompleter !== undefined) {
onSelectCallback = settings.autocomplete.onItemSelect;
settings.autocomplete.onItemSelect = function() {
$(data.real_input).addTag($(data.fake_input).val(), {focus: true, unique: (settings.unique)});
$(data.fake_input).resetAutosize(settings);
if (onSelectCallback) {
onSelectCallback();
}
}
$(data.fake_input).autocomplete(settings.autocomplete_url, settings.autocomplete);
$(data.fake_input).bind('result',data,function(event,data,formatted) {
if (data) {
$('#'+id).addTag(data[0] + "",{focus:true,unique:(settings.unique)});
}
});
} else if (jQuery.ui.autocomplete !== undefined) {
$(data.fake_input).autocomplete(autocomplete_options);
$(data.fake_input).bind('autocompleteselect',data,function(event,ui) {
$(event.data.real_input).addTag(ui.item.value,{focus:true,unique:(settings.unique)});
return false;
});
}
} else {
// if a user tabs out of the field, create a new tag
// this is only available if autocomplete is not used.
$(data.fake_input).bind('blur',data,function(event) {
var d = $(this).attr('data-default');
if ($(event.data.fake_input).val()!='' && $(event.data.fake_input).val()!=d) {
if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) )
$(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)});
} else {
$(event.data.fake_input).val($(event.data.fake_input).attr('data-default'));
$(event.data.fake_input).css('color',settings.placeholderColor);
}
return false;
});
}
// if user types a comma, create a new tag
$(data.fake_input).bind('keypress',data,function(event) {
if (event.which==event.data.delimiter.charCodeAt(0) || event.which==13 ) {
event.preventDefault();
if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) )
$(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)});
$(event.data.fake_input).resetAutosize(settings);
return false;
} else if (event.data.autosize) {
$(event.data.fake_input).doAutosize(settings);
}
});
//Delete last tag on backspace
data.removeWithBackspace && $(data.fake_input).bind('keydown', function(event)
{
if(event.keyCode == 8 && $(this).val() == '')
{
event.preventDefault();
var last_tag = $(this).closest('.tagsinput').find('.tag:last').text();
var id = $(this).attr('id').replace(/_tag$/, '');
last_tag = last_tag.replace(/[\s]+x$/, '');
$('#' + id).removeTag(escape(last_tag));
$(this).trigger('focus');
}
});
$(data.fake_input).blur();
//Removes the not_valid class when user changes the value of the fake input
if(data.unique) {
$(data.fake_input).keydown(function(event){
if(event.keyCode == 8 || String.fromCharCode(event.which).match(/\w+|[áéíóúÁÉÍÓÚñÑ,/]+/)) {
$(this).removeClass('not_valid');
}
});
}
} // if settings.interactive
});
return this;
};
$.fn.tagsInput.updateTagsField = function(obj,tagslist) {
var id = $(obj).attr('id');
$(obj).val(tagslist.join(delimiter[id]));
};
$.fn.tagsInput.importTags = function(obj,val) {
$(obj).val('');
var id = $(obj).attr('id');
var tags = val.split(delimiter[id]);
for (i=0; i<tags.length; i++) {
$(obj).addTag(tags[i],{focus:false,callback:false});
}
if(tags_callbacks[id] && tags_callbacks[id]['onChange'])
{
var f = tags_callbacks[id]['onChange'];
f.call(obj, obj, tags[i]);
}
};
})(jQuery);
/**
* Timeago is a jQuery plugin that makes it easy to support automatically
* updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
*
* @name timeago
* @version 0.11.4
* @requires jQuery v1.2.3+
* @author Ryan McGeary
* @license MIT License - http://www.opensource.org/licenses/mit-license.php
*
* For usage and examples, visit:
* http://timeago.yarp.com/
*
* Copyright (c) 2008-2012, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)
*/
(function($) {
$.timeago = function(timestamp) {
if (timestamp instanceof Date) {
return inWords(timestamp);
} else if (typeof timestamp === "string") {
return inWords($.timeago.parse(timestamp));
} else if (typeof timestamp === "number") {
return inWords(new Date(timestamp));
} else {
return inWords($.timeago.datetime(timestamp));
}
};
var $t = $.timeago;
$.extend($.timeago, {
settings: {
refreshMillis: 60000,
allowFuture: false,
strings: {
prefixAgo: null,
prefixFromNow: null,
suffixAgo: "ago",
suffixFromNow: "from now",
seconds: "less than a minute",
minute: "about a minute",
minutes: "%d minutes",
hour: "about an hour",
hours: "about %d hours",
day: "a day",
days: "%d days",
month: "about a month",
months: "%d months",
year: "about a year",
years: "%d years",
wordSeparator: " ",
numbers: []
}
},
inWords: function(distanceMillis) {
var $l = this.settings.strings;
var prefix = $l.prefixAgo;
var suffix = $l.suffixAgo;
if (this.settings.allowFuture) {
if (distanceMillis < 0) {
prefix = $l.prefixFromNow;
suffix = $l.suffixFromNow;
}
}
var seconds = Math.abs(distanceMillis) / 1000;
var minutes = seconds / 60;
var hours = minutes / 60;
var days = hours / 24;
var years = days / 365;
function substitute(stringOrFunction, number) {
var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;
var value = ($l.numbers && $l.numbers[number]) || number;
return string.replace(/%d/i, value);
}
var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
seconds < 90 && substitute($l.minute, 1) ||
minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
minutes < 90 && substitute($l.hour, 1) ||
hours < 24 && substitute($l.hours, Math.round(hours)) ||
hours < 42 && substitute($l.day, 1) ||
days < 30 && substitute($l.days, Math.round(days)) ||
days < 45 && substitute($l.month, 1) ||
days < 365 && substitute($l.months, Math.round(days / 30)) ||
years < 1.5 && substitute($l.year, 1) ||
substitute($l.years, Math.round(years));
var separator = $l.wordSeparator === undefined ? " " : $l.wordSeparator;
return $.trim([prefix, words, suffix].join(separator));
},
parse: function(iso8601) {
var s = $.trim(iso8601);
s = s.replace(/\.\d+/,""); // remove milliseconds
s = s.replace(/-/,"/").replace(/-/,"/");
s = s.replace(/T/," ").replace(/Z/," UTC");
s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400
return new Date(s);
},
datetime: function(elem) {
var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title");
return $t.parse(iso8601);
},
isTime: function(elem) {
// jQuery's `is()` doesn't play well with HTML5 in IE
return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time");
}
});
$.fn.timeago = function() {
var self = this;
self.each(refresh);
var $s = $t.settings;
if ($s.refreshMillis > 0) {
setInterval(function() { self.each(refresh); }, $s.refreshMillis);
}
return self;
};
function refresh() {
var data = prepareData(this);
if (!isNaN(data.datetime)) {
$(this).text(inWords(data.datetime));
}
return this;
}
function prepareData(element) {
element = $(element);
if (!element.data("timeago")) {
element.data("timeago", { datetime: $t.datetime(element) });
var text = $.trim(element.text());
if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) {
element.attr("title", text);
}
}
return element.data("timeago");
}
function inWords(date) {
return $t.inWords(distance(date));
}
function distance(date) {
return (new Date().getTime() - date.getTime());
}
// fix for IE6 suckage
document.createElement("abbr");
document.createElement("time");
}(jQuery));
/*!
* mustache.js - Logic-less {{mustache}} templates with JavaScript
* http://github.com/janl/mustache.js
*/
var Mustache;
(function (exports) {
if (typeof module !== "undefined") {
module.exports = exports; // CommonJS
} else if (typeof define === "function") {
define(exports); // AMD
} else {
Mustache = exports; // <script>
}
}(function () {
var exports = {};
exports.name = "mustache.js";
exports.version = "0.5.1-dev";
exports.tags = ["{{", "}}"];
exports.parse = parse;
exports.clearCache = clearCache;
exports.compile = compile;
exports.compilePartial = compilePartial;
exports.render = render;
exports.Scanner = Scanner;
exports.Context = Context;
exports.Renderer = Renderer;
// This is here for backwards compatibility with 0.4.x.
exports.to_html = function (template, view, partials, send) {
var result = render(template, view, partials);
if (typeof send === "function") {
send(result);
} else {
return result;
}
};
var whiteRe = /\s*/;
var spaceRe = /\s+/;
var nonSpaceRe = /\S/;
var eqRe = /\s*=/;
var curlyRe = /\s*\}/;
var tagRe = /#|\^|\/|>|\{|&|=|!/;
// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577
// See https://github.com/janl/mustache.js/issues/189
function testRe(re, string) {
return RegExp.prototype.test.call(re, string);
}
function isWhitespace(string) {
return !testRe(nonSpaceRe, string);
}
var isArray = Array.isArray || function (obj) {
return Object.prototype.toString.call(obj) === "[object Array]";
};
// OSWASP Guidelines: escape all non alphanumeric characters in ASCII space.
var jsCharsRe = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm;
function quote(text) {
var escaped = text.replace(jsCharsRe, function (c) {
return "\\u" + ('0000' + c.charCodeAt(0).toString(16)).slice(-4);
});
return '"' + escaped + '"';
}
function escapeRe(string) {
return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&");
}
var entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': '&quot;',
"'": '&#39;',
"/": '&#x2F;'
};
function escapeHtml(string) {
return String(string).replace(/[&<>"'\/]/g, function (s) {
return entityMap[s];
});
}
// Export these utility functions.
exports.isWhitespace = isWhitespace;
exports.isArray = isArray;
exports.quote = quote;
exports.escapeRe = escapeRe;
exports.escapeHtml = escapeHtml;
function Scanner(string) {
this.string = string;
this.tail = string;
this.pos = 0;
}
/**
* Returns `true` if the tail is empty (end of string).
*/
Scanner.prototype.eos = function () {
return this.tail === "";
};
/**
* Tries to match the given regular expression at the current position.
* Returns the matched text if it can match, `null` otherwise.
*/
Scanner.prototype.scan = function (re) {
var match = this.tail.match(re);
if (match && match.index === 0) {
this.tail = this.tail.substring(match[0].length);
this.pos += match[0].length;
return match[0];
}
return null;
};
/**
* Skips all text until the given regular expression can be matched. Returns
* the skipped string, which is the entire tail of this scanner if no match
* can be made.
*/
Scanner.prototype.scanUntil = function (re) {
var match, pos = this.tail.search(re);
switch (pos) {
case -1:
match = this.tail;
this.pos += this.tail.length;
this.tail = "";
break;
case 0:
match = null;
break;
default:
match = this.tail.substring(0, pos);
this.tail = this.tail.substring(pos);
this.pos += pos;
}
return match;
};
function Context(view, parent) {
this.view = view;
this.parent = parent;
this.clearCache();
}
Context.make = function (view) {
return (view instanceof Context) ? view : new Context(view);
};
Context.prototype.clearCache = function () {
this._cache = {};
};
Context.prototype.push = function (view) {
return new Context(view, this);
};
Context.prototype.lookup = function (name) {
var value = this._cache[name];
if (!value) {
if (name === ".") {
value = this.view;
} else {
var context = this;
while (context) {
if (name.indexOf(".") > 0) {
var names = name.split("."), i = 0;
value = context.view;
while (value && i < names.length) {
value = value[names[i++]];
}
} else {
value = context.view[name];
}
if (value != null) {
break;
}
context = context.parent;
}
}
this._cache[name] = value;
}
if (typeof value === "function") {
value = value.call(this.view);
}
return value;
};
function Renderer() {
this.clearCache();
}
Renderer.prototype.clearCache = function () {
this._cache = {};
this._partialCache = {};
};
Renderer.prototype.compile = function (tokens, tags) {
if (typeof tokens === "string") {
tokens = parse(tokens, tags);
}
var fn = compileTokens(tokens),
self = this;
return function (view) {
return fn(Context.make(view), self);
};
};
Renderer.prototype.compilePartial = function (name, tokens, tags) {
this._partialCache[name] = this.compile(tokens, tags);
return this._partialCache[name];
};
Renderer.prototype.render = function (template, view) {
var fn = this._cache[template];
if (!fn) {
fn = this.compile(template);
this._cache[template] = fn;
}
return fn(view);
};
Renderer.prototype._section = function (name, context, callback) {
var value = context.lookup(name);
switch (typeof value) {
case "object":
if (isArray(value)) {
var buffer = "";
for (var i = 0, len = value.length; i < len; ++i) {
buffer += callback(context.push(value[i]), this);
}
return buffer;
}
return value ? callback(context.push(value), this) : "";
case "function":
// TODO: The text should be passed to the callback plain, not rendered.
var sectionText = callback(context, this),
self = this;
var scopedRender = function (template) {
return self.render(template, context);
};
return value.call(context.view, sectionText, scopedRender) || "";
default:
if (value) {
return callback(context, this);
}
}
return "";
};
Renderer.prototype._inverted = function (name, context, callback) {
var value = context.lookup(name);
// From the spec: inverted sections may render text once based on the
// inverse value of the key. That is, they will be rendered if the key
// doesn't exist, is false, or is an empty list.
if (value == null || value === false || (isArray(value) && value.length === 0)) {
return callback(context, this);
}
return "";
};
Renderer.prototype._partial = function (name, context) {
var fn = this._partialCache[name];
if (fn) {
return fn(context, this);
}
return "";
};
Renderer.prototype._name = function (name, context, escape) {
var value = context.lookup(name);
if (typeof value === "function") {
value = value.call(context.view);
}
var string = (value == null) ? "" : String(value);
if (escape) {
return escapeHtml(string);
}
return string;
};
/**
* Low-level function that compiles the given `tokens` into a
* function that accepts two arguments: a Context and a
* Renderer. Returns the body of the function as a string if
* `returnBody` is true.
*/
function compileTokens(tokens, returnBody) {
var body = ['""'];
var token, method, escape;
for (var i = 0, len = tokens.length; i < len; ++i) {
token = tokens[i];
switch (token.type) {
case "#":
case "^":
method = (token.type === "#") ? "_section" : "_inverted";
body.push("r." + method + "(" + quote(token.value) + ", c, function (c, r) {\n" +
" " + compileTokens(token.tokens, true) + "\n" +
"})");
break;
case "{":
case "&":
case "name":
escape = token.type === "name" ? "true" : "false";
body.push("r._name(" + quote(token.value) + ", c, " + escape + ")");
break;
case ">":
body.push("r._partial(" + quote(token.value) + ", c)");
break;
case "text":
body.push(quote(token.value));
break;
}
}
// Convert to a string body.
body = "return " + body.join(" + ") + ";";
// Good for debugging.
// console.log(body);
if (returnBody) {
return body;
}
// For great evil!
return new Function("c, r", body);
}
function escapeTags(tags) {
if (tags.length === 2) {
return [
new RegExp(escapeRe(tags[0]) + "\\s*"),
new RegExp("\\s*" + escapeRe(tags[1]))
];
}
throw new Error("Invalid tags: " + tags.join(" "));
}
/**
* Forms the given linear array of `tokens` into a nested tree structure
* where tokens that represent a section have a "tokens" array property
* that contains all tokens that are in that section.
*/
function nestTokens(tokens) {
var tree = [];
var collector = tree;
var sections = [];
var token, section;
for (var i = 0; i < tokens.length; ++i) {
token = tokens[i];
switch (token.type) {
case "#":
case "^":
token.tokens = [];
sections.push(token);
collector.push(token);
collector = token.tokens;
break;
case "/":
if (sections.length === 0) {
throw new Error("Unopened section: " + token.value);
}
section = sections.pop();
if (section.value !== token.value) {
throw new Error("Unclosed section: " + section.value);
}
if (sections.length > 0) {
collector = sections[sections.length - 1].tokens;
} else {
collector = tree;
}
break;
default:
collector.push(token);
}
}
// Make sure there were no open sections when we're done.
section = sections.pop();
if (section) {
throw new Error("Unclosed section: " + section.value);
}
return tree;
}
/**
* Combines the values of consecutive text tokens in the given `tokens` array
* to a single token.
*/
function squashTokens(tokens) {
var lastToken;
for (var i = 0; i < tokens.length; ++i) {
var token = tokens[i];
if (lastToken && lastToken.type === "text" && token.type === "text") {
lastToken.value += token.value;
tokens.splice(i--, 1); // Remove this token from the array.
} else {
lastToken = token;
}
}
}
/**
* Breaks up the given `template` string into a tree of token objects. If
* `tags` is given here it must be an array with two string values: the
* opening and closing tags used in the template (e.g. ["<%", "%>"]). Of
* course, the default is to use mustaches (i.e. Mustache.tags).
*/
function parse(template, tags) {
tags = tags || exports.tags;
var tagRes = escapeTags(tags);
var scanner = new Scanner(template);
var tokens = [], // Buffer to hold the tokens
spaces = [], // Indices of whitespace tokens on the current line
hasTag = false, // Is there a {{tag}} on the current line?
nonSpace = false; // Is there a non-space char on the current line?
// Strips all whitespace tokens array for the current line
// if there was a {{#tag}} on it and otherwise only space.
var stripSpace = function () {
if (hasTag && !nonSpace) {
while (spaces.length) {
tokens.splice(spaces.pop(), 1);
}
} else {
spaces = [];
}
hasTag = false;
nonSpace = false;
};
var type, value, chr;
while (!scanner.eos()) {
value = scanner.scanUntil(tagRes[0]);
if (value) {
for (var i = 0, len = value.length; i < len; ++i) {
chr = value.charAt(i);
if (isWhitespace(chr)) {
spaces.push(tokens.length);
} else {
nonSpace = true;
}
tokens.push({type: "text", value: chr});
if (chr === "\n") {
stripSpace(); // Check for whitespace on the current line.
}
}
}
// Match the opening tag.
if (!scanner.scan(tagRes[0])) {
break;
}
hasTag = true;
type = scanner.scan(tagRe) || "name";
// Skip any whitespace between tag and value.
scanner.scan(whiteRe);
// Extract the tag value.
if (type === "=") {
value = scanner.scanUntil(eqRe);
scanner.scan(eqRe);
scanner.scanUntil(tagRes[1]);
} else if (type === "{") {
var closeRe = new RegExp("\\s*" + escapeRe("}" + tags[1]));
value = scanner.scanUntil(closeRe);
scanner.scan(curlyRe);
scanner.scanUntil(tagRes[1]);
} else {
value = scanner.scanUntil(tagRes[1]);
}
// Match the closing tag.
if (!scanner.scan(tagRes[1])) {
throw new Error("Unclosed tag at " + scanner.pos);
}
tokens.push({type: type, value: value});
if (type === "name" || type === "{" || type === "&") {
nonSpace = true;
}
// Set the tags for the next time around.
if (type === "=") {
tags = value.split(spaceRe);
tagRes = escapeTags(tags);
}
}
squashTokens(tokens);
return nestTokens(tokens);
}
// The high-level clearCache, compile, compilePartial, and render functions
// use this default renderer.
var _renderer = new Renderer();
/**
* Clears all cached templates and partials.
*/
function clearCache() {
_renderer.clearCache();
}
/**
* High-level API for compiling the given `tokens` down to a reusable
* function. If `tokens` is a string it will be parsed using the given `tags`
* before it is compiled.
*/
function compile(tokens, tags) {
return _renderer.compile(tokens, tags);
}
/**
* High-level API for compiling the `tokens` for the partial with the given
* `name` down to a reusable function. If `tokens` is a string it will be
* parsed using the given `tags` before it is compiled.
*/
function compilePartial(name, tokens, tags) {
return _renderer.compilePartial(name, tokens, tags);
}
/**
* High-level API for rendering the `template` using the given `view`. The
* optional `partials` object may be given here for convenience, but note that
* it will cause all partials to be re-compiled, thus hurting performance. Of
* course, this only matters if you're going to render the same template more
* than once. If so, it is best to call `compilePartial` before calling this
* function and to leave the `partials` argument blank.
*/
function render(template, view, partials) {
if (partials) {
for (var name in partials) {
compilePartial(name, partials[name]);
}
}
return _renderer.render(template, view);
}
return exports;
}()));
/*!
* Cross-Browser Split 1.1.1
* Copyright 2007-2012 Steven Levithan <stevenlevithan.com>
* Available under the MIT License
* ECMAScript compliant, uniform cross-browser split method
*/
/**
* Splits a string into an array of strings using a regex or string separator. Matches of the
* separator are not included in the result array. However, if `separator` is a regex that contains
* capturing groups, backreferences are spliced into the result each time `separator` is matched.
* Fixes browser bugs compared to the native `String.prototype.split` and can be used reliably
* cross-browser.
* @param {String} str String to split.
* @param {RegExp|String} separator Regex or string to use for separating the string.
* @param {Number} [limit] Maximum number of items to include in the result array.
* @returns {Array} Array of substrings.
* @example
*
* // Basic use
* split('a b c d', ' ');
* // -> ['a', 'b', 'c', 'd']
*
* // With limit
* split('a b c d', ' ', 2);
* // -> ['a', 'b']
*
* // Backreferences in result array
* split('..word1 word2..', /([a-z]+)(\d+)/i);
* // -> ['..', 'word', '1', ' ', 'word', '2', '..']
*/
var _split; // instead of split for a less common name; avoid conflict
// Avoid running twice; that would break the `nativeSplit` reference
_split = _split || function (undef) {
var nativeSplit = String.prototype.split,
compliantExecNpcg = /()??/.exec("")[1] === undef, // NPCG: nonparticipating capturing group
self;
self = function (str, separator, limit) {
// If `separator` is not a regex, use `nativeSplit`
if (Object.prototype.toString.call(separator) !== "[object RegExp]") {
return nativeSplit.call(str, separator, limit);
}
var output = [],
flags = (separator.ignoreCase ? "i" : "") +
(separator.multiline ? "m" : "") +
(separator.extended ? "x" : "") + // Proposed for ES6
(separator.sticky ? "y" : ""), // Firefox 3+
lastLastIndex = 0,
// Make `global` and avoid `lastIndex` issues by working with a copy
separator = new RegExp(separator.source, flags + "g"),
separator2, match, lastIndex, lastLength;
str += ""; // Type-convert
if (!compliantExecNpcg) {
// Doesn't need flags gy, but they don't hurt
separator2 = new RegExp("^" + separator.source + "$(?!\\s)", flags);
}
/* Values for `limit`, per the spec:
* If undefined: 4294967295 // Math.pow(2, 32) - 1
* If 0, Infinity, or NaN: 0
* If positive number: limit = Math.floor(limit); if (limit > 4294967295) limit -= 4294967296;
* If negative number: 4294967296 - Math.floor(Math.abs(limit))
* If other: Type-convert, then use the above rules
*/
limit = limit === undef ?
-1 >>> 0 : // Math.pow(2, 32) - 1
limit >>> 0; // ToUint32(limit)
while (match = separator.exec(str)) {
// `separator.lastIndex` is not reliable cross-browser
lastIndex = match.index + match[0].length;
if (lastIndex > lastLastIndex) {
output.push(str.slice(lastLastIndex, match.index));
// Fix browsers whose `exec` methods don't consistently return `undefined` for
// nonparticipating capturing groups
if (!compliantExecNpcg && match.length > 1) {
match[0].replace(separator2, function () {
for (var i = 1; i < arguments.length - 2; i++) {
if (arguments[i] === undef) {
match[i] = undef;
}
}
});
}
if (match.length > 1 && match.index < str.length) {
Array.prototype.push.apply(output, match.slice(1));
}
lastLength = match[0].length;
lastLastIndex = lastIndex;
if (output.length >= limit) {
break;
}
}
if (separator.lastIndex === match.index) {
separator.lastIndex++; // Avoid an infinite loop
}
}
if (lastLastIndex === str.length) {
if (lastLength || !separator.test("")) {
output.push("");
}
} else {
output.push(str.slice(lastLastIndex));
}
return output.length > limit ? output.slice(0, limit) : output;
};
// For convenience
String.prototype.split = function (separator, limit) {
return self(this, separator, limit);
};
return self;
}();
/*** Variables ***/
$comment-margin-left: 30px;
$discussion-title-size: 1.6em;
$comment-title-size: 1.0em;
$post-font-size: 0.9em;
$comment-info-size: 0.75em;
$comment-font-size: 0.8em;
$discussion-input-width: 100%;
$tag-background-color: #e7ecdd;
$tag-border-color: #babdb3;
$tag-text-color: #5b614f;
/*** Mixins ***/
@mixin discussion-font {
font-family: inherit;
}
@mixin discussion-clickable {
color: black;
&:hover {
text-decoration: none;
}
}
@mixin standard-discussion-link {
text-decoration: none;
&:hover {
color: #1C71DD;
text-decoration: none;
}
}
/*** Discussions ***/
.discussion {
#open_close_accordion {
display: none;
}
p + p, ul + p, ol + p {
margin-top: 0;
}
/*** Sidebar ***/
.sidebar-module {
@include clearfix;
padding: 0 24px 24px 0;
margin-bottom: 24px;
border-bottom: 1px solid #d3d3d3;
font-size: 0.8em;
header {
margin-bottom: 14px;
@include clearfix;
}
h4 {
float: left;
font-size: 1.1em;
font-weight: bold;
}
.sidebar-new-post-button, .sidebar-promote-moderator-button {
@include button;
}
.sidebar-revoke-moderator-button {
@include button(simple, gray);
}
.sidebar-new-post-button, .sidebar-promote-moderator-button, .sidebar-revoke-moderator-button {
display: block;
box-sizing: border-box;
width: 100%;
margin: 20px 0;
padding: 11px;
font-size: 1.1em;
text-align: center;
&:hover {
text-decoration: none;
}
}
.sidebar-view-all {
float: right;
font-size: 0.9em;
line-height: 1.6em;
@include standard-discussion-link;
}
.discussion-sidebar-following-list {
li {
@include clearfix;
margin-bottom: 8px;
border: none;
}
a {
@include standard-discussion-link;
background: none;
}
}
.discussion-sidebar-tags-list li {
@include clearfix;
border-bottom: none;
}
.sidebar-tag-count {
color: #9a9a9a;
font-size: .85em;
line-height: 3em;
}
.sidebar-following-name {
float: left;
width: 80%;
}
.sidebar-vote-count {
float: right;
width: 20%;
text-align: right;
color: #9a9a9a;
}
//user profile
.sidebar-username {
font-size: 1.5em;
font-weight: bold;
line-height: 1.5em;
}
.sidebar-user-roles {
color: darkGray;
font-style: italic;
margin-bottom: 15px;
}
.sidebar-threads-count, .sidebar-comments-count {
span {
font-size: 1.5em;
font-weight: bold;
line-height: 1.5em;
margin-right: 10px;
}
}
}
.discussion-non-content {
margin-left: flex-gutter();
}
/*** Post ***/
.discussion-title {
@include discussion-font;
@include discussion-clickable;
display: inline-block;
font-size: $discussion-title-size;
font-weight: bold;
margin-bottom: flex-gutter(6);
}
.discussion-title-wrapper {
.discussion-watch-discussion, .discussion-unwatch-discussion {
@include discussion-font;
display: none;
font-size: $comment-info-size;
margin-left: 5px;
}
}
.discussion-right-wrapper {
min-height: 40px;
margin: 24px 0 24px 68px;
}
.admin-actions {
float: right;
margin: 0.4em 1em 0 2em;
padding: 0;
li {
margin-bottom: 6px !important;
}
a {
display: block;
height: 25px;
padding-left: 25px;
border-radius: 50%;
background: url(../images/admin-actions-sprite.png) no-repeat;
font-size: .8em;
line-height: 25px;
color: #b8b8b8;
@include transition(color, .1s);
&:hover {
text-decoration: none;
}
&.admin-endorse {
background-position: 0 0;
&:hover {
color: #63b141;
background-position: 0 -75px;
}
}
&.admin-edit {
background-position: 0 -25px;
&:hover {
color: #009fe2;
background-position: 0 -100px;
}
}
&.admin-delete {
background-position: 0 -50px;
&:hover {
color: #d45050;
background-position: 0 -125px;
}
}
}
}
.comments {
.admin-actions {
margin-top: 0;
li {
margin-bottom: 2px !important;
}
a {
width: 20px;
height: 20px;
padding-left: 0;
overflow: hidden;
text-indent: -9999px;
&.admin-endorse {
background-position: 0 -150px;
&:hover {
background-position: 0 -225px;
}
}
&.admin-edit {
background-position: 0 -175px;
&:hover {
background-position: 0 -250px;
}
}
&.admin-delete {
background-position: 0 -200px;
&:hover {
background-position: 0 -275px;
}
}
}
}
}
/*** thread ***/
.thread {
//display: none;
.search-highlight {
display: inline;
font-weight: bold;
background-color: lightyellow;
}
.thread-title {
@include discussion-font;
@include discussion-clickable;
display: block;
margin-bottom: 1em;
font-size: $comment-title-size;
font-weight: bold;
line-height: 1.4em;
}
.thread-body, .content-body {
@include discussion-font;
font-size: $post-font-size;
margin-bottom: 4px;
margin-top: 3px;
p {
@include discussion-font;
}
}
.thread-tags {
display: inline-block;
}
.info {
@include discussion-font;
color: gray;
font-size: $comment-info-size;
font-style: italic;
margin-top: 1em;
a:hover {
text-decoration: none;
color: #1C71DD;
}
.comment-time {
display: inline;
float: right;
margin-right: 1em;
}
.comment-count {
display: inline;
margin-right: 20px;
}
.discussion-actions {
display: inline;
margin: 0;
padding: 0;
li {
display: inline;
margin-right: 20px;
}
}
.discussion-link {
@include discussion-font;
color: #1d9dd9;
display: inline;
&.discussion-unfollow-thread {
color: #dea03e;
}
}
}
.discussion-content {
border-top: lightgray 1px solid;
overflow: hidden;
// padding: 1.5% 0;
.discussion-reply-new {
@include discussion-font;
margin-left: 68px;
.reply-body {
@include discussion-font;
display: block;
font-size: $post-font-size;
margin-top: 10px;
width: 95%;
}
.reply-post-control {
margin-top: 1%;
}
}
}
//COMMENT STYLES
.comments {
overflow: hidden;
.discussion-votes {
margin-top: 8px;
}
.discussion-right-wrapper {
margin: 10px 0 10px 68px;
}
.comment {
margin-left: 68px;
.comment-body, .content-body {
@include discussion-font;
color: black;
display: block;
font-size: $comment-font-size;
margin-top: 3px;
}
&.endorsed {
> .discussion-content {
background-color: #fcfcea;
}
}
}
}
}
/*** Sorting ***/
.discussion-sort {
float: right;
font-size: 0.8em;
margin-top: -36px;
.discussion-label {
display: block;
float: left;
padding: 0 14px;
line-height: 34px;
}
.discussion-sort-link {
display: block;
float: left;
padding: 0 14px;
line-height: 34px;
&:hover {
color: #1C71DD;
text-decoration: none;
}
}
.discussion-sort-link.sorted {
color: #000;
border-bottom: 2px solid #000;
}
}
/*** Search ***/
.search-wrapper-inline {
display: inline-block;
margin-top: 3%;
width: 80%;
}
.search-wrapper {
margin-bottom: 50px;
margin-left: .5%;
}
.discussion-search-form {
display: inline-block;
margin-bottom: 1%;
width: flex-grid(12);
.discussion-link {
@include button(simple, #999);
color: white;
display: inline-block;
font-size: inherit;
font-weight: bold;
margin-left: 1%;
padding-top: 9px;
text-decoration: none;
}
.discussion-search-text {
@include discussion-font;
}
.search-input {
float: left;
font: inherit;
font-style: normal;
// width: 72%;
width: flex-grid(8);
margin-left: flex-grid(1);
}
}
.search-within {
display: block;
margin-bottom: 3%;
}
.discussion-search-within-board {
font: inherit;
font-size: $post-font-size;
font-style: normal;
}
/*** buttons ***/
.control-button {
@include button;
@include discussion-font;
background-color: #959595;
@include background-image(linear-gradient(top, #959595, #7B7B7B));
border: 1px solid #6F6F6F;
@include box-shadow(inset 0 1px 0 #A2A2A2, 0 0 3px #CCC);
color: white;
display: inline-block;
font-size: inherit;
font-weight: bold;
margin-bottom: 3%;
padding-top: 9px;
width: inherit;
text-decoration: none;
text-shadow: none;
&:hover {
background-color: #A2A2A2;
@include background-image(linear-gradient(top, #A2A2A2, #7B7B7B));
border: 1px solid #555;
@include box-shadow(inset 0 1px 0 #BBB, 0 0 3px #CCC);
}
}
.follow-wrapper {
display: inline;
}
/*** votes ***/
.discussion-votes {
float: left;
width: 60px;
margin-top: 18px;
text-align: center;
.discussion-vote {
display: block;
width: 50px;
height: 17px;
margin: auto;
background: url(../images/vote-arrows.png) no-repeat;
font-size: 15px;
font-weight: bold;
color: black;
@include hide-text;
@include transition(all, 0, easeOut);
}
.discussion-vote-up {
margin-bottom: 5px;
background-position: -50px -3px;
&:hover {
background-position: -50px -5px;
@include transition-duration(0.05s);
}
&.voted {
background-position: 0 -3px;
color: #1C71DD;
@include transition-duration(0);
}
}
.discussion-vote-down {
margin-top: 7px;
background-position: -50px -30px;
&:hover {
background-position: -50px -28px;
@include transition-duration(0.05s);
}
&.voted {
background-position: 0 -30px;
color: #1C71DD;
@include transition-duration(0);
}
}
.discussion-vote-count {
@include discussion-font;
font-size: $post-font-size;
}
.discussion-votes-point {
font-size: 1.1em;
font-weight: bold;
color: #9a9a9a;
}
}
/*** new post ***/
.new-post-form, .discussion-thread-edit {
.title-input, .body-input {
display: block !important;
font: inherit;
font-style: normal;
//width: $discussion-input-width !important;
}
.new-post-similar-posts-wrapper {
@include border-radius(3px);
border: 1px solid #EEE;
font-size: $post-font-size;
line-height: 150%;
margin-top: 1%;
padding: 1% 1.5%;
}
.hide-similar-posts {
float: right;
}
.new-post-similar-posts {
font: inherit;
.similar-post {
display: block;
line-height: 150%;
}
}
.discussion-errors {
color: #8F0E0E;
display: block;
margin-left: -5%;
}
.new-post-body {
}
.tagsinput {
background: #FAFAFA;
border: 1px solid #C8C8C8;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
-ms-border-radius: 3px;
-o-border-radius: 3px;
border-radius: 3px;
-webkit-box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.6), inset 0 0 3px 0 rgba(0, 0, 0, 0.1);
-moz-box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.6), inset 0 0 3px 0 rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.6), inset 0 0 3px 0 rgba(0, 0, 0, 0.1);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-top: flex-gutter();
vertical-align: top;
-webkit-font-smoothing: antialiased;
}
}
.discussion-content-edit, .discussion-reply-new, .new-post-form {
margin: 10px 0 10px 0;
.discussion-errors {
color: #8F0E0E;
display: block;
font: inherit;
font-size: $post_font_size;
list-style: none;
margin-left: -3%;
padding-left: 2em;
}
a:hover {
color: #1C71DD;
text-decoration: none;
}
.new-post-title {
display: block;
padding: 5px 12px;
border-width: 1px;
width: 100%;
}
.thread-title-edit {
width: 100%;
}
&.collapsed {
.new-post-title {
display: none;
visibility: hidden;
}
.wmd-button-row {
height: 0;
}
.wmd-input {
height: 100px;
@include border-radius(3px);
}
.wmd-preview {
height: 0;
padding: 0;
border-width: 0;
}
.post-options {
height: 0;
}
.post-control {
display: none;
}
.tagsinput {
display: none;
}
}
.new-post-control {
margin-left: 75%;
margin-top: 1%;
.discussion-cancel-post {
margin-right: 1.5%;
}
}
.reply-post-control {
.discussion-cancel-post {
margin-right: 1.5%;
}
}
.edit-post-control {
margin-top: 1%;
.discussion-cancel-update {
margin-right: 1.5%;
}
}
.control-button {
@include button;
@include discussion-font;
margin-right: 16px;
padding-top: 9px;
color: white;
display: inline-block;
font-size: inherit;
font-weight: bold;
text-decoration: none;
width: inherit;
&:hover {
color: white;
}
}
label {
display: inline;
font-family: $sans-serif;
font-size: .8em;
font-style: normal;
font-weight: 400;
}
}
.discussion-content-edit {
margin: 3%;
}
.new-post-form {
margin: 10px 0 40px 0;
}
.discussion-reply-new {
.discussion-auto-watch {
margin-left: 2%;
}
}
.thread-tag {
background: $tag-background-color;
border: 1px solid $tag-border-color;
-moz-border-radius: 2px;
-webkit-border-radius: 2px;
color: $tag-text-color;
float: left;
font-size: 13px;
margin: 5px 7px 5px 0;
padding: 5px 7px;
text-decoration: none;
&:hover {
border-color: #7b8761;
color: #2f381c;
text-decoration: none;
}
}
/*** pagination ***/
.discussion-paginator {
font-size: $post-font-size;
margin-bottom: 10px;
margin-top: 20px;
text-align: center;
div {
display: inline-block;
font-weight: bold;
margin: 0 5px;
a {
background: #EEE;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
-ms-border-radius: 3px;
-o-border-radius: 3px;
border-radius: 3px;
color: black;
font-weight: normal;
padding: 4px 10px;
text-decoration: none;
&:hover {
background: #DDD;
}
}
}
}
&.inline-discussion, .forum-discussion, .user-discussion {
.new-post-form {
margin: 24px 60px;
.post-options {
margin: 8px 0 16px 0;
overflow: hidden;
label {
margin-right: 15px;
display: inline;
}
}
.post-control {
overflow: hidden;
margin: 0 0 5% 0;
}
}
}
}
/*** base editor styles ***/
.wmd-panel {
width: 100%;
min-width: 500px;
}
.wmd-button-bar {
width: 100%;
background-color: Silver;
}
.wmd-input {
height: 150px;
width: 100%;
background-color: #e9e9e9;
border: 1px solid #c8c8c8;
font-family: Monaco, 'Lucida Console', monospace;
font-style: normal;
font-size: 0.8em;
line-height: 1.6em;
@include border-radius(3px 3px 0 0);
&::-webkit-input-placeholder {
color: #888;
}
}
.wmd-preview {
position: relative;
font-family: $sans-serif;
padding: 25px 20px 10px 20px;
margin-bottom: 5px;
box-sizing: border-box;
border: 1px solid #c8c8c8;
border-top-width: 0;
@include border-radius(0 0 3px 3px);
overflow: hidden;
@include transition(all, .2s, easeOut);
&:before {
content: 'PREVIEW';
position: absolute;
top: 3px;
left: 5px;
font-size: 11px;
color: #bbb;
}
p {
font-family: $sans-serif;
}
background-color: #fafafa;
}
.wmd-button-row {
position: relative;
margin-left: 5px;
margin-right: 5px;
margin-bottom: 5px;
margin-top: 10px;
padding: 0px;
height: 20px;
overflow: hidden;
@include transition(all, .2s, easeOut);
}
.wmd-spacer {
width: 1px;
height: 20px;
margin-left: 14px;
position: absolute;
background-color: Silver;
display: inline-block;
list-style: none;
}
.wmd-button {
width: 20px;
height: 20px;
padding-left: 2px;
padding-right: 3px;
position: absolute;
display: inline-block;
list-style: none;
cursor: pointer;
}
.wmd-button > span {
background-image: url('/static/images/wmd-buttons.png');
background-repeat: no-repeat;
background-position: 0px 0px;
width: 20px;
height: 20px;
display: inline-block;
}
.wmd-spacer1 {
left: 50px;
}
.wmd-spacer2 {
left: 175px;
}
.wmd-spacer3 {
left: 300px;
}
.wmd-prompt-background {
background-color: Black;
}
.wmd-prompt-dialog {
border: 1px solid #999999;
background-color: #F5F5F5;
}
.wmd-prompt-dialog > div {
font-size: 0.8em;
font-family: arial, helvetica, sans-serif;
}
.wmd-prompt-dialog > form > input[type="text"] {
border: 1px solid #999999;
color: black;
}
.wmd-prompt-dialog > form > input[type="button"] {
border: 1px solid #888888;
font-family: trebuchet MS, helvetica, sans-serif;
font-size: 0.8em;
font-weight: bold;
}
@mixin news-font {
font-family: inherit;
}
.notifications {
@include news-font;
font-size: 0.9em;
padding-left: 20px;
padding-top: 20px;
padding-bottom: 20px;
.notification {
@include news-font;
margin-top: 15px;
margin-botton: 15px;
a {
@include news-font;
}
}
}
...@@ -27,3 +27,6 @@ ...@@ -27,3 +27,6 @@
@import 'multicourse/password_reset'; @import 'multicourse/password_reset';
@import 'multicourse/error-pages'; @import 'multicourse/error-pages';
@import 'multicourse/help'; @import 'multicourse/help';
@import 'discussion';
@import 'news';
...@@ -80,6 +80,7 @@ div.course-wrapper { ...@@ -80,6 +80,7 @@ div.course-wrapper {
} }
.histogram { .histogram {
display: none;
width: 200px; width: 200px;
height: 150px; height: 150px;
} }
...@@ -87,6 +88,15 @@ div.course-wrapper { ...@@ -87,6 +88,15 @@ div.course-wrapper {
ul { ul {
list-style: disc outside none; list-style: disc outside none;
padding-left: 1em; padding-left: 1em;
&.discussion-errors {
list-style: none;
padding-left: 2em;
}
&.admin-actions {
list-style: none;
}
} }
nav.sequence-bottom { nav.sequence-bottom {
...@@ -131,6 +141,7 @@ div.course-wrapper { ...@@ -131,6 +141,7 @@ div.course-wrapper {
} }
div.staff_info { div.staff_info {
display: none;
@include clearfix(); @include clearfix();
white-space: pre-wrap; white-space: pre-wrap;
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
......
...@@ -20,14 +20,19 @@ def url_class(url): ...@@ -20,14 +20,19 @@ def url_class(url):
<li class="courseware"><a href="${reverse('courseware', args=[course.id])}" class="${url_class('courseware')}">Courseware</a></li> <li class="courseware"><a href="${reverse('courseware', args=[course.id])}" class="${url_class('courseware')}">Courseware</a></li>
<li class="info"><a href="${reverse('info', args=[course.id])}" class="${url_class('info')}">Course Info</a></li> <li class="info"><a href="${reverse('info', args=[course.id])}" class="${url_class('info')}">Course Info</a></li>
% if user.is_authenticated(): % if user.is_authenticated():
% if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'): % if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'):
% for index, textbook in enumerate(course.textbooks): % for index, textbook in enumerate(course.textbooks):
<li class="book"><a href="${reverse('book', args=[course.id, index])}" class="${url_class('book')}">${textbook.title}</a></li> <li class="book"><a href="${reverse('book', args=[course.id, index])}" class="${url_class('book')}">${textbook.title}</a></li>
% endfor % endfor
% endif % endif
% if settings.MITX_FEATURES.get('ENABLE_DISCUSSION'): % if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
<li class="discussion"><a href="${reverse('questions')}">Discussion</a></li> <li class="discussion"><a href="${reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id])}" class="${url_class('discussion')}">Discussion</a></li>
% endif <li class="news"><a href="${reverse('news', args=[course.id])}" class="${url_class('news')}">News</a></li>
% endif
% if settings.MITX_FEATURES.get('ENABLE_DISCUSSION'):
<li class="discussion"><a href="${reverse('questions')}">Discussion</a></li>
% endif
% endif % endif
% if settings.WIKI_ENABLED: % if settings.WIKI_ENABLED:
<li class="wiki"><a href="${reverse('course_wiki', args=[course.id])}" class="${url_class('wiki')}">Wiki</a></li> <li class="wiki"><a href="${reverse('course_wiki', args=[course.id])}" class="${url_class('wiki')}">Wiki</a></li>
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
<%static:js group='courseware'/> <%static:js group='courseware'/>
<%include file="../discussion/_js_dependencies.html" />
<%include file="/mathjax_include.html" /> <%include file="/mathjax_include.html" />
<!-- TODO: http://docs.jquery.com/Plugins/Validation --> <!-- TODO: http://docs.jquery.com/Plugins/Validation -->
...@@ -28,6 +29,10 @@ ...@@ -28,6 +29,10 @@
document.write('\x3Cscript type="text/javascript" src="' + document.write('\x3Cscript type="text/javascript" src="' +
document.location.protocol + '//www.youtube.com/player_api">\x3C/script>'); document.location.protocol + '//www.youtube.com/player_api">\x3C/script>');
</script> </script>
<script type="text/javascript">
var $$course_id = "${course.id}";
</script>
</%block> </%block>
<%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> <%include file="/courseware/course_navigation.html" args="active_page='courseware'" />
......
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_comments(thread.get('children'))}
<div class="blank-state">
% if performed_search:
Sorry! We can't find anything matching your search. Please try another search.
% else:
There are no posts here yet. Be the first one to post!
% endif
</div>
<div class="discussion-content">
<div class="discussion-content-wrapper">
<div class="discussion-votes">
<a class="discussion-vote discussion-vote-up" href="javascript:void(0)">&#9650;</a>
<div class="discussion-votes-point">{{content.votes.point}}</div>
<a class="discussion-vote discussion-vote-down" href="javascript:void(0)">&#9660;</a>
</div>
<div class="discussion-right-wrapper">
<ul class="admin-actions">
<li><a href="javascript:void(0)" class="admin-endorse">Endorse</a></li>
<li><a href="javascript:void(0)" class="admin-edit">Edit</a></li>
<li><a href="javascript:void(0)" class="admin-delete">Delete</a></li>
{{#thread}}
<li><a href="javascript:void(0)" class="admin-openclose">{{close_thread_text}}</a></li>
{{/thread}}
</ul>
{{#thread}}
<a class="thread-title" name="{{content.id}}" href="javascript:void(0)">{{{content.displayed_title}}}</a>
<div class="thread-raw-title" style="display: none">{{{content.title}}}</div>
{{/thread}}
<div class="discussion-content-view">
<a name="{{content.id}}" style="width: 0; height: 0; padding: 0; border: none;"></a>
<div class="content-body {{content.type}}-body" id="content-body-{{content.id}}">{{{content.displayed_body}}}</div>
<div class="content-raw-body {{content.type}}-raw-body" style="display: none">{{{content.body}}}</div>
{{#thread}}
<div class="thread-tags">
{{#content.tags}}
<a class="thread-tag" href="{{##url_for_tags}}{{.}}{{/url_for_tags}}">{{.}}</a>
{{/content.tags}}
</div>
<div class="thread-raw-tags" style="display: none">{{content.raw_tags}}</div>
{{/thread}}
<div class="info">
<div class="comment-time">
<span class="timeago" title="{{content.updated_at}}">sometime</span> by
{{#content.anonymous}}
anonymous
{{/content.anonymous}}
{{^content.anonymous}}
<a href="{{##url_for_user}}{{content.user_id}}{{/url_for_user}}">{{content.username}}</a>
{{/content.anonymous}}
</div>
<div class="comment-count">
{{#thread}}
{{#partial_comments}}
<a href="javascript:void(0)" class="discussion-show-comments first-time">Show all comments ({{content.comments_count}} total)</a>
{{/partial_comments}}
{{^partial_comments}}
<a href="javascript:void(0)" class="discussion-show-comments">Show {{##pluralize}}{{content.comments_count}} comment{{/pluralize}}</a>
{{/partial_comments}}
{{/thread}}
</div>
<ul class="discussion-actions">
<li><a class="discussion-link discussion-reply discussion-reply-{{content.type}}" href="javascript:void(0)">Reply</a></li>
<li><div class="follow-wrapper"></div></li>
<li><a class="discussion-link discussion-permanent-link" href="javascript:void(0)">Permanent Link</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<%! import django_comment_client.helpers as helpers %>
<%def name="render_content(content)">
${helpers.render_content(content)}
</%def>
<%def name="render_content_with_comments(content)">
<div class="${content['type']}" _id="${content['id']}" _discussion_id="${content.get('commentable_id')}" _author_id="${helpers.show_if(content['user_id'], content.get('anonymous'))}">
${render_content(content)}
% if content.get('children') is not None:
${render_comments(content['children'])}
% endif
</div>
</%def>
<%def name="render_comments(comments)">
<div class="comments">
% for comment in comments:
${render_content_with_comments(comment)}
% endfor
</div>
</%def>
<div class="discussion-module">
<a class="discussion-show control-button" href="javascript:void(0)" discussion_id="${discussion_id}">Show Discussion</a>
</div>
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion forum-discussion" _id="${discussion_id}">
<div class="discussion-non-content discussion-local">
<div class="search-wrapper">
<%include file="_search_bar.html" />
</div>
</div>
% if len(threads) == 0:
<div class="blank">
<%include file="_blank_slate.html" />
</div>
<div class="threads"></div>
% else:
<%include file="_sort.html" />
<div class="threads">
% for thread in threads:
${renderer.render_content_with_comments(thread)}
% endfor
</div>
<%include file="_paginator.html" />
% endif
</section>
<%include file="_js_data.html" />
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion inline-discussion" _id="${discussion_id}">
<div class="discussion-non-content discussion-local"></div>
<div class="threads">
% for thread in threads:
${renderer.render_content_with_comments(thread)}
% endfor
</div>
<%include file="_paginator.html" />
</section>
<%include file="_js_data.html" />
<%! from django.template.defaultfilters import escapejs %>
<script type="text/javascript">
var $$user_info = JSON.parse("${user_info | escapejs}");
var $$course_id = "${course_id | escapejs}";
if (typeof $$annotated_content_info === undefined || $$annotated_content_info === null) {
var $$annotated_content_info = {};
}
$$annotated_content_info = $.extend($$annotated_content_info, JSON.parse("${annotated_content_info | escapejs}"));
</script>
<%namespace name='static' file='../static_content.html'/>
<script type="text/x-mathjax-config">
MathJax.Hub.Config({
tex2jax: {
inlineMath: [
["\\(","\\)"],
],
displayMath: [
["\\[","\\]"],
]
}
});
</script>
## This must appear after all mathjax-config blocks, so it is after the imports from the other templates.
## It can't be run through static.url because MathJax uses crazy url introspection to do lazy loading of MathJax extension libraries
<script type="text/javascript" src="/static/js/vendor/mathjax-MathJax-c9db6ac/MathJax.js?config=TeX-MML-AM_HTMLorMML-full"></script>
<!---<script type="text/javascript" src="http://cdn.mathjax.org/mathjax/latest/MathJax.js"> </script>-->
<script type="text/javascript" src="${static.url('js/split.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.ajaxfileupload.js')}"></script>
<script type="text/javascript" src="${static.url('js/Markdown.Converter.js')}"></script>
<script type="text/javascript" src="${static.url('js/Markdown.Sanitizer.js')}"></script>
<script type="text/javascript" src="${static.url('js/Markdown.Editor.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.autocomplete.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.timeago.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.tagsinput.js')}"></script>
<script type="text/javascript" src="${static.url('js/mustache.js')}"></script>
<script type="text/javascript" src="${static.url('js/URI.min.js')}"></script>
<link href="${static.url('css/vendor/jquery.tagsinput.css')}" rel="stylesheet" type="text/css">
<link href="${static.url('css/vendor/jquery.autocomplete.css')}" rel="stylesheet" type="text/css">
<%! from urllib import urlencode %>
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def url_for_page(_page):
return base_url + '?' + urlencode(merge(query_params, {'page': _page}))
%>
<%def name="link_to_page(_page, text)">
<a class="discussion-page-link" href="javascript:void(0)" page-url="${url_for_page(_page)}">${text}</a>
</%def>
<%def name="div_page(_page)">
% if _page != page:
<div class="page-link">
${link_to_page(_page, str(_page))}
</div>
% else:
<div class="page-link">${_page}</div>
% endif
</%def>
<%def name="list_pages(*args)">
% for arg in args:
% if arg == 'dots':
<div class="page-dots">...</div>
% elif isinstance(arg, list):
% for _page in arg:
${div_page(_page)}
% endfor
% else:
${div_page(arg)}
% endif
% endfor
</%def>
<div class="discussion-${discussion_type}-paginator discussion-paginator">
<div class="prev-page">
% if page > 1:
${link_to_page(page - 1, "&lt; Previous page")}
% endif
</div>
% if num_pages <= 2 * pages_nearby_delta + 2:
${list_pages(range(1, num_pages + 1))}
% else:
% if page <= 2 * pages_nearby_delta:
${list_pages(range(1, 2 * pages_nearby_delta + 2), 'dots', num_pages)}
% elif num_pages - page + 1 <= 2 * pages_nearby_delta:
${list_pages(1, 'dots', range(num_pages - 2 * pages_nearby_delta, num_pages + 1))}
% else:
${list_pages(1, 'dots', range(page - pages_nearby_delta, page + pages_nearby_delta + 1), 'dots', num_pages)}
% endif
% endif
<div class="next-page">
% if page < num_pages:
${link_to_page(page + 1, "Next page &gt;")}
% endif
</div>
</div>
% if recent_active_threads:
<article class="discussion-sidebar-following sidebar-module">
<header>
<h4>Following</h4>
<a href="#" class="sidebar-view-all">view all &rsaquo;</a>
</header>
<ol class="discussion-sidebar-following-list">
% for thread in recent_active_threads:
<li><a href="#"><span class="sidebar-following-name">${thread['title']}</span> <span class="sidebar-vote-count">${thread['votes']['point']}</span></a></li>
% endfor
<ol>
</article>
% endif
<%! from urllib import urlencode %>
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def base_url_for_search():
return base_url + '?' + urlencode(merge(query_params, {'page': 1}))
%>
<form action="${base_url_for_search()}" method="get" class="discussion-search-form">
% if query_params.get('tags', None):
<input class="search-input" type="text" value="[${tags}]${text}" id="keywords" autocomplete="off"/>
% else:
<input class="search-input" type="text" value="${text}" id="keywords" autocomplete="off"/>
% endif
<div class="discussion-link discussion-search-link" href="javascript:void(0)">Search posts</div>
</form>
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion" _id="${discussion_id}">
<a class="discussion-title" href="javascript:void(0)">Discussion</a>
${renderer.render_content_with_comments(thread)}
</section>
<%include file="_js_data.html" />
<%! from urllib import urlencode %>
<%def name="link_to_sort(key, title)">
% if key == sort_key:
${_link_to_sort(key, None, title + '', 'sorted')}
<!---
% if sort_order.lower() == 'desc':
${_link_to_sort(key, 'asc', title + '', 'sorted')}
% else:
${_link_to_sort(key, 'desc', title + '', 'sorted')}
% endif
-->
% else:
${_link_to_sort(key, 'desc', title)}
% endif
</%def>
<%def name="_link_to_sort(key, order, title, cls='')">
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def url_for_sort(key, order):
if order is None:
return ''
else:
return base_url + '?' + urlencode(merge(query_params, {'page': 1, 'sort_key': key, 'sort_order': order}))
%>
<a class="discussion-sort-link ${cls}" href="javascript:void(0)" sort-url="${url_for_sort(key, order)}">${title}</a>
</%def>
<div class="discussion-sort discussion-local">
<span class="discussion-label">Sort by:</span>
${link_to_sort('activity', 'top')}
${link_to_sort('date', 'date')}
${link_to_sort('votes', 'votes')}
${link_to_sort('comments', 'comments')}
</div>
% if trending_tags:
<article class="discussion-sidebar-tags sidebar-module">
<header>
<h4>Recent Tags</h4>
</header>
<ol class="discussion-sidebar-tags-list">
% for tag, count in trending_tags:
<li><a href="#" class="thread-tag">${tag}</a><span class="sidebar-tag-count">&times;${count}</span></li>
% endfor
<ol>
</article>
% endif
<%namespace name="renderer" file="_thread.html"/>
<section class="discussion user-active-discussion">
<div class="discussion-non-content discussion-local"></div>
<div class="threads">
% for thread in threads:
${renderer.render_thread(course_id, thread, show_comments=True)}
% endfor
</div>
<%include file="_paginator.html" />
</section>
<%include file="_js_data.html" />
<%! from django_comment_client.utils import pluralize %>
<%! from django_comment_client.permissions import has_permission, check_permissions_by_view %>
<%! from operator import attrgetter %>
<div class="user-profile">
<%
role_names = sorted(map(attrgetter('name'), django_user.roles.all()))
%>
<div class="sidebar-username">${django_user.username}</div>
<div class="sidebar-user-roles">
${", ".join(role_names)}
</div>
<div class="sidebar-threads-count"><span>${profiled_user['threads_count']}</span> ${pluralize('discussion', profiled_user['threads_count'])} started</div>
<div class="sidebar-comments-count"><span>${profiled_user['comments_count']}</span> ${pluralize('comment', profiled_user['comments_count'])}</div>
% if check_permissions_by_view(user, course.id, content=None, name='update_moderator_status'):
% if "Moderator" in role_names:
<a href="javascript:void(0)" class="sidebar-revoke-moderator-button">Revoke Moderator provileges</a>
% else:
<a href="javascript:void(0)" class="sidebar-promote-moderator-button">Promote to Moderator</a>
% endif
% endif
</div>
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content_with_comments(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content_with_comments(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content(content)}
<%include file="_user_profile.html" />
<%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/>
<%block name="bodyclass">discussion</%block>
<%block name="title"><title>Discussion – MITx 6.002x</title></%block>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%block name="js_extra">
<%include file="_js_dependencies.html" />
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='discussion'" />
<section class="container">
<div class="course-wrapper">
<section aria-label="Course Navigation" class="course-index">
<nav>
<article class="sidebar-module discussion-sidebar">
<a href="#" class="sidebar-new-post-button">New Post</a>
</article>
<%include file="_recent_active_posts.html" />
<%include file="_trending_tags.html" />
</nav>
</section>
<section class="course-content">
${content}
</section>
</div>
</section>
<%! from django.template.defaultfilters import escapejs %>
<%namespace name="renderer" file="_thread.html"/>
<%inherit file="../main.html" />
<%namespace name='static' file='../static_content.html'/>
<%block name="bodyclass">discussion</%block>
<%block name="title"><title>Discussion – MITx 6.002x</title></%block>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%block name="js_extra">
<%include file="_js_dependencies.html" />
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='discussion'" />
<section class="container">
<div class="course-wrapper">
<section aria-label="User Profile" class="user-profile">
<nav>
<article class="sidebar-module discussion-sidebar">
<%include file="_user_profile.html" />
</article>
</nav>
</section>
<section class="course-content">
${content}
</section>
</div>
</section>
<script type="text/javascript">
var $$profiled_user_id = "${user.id | escapejs}";
var $$course_id = "${course.id | escapejs}";
</script>
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
<%static:js group='main_vendor'/> <%static:js group='main_vendor'/>
<%block name="headextra"/> <%block name="headextra"/>
<!--[if lt IE 9]> <!--[if lt IE 9]>
<script src="${static.url('js/html5shiv.js')}"></script> <script src="${static.url('js/html5shiv.js')}"></script>
<![endif]--> <![endif]-->
......
<%inherit file="main.html" />
<%namespace name='static' file='static_content.html'/>
<%block name="bodyclass">courseware news</%block>
<%block name="title"><title>News – MITx 6.002x</title></%block>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%block name="js_extra">
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='news'" />
<section class="container">
<div class="course-wrapper">
<section class="course-content">
<h3> Updates to Discussion Posts You Follow </h3>
${content}
</section>
</div>
</section>
<%! from django.core.urlresolvers import reverse %>
<%
def url_for_thread(discussion_id, thread_id):
return reverse('django_comment_client.forum.views.single_thread', args=[course.id, discussion_id, thread_id])
%>
<%
def url_for_comment(discussion_id, thread_id, comment_id):
return url_for_thread(discussion_id, thread_id) + "#" + comment_id
%>
<%
def url_for_discussion(discussion_id):
return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id, discussion_id])
%>
<%
def discussion_title(discussion_id):
return get_discussion_title(discussion_id=discussion_id)
%>
<%
def url_for_user(user_id): #TODO
return "javascript:void(0)"
%>
<div class="notifications">
% for notification in notifications:
${render_notification(notification)}
% endfor
</div>
<%def name="render_user_link(notification)">
<% info = notification['info'] %>
% if notification.get('actor_id', None):
<a href="${url_for_user(notification['actor_id'])}">${info['actor_username']}</a>
% else:
Anonymous
% endif
</%def>
<%def name="render_thread_link(notification)">
<% info = notification['info'] %>
<a href="${url_for_thread(info['commentable_id'], info['thread_id'])}">${info['thread_title']}</a>
</%def>
<%def name="render_comment_link(notification)">
<% info = notification['info'] %>
<a href="${url_for_comment(info['commentable_id'], info['thread_id'], info['comment_id'])}">comment</a>
</%def>
<%def name="render_discussion_link(notification)">
<% info = notification['info'] %>
<a href="${url_for_discussion(info['commentable_id'])}">${discussion_title(info['commentable_id'])}</a>
</%def>
<%def name="render_notification(notification)">
<div class="notification">
% if notification['notification_type'] == 'post_reply':
${render_user_link(notification)} posted a ${render_comment_link(notification)}
to the thread ${render_thread_link(notification)} in discussion ${render_discussion_link(notification)}
% elif notification['notification_type'] == 'post_topic':
${render_user_link(notification)} posted a new thread ${render_thread_link(notification)}
in discussion ${render_discussion_link(notification)}
% elif notification['notification_type'] == 'at_user':
${render_user(info)} mentioned you in
% if notification['info']['content_type'] == 'thread':
the thread ${render_thread_link(notification)}
in discussion ${render_discussion_link(notification)}
% else:
${render_comment_link(notification)}
to the thread ${render_thread_link(notification)} in discussion ${render_discussion_link(notification)}
% endif
% endif
</div>
</%def>
...@@ -147,6 +147,7 @@ if settings.COURSEWARE_ENABLED: ...@@ -147,6 +147,7 @@ if settings.COURSEWARE_ENABLED:
# For the instructor # For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
'courseware.views.instructor_dashboard', name="instructor_dashboard"), 'courseware.views.instructor_dashboard', name="instructor_dashboard"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'courseware.views.gradebook', name='gradebook'), 'courseware.views.gradebook', name='gradebook'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
...@@ -154,6 +155,16 @@ if settings.COURSEWARE_ENABLED: ...@@ -154,6 +155,16 @@ if settings.COURSEWARE_ENABLED:
) )
# discussion forums live within courseware, so courseware must be enabled first
if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
urlpatterns += (
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$',
'courseware.views.news', name="news"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/discussion/',
include('django_comment_client.urls'))
)
# Multicourse wiki # Multicourse wiki
if settings.WIKI_ENABLED: if settings.WIKI_ENABLED:
from wiki.urls import get_pattern as wiki_pattern from wiki.urls import get_pattern as wiki_pattern
...@@ -189,6 +200,8 @@ if settings.ASKBOT_ENABLED: ...@@ -189,6 +200,8 @@ if settings.ASKBOT_ENABLED:
# url(r'^robots.txt$', include('robots.urls')), # url(r'^robots.txt$', include('robots.urls')),
) )
if settings.DEBUG: if settings.DEBUG:
## Jasmine ## Jasmine
urlpatterns=urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),) urlpatterns=urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment