diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 67d8be7d..16cbe7ca 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -23,7 +23,7 @@ from reddit_base import RedditController from pylons.i18n import _ -from pylons import c, request, response +from pylons import c, request, response, g from pylons.controllers.util import etag_cache import hashlib @@ -202,6 +202,58 @@ def POST_verifyemail(self, res, code): c.user._commit() res._success() + @Json + @validate(VModhash(), + VSrCanBan('id'), + thing = VByName('id'), + ip = ValidIP(), + destination = VMoveURL('destination'), + reason = VComment('comment')) + def POST_move(self, res, thing, destination, reason, ip): + res._update('status_' + thing._fullname, innerHTML = '') + if res._chk_errors((errors.NO_URL, errors.BAD_URL), + thing._fullname): + res._focus("destination_url_" + thing._fullname) + return + if res._chk_error(errors.COMMENT_TOO_LONG, + thing._fullname): + res._focus("comment_replacement_" + thing._fullname) + return + if destination._id == thing.link_id: + c.errors.add(errors.ALREADY_MOVED) + res._chk_error(errors.ALREADY_MOVED, thing._fullname) + res._focus("destination_url_" + thing._fullname) + return + + currlink = Link._byID(thing.link_id) + currlink._incr('_descendant_karma', -(thing._descendant_karma + thing._ups - thing._downs)) + destination._incr('_descendant_karma', thing._descendant_karma + thing._ups - thing._downs) + if hasattr(thing, 'parent_id') and thing.parent_id is not None: + parent = Comment._byID(thing.parent_id) + parent.incr_descendant_karma([], -(thing._descendant_karma + thing._ups - thing._downs)) + else: + parent = None + + from r2.lib.comment_tree import lock_key, comments_key + + with g.make_lock(lock_key(thing.link_id)): + thing.recursive_move(currlink, destination, True) + + g.permacache.delete(comments_key(currlink._id)) + g.permacache.delete(comments_key(destination._id)) + + body = "A comment was moved to [here]({0}).\n\n".format(thing.make_anchored_permalink(destination)) + (reason if reason else '') + + comment, inbox_rel = Comment._new(c.user, + currlink, parent, body, + ip) + + + if g.write_query_queue: + queries.new_comment(comment, None) + + res._send_things([comment, thing]) + @Json @validate(VCaptcha(), VUser(), diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 5308a4b5..8a00a54f 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -177,7 +177,7 @@ def GET_comments(self, article, comment, context, sort, num_comments): context = context + 1 if context else 1, anchor = 'comments' ), - has_more_comments = hasattr(comment, 'parent_id') + has_more_comments = hasattr(comment, 'parent_id') and comment.parent_id ) displayPane.append(permamessage) diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 0d4de246..47c27478 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -377,6 +377,20 @@ def run(self, name): except NotFound: return name +class VMoveURL(VRequired): + def __init__(self, item, *a, **kw): + VRequired.__init__(self, item, errors.NO_URL, *a, **kw) + + def run(self, url): + if not url: + return self.error() + else: + link = Link._move_url(url) + if not link: + return self.error(errors.BAD_URL) + else: + return link + class VSubredditTitle(Validator): def run(self, title): if not title: diff --git a/r2/r2/lib/db/tdb_sql.py b/r2/r2/lib/db/tdb_sql.py index f360c7e4..4eae42fe 100644 --- a/r2/r2/lib/db/tdb_sql.py +++ b/r2/r2/lib/db/tdb_sql.py @@ -130,30 +130,50 @@ def get_rel_type_table(metadata): def get_thing_table(metadata, name): - table = sa.Table(settings.DB_APP_NAME + '_thing_' + name, metadata, - sa.Column('thing_id', BigInteger, primary_key = True), - sa.Column('ups', sa.Integer, default = 0, nullable = False), - sa.Column('downs', - sa.Integer, - default = 0, - nullable = False), - sa.Column('deleted', - sa.Boolean, - default = False, - nullable = False), - sa.Column('spam', - sa.Boolean, - default = False, - nullable = False), - sa.Column('date', - sa.DateTime(timezone = True), - default = sa.func.now(), - nullable = False)) - if name in ('comment', 'link'): - table.append_column(sa.Column('descendant_karma', - sa.Integer, - default = 0, - nullable = False)) + if name not in ('comment', 'link'): + table = sa.Table(settings.DB_APP_NAME + '_thing_' + name, metadata, + sa.Column('thing_id', BigInteger, primary_key = True), + sa.Column('ups', sa.Integer, default = 0, nullable = False), + sa.Column('downs', + sa.Integer, + default = 0, + nullable = False), + sa.Column('deleted', + sa.Boolean, + default = False, + nullable = False), + sa.Column('spam', + sa.Boolean, + default = False, + nullable = False), + sa.Column('date', + sa.DateTime(timezone = True), + default = sa.func.now(), + nullable = False)) + else: + table = sa.Table(settings.DB_APP_NAME + '_thing_' + name, metadata, + sa.Column('thing_id', BigInteger, primary_key = True), + sa.Column('ups', sa.Integer, default = 0, nullable = False), + sa.Column('downs', + sa.Integer, + default = 0, + nullable = False), + sa.Column('deleted', + sa.Boolean, + default = False, + nullable = False), + sa.Column('spam', + sa.Boolean, + default = False, + nullable = False), + sa.Column('date', + sa.DateTime(timezone = True), + default = sa.func.now(), + nullable = False), + sa.Column('descendant_karma', + sa.Integer, + default = 0, + nullable = False)) return table @@ -543,11 +563,19 @@ def get_thing(type_id, thing_id): #if single, only return one storage, otherwise make a dict res = {} if not single else None for row in r: - stor = storage(ups = row.ups, - downs = row.downs, - date = row.date, - deleted = row.deleted, - spam = row.spam) + if type_id in (types_name["link"].type_id, types_name["comment"].type_id): + stor = storage(ups = row.ups, + downs = row.downs, + date = row.date, + deleted = row.deleted, + spam = row.spam, + descendant_karma = row.descendant_karma) + else: + stor = storage(ups = row.ups, + downs = row.downs, + date = row.date, + deleted = row.deleted, + spam = row.spam) if single: res = stor else: diff --git a/r2/r2/lib/errors.py b/r2/r2/lib/errors.py index 681f2577..8fd01812 100644 --- a/r2/r2/lib/errors.py +++ b/r2/r2/lib/errors.py @@ -25,6 +25,7 @@ error_list = dict(( ('NO_URL', _('Url required')), + ('ALREADY_MOVED', _('This comment is already at that destination')), ('BAD_URL', _('You should check that url')), ('NO_TITLE', _('Title required')), ('TITLE_TOO_LONG', _('Title too long')), diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py index f5ed8621..f8187103 100644 --- a/r2/r2/lib/jsontemplates.py +++ b/r2/r2/lib/jsontemplates.py @@ -168,7 +168,10 @@ def thing_attr(self, thing, attr): return make_fullname(Link, thing.link_id) elif attr == "parent_id": try: - return make_fullname(Comment, thing.parent_id) + if thing.parent_id: + return make_fullname(Comment, thing.parent_id) + else: + return make_fullname(Link, thing.link_id) except AttributeError: return make_fullname(Link, thing.link_id) return ThingJsonTemplate.thing_attr(self, thing, attr) @@ -184,7 +187,10 @@ def rendered_data(self, wrapped): except AttributeError: parent_id = make_fullname(Link, wrapped.link_id) else: - parent_id = make_fullname(Comment, parent_id) + if parent_id: + parent_id = make_fullname(Comment, parent_id) + else: + parent_id = make_fullname(Link, wrapped.link_id) d = ThingJsonTemplate.rendered_data(self, wrapped) d.update(mass_part_render(wrapped, contentHTML = 'commentBody', contentTxt = 'commentText')) diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 82084c1a..3433908a 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -669,6 +669,7 @@ def __init__(self, link = None, comment = None, # link is a wrapped Link object self.link = self.link_listing.things[0] + self.movebox = MoveBox() link_title = ((self.link.title) if hasattr(self.link, 'title') else '') if comment: @@ -690,7 +691,7 @@ def __init__(self, link = None, comment = None, Reddit.__init__(self, title = title, body_class = 'post', robots = self.robots, *a, **kw) def content(self): - return self.content_stack(self.infobar, self.link_listing, self._content) + return self.content_stack(self.infobar, self.link_listing, self.movebox, self._content) def build_toolbars(self): return [] @@ -1042,6 +1043,11 @@ def __init__(self, link_name='', captcha=None, action = 'comment'): Wrapped.__init__(self, link_name = link_name, captcha = captcha, action = action) +class MoveBox(Wrapped): + """Used on LinkInfoPage to render the move thread form.""" + def __init__(self, link_name='', captcha=None): + Wrapped.__init__(self, link_name = link_name, captcha = captcha) + class CommentListing(Wrapped): """Comment heading and sort, limit options""" def __init__(self, content, num_comments, nav_menus = []): diff --git a/r2/r2/models/builder.py b/r2/r2/models/builder.py index 369b5aaf..725a75fc 100644 --- a/r2/r2/models/builder.py +++ b/r2/r2/models/builder.py @@ -455,11 +455,11 @@ def keep_item(self, item): class ToplevelCommentBuilder(UnbannedCommentBuilder): def keep_item(self, item): try: - item.parent_id + parent_id = item.parent_id except AttributeError: return True - else: - return False + + return parent_id is not None class ContextualCommentBuilder(CommentBuilderMixin, UnbannedCommentBuilder): def __init__(self, query, sr_ids, **kw): @@ -574,7 +574,7 @@ def get_items(self, num, nested = True, starting_depth = 0): top = self.comment dont_collapse.append(top._id) #add parents for context - while self.context > 0 and hasattr(top, 'parent_id'): + while self.context > 0 and hasattr(top, 'parent_id') and top.parent_id is not None: self.context -= 1 new_top = comment_dict[top.parent_id] comment_tree[new_top._id] = [top] @@ -657,7 +657,7 @@ def sort_candidates(): to_add = candidates.pop(0) direct_child = True #ignore top-level comments for now - if not hasattr(to_add, 'parent_id'): + if not hasattr(to_add, 'parent_id') or to_add.parent_id is not None: p_id = None else: #find the parent actually being displayed diff --git a/r2/r2/models/link.py b/r2/r2/models/link.py index 9e8661b5..eab6c472 100644 --- a/r2/r2/models/link.py +++ b/r2/r2/models/link.py @@ -83,6 +83,20 @@ def __init__(self, *a, **kw): def by_url_key(cls, url): return base_url(url.lower()).encode('utf8') + @classmethod + def _move_url(cls, url): + url_re = re.compile("(?:http://)?.*?(/r/.*?)?(/lw/.*?/.*)") + id_re = re.compile("/lw/(\w*)/.*") + matcher = url_re.match(url) + if not matcher: + return False + matcher = id_re.match(matcher.group(2)) + try: + link = Link._byID(int(matcher.group(1), 36)) + except NotFound: return None + if not link._loaded: link._load() + return link + @classmethod def _by_url(cls, url, sr): from subreddit import Default @@ -694,6 +708,19 @@ def prev_link(self): q = self._link_nav_query(sort = operators.desc('_date')) return self._link_for_query(q) + def __init__(self, ups = 0, downs = 0, date = None, deleted = False, + spam = False, id = None, descendant_karma = 0, **attrs): + + Thing.__init__(self, ups, downs, date, deleted, spam, id, **attrs) + + with self.safe_set_attr: + self._descendant_karma = descendant_karma + + @classmethod + def _build(cls, id, bases): + return cls(bases.ups, bases.downs, bases.date, + bases.deleted, bases.spam, id, bases.descendant_karma) + def _commit(self, *a, **kw): """Detect when we need to invalidate the sidebar recent posts. @@ -893,8 +920,7 @@ class Comment(Thing, Printable): banned_before_moderator = False, is_html = False, retracted = False, - show_response_to = False, - _descendant_karma = 0) + show_response_to = False) def _markdown(self): pass @@ -941,6 +967,37 @@ def _new(cls, author, link, parent, body, ip, spam = False, date = None): return (comment, inbox_rel) + @classmethod + def _move(cls, comment, currlink, newlink, top=False): + author = Account._byID(comment.author_id) + + if top: + if hasattr(comment, 'parent_id'): + comment.parent_id = None + comment.link_id = newlink._id + comment.sr_id = newlink.sr_id + + if not top: + parent = Comment._byID(comment.parent_id) + comment.parent_permalink = parent.make_anchored_permalink(Link._byID(parent.link_id), Subreddit._byID(parent.sr_id)) + comment.permalink = comment.make_permalink_slow() + comment._commit() + + currlink._incr('num_comments', -1) + newlink._incr('num_comments', 1) + + #clear that chache + clear_memo('builder.link_comments2', newlink._id) + clear_memo('builder.link_comments2', currlink._id) + + # flag search indexer that something has changed + tc.changed(comment) + + #update last modified + set_last_modified(author, 'overview') + set_last_modified(author, 'commented') + set_last_modified(newlink, 'comments') + def try_parent(self, func, default): """ If this comment has a parent, return `func(parent)`; otherwise @@ -1027,6 +1084,20 @@ def has_children(self): child = list(q) return len(child)>0 + def recursive_move(self, origin, destination, top=False): + """Self is the current comment. Origin is the link we're + moving the comment from. Destination is the link we're + moving the comment to.""" + children = Comment._query(Comment.c.parent_id == self._id) + + Comment._move(self, origin, destination, top) + + if not children: + pass + else: + for child in children: + child.recursive_move(origin, destination) + def can_delete(self): if not self._loaded: self._load() @@ -1069,8 +1140,7 @@ def reply_costs_karma(self): return self.try_parent(lambda p: p.reply_costs_karma, False) def incr_descendant_karma(self, comments, amount): - - old_val = getattr(self, '_descendant_karma') + old_val = self._get_item(self._type_id, self._id).descendant_karma comments.append(self._id) @@ -1169,7 +1239,7 @@ def add_props(cls, user, wrapped): item.link = links.get(item.link_id) if not hasattr(item, 'subreddit'): item.subreddit = item.subreddit_slow - if hasattr(item, 'parent_id'): + if hasattr(item, 'parent_id') and item.parent_id: parent = Comment._byID(item.parent_id, data=True) parent_author = Account._byID(parent.author_id, data=True) item.parent_author = parent_author @@ -1212,6 +1282,19 @@ def add_props(cls, user, wrapped): item.permalink = item.make_permalink(item.link, item.subreddit) item.can_be_deleted = item.can_delete() + def __init__(self, ups = 0, downs = 0, date = None, deleted = False, + spam = False, id = None, descendant_karma = 0, **attrs): + + Thing.__init__(self, ups, downs, date, deleted, spam, id, **attrs) + + with self.safe_set_attr: + self._descendant_karma = descendant_karma + + @classmethod + def _build(cls, id, bases): + return cls(bases.ups, bases.downs, bases.date, + bases.deleted, bases.spam, id, bases.descendant_karma) + def _commit(self, *a, **kw): """Detect when we need to invalidate the sidebar recent comments. diff --git a/r2/r2/public/static/comments.js b/r2/r2/public/static/comments.js index 1299fd8f..4e57499f 100644 --- a/r2/r2/public/static/comments.js +++ b/r2/r2/public/static/comments.js @@ -107,6 +107,29 @@ Comment.prototype.show_editor = function(listing, where, text) { return edit_box; }; +Comment.prototype.show_move = function(listing, where, text) { + var edit_box = this.cloneAndReIDNodeOnce("samplemove"); + if (edit_box.parentNode != listing.listing) { + if (edit_box.parentNode) { + edit_box.parentNode.removeChild(edit_box); + } + listing.insert_node_before(edit_box, where); + } + else if (edit_box.parentNode.firstChild != edit_box) { + var p = edit_box.parentNode; + p.removeChild(edit_box); + p.insertBefore(edit_box, p.firstChild); + } + var box = this.$parent("comment_replacement"); + clearTitle(box); + box.value = text; + box.setAttribute("data-orig-value", text); + show(edit_box); + BeforeUnload.bind(Comment.checkModified, this._id); + return edit_box; +}; + + Comment.prototype.edit = function() { this.show_editor(this.parent_listing(), this.row, this.text); this.$parent("commentform").replace.value = "yes"; @@ -145,6 +168,14 @@ Comment.prototype.reply = function (showFlamebaitOverlay) { this.$parent("comment_reply").focus(); }; + +Comment.prototype.move = function() { + this.show_move(this.parent_listing(), this.row, ''); + this.$parent("moveform").replace.value = ""; + this.$parent("destination_url").focus(); + this.hide(); +}; + Comment.prototype.cancel = function() { var edit_box = this.cloneAndReIDNodeOnce("samplecomment"); hide(edit_box); @@ -152,6 +183,13 @@ Comment.prototype.cancel = function() { this.show(); }; +Comment.prototype.cancelmove = function() { + var edit_box = this.cloneAndReIDNodeOnce("samplemove"); + hide(edit_box); + this.show(); +}; + + Comment.comment = function(r, context) { var id = r.id; var parent_id = r.parent; @@ -191,6 +229,14 @@ Comment.editcomment = function(r, context) { com.show(); }; +Comment.move = function(r, s, context) { + var com = new Comment(s.id, context); + com.get('body').innerHTML = unsafe(r.contentHTML); + com.get('edit_body').innerHTML = unsafe(r.contentTxt); + com.cancelmove(); + com.show(); +}; + Comment.submitballot = function(r) { var com = new Comment(r.id); com.get('body').innerHTML = unsafe(r.contentHTML); @@ -276,6 +322,9 @@ function cancelReply(canceler) { new Comment(_id(canceler), Thing.getThingRow(canceler)).cancel(); }; +function cancelMove(canceler) { + new Comment(_id(canceler), Thing.getThingRow(canceler)).cancelmove(); +}; function reply(id, link, showFlamebaitOverlay) { if (logged) { @@ -286,6 +335,16 @@ function reply(id, link, showFlamebaitOverlay) { } }; +function move(id, link) { + if (logged) { + var com = new Comment(id, Thing.getThingRow(link)).move(); + } + else { + showcover(true, 'reply_' + id); + } +}; + + function chkcomment(form) { if(checkInProgress(form)) { var r = confirm("Request still in progress\n(click Cancel to attempt to stop the request)"); @@ -314,6 +373,34 @@ function chkcomment(form) { }); }; +function chkmove(form) { + if(checkInProgress(form)) { + var r = confirm("Request still in progress\n(click Cancel to attempt to stop the request)"); + if (r==false) + tagInProgress(form, false); + return false; + } + + var action = 'move'; + var context = Thing.getThingRow(form); + + tagInProgress(form, true); + + function cleanup_func(res_obj) { + tagInProgress(form, false); + + var obj = res_obj && res_obj.response && res_obj.response.object; + if (obj && obj.length) + for (var o = 0, ol = obj.length; o < ol; o += 2) + Comment[action](obj[o].data, obj[o+1].data, context); + } + + return post_form(form, action, null, null, true, null, { + handle_obj: false, + cleanup_func: cleanup_func + }); +}; + function tagInProgress(form, inProgress) { if (inProgress) form.addClassName("inprogress"); diff --git a/r2/r2/public/static/lesswrong.css b/r2/r2/public/static/lesswrong.css index 4b8fc211..1191b911 100644 --- a/r2/r2/public/static/lesswrong.css +++ b/r2/r2/public/static/lesswrong.css @@ -183,6 +183,62 @@ div.inline-listing a:hover { text-decoration: none; } +.commentreplacement { + clear: both; + margin: 10px 15px 30px 0; +} +.commentreplacement textarea, .commentreplacement input { + border-color: #e9e9e9; + -webkit-box-shadow: inset 1px 1px 5px #dbdbdb; + -moz-box-shadow: inset 1px 1px 5px #dbdbdb; + box-shadow: inset 1px 1px 5px #dbdbdb; + margin: 0 0 10px; + width: 100%; +} +.commentreplacement .buttons { + float: left; +} +.commentreplacement .buttons button, .commentreplacement .help-toggle button, .poll-voting-area button { + background: #f0eeee; /* Old browsers */ + background: -moz-linear-gradient(top, #f0eeee 0%, #c1c1c1 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#f0eeee), color-stop(100%,#c1c1c1)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #f0eeee 0%,#c1c1c1 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #f0eeee 0%,#c1c1c1 100%); /* Opera11.10+ */ + background: -ms-linear-gradient(top, #f0eeee 0%,#c1c1c1 100%); /* IE10+ */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f0eeee', endColorstr='#c1c1c1',GradientType=0 ); /* IE6-9 */ + background: linear-gradient(top, #f0eeee 0%,#c1c1c1 100%); /* W3C */ + border-color: #c6c6c6; + color: #666666; + cursor: pointer; + font-weight: bold; + padding: 5px 30px; +} +.commentreplacement .help-toggle button { + margin-right: -6px; +} + +.commentreplacement table.help { + clear: both; + margin: 5px 0 0 1px; + width: 480px; + border-collapse: collapse; +} +.commentreplacement .help, +.commentreplacement .help td, +.commentreplacement .help tr { + border: 1px solid #C0C0C0; + padding: 4px; + margin: 0px; +} +.commentreplacement .help-toggle { + float: right; +} +.commentreplacement .help-toggle a { + color: #538D4D; + font-weight: bold; + text-decoration: none; +} + #comment-controls label { padding-right: 5px; margin-left: 1em; diff --git a/r2/r2/public/static/main.css b/r2/r2/public/static/main.css index 7533ad10..6a3691f7 100644 --- a/r2/r2/public/static/main.css +++ b/r2/r2/public/static/main.css @@ -854,7 +854,8 @@ div.comment-links ul li.retract a.yes, div.comment-links ul li.retract a.no { /* Temporary styling for text buttons until we have icons */ div.comment-links ul li.delete a, div.comment-links ul li.unban a, -div.comment-links ul li.ban a, div.comment-links ul li.ignore a { +div.comment-links ul li.ban a, div.comment-links ul li.ignore a, +div.comment-links ul li.move a { height: 20px; padding-top: 4px; } diff --git a/r2/r2/templates/comment.html b/r2/r2/templates/comment.html index c25053df..1e534f0e 100644 --- a/r2/r2/templates/comment.html +++ b/r2/r2/templates/comment.html @@ -164,6 +164,10 @@ <% allow_delete = thing.can_be_deleted %> ${parent.delete_or_report_buttons(allow_delete,False,True)} ${parent.buttons()} + %if thing.can_ban: +