diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index ab4631b58..1e1df9edc 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -339,6 +339,9 @@ def make_map(): requirements=dict(type='wikibannednote|bannednote'), action='relnote') mc('/api/:action', controller='api') + + mc('/api/recommend/sr/:srnames', controller='api', + action='subreddit_recommendations') mc("/api/multi/mine", controller="multiapi", action="my_multis") mc("/api/multi/copy", controller="multiapi", action="multi_copy") diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 7e9544e9d..8819ef4d9 100755 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -38,6 +38,7 @@ from r2.lib.validator import * from r2.models import * from r2.lib import amqp +from r2.lib import recommender from r2.lib.utils import get_title, sanitize_url, timeuntil, set_last_modified from r2.lib.utils import query_string, timefromnow, randstr @@ -3507,3 +3508,17 @@ class ApiController(RedditController, OAuth2ResourceController): c.user.pref_collapse_left_bar = collapsed c.user._commit() + @validate(srs=VSRByNames("srnames"), + to_omit=VSRByNames("omit", required=False)) + @api_doc(api_section.subreddits) + def GET_subreddit_recommendations(self, srs, to_omit): + """Return subreddits recommended for the given subreddit(s). + + Gets a list of subreddits recommended for `srnames`, filtering out any + that appear in the optional `omit` param. + + """ + rec_srs = recommender.get_recommendations(srs.values(), + to_omit=to_omit.values()) + sr_names = [sr.name for sr in rec_srs] + return json.dumps(sr_names) diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index f3ad34972..7b16cd2ef 100755 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -454,6 +454,7 @@ module["reddit"] = LocalizedModule("reddit.js", "apps.js", "gold.js", "multi.js", + "recommender.js", PermissionsDataSource(), ) diff --git a/r2/r2/lib/recommender.py b/r2/r2/lib/recommender.py index 66bd8406a..d3eec6d3a 100644 --- a/r2/r2/lib/recommender.py +++ b/r2/r2/lib/recommender.py @@ -29,7 +29,6 @@ from operator import itemgetter from r2.models import Subreddit from r2.lib.sgm import sgm from r2.lib.db import tdb_cassandra -from r2.lib.memoize import memoize from r2.lib.utils import tup from pylons import g diff --git a/r2/r2/lib/validator/validator.py b/r2/r2/lib/validator/validator.py index 681b27856..036788634 100644 --- a/r2/r2/lib/validator/validator.py +++ b/r2/r2/lib/validator/validator.py @@ -669,6 +669,33 @@ class VSRByName(Validator): } +class VSRByNames(Validator): + """Returns a dict mapping subreddit names to subreddit objects. + + sr_names_csv - a comma delimited string of subreddit names + required - if true (default) an empty subreddit name list is an error + + """ + def __init__(self, sr_names_csv, required=True): + self.required = required + Validator.__init__(self, sr_names_csv) + + def run(self, sr_names_csv): + if sr_names_csv: + sr_names = [s.strip() for s in sr_names_csv.split(',')] + return Subreddit._by_name(sr_names) + elif self.required: + self.set_error(errors.BAD_SR_NAME, code=400) + return + else: + return {} + + def param_docs(self): + return { + self.param: "comma-delimited list of subreddit names", + } + + class VSubredditTitle(Validator): def run(self, title): if not title: diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 60a451616..312fcaa79 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/r2/r2/public/static/css/reddit.less @@ -5409,6 +5409,103 @@ table.calendar { } } +.side .recommend-box { + margin: 15px 5px 30px 0px; + opacity: 0; + transition: all .1s ease-in-out; + + h1 { + display: inline-block; + font-size: 1.35em; + font-weight: bold; + white-space: nowrap; + } + + ul { + margin: 4px 0; + } + + .rec-item { + background-color: rgb(247, 247, 247); + border: solid thin silver; + display: inline-block; + font-size: 1.0em; + margin: 2px; + padding: 0 0 1px 5px; + position: relative; + width: 136px; + white-space: nowrap; + + a { + display: inline-block; + height: 100%; + overflow: hidden; + line-height: 1.8em; + padding-left: 2px; + text-overflow: ellipsis; + vertical-align: middle; + width: 111px; + } + + button.add { + background-color: rgb(247, 247, 247); + background-image: none; + border: none; + cursor: pointer; + height: 100%; + opacity: 0.3; + + &:after { + background-image: url(../add.png); /* SPRITE */ + content: ""; + display: block; + height: 15px; + width: 15px; + } + + &:hover { + opacity: 1.0; + } + } + } + + .more { + color: #369; + cursor: pointer; + display: inline-block; + font-weight: bold; + vertical-align: top; + } + + .endoflist { + background-color: #f7f7f7; + padding: 15px 25px; + + h1 { + margin-bottom: 10px; + } + + .heading { + color: #555; + font-weight: bold; + } + + ul { + font-size: x-small; + list-style-type: disc; + margin: 4px 0 0 20px; + } + + .reset { + cursor: pointer; + } + } +} + +.readonly .recommend-box li > button { + display: none; +} + .hover-bubble.multi-add-notice { @bg-color: lighten(orange, 42%); @border-color: lighten(orangered, 30%); diff --git a/r2/r2/public/static/js/multi.js b/r2/r2/public/static/js/multi.js index 56d898d09..a431dc087 100644 --- a/r2/r2/public/static/js/multi.js +++ b/r2/r2/public/static/js/multi.js @@ -19,6 +19,12 @@ r.multi = { if (location.hash == '#created') { detailsView.focusAdd() } + + // if page has a recs box, wire it up to refresh with the multi. + var recsEl = $('#multi-recs') + if (recsEl.length) { + detailsView.initRecommendations(recsEl) + } } var subscribeBubbleGroup = {} @@ -171,6 +177,10 @@ r.multi.MultiReddit = Backbone.Model.extend({ renameTo: function(newCollection, name) { return this._copyOp('rename', newCollection, name) + }, + + getSubredditNames: function() { + return this.subreddits.pluck('name') } }) @@ -291,6 +301,32 @@ r.multi.MultiDetails = Backbone.View.extend({ this.model.subreddits.each(this.addOne, this) }, + // create child model and view to manage recommendations + initRecommendations: function(recsEl) { + var recs = new r.recommend.RecommendationList() + this.recsView = new r.recommend.RecommendationsView({ + collection: recs, + el: recsEl + }) + + // fetch initial data + if (!this.model.subreddits.isEmpty()) { + recs.fetchForSrs(this.model.getSubredditNames()) + } + + // update recs when multi changes + this.listenTo(this.model.subreddits, 'add remove reset', + function() { + var srNames = this.model.getSubredditNames() + recs.fetchForSrs(srNames) + }) + // update multi when a rec is selected + this.recsView.bind('recs:select', + function(data) { + this.model.addSubreddit(data['srName']) + }, this) + }, + render: function() { var canEdit = this.model.get('can_edit') if (canEdit) { diff --git a/r2/r2/public/static/js/recommender.js b/r2/r2/public/static/js/recommender.js new file mode 100644 index 000000000..d74c66413 --- /dev/null +++ b/r2/r2/public/static/js/recommender.js @@ -0,0 +1,137 @@ +r.recommend = {} + +r.recommend.Recommendation = Backbone.Model.extend() + +/** + * Example usage: + * // generate recs for people who like book subreddits + * var recs = r.recommend.RecommendationList() + * recs.fetchForSrs(['books', 'writing']) // triggers reset event + * // get a new set of recs + * recs.fetchNewRecs() // triggers reset event + * // the user also likes /r/excerpts so generate recs for it too + * recs.fetchForSrs(['books', 'writing', 'excerpts']) + * // keep fetching until none are left + * while (recs.models.length > 0) { + * recs.fetchNewRecs() + * } + * // allow previously seen recs to appear again (but results might not be the + * // same as above because srNames has changed) + * recs.clearHistory() + * recs.fetchRecs() + */ +r.recommend.RecommendationList = Backbone.Collection.extend({ + + // { srName: 'books' } + model: r.recommend.Recommendation, + + // names of subreddits for which recommendations are generated + // (if user likes srNames, he will also like...) + srNames: [], + + // names of subreddits that should be excluded from future recs because + // the user has already seen and dismissed them + dismissed: [], + + // loads recs for srNames and stores srNames so they can be used in future + // fetches. fires reset event + fetchForSrs: function(srNames) { + if (!srNames.length) { // skip unnecessary request + this.srNames = [] + this.reset([]) + return + } + this.srNames = srNames + this.fetchRecs() + }, + + // adds current recs to the dismissed list so they won't be shown again + // and refetches. fires reset event + fetchNewRecs: function() { + var currentRecs = this.pluck('srName') + this.dismissed = _.union(this.dismissed, currentRecs) + this.fetchRecs() + }, + + // requests data from the server based on values of member vars + fetchRecs: function() { + var url = '/api/recommend/sr/' + this.srNames.join(',') + this.fetch({ url: url, + data: {'omit': this.dismissed.join(',')}, + reset: true, + error: _.bind(function() { + this.reset([]) + }, this)}) + }, + + parse: function(resp) { + if ($.isArray(resp)) { + return _.map(resp, function(srName) { + return new r.recommend.Recommendation({'srName': srName}) + }) + } + return [] + }, + + // allows previously dismissed recs to be shown again + clearHistory: function() { + this.dismissed = [] + } +}) + +r.recommend.RecommendationsView = Backbone.View.extend({ + collection: r.recommend.RecommendationList, + + tagName: 'div', + + itemTemplate: _.template('