summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEvgeny Fadeev <evgeny.fadeev@gmail.com>2012-06-11 03:20:16 -0400
committerEvgeny Fadeev <evgeny.fadeev@gmail.com>2012-06-11 03:20:16 -0400
commite50335853baf3238d600b0a04a5261510231474c (patch)
tree70abbf0ba988ca37ad32ac8546ec54add5c45e32
parentb13c5a97290c04601b8c069413fef584da018928 (diff)
downloadaskbot-e50335853baf3238d600b0a04a5261510231474c.tar.gz
askbot-e50335853baf3238d600b0a04a5261510231474c.tar.bz2
askbot-e50335853baf3238d600b0a04a5261510231474c.zip
category editor works minus that ability to rename and delete categories is unfinished
-rw-r--r--askbot/forms.py86
-rw-r--r--askbot/migrations/0125_save_category_tree_as_json.py (renamed from askbot/migrations/0125_add_email_signature__to__auth_user.py)81
-rw-r--r--askbot/migrations/0126_update_postgres_post_search.py29
-rw-r--r--askbot/skins/common/media/js/post.js547
-rw-r--r--askbot/skins/common/media/js/utils.js110
-rw-r--r--askbot/skins/default/media/style/style.less11
-rw-r--r--askbot/skins/default/templates/meta/category_tree_js.html5
-rw-r--r--askbot/skins/default/templates/meta/html_head_javascript.html3
-rw-r--r--askbot/skins/default/templates/widgets/three_column_category_selector.html11
-rw-r--r--askbot/tests/__init__.py1
-rw-r--r--askbot/tests/category_tree_tests.py116
-rw-r--r--askbot/urls.py5
-rw-r--r--askbot/utils/category_tree.py98
-rw-r--r--askbot/views/commands.py39
-rw-r--r--askbot/views/readers.py3
-rw-r--r--askbot/views/writers.py5
16 files changed, 951 insertions, 199 deletions
diff --git a/askbot/forms.py b/askbot/forms.py
index 23da169a..5e4b5a06 100644
--- a/askbot/forms.py
+++ b/askbot/forms.py
@@ -237,14 +237,48 @@ class AnswerEditorField(EditorField):
self.length_error_template_plural = 'answer must be > %d characters'
self.min_length = askbot_settings.MIN_ANSWER_BODY_LENGTH
+def clean_tag(tag_name):
+ """a function that cleans a single tag name"""
+ tag_length = len(tag_name)
+ if tag_length > askbot_settings.MAX_TAG_LENGTH:
+ #singular form is odd in english, but required for pluralization
+ #in other languages
+ msg = ungettext_lazy(
+ #odd but added for completeness
+ 'each tag must be shorter than %(max_chars)d character',
+ 'each tag must be shorter than %(max_chars)d characters',
+ tag_length
+ ) % {'max_chars':tag_length}
+ raise forms.ValidationError(msg)
+
+ #todo - this needs to come from settings
+ tagname_re = re.compile(const.TAG_REGEX, re.UNICODE)
+ if not tagname_re.search(tag_name):
+ raise forms.ValidationError(
+ _(message_keys.TAG_WRONG_CHARS_MESSAGE)
+ )
+
+ if askbot_settings.FORCE_LOWERCASE_TAGS:
+ #a simpler way to handle tags - just lowercase thew all
+ return tag_name.lower()
+ else:
+ try:
+ from askbot import models
+ stored_tag = models.Tag.objects.get(name__iexact = tag_name)
+ return stored_tag.name
+ except models.Tag.DoesNotExist:
+ return tag_name
+
+
class TagNamesField(forms.CharField):
def __init__(self, *args, **kwargs):
super(TagNamesField, self).__init__(*args, **kwargs)
self.required = askbot_settings.TAGS_ARE_REQUIRED
- self.widget = forms.TextInput(attrs={'size' : 50, 'autocomplete' : 'off'})
+ self.widget = forms.TextInput(
+ attrs={'size' : 50, 'autocomplete' : 'off'}
+ )
self.max_length = 255
self.label = _('tags')
- #self.help_text = _('please use space to separate tags (this enables autocomplete feature)')
self.help_text = ungettext_lazy(
'Tags are short keywords, with no spaces within. '
'Up to %(max_tags)d tag can be used.',
@@ -308,51 +342,11 @@ class TagNamesField(forms.CharField):
) % {'tags': get_text_list(models.tag.get_mandatory_tags())}
raise forms.ValidationError(msg)
- for tag in tag_strings:
- tag_length = len(tag)
- if tag_length > askbot_settings.MAX_TAG_LENGTH:
- #singular form is odd in english, but required for pluralization
- #in other languages
- msg = ungettext_lazy(
- #odd but added for completeness
- 'each tag must be shorter than %(max_chars)d character',
- 'each tag must be shorter than %(max_chars)d characters',
- tag_length
- ) % {'max_chars':tag_length}
- raise forms.ValidationError(msg)
-
- #todo - this needs to come from settings
- tagname_re = re.compile(const.TAG_REGEX, re.UNICODE)
- if not tagname_re.search(tag):
- raise forms.ValidationError(
- _(message_keys.TAG_WRONG_CHARS_MESSAGE)
- )
- #only keep unique tags
- if tag not in entered_tags:
- entered_tags.append(tag)
-
- #normalize character case of tags
cleaned_entered_tags = list()
- if askbot_settings.FORCE_LOWERCASE_TAGS:
- #a simpler way to handle tags - just lowercase thew all
- for name in entered_tags:
- lowercased_name = name.lower()
- if lowercased_name not in cleaned_entered_tags:
- cleaned_entered_tags.append(lowercased_name)
- else:
- #make names of tags in the input to agree with the database
- for entered_tag in entered_tags:
- try:
- #looks like we have to load tags one-by one
- #because we need tag name cases to be the same
- #as those stored in the database
- stored_tag = models.Tag.objects.get(
- name__iexact = entered_tag
- )
- if stored_tag.name not in cleaned_entered_tags:
- cleaned_entered_tags.append(stored_tag.name)
- except models.Tag.DoesNotExist:
- cleaned_entered_tags.append(entered_tag)
+ for tag in tag_strings:
+ cleaned_tag = clean_tag(tag)
+ if cleaned_tag not in cleaned_entered_tags:
+ cleaned_entered_tags.append(clean_tag(tag))
return u' '.join(cleaned_entered_tags)
diff --git a/askbot/migrations/0125_add_email_signature__to__auth_user.py b/askbot/migrations/0125_save_category_tree_as_json.py
index be3b9750..b13cd2fe 100644
--- a/askbot/migrations/0125_add_email_signature__to__auth_user.py
+++ b/askbot/migrations/0125_save_category_tree_as_json.py
@@ -1,21 +1,84 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
-from south.v2 import SchemaMigration
+from south.v2 import DataMigration
+from django.utils import simplejson
from django.db import models
+from askbot.conf import settings as askbot_settings
+def get_subtree(tree, path):
+ """#this might be simpler, but not tested
+ clevel = tree
+ for step in path:
+ try:
+ level = clevel[step]
+ except IndexError:
+ return False
+ return clevel
+ """
+ if len(path) == 1:
+ assert(path[0] == 0)
+ return tree
+ else:
+ import copy
+ parent_path = copy.copy(path)
+ leaf_index = parent_path.pop()
+ branch_index = parent_path[-1]
+ parent_tree = get_subtree(tree, parent_path)
+ return parent_tree[branch_index][1]
-class Migration(SchemaMigration):
+def parse_tree(text):
+ """parse tree represented as indented text
+ one item per line, with two spaces per level of indentation
+ """
+ lines = text.split('\n')
+ import re
+ in_re = re.compile(r'^([ ]*)')
- def forwards(self, orm):
+ tree = [['dummy', []]]
+ subtree_path = [0]
+ clevel = 0
+
+ for line in lines:
+ if line.strip() == '':
+ continue
+ match = in_re.match(line)
+ level = len(match.group(1))/2 + 1
+
+ if level > clevel:
+ subtree_path.append(0)#
+ elif level < clevel:
+ subtree_path = subtree_path[:level+1]
+ leaf_index = subtree_path.pop()
+ subtree_path.append(leaf_index + 1)
+ else:
+ leaf_index = subtree_path.pop()
+ subtree_path.append(leaf_index + 1)
+
+ clevel = level
try:
- # Adding field 'User.interesting_tags'
- db.add_column(u'auth_user', 'email_signature', self.gf('django.db.models.fields.TextField')(blank=True, default = ''), keep_default=False)
+ subtree = get_subtree(tree, subtree_path)
except:
- pass
+ return tree
+ subtree.append([line.strip(), []])
+
+ return tree
+
+
+class Migration(DataMigration):
+
+ def forwards(self, orm):
+ """reads category tree saved as string,
+ translates it to json and saves back"""
+ old_data = askbot_settings.CATEGORY_TREE
+ json_data = parse_tree(old_data)
+ json_string = simplejson.dumps(json_data)
+ askbot_settings.update('CATEGORY_TREE', json_string)
def backwards(self, orm):
- db.delete_column('auth_user', 'email_signature')
+ "Write your backwards methods here."
+ pass
+
models = {
'askbot.activity': {
@@ -193,7 +256,7 @@ class Migration(SchemaMigration):
'address': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '25'}),
'allowed_from_email': ('django.db.models.fields.EmailField', [], {'max_length': '150'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
- 'post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reply_addresses'", 'to': "orm['askbot.Post']"}),
+ 'post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reply_addresses'", 'null': 'True', 'to': "orm['askbot.Post']"}),
'reply_action': ('django.db.models.fields.CharField', [], {'default': "'auto_answer_or_comment'", 'max_length': '32'}),
'response_post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'edit_addresses'", 'null': 'True', 'to': "orm['askbot.Post']"}),
'used_at': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}),
@@ -279,6 +342,7 @@ class Migration(SchemaMigration):
'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_signature': ('django.db.models.fields.TextField', [], {'blank': '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'}),
@@ -318,3 +382,4 @@ class Migration(SchemaMigration):
}
complete_apps = ['askbot']
+ symmetrical = True
diff --git a/askbot/migrations/0126_update_postgres_post_search.py b/askbot/migrations/0126_update_postgres_post_search.py
deleted file mode 100644
index 156902fe..00000000
--- a/askbot/migrations/0126_update_postgres_post_search.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# encoding: utf-8
-import askbot
-from askbot.search import postgresql
-import os
-from south.v2 import DataMigration
-
-class Migration(DataMigration):
- """this migration is the same as 22 and 106
- just ran again to update the postgres search setup
- """
-
- def forwards(self, orm):
- "Write your forwards methods here."
-
- db_engine_name = askbot.get_database_engine_name()
- if 'postgresql_psycopg2' in db_engine_name:
- script_path = os.path.join(
- askbot.get_install_directory(),
- 'search',
- 'postgresql',
- 'thread_and_post_models_05222012.plsql'
- )
- postgresql.setup_full_text_search(script_path)
-
- def backwards(self, orm):
- "Write your backwards methods here."
- pass
-
- models = {}#we don't need orm for this migration
diff --git a/askbot/skins/common/media/js/post.js b/askbot/skins/common/media/js/post.js
index 40773009..aa9795e6 100644
--- a/askbot/skins/common/media/js/post.js
+++ b/askbot/skins/common/media/js/post.js
@@ -83,6 +83,35 @@ function setupFormValidation(form, validationRules, validationMessages, onSubmit
});
}
+/**
+ * generic tag cleaning function, settings
+ * are from askbot live settings and askbot.const
+ */
+var cleanTag = function(tag_name, settings) {
+ var tag_regex = new RegExp(settings['tag_regex']);
+ if (tag_regex.test(tag_name) === false) {
+ throw settings['messages']['wrong_chars']
+ }
+
+ var max_length = settings['max_tag_length'];
+ if (tag_name.length > max_length) {
+ throw interpolate(
+ ngettext(
+ 'must be shorter than %(max_chars)s character',
+ 'must be shorter than %(max_chars)s characters',
+ max_length
+ ),
+ {'max_chars': max_length },
+ true
+ );
+ }
+ if (settings['force_lowercase_tags']) {
+ return tag_name.toLowerCase();
+ } else {
+ return tag_name;
+ }
+};
+
var validateTagLength = function(value){
var tags = getUniqueWords(value);
var are_tags_ok = true;
@@ -2451,11 +2480,6 @@ TagEditor.prototype.cleanTag = function(tag_name, reject_dupe) {
);
}
- var tag_regex = new RegExp(this._settings['tag_regex']);
- if (tag_regex.test(tag_name) === false) {
- throw this._settings['messages']['wrong_chars']
- }
-
var max_tags = this._settings['max_tags_per_post'];
if (this.getSelectedTags().length + 1 > max_tags) {//count current
throw interpolate(
@@ -2468,23 +2492,8 @@ TagEditor.prototype.cleanTag = function(tag_name, reject_dupe) {
);
}
- var max_length = this._settings['max_tag_length'];
- if (tag_name.length > max_length) {
- throw interpolate(
- ngettext(
- 'must be shorter than %(max_chars)s character',
- 'must be shorter than %(max_chars)s characters',
- max_length
- ),
- {'max_chars': max_length },
- true
- );
- }
- if (this._settings['force_lowercase_tags']) {
- return tag_name.toLowerCase();
- } else {
- return tag_name;
- }
+ //generic cleaning
+ return cleanTag(tag_name, this._settings);
};
TagEditor.prototype.addTag = function(tag_name) {
@@ -2715,14 +2724,295 @@ TagEditor.prototype.decorate = function(element) {
});
};
+/**
+ * @constructor
+ * Category is a select box item
+ * that has CategoryEditControls
+ */
var Category = function() {
SelectBoxItem.call(this);
- tihs._is_editable = false;
+ this._state = 'display';
};
inherits(Category, SelectBoxItem);
+Category.prototype.getName = function() {
+ return this.getContent().getContent();
+};
+
+Category.prototype.setState = function(state) {
+ this._state = state;
+ if ( !this._element ) {
+ return;
+ }
+ this._input_box.val('');
+ if (state === 'display') {
+ this.showContent();
+ this.hideEditor();
+ this.hideEditControls();
+ } else if (state === 'editable') {
+ this.showContent();
+ this.hideEditor();
+ this.showEditControls();
+ } else if (state === 'edit') {
+ this._input_box.val(this.getName());
+ this.hideContent();
+ this.showEditor();
+ this.hideEditControls();
+ }
+};
+
+Category.prototype.hideEditControls = function() {
+ this._delete_button.hide();
+ this._edit_button.hide();
+ this._element.unbind('mouseenter mouseleave');
+};
+
+Category.prototype.showEditControls = function() {
+ var del = this._delete_button;
+ var edit = this._edit_button;
+ this._element.hover(
+ function(){
+ del.show();
+ edit.show();
+ },
+ function(){
+ del.hide();
+ edit.hide();
+ }
+ );
+};
+
+Category.prototype.hideContent = function() {
+ this.getContent().getElement().hide();
+};
+
+Category.prototype.showContent = function() {
+ this.getContent().getElement().show();
+};
+
+Category.prototype.showEditor = function() {
+ this._input_box.show();
+ this._save_button.show();
+ this._cancel_button.show();
+};
+
+Category.prototype.hideEditor = function() {
+ this._input_box.hide();
+ this._save_button.hide();
+ this._cancel_button.hide();
+};
+
+Category.prototype.getDeleteHandler = function() {
+ return function(){ return false; }
+};
+
+Category.prototype.getSaveHandler = function() {
+ var me = this;
+ //here we need old value and new value
+ return function(){
+ return false;
+ };
+};
+
+Category.prototype.addControls = function() {
+ var input_box = this.makeElement('input');
+ this._input_box = input_box;
+ this._element.append(input_box);
+
+ var save_button = this.makeButton(
+ gettext('save'),
+ this.getSaveHandler()
+ );
+ this._save_button = save_button;
+ this._element.append(save_button);
+
+ var me = this;
+ var cancel_button = this.makeButton(
+ 'x',
+ function(){
+ me.setState('editable');
+ return false;
+ }
+ );
+ this._cancel_button = cancel_button;
+ this._element.append(cancel_button);
+
+ var edit_button = this.makeButton(
+ gettext('edit'),
+ function(){
+ me.setState('edit');
+ return false;
+ }
+ );
+ this._edit_button = edit_button;
+ this._element.append(edit_button);
+
+ var delete_button = this.makeButton(
+ 'x', this.getDeleteHandler()
+ );
+ this._delete_button = delete_button;
+ this._element.append(delete_button);
+};
+
+Category.prototype.createDom = function() {
+ Category.superClass_.createDom.call(this);
+ this.addControls();
+ this.setState('display');
+};
+
Category.prototype.decorate = function(element) {
- this.superClass_.decorate.call(this, element);
+ Category.superClass_.decorate.call(this, element);
+ this.addControls();
+ this.setState('display');
+};
+
+var CategoryAdder = function() {
+ WrappedElement.call(this);
+ this._state = 'disabled';//waitedit
+ this._tree = undefined;//category tree
+ this._settings = JSON.parse(askbot['settings']['tag_editor']);
+};
+inherits(CategoryAdder, WrappedElement);
+
+CategoryAdder.prototype.setCategoryTreeObject = function(tree) {
+ this._tree = tree;
+};
+
+CategoryAdder.prototype.setLevel = function(level) {
+ this._level = level;
+};
+
+CategoryAdder.prototype.setState = function(state) {
+ this._state = state;
+ if (!this._element) {
+ return;
+ }
+ if (state === 'wait') {
+ this._element.show();
+ this._input.val('');
+ this._input.hide();
+ this._save_button.hide();
+ this._cancel_button.hide();
+ this._trigger.show();
+ } else if (state === 'edit') {
+ this._element.show();
+ this._input.show();
+ this._input.val('');
+ this._input.focus();
+ this._save_button.show();
+ this._cancel_button.show();
+ this._trigger.hide();
+ } else if (state === 'disabled') {
+ this.setState('wait');//a little hack
+ this._state = 'disabled';
+ this._element.hide();
+ }
+};
+
+CategoryAdder.prototype.cleanCategoryName = function(name) {
+ name = $.trim(name);
+ if (name === '') {
+ throw gettext('category name cannot be empty');
+ }
+ if ( this._tree.hasCategory(name) ) {
+ //throw interpolate(
+ throw gettext('this category already exists');
+ // [this._tree.getDisplayPathByName(name)]
+ //)
+ }
+ return cleanTag(name, this._settings);
+};
+
+CategoryAdder.prototype.getPath = function() {
+ var path = this._tree.getCurrentPath();
+ if (path.length > this._level + 1) {
+ return path.slice(0, this._level + 1);
+ } else {
+ return path;
+ }
+};
+
+CategoryAdder.prototype.startAdding = function() {
+ try {
+ var name = this._input.val();
+ name = this.cleanCategoryName(name);
+ } catch (error) {
+ alert(error);
+ return;
+ }
+ var me = this;
+ var tree = this._tree;
+ var current_path = tree.getCurrentPath();
+ var adder_path = this.getPath();
+
+ $.ajax({
+ type: 'POST',
+ dataType: 'json',
+ data: JSON.stringify({
+ path: adder_path,
+ new_category_name: name
+ }),
+ url: askbot['urls']['add_tag_category'],
+ cache: false,
+ success: function(data) {
+ if (data['success']) {
+ //rebuild category tree based on data
+ tree.setData(data['tree_data']);
+ //re-open current branch
+ tree.openPath(current_path);
+ me.setState('wait');
+ } else {
+ alert(data['message']);
+ }
+ }
+ });
+};
+
+CategoryAdder.prototype.createDom = function() {
+ this._element = this.makeElement('li');
+ //add open adder link
+ var trigger = this.makeElement('a');
+ this._trigger = trigger;
+ trigger.html(gettext('add category'));
+ this._element.append(trigger);
+ //add input box and the add button
+ var input = this.makeElement('input');
+ this._input = input;
+ input.addClass('add-category');
+ this._element.append(input);
+ //add save category button
+ var save_button = this.makeElement('button');
+ this._save_button = save_button;
+ save_button.html(gettext('save'));
+ this._element.append(save_button);
+
+ var cancel_button = this.makeElement('button');
+ this._cancel_button = cancel_button;
+ cancel_button.html('x');
+ this._element.append(cancel_button);
+
+ this.setState(this._state);
+
+ var me = this;
+ setupButtonEventHandlers(
+ trigger,
+ function(){ me.setState('edit'); }
+ )
+ setupButtonEventHandlers(
+ save_button,
+ function() {
+ me.startAdding();
+ return false;//prevent form submit
+ }
+ );
+ setupButtonEventHandlers(
+ cancel_button,
+ function() {
+ me.setState('wait');
+ return false;//prevent submit
+ }
+ );
+ //create input box, button and the "activate" link
};
/**
@@ -2733,45 +3023,166 @@ Category.prototype.decorate = function(element) {
var CategorySelectBox = function() {
SelectBox.call(this);
this._item_class = Category;
+ this._category_adder = undefined;
+ this._tree = undefined;//cat tree
+ this._level = undefined;
};
inherits(CategorySelectBox, SelectBox);
+CategorySelectBox.prototype.setState = function(state) {
+ this._state = state;
+ if (state === 'select') {
+ this._category_adder.setState('disabled');
+ $.each(this._items, function(idx, item){
+ item.setState('display');
+ });
+ } else if (state === 'edit') {
+ this._category_adder.setState('wait');
+ $.each(this._items, function(idx, item){
+ item.setState('editable');
+ });
+ }
+};
+
+CategorySelectBox.prototype.setCategoryTreeObject = function(tree) {
+ this._tree = tree;
+};
+
+CategorySelectBox.prototype.setLevel = function(level) {
+ this._level = level;
+};
+
+CategorySelectBox.prototype.appendCategoryAdder = function() {
+ var adder = new CategoryAdder();
+ adder.setLevel(this._level);
+ adder.setCategoryTreeObject(this._tree);
+ this._category_adder = adder;
+ this._element.append(adder.getElement());
+};
+
+CategorySelectBox.prototype.createDom = function() {
+ CategorySelectBox.superClass_.createDom();
+ if (askbot['data']['userIsAdmin']) {
+ this.appendCategoryAdder();
+ }
+};
+
+CategorySelectBox.prototype.decorate = function(element) {
+ CategorySelectBox.superClass_.decorate.call(this, element);
+ this.setState(this._state);
+ if (askbot['data']['userIsAdmin']) {
+ this.appendCategoryAdder();
+ }
+};
+
+/**
+ * @constructor
+ * turns on/off the category editor
+ */
+var CategoryEditorToggle = function() {
+ TwoStateToggle.call(this);
+};
+inherits(CategoryEditorToggle, TwoStateToggle);
+
+CategoryEditorToggle.prototype.setCategorySelector = function(sel) {
+ this._category_selector = sel;
+};
+
+CategoryEditorToggle.prototype.getCategorySelector = function() {
+ return this._category_selector;
+};
+
+CategoryEditorToggle.prototype.decorate = function(element) {
+ CategoryEditorToggle.superClass_.decorate.call(this, element);
+};
+
+CategoryEditorToggle.prototype.getDefaultHandler = function() {
+ var me = this;
+ return function() {
+ var editor = me.getCategorySelector();
+ if (me.isOn()) {
+ me.setState('off-state');
+ editor.setState('select');
+ } else {
+ me.setState('on-state');
+ editor.setState('edit');
+ }
+ };
+};
+
var CategorySelector = function() {
- WrappedElement.call(this);
+ Widget.call(this);
this._data = null;
- this._is_editable = false;
this._select_handler = function(){};//dummy default
+ this._current_path = [0];//path points to selected item in tree
+};
+inherits(CategorySelector, Widget);
+
+/**
+ * propagates state to the individual selectors
+ */
+CategorySelector.prototype.setState = function(state) {
+ this._state = state;
+ $.each(this._selectors, function(idx, selector){
+ selector.setState(state);
+ });
};
-inherits(CategorySelector, WrappedElement);
CategorySelector.prototype.setData = function(data) {
this._data = data;
};
-CategorySelector.prototype.setEditable = function(is_editable) {
- this._is_editable = is_editable;
+/**
+ * clears contents of selector boxes starting from
+ * the given level, in range 0..2
+ */
+CategorySelector.prototype.clearCategoryLevels = function(level) {
+ for (var i = level; i < 3; i++) {
+ this._selectors[i].removeAllItems();
+ }
};
-CategorySelector.prototype.populateCategoryLevel = function(source_path) {
- var level = source_path.length - 1;
- if (level > 3) {
- return;
+CategorySelector.prototype.hasCategory = function(category) {
+ function inData(category, data) {
+ for (var i = 0; i < data.length; i++) {
+ //check name of the current node
+ if (data[i][0] === category) {
+ return true;
+ }
+ //check in the subtree
+ if (inData(category, data[i][1])) {
+ return true;
+ }
+ }
+ return false;
}
+ return inData(category, this._data[0][1]);
+};
+
+CategorySelector.prototype.getLeafItems = function(selection_path) {
//traverse the tree down to items pointed to by the path
var data = this._data[0];
- for (var i = 1; i < source_path.length; i++) {
- data = data[1][source_path[i]];
+ for (var i = 1; i < selection_path.length; i++) {
+ data = data[1][selection_path[i]];
}
+ return data[1];
+}
- var items = data[1];
-
- //clear current and above selectors
- for (var i = level; i < 3; i++) {
- this._selectors[i].removeAllItems();
+/**
+ * called when a sub-level needs to open
+ */
+CategorySelector.prototype.populateCategoryLevel = function(source_path) {
+ var level = source_path.length - 1;
+ if (level > 3) {
+ return;
}
+ //clear all items downstream
+ this.clearCategoryLevels(level);
//populate current selector
var selector = this._selectors[level];
+ var items = this.getLeafItems(source_path);
+
$.each(items, function(idx, item) {
var tag_name = item[0];
selector.addItem(idx, tag_name, '');//first and last are dummy values
@@ -2780,11 +3191,15 @@ CategorySelector.prototype.populateCategoryLevel = function(source_path) {
}
});
+ this.setState(this._state);//update state
+
selector.clearSelection();
};
-CategorySelector.prototype.maybeAddEditButton = function(selector) {
- return;
+CategorySelector.prototype.openPath = function(path) {
+ for (var i = 1; i <= path.length; i++) {
+ this.populateCategoryLevel(path.slice(0, i));
+ }
};
CategorySelector.prototype.getSelectedPath = function(selected_level) {
@@ -2813,15 +3228,33 @@ CategorySelector.prototype.getSelectHandlerInternal = function() {
return this._select_handler;
};
+CategorySelector.prototype.setCurrentPath = function(path) {
+ return this._current_path = path;
+};
+
+CategorySelector.prototype.getCurrentPath = function() {
+ return this._current_path;
+};
+
+CategorySelector.prototype.getEditorToggle = function() {
+ return this._editor_toggle;
+};
+
CategorySelector.prototype.getSelectHandler = function(level) {
var me = this;
return function(item_data) {
//1) run the assigned select handler
var tag_name = item_data['title']
- me.getSelectHandlerInternal()(tag_name);
+ if (me.getState() === 'select') {
+ /* this one will actually select the tag
+ * maybe a bit too implicit
+ */
+ me.getSelectHandlerInternal()(tag_name);
+ }
//2) if appropriate, populate the higher level
if (level < 2) {
var current_path = me.getSelectedPath(level);
+ me.setCurrentPath(current_path);
me.populateCategoryLevel(current_path);
}
}
@@ -2832,23 +3265,35 @@ CategorySelector.prototype.decorate = function(element) {
this._selectors = [];
var selector0 = new CategorySelectBox();
+ selector0.setLevel(0);
+ selector0.setCategoryTreeObject(this);
selector0.decorate(element.find('.cat-col-0'));
selector0.setSelectHandler(this.getSelectHandler(0));
- this.maybeAddEditButton(selector0);
this._selectors.push(selector0);
var selector1 = new CategorySelectBox();
+ selector1.setLevel(1);
+ selector1.setCategoryTreeObject(this);
selector1.decorate(element.find('.cat-col-1'));
selector1.setSelectHandler(this.getSelectHandler(1));
- this.maybeAddEditButton(selector1);
this._selectors.push(selector1)
var selector2 = new CategorySelectBox();
+ selector2.setLevel(2);
+ selector2.setCategoryTreeObject(this);
selector2.decorate(element.find('.cat-col-2'));
selector2.setSelectHandler(this.getSelectHandler(2));
- this.maybeAddEditButton(selector2);
this._selectors.push(selector2);
+ if (askbot['data']['userIsAdminOrMod']) {
+ var editor_toggle = new CategoryEditorToggle();
+ editor_toggle.setCategorySelector(this);
+ var toggle_element = $('.category-editor-toggle');
+ toggle_element.show();
+ editor_toggle.decorate(toggle_element);
+ this._editor_toggle = editor_toggle;
+ }
+
this.populateCategoryLevel([0]);
};
@@ -2864,6 +3309,10 @@ var CategorySelectorLoader = function() {
};
inherits(CategorySelectorLoader, WrappedElement);
+CategorySelectorLoader.prototype.setCategorySelector = function(sel) {
+ this._category_selector = sel;
+};
+
CategorySelectorLoader.prototype.setLoaded = function(is_loaded) {
this._is_loaded = is_loaded;
};
@@ -2890,6 +3339,9 @@ CategorySelectorLoader.prototype.openEditor = function() {
this._display_tags_container.hide();
this._question_body.hide();
this._question_controls.hide();
+ var sel = this._category_selector;
+ sel.setState('select');
+ sel.getEditorToggle().setState('off-state');
};
CategorySelectorLoader.prototype.addEditorButtons = function() {
@@ -2906,7 +3358,8 @@ CategorySelectorLoader.prototype.getOnLoadHandler = function() {
me.setEditor(editor);
$('#question-tags').after(editor);
- askbot['functions']['initCategoryTree']();
+ var selector = askbot['functions']['initCategoryTree']();
+ me.setCategorySelector(selector);
me.addEditorButtons();
me.openEditor();
diff --git a/askbot/skins/common/media/js/utils.js b/askbot/skins/common/media/js/utils.js
index b4d08b2c..63f0027f 100644
--- a/askbot/skins/common/media/js/utils.js
+++ b/askbot/skins/common/media/js/utils.js
@@ -267,6 +267,31 @@ WrappedElement.prototype.dispose = function(){
};
/**
+ * @constructor
+ * Widget is a Wrapped element with state
+ */
+var Widget = function() {
+ WrappedElement.call(this);
+ this._state = undefined;
+};
+inherits(Widget, WrappedElement);
+
+Widget.prototype.setState = function(state) {
+ this._state = state;
+};
+
+Widget.prototype.getState = function() {
+ return this._state;
+};
+
+Widget.prototype.makeButton = function(label, handler) {
+ var button = this.makeElement('button');
+ button.html(label);
+ setupButtonEventHandlers(button, handler);
+ return button;
+};
+
+/**
* Can be used for an input box or textarea.
* The original value will be treated as an instruction.
* When user focuses on the field, the tip will be gone,
@@ -412,6 +437,34 @@ AlertBox.prototype.createDom = function(){
this._element.alert();//bootstrap.js alert
};
+/**
+ * @constructor
+ * just a span with content
+ * useful for subclassing
+ */
+var SimpleContent = function(){
+ WrappedElement.call(this);
+ this._content = '';
+};
+inherits(SimpleContent, WrappedElement);
+
+SimpleContent.prototype.setContent = function(text) {
+ //todo: for text we should use .html() for text nodes .append()
+ this._content = text;
+ if (this._element) {
+ this._element.append(text);
+ }
+};
+
+SimpleContent.prototype.getContent = function() {
+ return this._content;
+};
+
+SimpleContent.prototype.createDom = function() {
+ this._element = this.makeElement('span');
+ this._element.html(this._content);
+};
+
var SimpleControl = function(){
WrappedElement.call(this);
this._handler = null;
@@ -808,7 +861,7 @@ TwoStateToggle.prototype.decorate = function(element){
} else {
me.setState('on-prompt');
}
- element.css('background-color', 'red');
+ //element.css('background-color', 'red');
return false;
});
element.mouseout(function(){
@@ -818,24 +871,42 @@ TwoStateToggle.prototype.decorate = function(element){
} else {
me.setState('off-state');
}
- element.css('background-color', 'white');
+ //element.css('background-color', 'white');
return false;
});
setupButtonEventHandlers(element, this.getHandler());
};
+var BoxItemContent = function() {
+ SimpleContent.call(this);
+};
+inherits(BoxItemContent, SimpleContent);
+
+/**
+ * @override to allow for more complex content
+ */
+BoxItemContent.prototype.setName = function(name) {
+ BoxItemContent.superClass_.setContent.call(this, name);
+};
+BoxItemContent.prototype.getName = function() {
+ return BoxItemContent.superClass_.getContent.call(this);
+};
+
/**
* @constructor
* an item used for the select box described below
*/
var SelectBoxItem = function() {
- WrappedElement.call(this);
+ Widget.call(this);
this._id = null;
this._name = null;
this._description = null;
+ this._content_class = BoxItemContent;//default expects a single text node
+ //content element - instance of this._content_class
+ this._content = undefined;
};
-inherits(SelectBoxItem, WrappedElement);
+inherits(SelectBoxItem, Widget);
SelectBoxItem.prototype.setId = function(id) {
this._id = id;
@@ -846,8 +917,8 @@ SelectBoxItem.prototype.setId = function(id) {
SelectBoxItem.prototype.setName = function(name) {
this._name = name;
- if (this._element) {
- this._element.html(name);
+ if (this._content) {
+ this._content.setName(name);
}
};
@@ -872,6 +943,10 @@ SelectBoxItem.prototype.getData = function () {
};
};
+SelectBoxItem.prototype.getContent = function() {
+ return this._content;
+};
+
SelectBoxItem.prototype.isSelected = function() {
return this._element.hasClass('selected');
};
@@ -887,29 +962,42 @@ SelectBoxItem.prototype.setSelected = function(is_selected) {
SelectBoxItem.prototype.createDom = function() {
var elem = this.makeElement('li');
this._element = elem;
- elem.html(this._name);
elem.data('itemId', this._id);
elem.data('itemOriginalTitle', this._description);
+ var content = new this._content_class();
+ content.setName(this._name);
+ elem.append(content.getElement());
+ this._content = content;
}
SelectBoxItem.prototype.decorate = function(element) {
this._element = element;
+ //set id and description
this._id = element.data('itemId');
- this._name = element.html();
this._description = element.data('originalTitle');
+
+ //work on setting name
+ var content_source = element.contents().detach();
+ var content = new this._content_class();
+ //assume that we want first node only
+ content.setContent(content_source[0]);
+ this._content = content;
+ this._name = content.getName();//allows to abstract from structure
+
+ this._element.append(content.getElement());
};
/**
* A list of items from where one can be selected
*/
var SelectBox = function(){
- WrappedElement.call(this);
+ Widget.call(this);
this._items = [];
this._select_handler = function(){};//empty default
this._is_editable = false;
this._item_class = SelectBoxItem;
};
-inherits(SelectBox, WrappedElement);
+inherits(SelectBox, Widget);
SelectBox.prototype.setEditable = function(is_editable) {
this._is_editable = is_editable;
@@ -1209,7 +1297,7 @@ Tag.prototype.createDom = function(){
//Search Engine Keyword Highlight with Javascript
//http://scott.yang.id.au/code/se-hilite/
Hilite={elementid:"content",exact:true,max_nodes:1000,onload:true,style_name:"hilite",style_name_suffix:true,debug_referrer:""};Hilite.search_engines=[["local","q"],["cnprog\\.","q"],["google\\.","q"],["search\\.yahoo\\.","p"],["search\\.msn\\.","q"],["search\\.live\\.","query"],["search\\.aol\\.","userQuery"],["ask\\.com","q"],["altavista\\.","q"],["feedster\\.","q"],["search\\.lycos\\.","q"],["alltheweb\\.","q"],["technorati\\.com/search/([^\\?/]+)",1],["dogpile\\.com/info\\.dogpl/search/web/([^\\?/]+)",1,true]];Hilite.decodeReferrer=function(d){var g=null;var e=new RegExp("");for(var c=0;c<Hilite.search_engines.length;c++){var f=Hilite.search_engines[c];e.compile("^http://(www\\.)?"+f[0],"i");var b=d.match(e);if(b){var a;if(isNaN(f[1])){a=Hilite.decodeReferrerQS(d,f[1])}else{a=b[f[1]+1]}if(a){a=decodeURIComponent(a);if(f.length>2&&f[2]){a=decodeURIComponent(a)}a=a.replace(/\'|"/g,"");a=a.split(/[\s,\+\.]+/);return a}break}}return null};Hilite.decodeReferrerQS=function(f,d){var b=f.indexOf("?");var c;if(b>=0){var a=new String(f.substring(b+1));b=0;c=0;while((b>=0)&&((c=a.indexOf("=",b))>=0)){var e,g;e=a.substring(b,c);b=a.indexOf("&",c)+1;if(e==d){if(b<=0){return a.substring(c+1)}else{return a.substring(c+1,b-1)}}else{if(b<=0){return null}}}}return null};Hilite.hiliteElement=function(f,e){if(!e||f.childNodes.length==0){return}var c=new Array();for(var b=0;b<e.length;b++){e[b]=e[b].toLowerCase();if(Hilite.exact){c.push("\\b"+e[b]+"\\b")}else{c.push(e[b])}}c=new RegExp(c.join("|"),"i");var a={};for(var b=0;b<e.length;b++){if(Hilite.style_name_suffix){a[e[b]]=Hilite.style_name+(b+1)}else{a[e[b]]=Hilite.style_name}}var d=function(m){var j=c.exec(m.data);if(j){var n=j[0];var i="";var h=m.splitText(j.index);var g=h.splitText(n.length);var l=m.ownerDocument.createElement("SPAN");m.parentNode.replaceChild(l,h);l.className=a[n.toLowerCase()];l.appendChild(h);return l}else{return m}};Hilite.walkElements(f.childNodes[0],1,d)};Hilite.hilite=function(){var a=Hilite.debug_referrer?Hilite.debug_referrer:document.referrer;var b=null;a=Hilite.decodeReferrer(a);if(a&&((Hilite.elementid&&(b=document.getElementById(Hilite.elementid)))||(b=document.body))){Hilite.hiliteElement(b,a)}};Hilite.walkElements=function(d,f,e){var a=/^(script|style|textarea)/i;var c=0;while(d&&f>0){c++;if(c>=Hilite.max_nodes){var b=function(){Hilite.walkElements(d,f,e)};setTimeout(b,50);return}if(d.nodeType==1){if(!a.test(d.tagName)&&d.childNodes.length>0){d=d.childNodes[0];f++;continue}}else{if(d.nodeType==3){d=e(d)}}if(d.nextSibling){d=d.nextSibling}else{while(f>0){d=d.parentNode;f--;if(d.nextSibling){d=d.nextSibling;break}}}}};if(Hilite.onload){if(window.attachEvent){window.attachEvent("onload",Hilite.hilite)}else{if(window.addEventListener){window.addEventListener("load",Hilite.hilite,false)}else{var __onload=window.onload;window.onload=function(){Hilite.hilite();__onload()}}}};
-/* json2.js by D. Crockford */
+
if(!this.JSON){this.JSON={}}(function(){function f(n){return n<10?"0"+n:n}if(typeof Date.prototype.toJSON!=="function"){Date.prototype.toJSON=function(key){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null};String.prototype.toJSON=Number.prototype.toJSON=Boolean.prototype.toJSON=function(key){return this.valueOf()}}var cx=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,escapable=/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,gap,indent,meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},rep;function quote(string){escapable.lastIndex=0;return escapable.test(string)?'"'+string.replace(escapable,function(a){var c=meta[a];return typeof c==="string"?c:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+string+'"'}function str(key,holder){var i,k,v,length,mind=gap,partial,value=holder[key];if(value&&typeof value==="object"&&typeof value.toJSON==="function"){value=value.toJSON(key)}if(typeof rep==="function"){value=rep.call(holder,key,value)}switch(typeof value){case"string":return quote(value);case"number":return isFinite(value)?String(value):"null";case"boolean":case"null":return String(value);case"object":if(!value){return"null"}gap+=indent;partial=[];if(Object.prototype.toString.apply(value)==="[object Array]"){length=value.length;for(i=0;i<length;i+=1){partial[i]=str(i,value)||"null"}v=partial.length===0?"[]":gap?"[\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"]":"["+partial.join(",")+"]";gap=mind;return v}if(rep&&typeof rep==="object"){length=rep.length;for(i=0;i<length;i+=1){k=rep[i];if(typeof k==="string"){v=str(k,value);if(v){partial.push(quote(k)+(gap?": ":":")+v)}}}}else{for(k in value){if(Object.hasOwnProperty.call(value,k)){v=str(k,value);if(v){partial.push(quote(k)+(gap?": ":":")+v)}}}}v=partial.length===0?"{}":gap?"{\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"}":"{"+partial.join(",")+"}";gap=mind;return v}}if(typeof JSON.stringify!=="function"){JSON.stringify=function(value,replacer,space){var i;gap="";indent="";if(typeof space==="number"){for(i=0;i<space;i+=1){indent+=" "}}else{if(typeof space==="string"){indent=space}}rep=replacer;if(replacer&&typeof replacer!=="function"&&(typeof replacer!=="object"||typeof replacer.length!=="number")){throw new Error("JSON.stringify")}return str("",{"":value})}}if(typeof JSON.parse!=="function"){JSON.parse=function(text,reviver){var j;function walk(holder,key){var k,v,value=holder[key];if(value&&typeof value==="object"){for(k in value){if(Object.hasOwnProperty.call(value,k)){v=walk(value,k);if(v!==undefined){value[k]=v}else{delete value[k]}}}}return reviver.call(holder,key,value)}text=String(text);cx.lastIndex=0;if(cx.test(text)){text=text.replace(cx,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})}if(/^[\],:{}\s]*$/.test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,"@").replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,"]").replace(/(?:^|:|,)(?:\s*\[)+/g,""))){j=eval("("+text+")");return typeof reviver==="function"?walk({"":j},""):j}throw new SyntaxError("JSON.parse")}}}());
//jquery fieldselection
(function(){var a={getSelection:function(){var b=this.jquery?this[0]:this;return(("selectionStart" in b&&function(){var c=b.selectionEnd-b.selectionStart;return{start:b.selectionStart,end:b.selectionEnd,length:c,text:b.value.substr(b.selectionStart,c)}})||(document.selection&&function(){b.focus();var d=document.selection.createRange();if(d==null){return{start:0,end:b.value.length,length:0}}var c=b.createTextRange();var e=c.duplicate();c.moveToBookmark(d.getBookmark());e.setEndPoint("EndToStart",c);return{start:e.text.length,end:e.text.length+d.text.length,length:d.text.length,text:d.text}})||function(){return{start:0,end:b.value.length,length:0}})()},replaceSelection:function(){var b=this.jquery?this[0]:this;var c=arguments[0]||"";return(("selectionStart" in b&&function(){b.value=b.value.substr(0,b.selectionStart)+c+b.value.substr(b.selectionEnd,b.value.length);return this})||(document.selection&&function(){b.focus();document.selection.createRange().text=c;return this})||function(){b.value+=c;return this})()}};jQuery.each(a,function(b){jQuery.fn[b]=this})})();
diff --git a/askbot/skins/default/media/style/style.less b/askbot/skins/default/media/style/style.less
index 02635b7c..993daa33 100644
--- a/askbot/skins/default/media/style/style.less
+++ b/askbot/skins/default/media/style/style.less
@@ -3486,13 +3486,19 @@ textarea.tipped-input {
padding-left: 7px;
font-size: 14px;
line-height: 25px;
+ input {
+ margin: 0 0 2px -5px;
+ font-size: 14px;
+ line-height: 14px;
+ vertical-align: middle;
+ color: @info-text;
+ }
}
li.selected,
li.selected:hover {
background-color: #fcf8e3;
color: #c09853;
}
-
li:hover {
background-color: #cecece;
color: white;
@@ -3537,6 +3543,9 @@ textarea.tipped-input {
.question-page {
.category-selector ul.select-box {
width: 217px;
+ input {
+ width: 95px;
+ }
}
.tag-editor {
width: 660px;
diff --git a/askbot/skins/default/templates/meta/category_tree_js.html b/askbot/skins/default/templates/meta/category_tree_js.html
index e480cc2c..1a0af93a 100644
--- a/askbot/skins/default/templates/meta/category_tree_js.html
+++ b/askbot/skins/default/templates/meta/category_tree_js.html
@@ -5,9 +5,6 @@
if (sel_elems.length > 0) {
var selector = new CategorySelector();
selector.setData(JSON.parse("{{category_tree_data|escapejs}}"));
- if (askbot['data']['userIsAdminOrMod']) {
- selector.setEditable(true);
- }
selector.decorate(sel_elems);
var tag_editor = new TagEditor();
@@ -18,7 +15,9 @@
{% endfor %}
{% endif %}
selector.setSelectHandler(tag_editor.getAddTagHandler());
+ return selector;
}
};
askbot['functions']['initCategoryTree']();
+ askbot['urls']['add_tag_category'] = '{% url add_tag_category %}';
</script>
diff --git a/askbot/skins/default/templates/meta/html_head_javascript.html b/askbot/skins/default/templates/meta/html_head_javascript.html
index 65d0bdce..6e480f8b 100644
--- a/askbot/skins/default/templates/meta/html_head_javascript.html
+++ b/askbot/skins/default/templates/meta/html_head_javascript.html
@@ -9,6 +9,9 @@
request.user.is_administrator()
or request.user.is_moderator()
%}true{% else %}false{% endif %};
+ askbot['data']['userIsAdmin'] = {% if
+ request.user.is_administrator()
+ %}true{% else %}false{% endif %};
askbot['data']['userReputation'] = {{request.user.reputation}};
{% else %}
askbot['data']['userIsAuthenticated'] = false;
diff --git a/askbot/skins/default/templates/widgets/three_column_category_selector.html b/askbot/skins/default/templates/widgets/three_column_category_selector.html
index 9c7b7e6d..ab0886c6 100644
--- a/askbot/skins/default/templates/widgets/three_column_category_selector.html
+++ b/askbot/skins/default/templates/widgets/three_column_category_selector.html
@@ -1,7 +1,16 @@
{# just a skeleton for the category selector - filled by js #}
<table class="category-selector">
<thead>
- <th colspan="3">{% trans %}Categorize your question using this tag selector or entering text in tag box{% endtrans %}</th>
+ <th colspan="3">{% trans %}Categorize your question using this tag selector or entering text in tag box.{% endtrans %}
+ <a style="display:none;"
+ class='category-editor-toggle'
+ data-on-state-text='{% trans %}(done editing){% endtrans %}'
+ data-off-state-text='{% trans %}(edit categories){% endtrans %}'
+ data-on-prompt-text='{% trans %}(edit categories){% endtrans %}'
+ data-off-prompt-text='{% trans %}(done editing){% endtrans %}'
+ >{% trans %}(edit categories){% endtrans %}
+ </a>
+ </th>
</thead>
<tbody>
<tr>
diff --git a/askbot/tests/__init__.py b/askbot/tests/__init__.py
index 1b25e064..198bf9e8 100644
--- a/askbot/tests/__init__.py
+++ b/askbot/tests/__init__.py
@@ -15,3 +15,4 @@ from askbot.tests.markup_test import *
from askbot.tests.misc_tests import *
from askbot.tests.post_model_tests import *
from askbot.tests.reply_by_email_tests import *
+from askbot.tests.category_tree_tests import CategoryTreeTests
diff --git a/askbot/tests/category_tree_tests.py b/askbot/tests/category_tree_tests.py
new file mode 100644
index 00000000..7f6ed592
--- /dev/null
+++ b/askbot/tests/category_tree_tests.py
@@ -0,0 +1,116 @@
+import unittest
+from askbot.utils import category_tree as ct
+from django.utils import simplejson
+
+class CategoryTreeTests(unittest.TestCase):
+ def setUp(self):
+ self.tree = [
+ [
+ u'dummy', [#dummy is a sentinel node
+ [
+ u'cars', [
+ [u'volkswagen', []],
+ [u'zhiguli', []]
+ ]
+ ],
+ [
+ u'cats', [
+ [u'meow', []],
+ [u'tigers', [
+ [u'rrrr', []]
+ ]
+ ]
+ ]
+ ],
+ [
+ u'music', [
+ [u'play', [
+ [u'loud', []]]
+ ],
+ [u'listen', []],
+ [u'buy', []],
+ [u'download', []]
+ ]
+ ]
+ ]
+ ]
+ ]
+
+ def test_dummy_is_absent(self):
+ self.assertEqual(
+ ct.has_category(self.tree, 'dummy'),
+ False
+ )
+
+ def test_first_level_subcat_is_there(self):
+ self.assertEqual(
+ ct.has_category(self.tree, 'cars'),
+ True
+ )
+
+ def test_deep_level_subcat_is_there(self):
+ self.assertEqual(
+ ct.has_category(self.tree, 'download'),
+ True
+ )
+
+ def test_get_subtree_dummy(self):
+ dummy = ct.get_subtree(self.tree, [0])
+ self.assertEqual(dummy[0], 'dummy')
+
+ def test_get_subtree_cars(self):
+ cars = ct.get_subtree(self.tree, [0,0])
+ self.assertEqual(cars[0], 'cars')
+
+ def test_get_subtree_listen_music(self):
+ listen_music = ct.get_subtree(self.tree, [0,2,1])
+ self.assertEqual(listen_music[0], 'listen')
+
+ def test_path_is_valid_dummy(self):
+ self.assertEqual(
+ ct.path_is_valid(self.tree, [0]), True
+ )
+
+ def test_path_is_valid_deep(self):
+ self.assertEqual(
+ ct.path_is_valid(self.tree, [0,2,0,0]), True
+ )
+ def test_path_is_nvalid_too_deep(self):
+ self.assertEqual(
+ ct.path_is_valid(self.tree, [0,2,0,0,0]), False
+ )
+
+ def test_add_category(self):
+ ct.add_category(self.tree, 'appreciate', [0, 2])
+ appreciate = ct.get_subtree(self.tree, [0, 2, 4])
+ self.assertEqual(appreciate[0] , 'appreciate')
+
+ def test_sort_data(self):
+ unsorted_data = [
+ [
+ 'dummy',
+ [
+ [
+ 'cars',
+ []
+ ],
+ [
+ 'audio',
+ [
+ [
+ 'mp3', []
+ ],
+ [
+ 'amadeus', []
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ sorted_data = ct.sort_tree(unsorted_data)
+ sorted_dump = simplejson.dumps(sorted_data)
+ self.assertEqual(
+ sorted_dump,
+ '[["dummy", [["audio", [["amadeus", []], ["mp3", []]]], ["cars", []]]]]'
+ )
diff --git a/askbot/urls.py b/askbot/urls.py
index 39170660..1250a685 100644
--- a/askbot/urls.py
+++ b/askbot/urls.py
@@ -210,6 +210,11 @@ urlpatterns = patterns('',
name = 'save_tag_wiki_text'
),
url(#ajax only
+ r'^add-tag-category/',
+ views.commands.add_tag_category,
+ name = 'add_tag_category'
+ ),
+ url(#ajax only
r'^save-group-logo-url/',
views.commands.save_group_logo_url,
name = 'save_group_logo_url'
diff --git a/askbot/utils/category_tree.py b/askbot/utils/category_tree.py
index 501b74fa..fb5e622f 100644
--- a/askbot/utils/category_tree.py
+++ b/askbot/utils/category_tree.py
@@ -28,53 +28,26 @@ example of desired structure, when input is parsed
from askbot.conf import settings as askbot_settings
from django.utils import simplejson
-def get_subtree(tree, path):
- if len(path) == 1:
- assert(path[0] == 0)
- return tree
- else:
- import copy
- parent_path = copy.copy(path)
- leaf_index = parent_path.pop()
- branch_index = parent_path[-1]
- parent_tree = get_subtree(tree, parent_path)
- return parent_tree[branch_index][1]
-
-def parse_tree(text):
- """parse tree represented as indented text
- one item per line, with two spaces per level of indentation
- """
- lines = text.split('\n')
- import re
- in_re = re.compile(r'^([ ]*)')
-
- tree = [['dummy', []]]
- subtree_path = [0]
- clevel = 0
+def _get_subtree(tree, path):
+ clevel = tree
+ for pace in path:
+ clevel = clevel[1][pace]
+ return clevel
- for line in lines:
- if line.strip() == '':
- continue
- match = in_re.match(line)
- level = len(match.group(1))/2 + 1
-
- if level > clevel:
- subtree_path.append(0)#
- elif level < clevel:
- subtree_path = subtree_path[:level+1]
- leaf_index = subtree_path.pop()
- subtree_path.append(leaf_index + 1)
- else:
- leaf_index = subtree_path.pop()
- subtree_path.append(leaf_index + 1)
-
- clevel = level
- try:
- subtree = get_subtree(tree, subtree_path)
- except:
- return tree
- subtree.append([line.strip(), []])
+def get_subtree(tree, path):
+ """path always starts with 0,
+ and is a list of integers"""
+ assert(path[0] == 0)
+ if len(path) == 1:#special case
+ return tree[0]
+ else:
+ return _get_subtree(tree[0], path[1:])
+def sort_tree(tree):
+ """sorts contents of the nodes alphabetically"""
+ tree = sorted(tree, lambda x,y: cmp(x[0], y[0]))
+ for item in tree:
+ item[1] = sort_tree(item[1])
return tree
def get_data():
@@ -82,7 +55,38 @@ def get_data():
or None, if category_tree is disabled
"""
if askbot_settings.TAG_SOURCE == 'category-tree':
- cat_tree = parse_tree(askbot_settings.CATEGORY_TREE)
- return simplejson.dumps(cat_tree)
+ return simplejson.loads(askbot_settings.CATEGORY_TREE)
else:
return None
+
+def path_is_valid(tree, path):
+ try:
+ get_subtree(tree, path)
+ return True
+ except IndexError:
+ return False
+ except AssertionError:
+ return False
+
+def add_category(tree, category_name, path):
+ subtree = get_subtree(tree, path)
+ subtree[1].append([category_name, []])
+
+def _has_category(tree, category_name):
+ for item in tree:
+ if item[0] == category_name:
+ return True
+ if _has_category(item[1], category_name):
+ return True
+ return False
+
+def has_category(tree, category_name):
+ """true if category is in tree"""
+ #skip the dummy
+ return _has_category(tree[0][1], category_name)
+
+def save_data(tree):
+ assert(askbot_settings.TAG_SOURCE == 'category-tree')
+ tree = sort_tree(tree)
+ tree_json = simplejson.dumps(tree)
+ askbot_settings.update('CATEGORY_TREE', tree_json)
diff --git a/askbot/views/commands.py b/askbot/views/commands.py
index 79c266c2..a0af45de 100644
--- a/askbot/views/commands.py
+++ b/askbot/views/commands.py
@@ -20,6 +20,7 @@ from askbot import models
from askbot import forms
from askbot.conf import should_show_sort_by_relevance
from askbot.conf import settings as askbot_settings
+from askbot.utils import category_tree
from askbot.utils import decorators
from askbot.utils import url_utils
from askbot import mail
@@ -477,6 +478,7 @@ def get_html_template(request):
allowed_templates = (
'widgets/tag_category_selector.html',
)
+ #have allow simple context for the templates
if template_name not in allowed_templates:
raise Http404
return {
@@ -526,7 +528,42 @@ def save_tag_wiki_text(request):
return {'html': tag_wiki.html}
else:
raise ValueError('invalid post data')
-
+
+@csrf.csrf_exempt
+@decorators.ajax_only
+@decorators.post_only
+def add_tag_category(request):
+ """adds a category at the tip of a given path expects
+ the following keys in the ``request.POST``
+ * path - array starting with zero giving path to
+ the category page where to add the category
+ * new_category_name - string that must satisfy the
+ same requiremets as a tag
+
+ return json with the category tree data
+ todo: switch to json stored in the live settings
+ now we have indented input
+ """
+ if request.user.is_anonymous() \
+ or not request.user.is_administrator_or_moderator():
+ raise exceptions.PermissionDenied()
+
+ post_data = simplejson.loads(request.raw_post_data)
+ category_name = forms.clean_tag(post_data['new_category_name'])
+ path = post_data['path']
+
+ tree = category_tree.get_data()
+
+ if category_tree.has_category(tree, category_name):
+ raise ValueError('category already exists')
+
+ if category_tree.path_is_valid(tree, path) == False:
+ raise ValueError('category insertion path is invalid')
+
+ category_tree.add_category(tree, category_name, path)
+ category_tree.save_data(tree)
+ return {'tree_data': tree}
+
@decorators.get_only
def get_groups_list(request):
diff --git a/askbot/views/readers.py b/askbot/views/readers.py
index c4778d7a..d27846e3 100644
--- a/askbot/views/readers.py
+++ b/askbot/views/readers.py
@@ -32,7 +32,6 @@ from askbot import models
from askbot import schedules
from askbot.models.tag import Tag
from askbot import const
-from askbot.utils import category_tree
from askbot.utils import functions
from askbot.utils.decorators import anonymous_forbidden, ajax_only, get_only
from askbot.search.state_manager import SearchState, DummySearchState
@@ -541,7 +540,7 @@ def question(request, id):#refactor - long subroutine. display question body, an
'answer' : answer_form,
'answers' : page_objects.object_list,
'answer_count': len(answers),
- 'category_tree_data': category_tree.get_data(),
+ 'category_tree_data': askbot_settings.CATEGORY_TREE,
'user_votes': user_votes,
'user_post_id_list': user_post_id_list,
'user_can_post_comment': user_can_post_comment,#in general
diff --git a/askbot/views/writers.py b/askbot/views/writers.py
index 41391887..b5beb65b 100644
--- a/askbot/views/writers.py
+++ b/askbot/views/writers.py
@@ -32,7 +32,6 @@ from askbot.utils import decorators
from askbot.utils.functions import diff_date
from askbot.utils import url_utils
from askbot.utils.file_utils import store_file
-from askbot.utils import category_tree
from askbot.views import context
from askbot.templatetags import extra_filters_jinja as template_filters
from askbot.importers.stackexchange import management as stackexchange#todo: may change
@@ -268,7 +267,7 @@ def ask(request):#view used to ask a new question
'form' : form,
'mandatory_tags': models.tag.get_mandatory_tags(),
'email_validation_faq_url':reverse('faq') + '#validate',
- 'category_tree_data': category_tree.get_data(),
+ 'category_tree_data': askbot_settings.CATEGORY_TREE,
'tag_names': list()#need to keep context in sync with edit_question for tag editor
}
data.update(context.get_for_tag_editor())
@@ -408,7 +407,7 @@ def edit_question(request, id):
'mandatory_tags': models.tag.get_mandatory_tags(),
'form' : form,
'tag_names': question.thread.get_tag_names(),
- 'category_tree_data': category_tree.get_data()
+ 'category_tree_data': askbot_settings.CATEGORY_TREE
}
data.update(context.get_for_tag_editor())
return render_into_skin('question_edit.html', data, request)