From 77e51a304d1b4034614d75c5bf4c07b216400a42 Mon Sep 17 00:00:00 2001 From: spez Date: Thu, 18 Jun 2009 10:55:14 -0700 Subject: [PATCH] A new submit page. The ability to submit to any subreddit from the regular submit page. The ability to add text to a self-post. Improved comments pages. Support for HD Youtube videos. Numerous bug-fixes. --- r2/r2/config/environment.py | 4 +- r2/r2/controllers/api.py | 267 ++++++----- r2/r2/controllers/errors.py | 37 +- r2/r2/controllers/feedback.py | 33 +- r2/r2/controllers/front.py | 17 +- r2/r2/controllers/validator/validator.py | 126 ++--- r2/r2/lib/emailer.py | 1 + r2/r2/lib/filters.py | 8 +- r2/r2/lib/jsonresponse.py | 105 +++-- r2/r2/lib/jsontemplates.py | 11 +- r2/r2/lib/menus.py | 38 +- r2/r2/lib/pages/pages.py | 216 +++++++-- r2/r2/lib/scraper.py | 2 +- r2/r2/lib/strings.py | 3 + r2/r2/lib/subreddit_search.py | 46 ++ r2/r2/lib/template_helpers.py | 10 +- r2/r2/lib/wrapped.py | 24 + r2/r2/models/builder.py | 8 + r2/r2/models/link.py | 47 +- r2/r2/public/static/blog-collapsed-hover.png | Bin 0 -> 452 bytes r2/r2/public/static/blog-collapsed.png | Bin 0 -> 428 bytes r2/r2/public/static/blog-expanded-hover.png | Bin 0 -> 440 bytes r2/r2/public/static/blog-expanded.png | Bin 0 -> 411 bytes r2/r2/public/static/css/reddit.css | 445 ++++++++++++++---- r2/r2/public/static/js/jquery.reddit.js | 16 +- r2/r2/public/static/js/reddit.js | 392 ++++++++++++--- r2/r2/public/static/poll-collapsed-hover.png | Bin 0 -> 230 bytes r2/r2/public/static/poll-collapsed.png | Bin 0 -> 228 bytes r2/r2/public/static/poll-expanded-hover.png | Bin 0 -> 220 bytes r2/r2/public/static/poll-expanded.png | Bin 0 -> 217 bytes r2/r2/public/static/rightarrow.png | Bin 0 -> 183 bytes r2/r2/public/static/throbber.gif | Bin 0 -> 1100 bytes r2/r2/public/static/vid-collapsed-hover.png | Bin 0 -> 454 bytes r2/r2/public/static/vid-collapsed.png | Bin 0 -> 376 bytes r2/r2/public/static/vid-expanded-hover.png | Bin 0 -> 442 bytes r2/r2/public/static/vid-expanded.png | Bin 0 -> 356 bytes r2/r2/templates/captcha.html | 45 +- r2/r2/templates/comment.html | 23 +- r2/r2/templates/comment_skeleton.html | 17 +- r2/r2/templates/commentreplybox.html | 90 ---- r2/r2/templates/commentreplybox.htmllite | 22 - r2/r2/templates/commentreplybox.xml | 22 - r2/r2/templates/createsubreddit.html | 16 +- r2/r2/templates/feedback.html | 122 ++--- r2/r2/templates/link.html | 69 ++- r2/r2/templates/linkinfobar.html | 2 +- r2/r2/templates/login.html | 18 +- r2/r2/templates/loginformwide.html | 2 +- r2/r2/templates/message.html | 12 +- r2/r2/templates/messagecompose.html | 91 ++-- r2/r2/templates/morechildren.html | 3 - r2/r2/templates/morerecursion.html | 3 - r2/r2/templates/navbutton.html | 2 +- r2/r2/templates/navmenu.html | 3 +- r2/r2/templates/newlink.html | 179 +++---- r2/r2/templates/panestack.html | 4 + r2/r2/templates/password.html | 39 +- r2/r2/templates/permalinkmessage.html | 13 +- r2/r2/templates/prefdelete.html | 28 +- r2/r2/templates/prefupdate.html | 60 ++- r2/r2/templates/printable.html | 5 +- r2/r2/templates/promotelinkform.html | 15 +- r2/r2/templates/redditheader.html | 6 + r2/r2/templates/resetpassword.html | 68 +-- .../{commentreplybox.mobile => selftext.html} | 13 +- r2/r2/templates/sharelink.html | 21 +- r2/r2/templates/subredditstylesheet.html | 2 +- r2/r2/templates/tabbedpane.html | 14 + r2/r2/templates/userlist.html | 2 +- r2/r2/templates/usertext.html | 145 ++++++ r2/r2/templates/utils.html | 29 +- 71 files changed, 1949 insertions(+), 1112 deletions(-) create mode 100644 r2/r2/lib/subreddit_search.py create mode 100644 r2/r2/public/static/blog-collapsed-hover.png create mode 100644 r2/r2/public/static/blog-collapsed.png create mode 100644 r2/r2/public/static/blog-expanded-hover.png create mode 100644 r2/r2/public/static/blog-expanded.png create mode 100644 r2/r2/public/static/poll-collapsed-hover.png create mode 100644 r2/r2/public/static/poll-collapsed.png create mode 100644 r2/r2/public/static/poll-expanded-hover.png create mode 100644 r2/r2/public/static/poll-expanded.png create mode 100644 r2/r2/public/static/rightarrow.png create mode 100644 r2/r2/public/static/throbber.gif create mode 100644 r2/r2/public/static/vid-collapsed-hover.png create mode 100644 r2/r2/public/static/vid-collapsed.png create mode 100644 r2/r2/public/static/vid-expanded-hover.png create mode 100644 r2/r2/public/static/vid-expanded.png delete mode 100644 r2/r2/templates/commentreplybox.html delete mode 100644 r2/r2/templates/commentreplybox.htmllite delete mode 100644 r2/r2/templates/commentreplybox.xml rename r2/r2/templates/{commentreplybox.mobile => selftext.html} (81%) create mode 100644 r2/r2/templates/tabbedpane.html create mode 100644 r2/r2/templates/usertext.html diff --git a/r2/r2/config/environment.py b/r2/r2/config/environment.py index e82b48915..1001ce881 100644 --- a/r2/r2/config/environment.py +++ b/r2/r2/config/environment.py @@ -61,9 +61,9 @@ def load_environment(global_conf={}, app_conf={}): #tmpl_options['myghty.escapes'] = dict(l=webhelpers.auto_link, s=webhelpers.simple_format) tmpl_options = config['buffet.template_options'] - tmpl_options['mako.default_filters'] = ["websafe"] + tmpl_options['mako.default_filters'] = ["mako_websafe"] tmpl_options['mako.imports'] = \ - ["from r2.lib.filters import websafe, unsafe", + ["from r2.lib.filters import websafe, unsafe, mako_websafe", "from pylons import c, g, request", "from pylons.i18n import _, ungettext"] diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 59e543d92..3262a1fe3 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -43,11 +43,12 @@ from r2.lib.menus import CommentSortMenu from r2.lib.normalized_hot import expire_hot from r2.lib.captcha import get_iden from r2.lib.strings import strings -from r2.lib.filters import _force_unicode, websafe_json, spaceCompress +from r2.lib.filters import _force_unicode, websafe_json, websafe, spaceCompress from r2.lib.db import queries from r2.lib.media import force_thumbnail, thumbnail_url from r2.lib.comment_tree import add_comment, delete_comment from r2.lib import tracking, sup, cssfilter, emailer +from r2.lib.subreddit_search import search_reddits from simplejson import dumps @@ -89,28 +90,24 @@ class ApiController(RedditController): @validatedForm(VCaptcha(), name=VRequired('name', errors.NO_NAME), - email=VRequired('email', errors.NO_EMAIL), - replyto = ValidEmails("replyto", num = 1), + email=ValidEmails('email', num = 1), reason = VOneOf('reason', ('ad_inq', 'feedback')), - message=VRequired('message', errors.NO_MESSAGE), + message=VRequired('text', errors.NO_TEXT), ) - def POST_feedback(self, form, jquery, name, email, - replyto, reason, message): + def POST_feedback(self, form, jquery, name, email, reason, message): if not (form.has_errors('name', errors.NO_NAME) or - form.has_errors('email', errors.NO_EMAIL) or - (request.POST.get("replyto") and replyto is None and - form.has_errors("replyto", errors.BAD_EMAILS)) or - form.has_errors('personal', errors.NO_MESSAGE) or - form.chk_captcha(errors.BAD_CAPTCHA)): + form.has_errors('email', errors.BAD_EMAILS) or + form.has_errors('text', errors.NO_TEXT) or + form.has_errors('captcha', errors.BAD_CAPTCHA)): + if reason != 'ad_inq': - emailer.feedback_email(email, message, name = name or '', - reply_to = replyto or '') + emailer.feedback_email(email, message, name, reply_to = '') else: - emailer.ad_inq_email(email, message, name = name or '', - reply_to = replyto or '') + emailer.ad_inq_email(email, message, name, reply_to = '') + form.set_html(".status", _("thanks for your message! " "you should hear back from us shortly.")) - form.set_inputs(personal = "", captcha = "") + form.set_inputs(text = "", captcha = "") POST_ad_inq = POST_feedback @@ -121,7 +118,7 @@ class ApiController(RedditController): ip = ValidIP(), to = VExistingUname('to'), subject = VRequired('subject', errors.NO_SUBJECT), - body = VMessage('message')) + body = VMessage(['text', 'message'])) def POST_compose(self, form, jquery, to, subject, body, ip): """ handles message composition under /message/compose. @@ -129,16 +126,15 @@ class ApiController(RedditController): if not (form.has_errors("to", errors.USER_DOESNT_EXIST, errors.NO_USER) or form.has_errors("subject", errors.NO_SUBJECT) or - form.has_errors("message", errors.NO_MSG_BODY, - errors.COMMENT_TOO_LONG) or - form.chk_captcha(errors.BAD_CAPTCHA)): + form.has_errors("text", errors.NO_TEXT, errors.TOO_LONG) or + form.has_errors("captcha", errors.BAD_CAPTCHA)): spam = (c.user._spam or errors.BANNED_IP in c.errors or errors.BANNED_DOMAIN in c.errors) m, inbox_rel = Message._new(c.user, to, subject, body, ip, spam) - form.set_html(".success", _("your message has been delivered")) - form.set_inputs(to = "", subject = "", message = "") + form.set_html(".status", _("your message has been delivered")) + form.set_inputs(to = "", subject = "", text = "", captcha="") if g.write_query_queue: queries.new_message(m, inbox_rel) @@ -155,39 +151,57 @@ class ApiController(RedditController): url = VUrl(['url', 'sr']), title = VTitle('title'), save = VBoolean('save'), - then = VOneOf('then', ('tb', 'comments'), default='comments') - ) - def POST_submit(self, form, jquery, url, title, save, sr, ip, then): + selftext = VSelfText('text'), + kind = VOneOf('kind', ['link', 'self', 'poll']), + then = VOneOf('then', ('tb', 'comments'), default='comments')) + def POST_submit(self, form, jquery, url, selftext, kind, title, save, + sr, ip, then): + #backwards compatability + if url == 'self': + kind = 'self' + if isinstance(url, (unicode, str)): + # VUrl may have replaced 'url' by adding 'http://' form.set_inputs(url = url) - - should_ratelimit = sr.should_ratelimit(c.user, 'link') - #remove the ratelimit error if the user's karma is high - if not should_ratelimit: - c.errors.remove(errors.RATELIMIT) + if not kind: + # this should only happen if somebody is trying to post + # links in some automated manner outside of the regular + # submission page, and hasn't updated their script + return - # check for no url, or clear that error field on return - if form.has_errors("url", errors.NO_URL, errors.BAD_URL): - pass - elif form.has_errors("url", errors.ALREADY_SUB): - form.redirect(url[0].already_submitted_link) - # check for title, otherwise look it up and return it - elif form.has_errors("title", errors.NO_TITLE): - # try to fetch the title - title = get_title(url) - if title: - # note: focus first, since it clears the form - form.set_inputs(title = title) - # wipe out the no title error - form.clear_errors(errors.NO_TITLE) - return + if form.has_errors('sr', errors.SUBREDDIT_NOEXIST, + errors.SUBREDDIT_REQUIRED): + # checking to get the error set in the form, but we can't + # check for rate-limiting if there's no subreddit + return + else: + should_ratelimit = sr.should_ratelimit(c.user, 'link') + #remove the ratelimit error if the user's karma is high + if not should_ratelimit: + c.errors.remove((errors.RATELIMIT, 'ratelimit')) - elif (form.has_errors("title", errors.TITLE_TOO_LONG) or - form.chk_captcha(errors.BAD_CAPTCHA, errors.RATELIMIT)): + if kind == 'link': + # check for no url, or clear that error field on return + if form.has_errors("url", errors.NO_URL, errors.BAD_URL): + pass + elif form.has_errors("url", errors.ALREADY_SUB): + form.redirect(url[0].already_submitted_link) + # check for title, otherwise look it up and return it + elif form.has_errors("title", errors.NO_TEXT): + pass + + elif kind == 'self' and form.has_errors('text', errors.TOO_LONG): pass - if form.has_error() or not title: return + if form.has_errors("title", errors.TOO_LONG, errors.NO_TEXT): + pass + + if form.has_errors('ratelimit', errors.RATELIMIT): + pass + + if form.has_error() or not title: + return # check whether this is spam: spam = (c.user._spam or @@ -195,12 +209,17 @@ class ApiController(RedditController): errors.BANNED_DOMAIN in c.errors) # well, nothing left to do but submit it - l = Link._submit(request.post.title, url, c.user, sr, ip, spam) - if url.lower() == 'self': + l = Link._submit(request.post.title, url if kind == 'link' else 'self', + c.user, sr, ip, spam) + + if kind == 'self': l.url = l.make_permalink_slow() l.is_self = True + l.selftext = selftext + l._commit() l.set_url_cache() + v = Vote.vote(c.user, l, True, ip, spam) if save: r = l._save(c.user) @@ -240,6 +259,18 @@ class ApiController(RedditController): form.redirect(path) + + @validatedForm(VUser(), + url = VSanitizedUrl(['url'])) + def POST_fetch_title(self, form, jquery, url): + if url: + title = get_title(url) + if title: + form.set_inputs(title = title) + form.set_html(".title-status", ""); + else: + form.set_html(".title-status", _("no title found")) + def _login(self, form, user, dest='', rem = None): """ AJAX login handler, used by both login and register to set the @@ -282,7 +313,9 @@ class ApiController(RedditController): form.has_errors("email", errors.BAD_EMAILS) or form.has_errors("passwd", errors.BAD_PASSWORD) or form.has_errors("passwd2", errors.BAD_PASSWORD_MATCH) or - form.chk_captcha(errors.BAD_CAPTCHA,errors.RATELIMIT)): + form.has_errors('ratelimit', errors.RATELIMIT) or + form.has_errors('captcha', errors.BAD_CAPTCHA)): + user = register(name, password) VRatelimit.ratelimit(rate_ip = True, prefix = "rate_register_") @@ -310,7 +343,6 @@ class ApiController(RedditController): self._subscribe(sr, sub) self._login(form, user, dest, rem) - @noresponse(VUser(), VModhash(), @@ -429,7 +461,6 @@ class ApiController(RedditController): """ # password is required to proceed if form.has_errors("curpass", errors.WRONG_PASSWORD): - form.set_input(curpass = "") return # check if the email is valid. If one is given and it is @@ -444,7 +475,7 @@ class ApiController(RedditController): updated = True # change password - if (password and + if (password and not (form.has_errors("newpass", errors.BAD_PASSWORD) or form.has_errors("verpass", errors.BAD_PASSWORD_MATCH))): change_password(c.user, password) @@ -454,6 +485,7 @@ class ApiController(RedditController): else: form.set_html('.status', _('your password has been updated')) + form.set_inputs(curpass = "", newpass = "", verpass = "") # the password has changed, so the user's cookie has been # invalidated. drop a new cookie. self.login(c.user) @@ -509,32 +541,41 @@ class ApiController(RedditController): Report.new(c.user, thing) - @validatedForm(VUser(), VModhash(), - comment = VByNameIfAuthor('parent'), - body = VComment('comment')) - def POST_editcomment(self, form, jquery, comment, body): - - if not form.has_errors("comment", - errors.BAD_COMMENT, errors.COMMENT_TOO_LONG, + @validatedForm(VUser(), + VModhash(), + item = VByNameIfAuthor('thing_id'), + text = VComment('text')) + def POST_editusertext(self, form, jquery, item, text): + if not form.has_errors("text", + errors.NO_TEXT, errors.TOO_LONG, errors.NOT_AUTHOR): - comment.body = body - comment.editted = True - comment._commit() - jquery.replace_things(comment, True, True) + if isinstance(item, Comment): + kind = 'comment' + item.body = text + elif isinstance(item, Link): + kind = 'link' + item.selftext = text - # flag search indexer that something has changed - tc.changed(comment) + item.editted = True + item._commit() + tc.changed(item) + if kind == 'link': + set_last_modified(item, 'comments') + + wrapper = make_wrapper(ListingController.builder_wrapper, + expand_children = True) + jquery(".content").replace_things(item, True, True, wrap = wrapper) @validatedForm(VUser(), - VModhash(), - VRatelimit(rate_user = True, rate_ip = True, - prefix = "rate_comment_"), - ip = ValidIP(), - parent = VSubmitParent('parent'), - comment = VComment('comment')) + VModhash(), + VRatelimit(rate_user = True, rate_ip = True, + prefix = "rate_comment_"), + ip = ValidIP(), + parent = VSubmitParent(['thing_id', 'parent']), + comment = VComment(['text', 'comment'])) def POST_comment(self, commentform, jquery, parent, comment, ip): should_ratelimit = True #check the parent type here cause we need that for the @@ -559,14 +600,14 @@ class ApiController(RedditController): if not should_ratelimit: c.errors.remove(errors.RATELIMIT) - if (not commentform.has_errors("comment", - errors.BAD_COMMENT, - errors.COMMENT_TOO_LONG, + if (not commentform.has_errors("text", + errors.NO_TEXT, + errors.TOO_LONG, errors.RATELIMIT) and not commentform.has_errors("parent", errors.DELETED_COMMENT)): spam = (c.user._spam or errors.BANNED_IP in c.errors) - + if is_message: to = Account._byID(parent.author_id) subject = parent.subject @@ -607,7 +648,6 @@ class ApiController(RedditController): # remove any null listings that may be present jquery("#noresults").hide() - #update the queries if g.write_query_queue: if is_message: @@ -627,10 +667,10 @@ class ApiController(RedditController): VCaptcha(), VRatelimit(rate_user = True, rate_ip = True, prefix = "rate_share_"), - share_from = VLength('share_from', length = 100), + share_from = VLength('share_from', max_length = 100), emails = ValidEmails("share_to"), reply_to = ValidEmails("replyto", num = 1), - message = VLength("message", length = 1000), + message = VLength("message", max_length = 1000), thing = VByName('parent')) def POST_share(self, shareform, jquery, emails, thing, share_from, reply_to, message): @@ -641,14 +681,11 @@ class ApiController(RedditController): if not should_ratelimit: c.errors.remove(errors.RATELIMIT) - if emails and (errors.NO_EMAILS in c.errors): - c.errors.remove(errors.NO_EMAILS) - - # share_from and messages share a comment_too_long error. + # share_from and messages share a too_long error. # finding an error on one necessitates hiding the other error - if shareform.has_errors("share_from", errors.COMMENT_TOO_LONG): + if shareform.has_errors("share_from", errors.TOO_LONG): shareform.find(".message-errors").children().hide() - elif shareform.has_errors("message", errors.COMMENT_TOO_LONG): + elif shareform.has_errors("message", errors.TOO_LONG): shareform.find(".share-form-errors").children().hide() # reply_to and share_to also share errors... elif shareform.has_errors("share_to", errors.BAD_EMAILS, @@ -656,11 +693,12 @@ class ApiController(RedditController): errors.TOO_MANY_EMAILS): shareform.find(".reply-to-errors").children().hide() elif shareform.has_errors("replyto", errors.BAD_EMAILS, - errors.NO_EMAILS, errors.TOO_MANY_EMAILS): shareform.find(".share-to-errors").children().hide() # lastly, check the captcha. - elif shareform.chk_captcha(errors.BAD_CAPTCHA, errors.RATELIMIT): + elif shareform.has_errors("captcha", errors.BAD_CAPTCHA): + pass + elif shareform.has_errors("ratelimit", errors.RATELIMIT): pass else: c.user.add_share_emails(emails) @@ -845,7 +883,7 @@ class ApiController(RedditController): @validate(VSrModerator(), VModhash(), - file = VLength('file', length=1024*500), + file = VLength('file', max_length=1024*500), name = VCssName("name"), header = nop('header')) def POST_upload_sr_img(self, file, header, name): @@ -910,9 +948,9 @@ class ApiController(RedditController): prefix = 'create_reddit_'), sr = VByName('sr'), name = VSubredditName("name"), - title = VSubredditTitle("title"), + title = VLength("title", max_length = 100), domain = VCnameDomain("domain"), - description = VSubredditDesc("description"), + description = VLength("description", max_length = 500), lang = VLang("lang"), over_18 = VBoolean('over_18'), show_media = VBoolean('show_media'), @@ -946,12 +984,12 @@ class ApiController(RedditController): elif not sr and form.has_errors("name", errors.SUBREDDIT_EXISTS, errors.BAD_SR_NAME): form.find('#example_name').hide() - elif form.has_errors('title', errors.NO_TITLE, errors.TITLE_TOO_LONG): + elif form.has_errors('title', errors.NO_TEXT, errors.TOO_LONG): form.find('#example_title').hide() elif form.has_errors('domain', errors.BAD_CNAME, errors.USED_CNAME): form.find('#example_domain').hide() elif (form.has_errors(None, errors.INVALID_OPTION) or - form.has_errors('description', errors.DESC_TOO_LONG)): + form.has_errors('description', errors.TOO_LONG)): pass #creating a new reddit elif not sr: @@ -1141,8 +1179,11 @@ class ApiController(RedditController): @validatedForm(user = VUserWithEmail('name')) def POST_password(self, form, jquery, user): - if not form.has_errors('name', errors.USER_DOESNT_EXIST, - errors.NO_EMAIL_FOR_USER): + if form.has_errors('name', errors.USER_DOESNT_EXIST): + return + elif form.has_errors('name', errors.NO_EMAIL_FOR_USER): + return + else: emailer.password_email(user) form.set_html(".status", _("an email will be sent to that account's address shortly")) @@ -1150,16 +1191,17 @@ class ApiController(RedditController): @validatedForm(cache_evt = VCacheKey('reset', ('key', 'name')), password = VPassword(['passwd', 'passwd2'])) def POST_resetpassword(self, form, jquery, cache_evt, password): - if errors.BAD_USERNAME in c.errors: - # clear reset event -- the user failed to know their user name + if form.has_errors('name', errors.EXPIRED): cache_evt.clear() - return form.redirect('/password') - elif (not form.has_errors('passwd', errors.BAD_PASSWORD) and - not form.has_errors('passwd2', errors.BAD_PASSWORD_MATCH) and - cache_evt.user): + form.redirect('/password') + elif form.has_errors('passwd', errors.BAD_PASSWORD): + pass + elif form.has_errors('passwd2', errors.BAD_PASSWORD_MATCH): + pass + elif cache_evt.user: # successfully entered user name and valid new password change_password(cache_evt.user, password) - self._login(jquery, cache_evt.user, '/resetpassword') + self._login(jquery, cache_evt.user, '/') cache_evt.clear() @@ -1239,7 +1281,7 @@ class ApiController(RedditController): l.num_margin = 0 l.mid_margin = 0 - jquery.replace_things(l, stubs = True) + jquery(".content").replace_things(l, stubs = True) if show: jquery('.organic-listing .link:visible').hide() @@ -1289,7 +1331,7 @@ class ApiController(RedditController): # we're allowing mutliple submissions, so we really just # want the URL url = url[0].url - if form.has_errors('title', errors.NO_TITLE): + if form.has_errors('title', errors.NO_TEXT, errors.TOO_LONG): pass elif form.has_errors('url', errors.NO_URL, errors.BAD_URL): pass @@ -1345,7 +1387,7 @@ class ApiController(RedditController): @validate(VSponsor(), link = VByName('link_id'), - file = VLength('file',500*1024)) + file = VLength('file', 500*1024)) def POST_link_thumb(self, link=None, file=None): errors = dict(BAD_CSS_NAME = "", IMAGE_ERROR = "") try: @@ -1407,3 +1449,18 @@ class ApiController(RedditController): ip = request.ip) ) + @json_validate(query = nop('query')) + def POST_search_reddit_names(self, query): + names = [] + if query: + names = search_reddits(query) + + return {'names': names} + + @validate(link = VByName('link_id', thing_cls = Link)) + def POST_expando(self, link): + if not link: + abort(404, 'not found') + + wrapped = IDBuilder([link._fullname]).get_items()[0][0] + return spaceCompress(websafe(wrapped.link_child.content())) diff --git a/r2/r2/controllers/errors.py b/r2/r2/controllers/errors.py index 712ddd5f6..d46972504 100644 --- a/r2/r2/controllers/errors.py +++ b/r2/r2/controllers/errors.py @@ -25,17 +25,13 @@ from copy import copy error_list = dict(( ('USER_REQUIRED', _("please login to do that")), - ('NO_URL', _('url required')), + ('NO_URL', _('a url is required')), ('BAD_URL', _('you should check that url')), - ('NO_TITLE', _('title required')), - ('TITLE_TOO_LONG', _('you can be more succinct than that')), - ('COMMENT_TOO_LONG', _('you can be more succinct than that')), - ('BAD_CAPTCHA', _('your letters stink')), + ('BAD_CAPTCHA', _('care to try these again?')), ('BAD_USERNAME', _('invalid user name')), ('USERNAME_TAKEN', _('that username is already taken')), ('NO_THING_ID', _('id not specified')), ('NOT_AUTHOR', _("you can't do that")), - ('BAD_COMMENT', _('please enter a comment')), ('DELETED_COMMENT', _('that comment has been deleted')), ('DELETED_THING', _('that element has been deleted.')), ('BAD_PASSWORD', _('invalid password')), @@ -44,9 +40,7 @@ error_list = dict(( ('NO_NAME', _('please enter a name')), ('NO_EMAIL', _('please enter an email address')), ('NO_EMAIL_FOR_USER', _('no email address for that user')), - ('NO_MESSAGE', _('please enter a message')), ('NO_TO_ADDRESS', _('send it to whom?')), - ('NO_MSG_BODY', _('please enter a message')), ('NO_SUBJECT', _('please enter a subject')), ('USER_DOESNT_EXIST', _("that user doesn't exist")), ('NO_USER', _('please enter a username')), @@ -55,6 +49,7 @@ error_list = dict(( ('ALREADY_SUB', _("that link has already been submitted")), ('SUBREDDIT_EXISTS', _('that reddit already exists')), ('SUBREDDIT_NOEXIST', _('that reddit doesn\'t exist')), + ('SUBREDDIT_REQUIRED', _('you must specify a reddit')), ('BAD_SR_NAME', _('that name isn\'t going to work')), ('RATELIMIT', _('you are trying to submit too fast. try again in %(time)s.')), ('EXPIRED', _('your session has expired')), @@ -64,11 +59,13 @@ error_list = dict(( ('BAD_CNAME', "that domain isn't going to work"), ('USED_CNAME', "that domain is already in use"), ('INVALID_OPTION', _('that option is not valid')), - ('DESC_TOO_LONG', _('description is too long')), ('CHEATER', 'what do you think you\'re doing there?'), ('BAD_EMAILS', _('the following emails are invalid: %(emails)s')), ('NO_EMAILS', _('please enter at least one email address')), ('TOO_MANY_EMAILS', _('please only share to %(num)s emails at a time.')), + + ('TOO_LONG', _("this is too long (max: %(max_length)s)")), + ('NO_TEXT', _('we need something here')), )) errors = Storage([(e, e) for e in error_list.keys()]) @@ -97,8 +94,10 @@ class ErrorSet(object): def __init__(self): self.errors = {} - def __contains__(self, error_name): - return self.errors.has_key(error_name) + def __contains__(self, pair): + """Expectes an (error_name, field_name) tuple and checks to + see if it's in the errors list.""" + return self.errors.has_key(pair) def __getitem__(self, name): return self.errors[name] @@ -110,16 +109,16 @@ class ErrorSet(object): for x in self.errors: yield x - def _add(self, error_name, msg, msg_params = {}, field = None): - self.errors[error_name] = Error(error_name, msg, msg_params, - field = field) - def add(self, error_name, msg_params = {}, field = None): msg = error_list[error_name] - self._add(error_name, msg, msg_params = msg_params, field = field) + for field_name in tup(field): + e = Error(error_name, msg, msg_params, field = field_name) + self.errors[(error_name, field_name)] = e - def remove(self, error_name): - if self.errors.has_key(error_name): - del self.errors[error_name] + def remove(self, pair): + """Expectes an (error_name, field_name) tuple and removes it + from the errors list.""" + if self.errors.has_key(pair): + del self.errors[pair] class UserRequiredException(Exception): pass diff --git a/r2/r2/controllers/feedback.py b/r2/r2/controllers/feedback.py index c660ae419..fb123289a 100644 --- a/r2/r2/controllers/feedback.py +++ b/r2/r2/controllers/feedback.py @@ -26,29 +26,16 @@ from r2.lib.pages import FormPage, Feedback, Captcha class FeedbackController(RedditController): - def _feedback(self, name = '', email = '', message='', - replyto='', action=''): - title = _("inquire about advertising on reddit") if action else '' - captcha = Captcha() if not c.user_is_loggedin \ - or c.user.needs_captcha() else None - if request.get.has_key("done"): - success = _("thanks for your message! you should hear back from us shortly.") - else: - success = '' - return FormPage(_("advertise") if action == 'ad_inq' \ - else _("feedback"), - content = Feedback(captcha=captcha, - message=message, - replyto=replyto, - email=email, name=name, - success=success, - action=action, - title=title), + def GET_ad_inq(self): + title = _("inquire about advertising on reddit") + return FormPage('advertise', + content = Feedback(title=title, + action='ad_inq'), loginbox = False).render() - - def GET_ad_inq(self): - return self._feedback(action='ad_inq') - def GET_feedback(self): - return self._feedback() + title = _("send reddit feedback") + return FormPage('feedback', + content = Feedback(title=title, + action='feedback'), + loginbox = False).render() diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 8e582b20a..82fcfee61 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -109,7 +109,7 @@ class FrontController(RedditController): if not key and request.referer: referer_path = request.referer.split(g.domain)[-1] done = referer_path.startswith(request.fullpath) - elif not cache_evt.user: + elif not getattr(cache_evt, "user", None): return self.abort404() return BoringPage(_("reset password"), content=ResetPassword(key=key, done=done)).render() @@ -174,12 +174,11 @@ class FrontController(RedditController): # insert reply box only for logged in user if c.user_is_loggedin and article.subreddit_slow.can_comment(c.user): #no comment box for permalinks - if not comment: - displayPane.append(CommentReplyBox(link_name = - article._fullname)) - else: - displayPane.append(CommentReplyBox()) - + displayPane.append(UserText(item = article, creating = True, + post_form = 'comment', + display = not bool(comment), + cloneable = True)) + # finally add the comment listing displayPane.append(listing.listing()) @@ -498,7 +497,9 @@ class FrontController(RedditController): return res captcha = Captcha() if c.user.needs_captcha() else None - sr_names = Subreddit.submit_sr_names(c.user) if c.default_sr else () + sr_names = (Subreddit.submit_sr_names(c.user) or + Subreddit.submit_sr_names(None)) + return FormPage(_("submit"), content=NewLink(url=url or '', diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 6b9abfabb..451b025e3 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -48,12 +48,15 @@ class Validator(object): self.default = default self.post, self.get, self.url = post, get, url - def set_error(self, error, msg_params = {}): + def set_error(self, error, msg_params = {}, field = False): """ Adds the provided error to c.errors and flags that it is come from the validator's param """ - c.errors.add(error, msg_params = msg_params, field = self.param) + if field is False: + field = self.param + + c.errors.add(error, msg_params = msg_params, field = field) def __call__(self, url): a = [] @@ -135,7 +138,7 @@ def api_validate(response_function): simple_vals, param_vals, *a, **kw) except UserRequiredException: responder.send_failure(errors.USER_REQUIRED) - return self.response_func(dict(iter(responder))) + return self.response_func(responder.make_response()) return newfn return val return _api_validate @@ -146,6 +149,10 @@ def noresponse(self, self_method, responder, simple_vals, param_vals, *a, **kw): self_method(self, *a, **kw) return self.response_func({}) +@api_validate +def json_validate(self, self_method, responder, simple_vals, param_vals, *a, **kw): + r = self_method(self, *a, **kw) + return self.response_func(r) @api_validate def validatedForm(self, self_method, responder, simple_vals, param_vals, @@ -156,16 +163,19 @@ def validatedForm(self, self_method, responder, simple_vals, param_vals, # clear out the status line as a courtesy form.set_html(".status", "") - # do the actual work - val = self_method(self, form, responder, *a, **kw) - # auto-refresh the captcha if there are errors. if (c.errors.errors and any(isinstance(v, VCaptcha) for v in simple_vals)): + form.has_errors('captcha', errors.BAD_CAPTCHA) form.new_captcha() - if val: return val - return self.response_func(dict(iter(responder))) + # do the actual work + val = self_method(self, form, responder, *a, **kw) + + if val: + return val + else: + return self.response_func(responder.make_response()) @@ -266,46 +276,41 @@ def chksrname(x): class VLength(Validator): - def __init__(self, item, length = 10000, - empty_error = errors.BAD_COMMENT, - length_error = errors.COMMENT_TOO_LONG, **kw): - Validator.__init__(self, item, **kw) - self.length = length - self.len_error = length_error - self.emp_error = empty_error + only_whitespace = re.compile(r"^\s*$", re.UNICODE) - def run(self, title): - if not title: - self.set_error(self.emp_error) - elif len(title) > self.length: - self.set_error(self.len_error) + def __init__(self, param, max_length, + empty_error = errors.NO_TEXT, + length_error = errors.TOO_LONG, + **kw): + Validator.__init__(self, param, **kw) + self.max_length = max_length + self.length_error = length_error + self.empty_error = empty_error + + def run(self, text, text2 = ''): + text = text or text2 + if self.empty_error and (not text or self.only_whitespace.match(text)): + self.set_error(self.empty_error) + elif len(text) > self.max_length: + self.set_error(self.length_error, {'max_length': self.max_length}) else: - return title + return text class VTitle(VLength): - only_whitespace = re.compile(r"^\s*$", re.UNICODE) - - def __init__(self, item, length = 300, **kw): - VLength.__init__(self, item, length = length, - empty_error = errors.NO_TITLE, - length_error = errors.TITLE_TOO_LONG, **kw) - - def run(self, title): - title = VLength.run(self, title) - if title and self.only_whitespace.match(title): - self.set_error(errors.NO_TITLE) - else: - return title + def __init__(self, param, max_length = 300, **kw): + VLength.__init__(self, param, max_length, **kw) class VComment(VLength): - def __init__(self, item, length = 10000, **kw): - VLength.__init__(self, item, length = length, **kw) + def __init__(self, param, max_length = 10000, **kw): + VLength.__init__(self, param, max_length, **kw) +class VSelfText(VLength): + def __init__(self, param, max_length = 10000, **kw): + VLength.__init__(self, param, max_length, **kw) class VMessage(VLength): - def __init__(self, item, length = 10000, **kw): - VLength.__init__(self, item, length = length, - empty_error = errors.NO_MSG_BODY, **kw) + def __init__(self, param, max_length = 10000, **kw): + VLength.__init__(self, param, max_length, **kw) class VSubredditName(VRequired): @@ -388,7 +393,7 @@ class VByNameIfAuthor(VByName): if not thing._loaded: thing._load() if c.user_is_loggedin and thing.author_id == c.user._id: return thing - return self.error(errors.NOT_AUTHOR) + return self.set_error(errors.NOT_AUTHOR) class VCaptcha(Validator): default_param = ('iden', 'captcha') @@ -466,7 +471,9 @@ class VSRSubmitPage(Validator): abort(403, "forbidden") class VSubmitParent(VByName): - def run(self, fullname): + def run(self, fullname, fullname2): + #for backwards compatability (with iphone app) + fullname = fullname or fullname2 if fullname: parent = VByName.run(self, fullname) if parent and parent._deleted: @@ -482,30 +489,34 @@ class VSubmitParent(VByName): class VSubmitSR(Validator): def run(self, sr_name): + if not sr_name: + self.set_error(errors.SUBREDDIT_REQUIRED) + return None + try: sr = Subreddit._by_name(sr_name) except (NotFound, AttributeError): self.set_error(errors.SUBREDDIT_NOEXIST) - sr = None + return None if sr and not (c.user_is_loggedin and sr.can_submit(c.user)): abort(403, "forbidden") else: return sr -pass_rx = re.compile(r".{3,20}") +pass_rx = re.compile(r"^.{3,20}$") def chkpass(x): return x if x and pass_rx.match(x) else None -class VPassword(VRequired): - def __init__(self, item, *a, **kw): - VRequired.__init__(self, item, errors.BAD_PASSWORD, *a, **kw) +class VPassword(Validator): def run(self, password, verify): if not chkpass(password): - return self.error() + self.set_error(errors.BAD_PASSWORD) + return elif verify != password: - return self.error(errors.BAD_PASSWORD_MATCH) + self.set_error(errors.BAD_PASSWORD_MATCH) + return password else: return password @@ -695,11 +706,11 @@ class VRatelimit(Validator): # when errors have associated field parameters, we'll need # to add that here if self.error == errors.RATELIMIT: - self.set_error(errors.RATELIMIT, {'time': time}) + self.set_error(errors.RATELIMIT, {'time': time}, + field = 'ratelimit') else: self.set_error(self.error) - @classmethod def ratelimit(self, rate_user = False, rate_ip = False, prefix = "rate_", seconds = None): @@ -736,16 +747,13 @@ class VCacheKey(Validator): self.key = key if key: uid = g.cache.get(str(self.cache_prefix + "_" + self.key)) - try: - a = Account._byID(uid, data = True) - if name and a.name.lower() != name.lower(): - self.set_error(errors.BAD_USERNAME) - else: - self.user = a - except NotFound: - self.set_error(errors.BAD_USERNAME) + if uid: + try: + self.user = Account._byID(uid, data = True) + except NotFound: + return + #found everything we need return self - self.set_error(errors.EXPIRED) class VOneOf(Validator): diff --git a/r2/r2/lib/emailer.py b/r2/r2/lib/emailer.py index 0779bf0b2..20792088d 100644 --- a/r2/r2/lib/emailer.py +++ b/r2/r2/lib/emailer.py @@ -51,6 +51,7 @@ def simple_email(to, fr, subj, body): def password_email(user): key = passhash(random.randint(0, 1000), user.email) passlink = 'http://' + g.domain + '/resetpassword/' + key + print passlink cache.set("reset_%s" %key, user._id, time=1800) simple_email(user.email, 'reddit@reddit.com', 'reddit.com password reset', diff --git a/r2/r2/lib/filters.py b/r2/r2/lib/filters.py index 7f3a4378e..5453486d3 100644 --- a/r2/r2/lib/filters.py +++ b/r2/r2/lib/filters.py @@ -88,13 +88,19 @@ def unsafe(text=''): def websafe_json(text=""): return c_websafe_json(_force_unicode(text)) -def websafe(text=''): +def mako_websafe(text = ''): if text.__class__ == _Unsafe: return text elif text.__class__ != unicode: text = _force_unicode(text) return c_websafe(text) +def websafe(text=''): + if text.__class__ != unicode: + text = _force_unicode(text) + #wrap the response in _Unsafe so make_websafe doesn't unescape it + return _Unsafe(c_websafe(text)) + from mako.filters import url_escape def edit_comment_filter(text = ''): try: diff --git a/r2/r2/lib/jsonresponse.py b/r2/r2/lib/jsonresponse.py index ee1bcae2b..b67ecd434 100644 --- a/r2/r2/lib/jsonresponse.py +++ b/r2/r2/lib/jsonresponse.py @@ -26,11 +26,17 @@ from r2.lib.filters import websafe_json from r2.lib.template_helpers import replace_render from r2.lib.jsontemplates import get_api_subtype from r2.lib.base import BaseController +from r2.models import IDBuilder, Listing + import simplejson -from pylons import c +from pylons import c, g def json_respond(x): - return websafe_json(simplejson.dumps(x or '')) + if g.debug: + return websafe_json(simplejson.dumps(x or '', + sort_keys=True, indent=4)) + else: + return websafe_json(simplejson.dumps(x or '')) class JsonResponse(object): """ @@ -42,14 +48,14 @@ class JsonResponse(object): self._clear() def _clear(self): - self._has_errors = set([]) + self._errors = set() self._new_captcha = False self._data = {} def send_failure(self, error): c.errors.add(error) self._clear() - self._has_errors.add(error) + self._errors.add((error, None)) def __call__(self, *a): return self @@ -57,58 +63,39 @@ class JsonResponse(object): def __getattr__(self, key): return self - def __iter__(self): + def make_response(self): res = {} if self._data: res['data'] = self._data - res['errors'] = [(e, c.errors[e].message) for e in self._has_errors] - yield ("json", res) - - def _mark_error(self, e): - pass - - def _unmark_error(self, e): - pass + res['errors'] = [(e[0], c.errors[e].message) for e in self._errors] + return {"json": res} + + def set_error(self, error_name, field_name): + self._errors.add((error_name, field_name)) def has_error(self): - return bool(self._has_errors) + return bool(self._errors) - def has_errors(self, input, *errors, **kw): - rval = False - for e in errors: - if e in c.errors: - # get list of params checked to generate this error - # if they exist, make sure they match input checked - fields = c.errors[e].fields - if not input or not fields or input in fields: - self._has_errors.add(e) - rval = True - self._mark_error(e) - else: - self._unmark_error(e) - - if rval and input: - self.focus_input(input) - return rval - - def clear_errors(self, *errors): - for e in errors: - if e in self._has_errors: - self._has_errors.remove(e) - self._unmark_error(e) + def has_errors(self, field_name, *errors, **kw): + have_error = False + for error_name in errors: + if (error_name, field_name) in c.errors: + self.set_error(error_name, field_name) + have_error = True + return have_error def _things(self, things, action, *a, **kw): """ function for inserting/replacing things in listings. """ - from r2.models import IDBuilder, Listing listing = None if isinstance(things, Listing): listing = things.listing() things = listing.things things = tup(things) if not all(isinstance(t, Wrapped) for t in things): - b = IDBuilder([t._fullname for t in things]) + wrap = kw.pop('wrap', Wrapped) + b = IDBuilder([t._fullname for t in things], wrap) things = b.get_items()[0] data = [replace_render(listing, t) for t in things] @@ -162,7 +149,9 @@ class JQueryResponse(JsonResponse): JsonResponse._clear(self) def send_failure(self, error): - JsonResponse.send_failure(self, error) + c.errors.add(error) + self._clear() + self._errors.add((self, error, None)) self.refresh() def __call__(self, *a): @@ -178,8 +167,25 @@ class JQueryResponse(JsonResponse): self.ops.append([self.objs[obj], newi, op, args]) return new - def __iter__(self): - yield ("jquery", self.ops) + def set_error(self, error_name, field_name): + #self is the form that had the error checked, but we need to + #add this error to the top_node of this response and give it a + #reference to the form. + self.top_node._errors.add((self, error_name, field_name)) + + def has_error(self): + return bool(self.top_node._errors) + + def make_response(self): + #add the error messages + for (form, error_name, field_name) in self._errors: + selector = ".error." + error_name + if field_name: + selector += ".field-" + field_name + message = c.errors[(error_name, field_name)].message + form.find(selector).show().html(message).end() + + return {"jquery": self.ops} # thing methods #-------------- @@ -196,22 +202,17 @@ class JQueryResponse(JsonResponse): # convenience methods: # -------------------- - def _mark_error(self, e): - self.find("." + e).show().html(c.errors[e].message).end() - - def _unmark_error(self, e): - self.find("." + e).html("").end() + #def _mark_error(self, e, field): + # self.find("." + e).show().html(c.errors[e].message).end() + # + #def _unmark_error(self, e): + # self.find("." + e).html("").end() def new_captcha(self): if not self._new_captcha: self.captcha(get_iden()) self._new_captcha = True - def chk_captcha(self, *errors): - if self.has_errors(None, *errors): - self.new_captcha() - return True - def get_input(self, name): return self.find("*[name=%s]" % name) diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py index 4dc2ce04f..d0b564282 100644 --- a/r2/r2/lib/jsontemplates.py +++ b/r2/r2/lib/jsontemplates.py @@ -42,10 +42,6 @@ def make_typename(typ): def make_fullname(typ, _id): return '%s_%s' % (make_typename(typ), to36(_id)) -def mass_part_render(thing, **kw): - return dict([(k, spaceCompress(thing.part_render(v)).strip(' ')) \ - for k, v in kw.iteritems()]) - class JsonTemplate(Template): def __init__(self): pass @@ -253,8 +249,8 @@ class CommentJsonTemplate(ThingJsonTemplate): else: parent_id = make_fullname(Comment, parent_id) d = ThingJsonTemplate.rendered_data(self, wrapped) - d.update(mass_part_render(wrapped, contentHTML = 'commentBody', - contentTxt = 'commentText')) + d['contentText'] = self.thing_attr(wrapped, 'body') + d['contentHTML'] = self.thing_attr(wrapped, 'body_html') d['parent'] = parent_id d['link'] = make_fullname(Link, wrapped.link_id) return d @@ -268,6 +264,9 @@ class MoreCommentJsonTemplate(CommentJsonTemplate): def kind(self, wrapped): return "more" + def rendered_data(self, wrapped): + return ThingJsonTemplate.rendered_data(self, wrapped) + class MessageJsonTemplate(ThingJsonTemplate): _data_attrs_ = ThingJsonTemplate.data_attrs(new = "new", subject = "subject", diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py index 27b244da0..3eca1a5d5 100644 --- a/r2/r2/lib/menus.py +++ b/r2/r2/lib/menus.py @@ -20,7 +20,7 @@ # All portions of the code written by CondeNet are Copyright (c) 2006-2009 # CondeNet, Inc. All Rights Reserved. ################################################################################ -from wrapped import Wrapped +from wrapped import Wrapped, Styled from pylons import c, request, g from utils import query_string, timeago from strings import StringHandler, plurals @@ -150,30 +150,6 @@ menu = MenuHandler(hot = _('hot'), current_promos = _('promoted links'), ) -class Styled(Wrapped): - """Rather than creating a separate template for every possible - menu/button style we might want to use, this class overrides the - render function to render only the <%def> in the template whose - name matches 'style'. - - Additionally, when rendering, the '_id' and 'css_class' attributes - are intended to be used in the outermost container's id and class - tag. - """ - def __init__(self, style, _id = '', css_class = '', **kw): - self._id = _id - self.css_class = css_class - self.style = style - Wrapped.__init__(self, **kw) - - def render(self, **kw): - """Using the canonical template file, only renders the <%def> - in the template whose name is given by self.style""" - style = kw.get('style', c.render_style or 'html') - return Wrapped.part_render(self, self.style, style = style, **kw) - - - def menu_style(type): """Simple manager function for the styled menus. Returns a (style, css_class) pair given a 'type', defaulting to style = @@ -185,12 +161,11 @@ def menu_style(type): srdrop = ('dropdown', 'srdrop'), flatlist = ('flatlist', 'flat-list'), tabmenu = ('tabmenu', ''), + formtab = ('tabmenu', 'formtab'), flat_vert = ('flatlist', 'flat-vert'), ) return d.get(type, default) - - class NavMenu(Styled): """generates a navigation menu. The intention here is that the 'style' parameter sets what template/layout to use to differentiate, say, @@ -337,7 +312,7 @@ class JsButton(NavButton): """A button which fires a JS event and thus has no path and cannot be in the 'selected' state""" def __init__(self, title, style = 'js', **kw): - NavButton.__init__(self, title, '', style = style, **kw) + NavButton.__init__(self, title, '#', style = style, **kw) def build(self, *a, **kw): self.path = 'javascript:void(0)' @@ -391,7 +366,7 @@ class SortMenu(SimpleGetMenu): options = ('hot', 'new', 'top', 'old', 'controversial') def __init__(self, **kw): - kw['title'] = _("sort by") + kw['title'] = _("sorted by") SimpleGetMenu.__init__(self, **kw) @classmethod @@ -521,6 +496,11 @@ class SubredditMenu(NavMenu): """Always return False so the title is always displayed""" return None +class JsNavMenu(NavMenu): + def find_selected(self): + """Always return the first element.""" + return self.options[0] + # -------------------- # TODO: move to admin area class AdminReporterMenu(SortMenu): diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 92c07c58e..2d1caabcb 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -19,8 +19,11 @@ # All portions of the code written by CondeNet are Copyright (c) 2006-2009 # CondeNet, Inc. All Rights Reserved. ################################################################################ -from r2.lib.wrapped import Wrapped, NoTemplateFound -from r2.models import IDBuilder, LinkListing, Account, Default, FakeSubreddit, Subreddit, Friends, All, Sub, NotFound, DomainSR +from r2.lib.wrapped import Wrapped, NoTemplateFound, Styled +from r2.models import IDBuilder, LinkListing, Account, Default +from r2.models import FakeSubreddit, Subreddit +from r2.models import Friends, All, Sub, NotFound, DomainSR +from r2.models import make_wrapper from r2.config import cache from r2.lib.jsonresponse import json_respond from r2.lib.jsontemplates import is_api @@ -32,13 +35,16 @@ from r2.lib.traffic import load_traffic, load_summary from r2.lib.captcha import get_iden from r2.lib.filters import spaceCompress, _force_unicode, _force_utf8 from r2.lib.menus import NavButton, NamedButton, NavMenu, PageNameNav, JsButton -from r2.lib.menus import SubredditButton, SubredditMenu, OffsiteButton, menu +from r2.lib.menus import SubredditButton, SubredditMenu +from r2.lib.menus import OffsiteButton, menu, JsNavMenu from r2.lib.strings import plurals, rand_strings, strings, Score from r2.lib.utils import title_to_url, query_string, UrlParser, to_js, vote_hash from r2.lib.template_helpers import add_sr, get_domain +from r2.lib.subreddit_search import popular_searches -import sys, random, datetime, locale, calendar, re +import sys, random, datetime, locale, calendar, simplejson, re import graph +from itertools import chain from urllib import quote datefmt = _force_utf8(_('%d %b %Y')) @@ -274,7 +280,6 @@ class Reddit(Wrapped): if c.user_is_loggedin: more_buttons.append(NamedButton('saved', False)) - more_buttons.append(NamedButton('recommended', False)) if c.user_is_admin: more_buttons.append(NamedButton('admin')) @@ -287,6 +292,11 @@ class Reddit(Wrapped): if c.user_is_admin: more_buttons.append(NamedButton('traffic')) + #if there's only one button in the dropdown, get rid of the dropdown + if len(more_buttons) == 1: + main_buttons.append(more_buttons[0]) + more_buttons = [] + toolbar = [NavMenu(main_buttons, type='tabmenu')] if more_buttons: toolbar.append(NavMenu(more_buttons, title=menu.more, type='tabdrop')) @@ -300,13 +310,13 @@ class Reddit(Wrapped): return "" @staticmethod - def content_stack(*a): + def content_stack(panes, css_class = None): """Helper method for reordering the content stack.""" - return PaneStack(filter(None, a)) + return PaneStack(filter(None, panes), css_class = css_class) def content(self): """returns a Wrapped (or renderable) item for the main content div.""" - return self.content_stack(self.infobar, self.nav_menu, self._content) + return self.content_stack((self.infobar, self.nav_menu, self._content)) class ClickGadget(Wrapped): def __init__(self, links, *a, **kw): @@ -417,11 +427,15 @@ class MessagePage(Reddit): if not kw.has_key('show_sidebar'): kw['show_sidebar'] = False Reddit.__init__(self, *a, **kw) - self.replybox = CommentReplyBox() + self.replybox = UserText(item = None, creating = True, + post_form = 'comment', display = False, + cloneable = True) def content(self): - return self.content_stack(self.replybox, self.infobar, - self.nav_menu, self._content) + return self.content_stack((self.replybox, + self.infobar, + self.nav_menu, + self._content)) def build_toolbars(self): buttons = [NamedButton('compose'), @@ -486,8 +500,11 @@ class LoginPage(BoringPage): class Login(Wrapped): """The two-unit login and register form.""" def __init__(self, user_reg = '', user_login = '', dest=''): - Wrapped.__init__(self, user_reg = user_reg, user_login = user_login, - dest = dest) + Wrapped.__init__(self, + user_reg = user_reg, + user_login = user_login, + dest = dest, + captcha = Captcha()) class SearchPage(BoringPage): @@ -501,8 +518,8 @@ class SearchPage(BoringPage): BoringPage.__init__(self, pagename, robots='noindex', *a, **kw) def content(self): - return self.content_stack(self.searchbar, self.infobar, - self.nav_menu, self._content) + return self.content_stack((self.searchbar, self.infobar, + self.nav_menu, self._content)) class CommentsPanel(Wrapped): """the side-panel on the reddit toolbar frame that shows the top @@ -530,10 +547,11 @@ class LinkInfoPage(Reddit): def __init__(self, link = None, comment = None, link_title = '', *a, **kw): - # TODO: temp hack until we find place for builder_wrapper from r2.controllers.listingcontroller import ListingController + wrapper = make_wrapper(ListingController.builder_wrapper, + expand_children = True) link_builder = IDBuilder(link._fullname, - wrap = ListingController.builder_wrapper) + wrap = wrapper) # link_listing will be the one-element listing at the top self.link_listing = LinkListing(link_builder, nextprev=False).listing() @@ -553,6 +571,7 @@ class LinkInfoPage(Reddit): else: params = {'title':_force_unicode(link_title), 'site' : c.site.name} title = strings.link_info_title % params + Reddit.__init__(self, title = title, *a, **kw) def build_toolbars(self): @@ -579,8 +598,11 @@ class LinkInfoPage(Reddit): return toolbar def content(self): - return self.content_stack(self.infobar, self.link_listing, - self.nav_menu, self._content) + return self.content_stack((self.infobar, self.link_listing, + PaneStack([PaneStack((self.nav_menu, + self._content))], + title = _("comments"), + css_class = "commentarea"))) def rightbox(self): rb = Reddit.rightbox(self) @@ -612,8 +634,6 @@ class EditReddit(Reddit): else: return [] - - class SubredditsPage(Reddit): """container for rendering a list of reddits. The corner searchbox is hidden and its functionality subsumed by an in page @@ -651,8 +671,8 @@ class SubredditsPage(Reddit): NavMenu(buttons, base_path = '/reddits', type="tabmenu")] def content(self): - return self.content_stack(self.searchbar, self.nav_menu, - self.sr_infobar, self._content) + return self.content_stack((self.searchbar, self.nav_menu, + self.sr_infobar, self._content)) def rightbox(self): ps = Reddit.rightbox(self) @@ -663,7 +683,7 @@ class MySubredditsPage(SubredditsPage): """Same functionality as SubredditsPage, without the search box.""" def content(self): - return self.content_stack(self.nav_menu, self.infobar, self._content) + return self.content_stack((self.nav_menu, self.infobar, self._content)) def votes_visible(user): @@ -868,15 +888,6 @@ class Captcha(Wrapped): self.iden = get_captcha() Wrapped.__init__(self) -class CommentReplyBox(Wrapped): - """Used on LinkInfoPage to render the comment reply form at the - top of the comment listing as well as the template for the forms - which are JS inserted when clicking on 'reply' in either a comment - or message listing.""" - def __init__(self, link_name='', captcha=None, action = 'comment'): - Wrapped.__init__(self, link_name = link_name, captcha = captcha, - action = action) - class PermalinkMessage(Wrapped): """renders the box on comment pages that state 'you are viewing a single comment's thread'""" @@ -886,12 +897,14 @@ class PermalinkMessage(Wrapped): class PaneStack(Wrapped): """Utility class for storing and rendering a list of block elements.""" - def __init__(self, panes=[], div_id = None, css_class=None, div=False): + def __init__(self, panes=[], div_id = None, css_class=None, div=False, + title=""): div = div or div_id or css_class or False self.div_id = div_id self.css_class = css_class self.div = div self.stack = list(panes) + self.title = title Wrapped.__init__(self) def append(self, item): @@ -952,7 +965,6 @@ class Frame(Wrapped): dorks_re = re.compile(r"https?://?([-\w.]*\.)?digg\.com/\w+\.\w+(/|$)") class FrameToolbar(Wrapped): """The reddit voting toolbar used together with Frame.""" - extension_handling = False def __init__(self, link = None, title = None, url = None, expanded = False, **kw): self.title = title self.url = url @@ -998,11 +1010,41 @@ class FrameToolbar(Wrapped): Wrapped.__init__(self, **kw) + extension_handling = False class NewLink(Wrapped): """Render the link submission form""" def __init__(self, captcha = None, url = '', title= '', subreddits = (), then = 'comments'): + tabs = (('link', ('link-desc', 'url-field')), + ('text', ('text-desc', 'text-field'))) + all_fields = set(chain(*(parts for (tab, parts) in tabs))) + + buttons = [] + self.default_tabs = tabs[0][1] + self.default_tab = tabs[0][0] + for tab_name, parts in tabs: + to_show = ','.join('#' + p for p in parts) + to_hide = ','.join('#' + p for p in all_fields if p not in parts) + onclick = "return select_form_tab(this, '%s', '%s');" + onclick = onclick % (to_show, to_hide) + + if tab_name == self.default_tab: + self.default_show = to_show + self.default_hide = to_hide + + buttons.append(JsButton(tab_name, onclick=onclick, css_class=tab_name)) + + self.formtabs_menu = JsNavMenu(buttons, type = 'formtab') + self.default_tabs = tabs[0][1] + + self.sr_searches = simplejson.dumps(popular_searches()) + + if isinstance(c.site, FakeSubreddit): + self.default_sr = subreddits[0] if subreddits else g.default_sr + else: + self.default_sr = c.site.name + Wrapped.__init__(self, captcha = captcha, url = url, title = title, subreddits = subreddits, then = then) @@ -1082,11 +1124,22 @@ class ButtonDemoPanel(Wrapped): class Feedback(Wrapped): """The feedback and ad inquery form(s)""" - def __init__(self, captcha=None, title=None, action='/feedback', - message='', name='', email='', replyto='', success = False): - Wrapped.__init__(self, captcha = captcha, title = title, action = action, - message = message, name = name, email = email, replyto = replyto, - success = success) + def __init__(self, title, action): + email = name = '' + if c.user_is_loggedin: + email = getattr(c.user, "email", "") + name = c.user.name + + captcha = None + if not c.user_is_loggedin or c.user.needs_captcha(): + captcha = Captcha() + + Wrapped.__init__(self, + captcha = captcha, + title = title, + action = action, + email = email, + name = name) class WidgetDemoPanel(Wrapped): @@ -1260,7 +1313,7 @@ class DetailsPage(LinkInfoPage): def content(self): # TODO: a better way? from admin_pages import Details - return self.content_stack(self.link_listing, Details(link = self.link)) + return self.content_stack((self.link_listing, Details(link = self.link))) class Cnameframe(Wrapped): """The frame page.""" @@ -1289,8 +1342,7 @@ class PromotePage(Reddit): buttons = [NamedButton('current_promos', dest = ''), NamedButton('new_promo')] - menu = NavMenu(buttons, title='show', base_path = '/promote', - type='flatlist') + menu = NavMenu(buttons, base_path = '/promote', type='flatlist') if nav_menus: nav_menus.insert(0, menu) @@ -1330,6 +1382,83 @@ class PromoteLinkForm(Wrapped): listing = listing, *a, **kw) +class TabbedPane(Wrapped): + def __init__(self, tabs): + """Renders as tabbed area where you can choose which tab to + render. Tabs is a list of tuples (tab_name, tab_pane).""" + buttons = [] + for tab_name, title, pane in tabs: + buttons.append(JsButton(title, onclick="return select_tab_menu(this, '%s');" % tab_name)) + + self.tabmenu = JsNavMenu(buttons, type = 'tabpane') + self.tabs = tabs + + Wrapped.__init__(self) + +class LinkChild(Wrapped): + def __init__(self, link, load = False, expand = False, nofollow = False): + self.link = link + self.expand = expand + self.load = load or expand + self.nofollow = nofollow + Wrapped.__init__(self) + + def content(self): + return '' + +class MediaChild(LinkChild): + css_style = "video" + def content(self): + return self.link.media_object + +class SelfTextChild(LinkChild): + css_style = "selftext" + def content(self): + u = UserText(self.link, self.link.selftext, + editable = c.user == self.link.author, + nofollow = self.nofollow) + #have to force the render style to html for now cause of some + #c.render_style weirdness + return u.render(style = 'html') + +class SelfText(Wrapped): + def __init__(self, link): + Wrapped.__init__(self, link = link) + +class UserText(Wrapped): + def __init__(self, + item, + text = '', + have_form = True, + editable = False, + creating = False, + nofollow = False, + display = True, + post_form = 'editusertext', + cloneable = False, + extra_css = ''): + + css_class = "usertext" + if cloneable: + css_class += " cloneable" + if extra_css: + css_class += " " + extra_css + + Wrapped.__init__(self, + item = item, + text = text, + have_form = have_form, + editable = editable, + creating = creating, + nofollow = nofollow, + display = display, + post_form = post_form, + cloneable = cloneable, + css_class = css_class) + + def button(self): + pass + class Traffic(Wrapped): @staticmethod def slice_traffic(traffic, *indices): @@ -1491,3 +1620,4 @@ class RedditTraffic(Traffic): class InnerToolbarFrame(Wrapped): def __init__(self, link, expanded = False): Wrapped.__init__(self, link = link, expanded = expanded) + diff --git a/r2/r2/lib/scraper.py b/r2/r2/lib/scraper.py index ab5cf556c..a2ddd1a23 100644 --- a/r2/r2/lib/scraper.py +++ b/r2/r2/lib/scraper.py @@ -281,7 +281,7 @@ def make_scraper(url): #Youtube class YoutubeScraper(MediaScraper): - media_template = '' + media_template = '' thumbnail_template = 'http://img.youtube.com/vi/$video_id/default.jpg' video_id_rx = re.compile('.*v=([A-Za-z0-9-_]+).*') diff --git a/r2/r2/lib/strings.py b/r2/r2/lib/strings.py index 79a90adb0..dcf7f8795 100644 --- a/r2/r2/lib/strings.py +++ b/r2/r2/lib/strings.py @@ -121,6 +121,9 @@ string_dict = dict( also get there by clicking the link's title (in the middle of the toolbar, to the right of the comments button). """), + + submit_link = _("""You are submitting a link. The key to a successful submission is interesting content and a deceptive title."""), + submit_text = _("""You are submitting a text-based post. Speak your mind. A title is required, but expanding further in the text field is not. Beginning your title with "vote up if" is violation of intergalactic law."""), ) class StringHandler(object): diff --git a/r2/r2/lib/subreddit_search.py b/r2/r2/lib/subreddit_search.py new file mode 100644 index 000000000..d70b2f742 --- /dev/null +++ b/r2/r2/lib/subreddit_search.py @@ -0,0 +1,46 @@ +from r2.models import * +from r2.lib import utils + +from pylons import g + +sr_prefix = 'sr_search_' + + +def load_all_reddits(): + query_cache = {} + + q = Subreddit._query(Subreddit.c.type == 'public', + Subreddit.c._downs > 1, + sort = (desc('_downs'), desc('_ups')), + data = True) + for sr in utils.fetch_things2(q): + name = sr.name.lower() + for i in xrange(len(name)): + prefix = name[:i + 1] + names = query_cache.setdefault(prefix, []) + if len(names) < 10: + names.append(sr.name) + + g.rendercache.set_multi(query_cache, prefix = sr_prefix) + +def search_reddits_cached(query): + return g.rendercache.get(sr_prefix + query) or [] + +def search_reddits(query): + return search_reddits_cached(str(query.lower())) + +@memoize('popular_searches', time = 3600) +def popular_searches(): + top_reddits = Subreddit._query(Subreddit.c.type == 'public', + sort = desc('_downs'), + limit = 100, + data = True) + top_searches = {} + for sr in top_reddits: + name = sr.name.lower() + for i in xrange(min(len(name), 3)): + query = name[:i + 1] + r = search_reddits(query) + top_searches[query] = r + return top_searches + diff --git a/r2/r2/lib/template_helpers.py b/r2/r2/lib/template_helpers.py index e7ebb7981..8c92cb184 100644 --- a/r2/r2/lib/template_helpers.py +++ b/r2/r2/lib/template_helpers.py @@ -20,6 +20,7 @@ # CondeNet, Inc. All Rights Reserved. ################################################################################ from r2.models import * +from r2.lib.jsontemplates import is_api from filters import unsafe, websafe from r2.lib.utils import vote_hash, UrlParser @@ -102,8 +103,11 @@ def replace_render(listing, item, style = None, display = True): pass return rendered_item - child_txt = ( hasattr(item, "child") and item.child )\ - and item.child.render(style = style) or "" + if is_api(): + child_txt = "" + else: + child_txt = ( hasattr(item, "child") and item.child )\ + and item.child.render(style = style) or "" # handle API calls differently from normal request: dicts not strings are passed around if isinstance(rendered_item, dict): @@ -239,7 +243,7 @@ def add_sr(path, sr_path = True, nocname=False, force_hostname = False): path to include c.site.path. """ # don't do anything if it is just an anchor - if path.startswith('#'): + if path.startswith('#') or path.startswith('javascript:'): return path u = UrlParser(path) diff --git a/r2/r2/lib/wrapped.py b/r2/r2/lib/wrapped.py index a56003747..5191fbebe 100644 --- a/r2/r2/lib/wrapped.py +++ b/r2/r2/lib/wrapped.py @@ -24,6 +24,7 @@ from utils import storage from itertools import chain import sys + sys.setrecursionlimit(500) class NoTemplateFound(Exception): pass @@ -111,3 +112,26 @@ def SimpleWrapped(**kw): kw.update(kw1) Wrapped.__init__(self, *a, **kw) return _SimpleWrapped + +class Styled(Wrapped): + """Rather than creating a separate template for every possible + menu/button style we might want to use, this class overrides the + render function to render only the <%def> in the template whose + name matches 'style'. + + Additionally, when rendering, the '_id' and 'css_class' attributes + are intended to be used in the outermost container's id and class + tag. + """ + def __init__(self, style, _id = '', css_class = '', **kw): + self._id = _id + self.css_class = css_class + self.style = style + Wrapped.__init__(self, **kw) + + def render(self, **kw): + """Using the canonical template file, only renders the <%def> + in the template whose name is given by self.style""" + from pylons import c + style = kw.get('style', c.render_style or 'html') + return Wrapped.part_render(self, self.style, style = style, **kw) diff --git a/r2/r2/models/builder.py b/r2/r2/models/builder.py index 9bf518a09..2b9d51754 100644 --- a/r2/r2/models/builder.py +++ b/r2/r2/models/builder.py @@ -596,6 +596,14 @@ class CommentBuilder(Builder): return final +def make_wrapper(parent_wrapper = Wrapped, **params): + def wrapper_fn(thing): + w = parent_wrapper(thing) + for k, v in params.iteritems(): + setattr(w, k, v) + return w + return wrapper_fn + class TopCommentBuilder(CommentBuilder): """A comment builder to fetch only the top-level, non-spam, non-deleted comments""" diff --git a/r2/r2/models/link.py b/r2/r2/models/link.py index 67f510f7f..c0e33375e 100644 --- a/r2/r2/models/link.py +++ b/r2/r2/models/link.py @@ -52,6 +52,7 @@ class Link(Thing, Printable): promote_until = None, promoted_by = None, disable_comments = False, + selftext = '', ip = '0.0.0.0') def __init__(self, *a, **kw): @@ -204,6 +205,7 @@ class Link(Thing, Printable): if c.user_is_admin: return False + link_child = wrapped.link_child s = (str(i) for i in (wrapped._fullname, bool(c.user_is_sponsor), bool(c.user_is_loggedin), @@ -224,7 +226,12 @@ class Link(Thing, Printable): wrapped.show_reports, wrapped.can_ban, wrapped.thumbnail, - wrapped.moderator_banned)) + wrapped.moderator_banned, + #link child stuff + bool(link_child), + bool(link_child) and link_child.load, + bool(link_child) and link_child.expand + )) # htmllite depends on other get params s = ''.join(s) if c.render_style == "htmllite": @@ -284,7 +291,8 @@ class Link(Thing, Printable): item.thumbnail = thumbnail_url(item) else: item.thumbnail = g.default_thumb - + + item.score = max(0, item.score) item.domain = (domain(item.url) if not item.is_self @@ -329,6 +337,20 @@ class Link(Thing, Printable): item.domain_path = "/domain/%s" % item.domain if item.is_self: item.domain_path = item.subreddit_path + + #this is wrong, but won't be so wrong when we move this + #whole chunk of code into pages.py + from r2.lib.pages import MediaChild, SelfTextChild + item.link_child = None + item.editable = False + if item.media_object: + item.link_child = MediaChild(item, load = True) + elif item.selftext: + expand = getattr(item, 'expand_children', False) + item.link_child = SelfTextChild(item, expand = expand, + nofollow = item.nofollow) + #draw the edit button if the contents are pre-expanded + item.editable = expand and item.author == c.user item.tblink = "http://%s/tb/%s" % ( get_domain(cname = c.cname, subreddit=False), @@ -549,9 +571,18 @@ class Comment(Thing, Printable): item.author != c.user and not item.show_spam))) - if item._deleted and not c.user_is_admin: - item.author = DeletedUser() - item.body = '[deleted]' + extra_css = '' + if item._deleted: + if c.user_is_admin: + extra_css += "grayed" + else: + item.author = DeletedUser() + item.body = '[deleted]' + + + if c.focal_comment == item._id36: + extra_css += 'border' + # don't collapse for admins, on profile pages, or if deleted item.collapsed = ((item.score < min_score) and @@ -570,6 +601,12 @@ class Comment(Thing, Printable): item.score_fmt = Score.points item.permalink = item.make_permalink(item.link, item.subreddit) + #will seem less horrible when add_props is in pages.py + from r2.lib.pages import UserText + item.usertext = UserText(item, item.body, + editable = item.author == c.user, + nofollow = item.nofollow, + extra_css = extra_css) class StarkComment(Comment): """Render class for the comments in the top-comments display in the reddit toolbar""" diff --git a/r2/r2/public/static/blog-collapsed-hover.png b/r2/r2/public/static/blog-collapsed-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..57239808ab1932c2208f3a3e3bc7299d57b39cf1 GIT binary patch literal 452 zcmV;#0XzPQP)_|G&I`!9Xp* zBPzkLI#r*M1!VA(V>=jrvk6ko@DH~yFoZHN{AU8W7}?+$S6zloT?}jLgTZ`|MKCZi$Bn^MT?)?cTyczH z6_0Op1t|ZuFdy0Q{3*NOY=3JthAcl5;tlt=RtLKV1VEO6G*8=f z79~DF7J__4Qm}wD@p7>-v?Mv8C;$Zm$Rdz|S$@V~%aXb_gT+C?M3mtm#UL-jTn+-| z5mxY^k`m!%`26h`IJjVrAq2pw1>|CoVwgr402vDN5lHUF<2T@l1|?M~5gvkuGkUb{ z`2YIhLk4OCQF(dHbWTk$xD7uuGi`{voanNJ+@L__o21X8%y47_Kn5f8hew!GY&fnU u0UHjgOj&=ur>cuV=7G#*q-E_75MTgK?UYcp>6WYj0000K6cQ8?LDCt>3l71OD2fIjzy?$U!tOHHb@@JQ8by9Oop_0z zav+XlDvE-WH%&v5Byqa{>_3CpSH51a2T54dRRmI5mU*LtAfW5@`rMbB4eWN9O$qRU zSEM)Mv&ntm|D(!B|65+Qq91#nH{Da*?RNi9@jC_{KQ literal 0 HcmV?d00001 diff --git a/r2/r2/public/static/blog-expanded-hover.png b/r2/r2/public/static/blog-expanded-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..a0524faab5c52b0ff59239879bf5224264c2b8f6 GIT binary patch literal 440 zcmV;p0Z0CcP)_|G&I`!9Xp* zBPzkLI#r*M1!VA(V>=jrvk6ko@DH~yFoZHN{AU8W7}?+$S6zloT?}jLgTZ`|MKCZi$Bn^MT?)?cTyczH z6_0Op1t|ZuFdy0Q{3*NOY=3JthAcl5;tlt=RtLKV1VEO6G*8=f z79~DF7J__4Qm}wD@p7>-v?Mv8C;$Zm$Rdz|S$@V~%aXb_gT+C?M3mtm#UL-jTn+-| z5mxY^k`m!%`26h`IJjVrAq2pw1>|CoVwgr402vDN5lHUF<2T@l1|?M~5gvkuGkUb{ z`2YIhLk4OCQF(dHbWTk$YB>E3XCX(^nVD%gb7~le!oH00005A%Y=-3W2>j3kJP#$)DaTr*d|jZ)VOGD=@~WC<=DDTxcq3 znnvsOS`hL)XUpZ1A50|-+B6Nx_^>RCBuV0lEX!!W-;?XQbj=u^-8|)Q<5Y+Mx$Qa?Uv5x^F8)Fk8In14vuMcU8lujL55-QcnA-I zV6-9Zdm4@cOarY}tGoBTa35%hjb5?^MX+SI+l_vJ4cG?2zS(B8;pedFDR@;?e8qlp zC{0s3olYFybsfpF%