├── .gitignore
├── .npmignore
├── src
├── apiBase
│ ├── Events.es6.js
│ ├── Record.es6.js
│ ├── errors
│ │ ├── NoModelError.js
│ │ ├── NotImplementedError.js
│ │ ├── ValidationError.js
│ │ ├── BadCaptchaError.es6.js
│ │ ├── FakeError.es6.js
│ │ └── ResponseError.js
│ ├── APIResponsePaging.es6.js
│ ├── apiRequest.js
│ ├── APIResponse.es6.js
│ ├── APIRequestUtils.es6.js
│ └── Model.es6.js
├── models
│ ├── award.es6.js
│ ├── multi.es6.js
│ ├── promocampaign.es6.js
│ ├── multisubscription.es6.js
│ ├── trophy.es6.js
│ ├── block.es6.js
│ ├── wikiRevision.es6.js
│ ├── wikiPageListing.es6.js
│ ├── wikiPage.es6.js
│ ├── vote.es6.js
│ ├── BlockedUser.es6.js
│ ├── stylesheet.es6.js
│ ├── account.es6.js
│ ├── subscription.es6.js
│ ├── wikiPageSettings.es6.js
│ ├── message.es6.js
│ ├── report.es6.js
│ └── base.es6.js
├── lib
│ ├── isThingID.es6.js
│ ├── markdown.es6.js
│ ├── unredditifyLink.es6.js
│ └── commentTreeUtils.es6.js
├── collections
│ ├── HiddenPostsAndComments.es6.js
│ ├── SavedPostsAndComments.es6.js
│ ├── PostsFromSubreddit.es6.js
│ ├── CommentsPage.es6.js
│ ├── SubredditLists.js
│ ├── SearchQuery.es6.js
│ └── Listing.es6.js
├── apis
│ ├── SavedEndpoint.es6.js
│ ├── HiddenEndpoint.es6.js
│ ├── SubredditAutocomplete.es6.js
│ ├── reports.es6.js
│ ├── trophies.es6.js
│ ├── captcha.es6.js
│ ├── multisubscriptions.es6.js
│ ├── subscriptions.es6.js
│ ├── VoteEndpoint.es6.js
│ ├── RecommendedSubreddits.es6.js
│ ├── PreferencesEndpoint.es6.js
│ ├── SimilarPosts.es6.js
│ ├── SubredditsByPost.es6.js
│ ├── SubredditsToPostsByPost.es6.js
│ ├── stylesheets.es6.js
│ ├── accounts.es6.js
│ ├── wikis.es6.js
│ ├── activities.es6.js
│ ├── modListing.es6.js
│ ├── EditUserTextEndpoint.js
│ ├── subredditRelationships.es6.js
│ ├── SearchEndpoint.js
│ ├── SavedAndHiddenCommon.es6.js
│ ├── BaseContentEndpoint.js
│ ├── PostsEndpoint.js
│ ├── MessagesEndpoint.es6.js
│ ├── CommentsEndpoint.es6.js
│ ├── SubredditEndpoint.es6.js
│ ├── multis.es6.js
│ ├── modTools.es6.js
│ └── SubredditRulesEndpoint.es6.js
├── models2
│ ├── mockgenerators
│ │ ├── mockLink.es6.js
│ │ └── mockHTML.es6.js
│ ├── mixins
│ │ ├── replyable.js
│ │ ├── mixin.js
│ │ └── votable.js
│ ├── Wiki.es6.js
│ ├── MessageModel.es6.js
│ ├── SubredditRule.es6.js
│ ├── thingTypes.es6.js
│ ├── RedditModel.es6.js
│ ├── Account.es6.js
│ ├── CommentModel.es6.js
│ ├── Preferences.es6.js
│ ├── Subreddit.es6.js
│ └── PostModel.es6.js
└── index.es6.js
├── .travis.yml
├── run.js
├── blueprints.config.js
├── LICENSE
├── package.json
├── .eslintrc
├── repl
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build.js
3 | npm-debug*
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | blueprints.config.js
3 | npm-debug*
4 |
5 | .eslintrc
6 | .gitignore
7 | .npmignore
8 | .travis.yml
9 | .git
10 |
--------------------------------------------------------------------------------
/src/apiBase/Events.es6.js:
--------------------------------------------------------------------------------
1 | export default {
2 | request: 'request',
3 | response: 'response',
4 | error: 'error',
5 | result: 'result',
6 | };
7 |
--------------------------------------------------------------------------------
/src/models/award.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class Award extends Base {
4 | _type = 'Award';
5 | }
6 |
7 | export default Award;
8 |
--------------------------------------------------------------------------------
/src/models/multi.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class Multi extends Base {
4 | _type = 'Multi';
5 | }
6 |
7 | export default Multi;
8 |
--------------------------------------------------------------------------------
/src/lib/isThingID.es6.js:
--------------------------------------------------------------------------------
1 | const THING_ID_REGEX = new RegExp('^t\\d_[0-9a-z]+', 'i');
2 |
3 | export default function isThingID(val) {
4 | return THING_ID_REGEX.test(val);
5 | }
6 |
--------------------------------------------------------------------------------
/src/models/promocampaign.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class PromoCampaign extends Base {
4 | _type = 'PromoCampaign';
5 | }
6 |
7 | export default PromoCampaign;
8 |
--------------------------------------------------------------------------------
/src/lib/markdown.es6.js:
--------------------------------------------------------------------------------
1 | export default function process(text) {
2 | if (!text) return text;
3 |
4 | text = text.replace(/ {
4 | return `user/${query.user}/saved.json`;
5 | };
6 |
7 | export default SavedOrHiddenEndpoint(
8 | getPath,
9 | 'api/unsave',
10 | 'api/save',
11 | );
12 |
--------------------------------------------------------------------------------
/src/apis/HiddenEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import SavedOrHiddenEndpoint from './SavedAndHiddenCommon';
2 |
3 | const getPath = (query) => {
4 | return `user/${query.user}/hidden.json`;
5 | };
6 |
7 | export default SavedOrHiddenEndpoint(
8 | getPath,
9 | 'api/unhide',
10 | 'api/hide',
11 | );
12 |
--------------------------------------------------------------------------------
/src/apiBase/errors/NoModelError.js:
--------------------------------------------------------------------------------
1 | import FakeError from './FakeError';
2 |
3 | export default class NoModelError extends FakeError {
4 | constructor (endpoint) {
5 | super(`No model given for api endpoint ${endpoint}`);
6 |
7 | this.name = 'NoModelError';
8 | this.status = 400;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/models2/mockgenerators/mockLink.es6.js:
--------------------------------------------------------------------------------
1 | export default function mockLink() {
2 | const seed = Math.toFixed(Math.random() * 10);
3 | if (seed <= 3) { return 'https://www.reddit.com/r/theonion'; }
4 | if (seed <= 6) { return 'https://www.reddit.com/r/nothteonion'; }
5 | return 'https://www.theonion.com';
6 | }
7 |
--------------------------------------------------------------------------------
/src/models2/mockgenerators/mockHTML.es6.js:
--------------------------------------------------------------------------------
1 | export default function mockHTML() {
2 | // more randomization here at somepoint
3 | /* eslint-disable max-len */
4 | return 'This is a header or something
TMreactjs subreddit';
5 | /* eslint-enable */
6 | }
7 |
--------------------------------------------------------------------------------
/src/models/wikiRevision.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class WikiRevision extends Base {
4 | _type = 'WikiRevision';
5 |
6 | constructor(props) {
7 | if (props.author && props.author.data) {
8 | props.author = props.author.data;
9 | }
10 |
11 | super(props);
12 | }
13 | }
14 |
15 | export default WikiRevision;
16 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "4"
5 | - "5"
6 |
7 | script:
8 | # - npm run lint
9 | - npm run test
10 |
11 | branches:
12 | except:
13 | - staging
14 |
15 | env:
16 | - CXX=g++-4.8
17 |
18 | addons:
19 | apt:
20 | sources:
21 | - ubuntu-toolchain-r-test
22 | packages:
23 | - g++-4.8
24 |
--------------------------------------------------------------------------------
/src/apiBase/errors/NotImplementedError.js:
--------------------------------------------------------------------------------
1 | import FakeError from './FakeError';
2 |
3 | export default class NotImplementedError extends FakeError {
4 | constructor (method, endpoint) {
5 | super(`Method ${method} not implemented for api endpoint ${endpoint}`);
6 |
7 | this.name = 'NotImplementedError';
8 | this.status = 405;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/apis/SubredditAutocomplete.es6.js:
--------------------------------------------------------------------------------
1 | import { runForm } from '../apiBase/APIRequestUtils';
2 |
3 | const PATH = '/api/search_reddit_names.json'
4 |
5 | export default {
6 | get(apiOptions, searchTerm, over18) {
7 | const query = { query: searchTerm, include_over_18: over18 };
8 | return runForm(apiOptions, 'post', PATH, query);
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/models/wikiPageListing.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class WikiPageListing extends Base {
4 | _type = 'WikiPageListing';
5 |
6 | constructor(props) {
7 | props.pages = props.data.slice();
8 | delete props.data;
9 | delete props.kind;
10 |
11 | super(props);
12 | }
13 | }
14 |
15 | export default WikiPageListing;
16 |
--------------------------------------------------------------------------------
/src/models2/mixins/replyable.js:
--------------------------------------------------------------------------------
1 | import mixin from './mixin';
2 | import comment from '../../apis/CommentsEndpoint';
3 |
4 | export function reply (apiOptions, text) {
5 | const oldModel = this;
6 |
7 | return comment.post(apiOptions, {
8 | thingId: this.uuid,
9 | text,
10 | });
11 | };
12 |
13 | export default (cls) => mixin(cls, { reply });
14 |
--------------------------------------------------------------------------------
/src/models/wikiPage.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class WikiPage extends Base {
4 | _type = 'WikiPage';
5 |
6 | constructor(props) {
7 | delete props.content_html;
8 |
9 | if (props.revision_by) {
10 | props.revision_by = props.revision_by.data;
11 | }
12 |
13 | super(props);
14 | }
15 | }
16 |
17 | export default WikiPage;
18 |
--------------------------------------------------------------------------------
/src/models2/mixins/mixin.js:
--------------------------------------------------------------------------------
1 | export default (cls, fns) => {
2 | Object.keys(fns).map(k => {
3 | cls.prototype[k] = fns[k];
4 | });
5 |
6 | const oldConstructor = cls.constructor;
7 | cls.constructor = function () {
8 | Object.keys(fns).map(k => {
9 | this[k] = this[k].bind(this);
10 | });
11 |
12 | oldConstructor(...arguments);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/models/vote.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class Vote extends Base {
4 | _type = 'Vote';
5 |
6 | validators () {
7 | const direction = this.directionValidator.bind(this);
8 |
9 | return {
10 | direction,
11 | };
12 | }
13 |
14 | directionValidator (v) {
15 | return ([-1,0,1].indexOf(v) > -1);
16 | }
17 | }
18 |
19 | export default Vote;
20 |
--------------------------------------------------------------------------------
/src/models/BlockedUser.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | export default class BlockedUser extends Base {
4 | _type = 'BlockedUser';
5 |
6 |
7 | validators() {
8 | const date = Base.validators.integer;
9 | const id = Base.validators.thingId;
10 | const name =Base.validators.string;
11 |
12 | return {
13 | date,
14 | id,
15 | name
16 | };
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/models/stylesheet.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | // decode websafe_json encoding
4 | function unsafeJson(text) {
5 | return text.replace(/>/g, '>')
6 | .replace(/</g, '<')
7 | .replace(/&/g, '&');
8 | }
9 |
10 | class Stylesheet extends Base {
11 | _type = 'Stylesheet';
12 |
13 | get stylesheet () {
14 | return unsafeJson(this.get('stylesheet'));
15 | }
16 | }
17 |
18 | export default Stylesheet;
19 |
--------------------------------------------------------------------------------
/run.js:
--------------------------------------------------------------------------------
1 | require('babel-polyfill');
2 |
3 | require('babel-register')({
4 | ignore: false,
5 | only: /.+(?:(?:\.es6\.js)|(?:.jsx))$/,
6 | extensions: ['.js', '.es6.js'],
7 | sourceMap: true,
8 | presets: [
9 | 'es2015',
10 | ],
11 | plugins: [
12 | 'transform-object-rest-spread',
13 | 'transform-async-to-generator',
14 | 'transform-class-properties',
15 | 'syntax-trailing-function-commas',
16 | ],
17 | });
18 |
19 | module.exports = require('./src/index').default;
20 |
--------------------------------------------------------------------------------
/src/apiBase/errors/ValidationError.js:
--------------------------------------------------------------------------------
1 | import FakeError from './FakeError';
2 |
3 | const msgText = (api, errors) => `${api} had errors in ${errors.join(',')}`;
4 |
5 | export default class ValidationError extends FakeError {
6 | constructor (apiName, errors, status) {
7 | const message = errors && errors.length ?
8 | msgText(apiName, errors) : `Validation error in '${apiName}'`;
9 | super(message);
10 | this.name = 'ValidationError';
11 | this.errors = errors;
12 | this.status = status;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/apiBase/errors/BadCaptchaError.es6.js:
--------------------------------------------------------------------------------
1 | import FakeError from './FakeError';
2 |
3 | const INCORRECT_CAPTCHA = 'Incorrect captcha provided.';
4 | const NO_CAPTCHA = 'No captcha provided.';
5 |
6 | export default class BadCaptchaError extends FakeError {
7 | constructor(captcha, newCaptcha, errors) {
8 | const message = captcha ? INCORRECT_CAPTCHA : NO_CAPTCHA;
9 | super(message);
10 | this.name = 'BadCaptchaError';
11 | this.captcha = captcha;
12 | this.newCaptcha = newCaptcha;
13 | this.errors = errors;
14 | this.status = 200;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/models/account.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 | import { USER_TYPE } from '../models2/thingTypes';
3 |
4 | export default class Account extends Base {
5 | _type = 'Account';
6 |
7 | validators () {
8 | const thingId = this.thingIdValidator.bind(this);
9 |
10 | return {
11 | thingId,
12 | };
13 | }
14 |
15 | uuid(props) {
16 | if (Base.validators.thingId(props.id)) {
17 | return props.id;
18 | }
19 |
20 | return `${USER_TYPE}_${props.id}`;
21 | }
22 |
23 | thingIdValidator () {
24 | const thingId = this.get('thingId');
25 | return Base.validators.thingId(thingId);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/collections/SavedPostsAndComments.es6.js:
--------------------------------------------------------------------------------
1 | import Listing from './Listing';
2 | import SavedEndpoint from '../apis/SavedEndpoint';
3 |
4 | export default class SavedPostsAndComments extends Listing {
5 | static endpoint = SavedEndpoint;
6 |
7 | static fetch(apiOptions, userOrOptions, options={}) {
8 | if (typeof userOrOptions === 'string') {
9 | options.user = userOrOptions;
10 | } else {
11 | options = userOrOptions || {};
12 | }
13 |
14 | return super.fetch(apiOptions, options);
15 | }
16 |
17 | get postsAndComments() {
18 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/models/subscription.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | const _subscriptionAllowedActions = {
4 | 'sub': true,
5 | 'unsub': true,
6 | };
7 |
8 | class Subscription extends Base {
9 | _type = 'Subscription';
10 |
11 | validators () {
12 | const action = this.actionValidator.bind(this);
13 | const sr = this.srValidator.bind(this);
14 |
15 | return {
16 | action,
17 | sr,
18 | };
19 | }
20 |
21 | actionValidator (val) {
22 | return _subscriptionAllowedActions[val];
23 | }
24 |
25 | srValidator (val) {
26 | return Base.validators.string(val);
27 | }
28 | }
29 |
30 | export default Subscription;
31 |
--------------------------------------------------------------------------------
/src/apis/reports.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 | import Report from '../models/report';
3 |
4 | export default class ReportsEndpoint extends BaseEndpoint {
5 | model = Report;
6 |
7 | move = this.notImplemented('move');
8 | copy = this.notImplemented('copy');
9 | get = this.notImplemented('get');
10 | put = this.notImplemented('put');
11 | patch = this.notImplemented('patch');
12 | del = this.notImplemented('del');
13 |
14 | path() {
15 | return 'api/report';
16 | }
17 |
18 | post(data) {
19 | return super.post({
20 | ...data,
21 | reason: 'other',
22 | api_type: 'json',
23 | });
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/blueprints.config.js:
--------------------------------------------------------------------------------
1 | module.exports = [{
2 | name: 'apiClient',
3 | webpack: {
4 | entry: {
5 | build: './src/index.es6.js',
6 | },
7 | output: {
8 | library: '[name].js',
9 | libraryTarget: 'umd',
10 | },
11 | resolve: {
12 | generator: 'npm-and-modules',
13 | paths: [''],
14 | extensions: ['', '.js', '.jsx', '.es6.js', '.json'],
15 | },
16 | loaders: [
17 | 'esnextreact',
18 | 'json',
19 | ],
20 | plugins: [
21 | 'production-loaders',
22 | 'set-node-env',
23 | 'abort-if-errors',
24 | 'minify-and-treeshake',
25 | ],
26 | externals: 'node-modules',
27 | },
28 | }];
29 |
--------------------------------------------------------------------------------
/src/collections/PostsFromSubreddit.es6.js:
--------------------------------------------------------------------------------
1 | import Listing from './Listing';
2 | import PostsEndpoint from '../apis/PostsEndpoint';
3 |
4 | export default class PostsFromSubreddit extends Listing {
5 | static endpoint = PostsEndpoint;
6 |
7 | static fetch(apiOptions, subredditNameOrOptions, options={}) {
8 | if (typeof subredditNameOrOptions === 'string') {
9 | options.subredditName = subredditNameOrOptions;
10 | } else {
11 | options = subredditNameOrOptions || {};
12 | }
13 |
14 | return super.fetch(apiOptions, options);
15 | }
16 |
17 | get posts() {
18 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/apis/trophies.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 |
3 | import Trophy from '../models/trophy';
4 |
5 | export default class TrophiesEndpoint extends BaseEndpoint {
6 | move = this.notImplemented('move');
7 | copy = this.notImplemented('copy');
8 | put = this.notImplemented('put');
9 | patch = this.notImplemented('patch');
10 | post = this.notImplemented('post');
11 | del = this.notImplemented('del');
12 |
13 | path (method, query={}) {
14 | return `api/v1/user/${query.user}/trophies.json`;
15 | }
16 |
17 | formatBody (res) {
18 | const { body } = res;
19 |
20 | if (body) {
21 | return new Trophy(body.data).toJSON();
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/models/wikiPageSettings.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class WikiPageSettings extends Base {
4 | _type = 'WikiPageSettings';
5 |
6 | constructor(props) {
7 | props.pageEditorsList = props.editors.map(function (item) {
8 | return item.data;
9 | });
10 |
11 | delete props.editors;
12 |
13 | props.listedInPagesIndex = props.listed;
14 | delete props.listed;
15 |
16 | props.editingPermissionLevel = WikiPageSettings._permissionLevels[props.permlevel];
17 | delete props.permlevel;
18 |
19 | super(props);
20 | }
21 |
22 | static _permissionLevels = {
23 | 0: 'use wiki settings',
24 | 1: 'only approved editors',
25 | 2: 'only Mods',
26 | };
27 | }
28 |
29 | export default WikiPageSettings;
30 |
--------------------------------------------------------------------------------
/src/apis/captcha.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 | import { has } from 'lodash/object';
3 |
4 | export default class CaptchaEndpoint extends BaseEndpoint {
5 | move = this.notImplemented('move');
6 | copy = this.notImplemented('copy');
7 | put = this.notImplemented('put');
8 | patch = this.notImplemented('patch');
9 | del = this.notImplemented('del');
10 |
11 | path(method) {
12 | return `api/${method === 'post' ? 'new_captcha' : 'needs_captcha'}`;
13 | }
14 |
15 | formatBody(res) {
16 | const { body } = res;
17 |
18 | if (has(body, 'json.errors.0')) {
19 | return body.json.errors;
20 | } else if (has(body, 'json.data.iden')) {
21 | return body.json.data;
22 | }
23 |
24 | return body;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/models2/Wiki.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 | import Record from '../apiBase/Record';
3 |
4 | import { WIKI } from './thingTypes';
5 |
6 | const T = RedditModel.Types;
7 |
8 | export default class Wiki extends RedditModel {
9 | static type = WIKI;
10 |
11 | static PROPERTIES = {
12 | contentHTML: T.string,
13 | contentMD: T.string,
14 | path: T.string,
15 | mayRevise: T.bool,
16 | revisionBy: T.nop,
17 | revisionDate: T.number,
18 | }
19 |
20 | static API_ALIASES = {
21 | content_html: 'contentHTML',
22 | content_md: 'contentMD',
23 | may_revise: 'mayRevise',
24 | revision_by: 'revisionBy',
25 | revision_date: 'revisionDate',
26 | }
27 |
28 | makeUUID(data) {
29 | return data.path;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/apis/multisubscriptions.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 | import MultiSubscription from '../models/multi';
3 |
4 | import Multis from './multis';
5 |
6 | export default class MultiSubscriptionsEndpoint extends BaseEndpoint {
7 | model = MultiSubscription;
8 |
9 | move = this.notImplemented('move');
10 | copy = this.notImplemented('copy');
11 | get = this.notImplemented('get');
12 | post = this.notImplemented('post');
13 | patch = this.notImplemented('patch');
14 |
15 | path (method, query) {
16 | const id = Multis.buildId(query);
17 | return `api/multi/${id}/r/${query.subreddit}`;
18 | }
19 |
20 | formatData (data, method) {
21 | if (method === 'put') {
22 | return {
23 | model: JSON.stringify({ name: data.subreddit }),
24 | };
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/apis/subscriptions.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils';
2 |
3 | const path = 'api/subscribe';
4 |
5 | const validator = data => !!data.subreddit;
6 |
7 | const post = (apiOptions, data) => {
8 | validateData(data, 'post', 'subscriptions', validator);
9 |
10 | const postData = {
11 | sr: data.subreddit,
12 | action: 'sub',
13 | api_type: 'json',
14 | };
15 |
16 | return runQuery(apiOptions, 'post', path, postData, data);
17 | }
18 |
19 | const del = (apiOptions, data) => {
20 | validateData(data, 'del', 'subscriptions', validator);
21 |
22 | const postData = {
23 | sr: data.subreddit,
24 | action: 'unsub',
25 | api_type: 'json',
26 | };
27 |
28 | return runQuery(apiOptions, 'post', path, postData, data);
29 | }
30 |
31 | export default {
32 | post,
33 | del,
34 | };
35 |
--------------------------------------------------------------------------------
/src/apis/VoteEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import { runForm, validateData } from '../apiBase/APIRequestUtils';
2 |
3 | const path = 'api/vote';
4 |
5 | const validator = data => (
6 | !!data.thingId && typeof data.direction === 'number'
7 | );
8 |
9 | const post = (apiOptions, data) => {
10 | validateData(data, 'post', 'votes', validator);
11 |
12 | const postData = {
13 | id: data.thingId,
14 | dir: data.direction,
15 | api_type: 'json',
16 | };
17 |
18 | return runForm(apiOptions, 'post', path, postData);
19 | }
20 |
21 | const del = (apiOptions, data) => {
22 | validateData(data, 'del', 'votes', validator);
23 |
24 | const postData = {
25 | id: data.thingId,
26 | dir: 0,
27 | api_type: 'json',
28 | };
29 |
30 | return runForm(apiOptions, 'post', path, postData);
31 | }
32 |
33 | export default {
34 | post,
35 | del,
36 | };
37 |
--------------------------------------------------------------------------------
/src/apis/RecommendedSubreddits.es6.js:
--------------------------------------------------------------------------------
1 | import { isEmpty } from 'lodash/lang';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import Subreddit from '../models2/Subreddit';
5 |
6 | const parseBody = (apiResponse) => {
7 | const { body } = apiResponse.response;
8 |
9 | if (body.data && Array.isArray(body.data.children)) {
10 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data)));
11 | // sometimes, we get back empty object and 200 for invalid sorts like
12 | // `mine` when logged out
13 | } else if (!isEmpty(body)) {
14 | apiResponse.addResult(Subreddit.fromJSON(body.data || body));
15 | }
16 |
17 | return apiResponse;
18 | };
19 |
20 | const get = (apiOptions, query) => {
21 | return apiRequest(apiOptions, 'GET', 'api/similar_subreddits.json', { query })
22 | .then(parseBody);
23 | };
24 |
25 | export default { get }
26 |
--------------------------------------------------------------------------------
/src/apis/PreferencesEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery, runJson } from '../apiBase/APIRequestUtils';
2 |
3 | import Preferences from '../models2/Preferences';
4 |
5 | const PREFS_URL = '/api/v1/me/prefs';
6 |
7 | // We opt-out of using the automatic parsing from `runQuery` and `runJson`,
8 | // because the preferences object doesn't really make sense in the normalized
9 | // response model.
10 |
11 | export default {
12 | get: async (apiOptions) => {
13 | const responseBody = await runQuery(apiOptions, 'get', PREFS_URL, {}, {});
14 | return Preferences.fromJSON(responseBody || {});
15 | },
16 |
17 | patch: async (apiOptions, changes) => {
18 | const data = {
19 | ...changes,
20 | api_type: 'json',
21 | };
22 |
23 | const responseBody = await runJson(apiOptions, 'patch', PREFS_URL, data);
24 | return Preferences.fromJSON(responseBody || {});
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/src/apis/SimilarPosts.es6.js:
--------------------------------------------------------------------------------
1 | import { isEmpty } from 'lodash/lang';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import PostModel from '../models2/PostModel';
5 |
6 | const parseBody = (apiResponse) => {
7 | const { body } = apiResponse.response;
8 |
9 | if (body.data && Array.isArray(body.data.children)) {
10 | body.data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data)));
11 | // sometimes, we get back empty object and 200 for invalid sorts like
12 | // `mine` when logged out
13 | } else if (!isEmpty(body)) {
14 | apiResponse.addResult(PostModel.fromJSON(body.data || body));
15 | }
16 |
17 | return apiResponse;
18 | };
19 |
20 | const get = (apiOptions, query) => {
21 | return apiRequest(apiOptions, 'GET', 'api/similar_links.json', { 'query': {...query, raw_json: 1} })
22 | .then(parseBody);
23 | };
24 |
25 | export default { get }
26 |
--------------------------------------------------------------------------------
/src/apis/SubredditsByPost.es6.js:
--------------------------------------------------------------------------------
1 | import { isEmpty } from 'lodash/lang';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import Subreddit from '../models2/Subreddit';
5 |
6 | const parseBody = (apiResponse) => {
7 | const { body } = apiResponse.response;
8 |
9 | if (body.data && Array.isArray(body.data.children)) {
10 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data)));
11 | // sometimes, we get back empty object and 200 for invalid sorts like
12 | // `mine` when logged out
13 | } else if (!isEmpty(body)) {
14 | apiResponse.addResult(Subreddit.fromJSON(body.data || body));
15 | }
16 |
17 | return apiResponse;
18 | };
19 |
20 | const get = (apiOptions, query) => {
21 | return apiRequest(apiOptions, 'GET', 'api/subreddits_by_link.json', { 'query': {...query, raw_json: 1} })
22 | .then(parseBody);
23 | };
24 |
25 | export default { get }
26 |
--------------------------------------------------------------------------------
/src/apis/SubredditsToPostsByPost.es6.js:
--------------------------------------------------------------------------------
1 | import { isEmpty } from 'lodash/lang';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import PostModel from '../models2/PostModel';
5 |
6 | const parseBody = (apiResponse) => {
7 | const { body } = apiResponse.response;
8 |
9 | if (body.data && Array.isArray(body.data.children)) {
10 | body.data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data)));
11 | // sometimes, we get back empty object and 200 for invalid sorts like
12 | // `mine` when logged out
13 | } else if (!isEmpty(body)) {
14 | apiResponse.addResult(PostModel.fromJSON(body.data || body));
15 | }
16 |
17 | return apiResponse;
18 | };
19 |
20 | const get = (apiOptions, query) => {
21 | return apiRequest(apiOptions, 'GET', 'api/subreddits_to_links_by_link.json', { 'query': {...query, raw_json: 1} })
22 | .then(parseBody);
23 | };
24 |
25 | export default { get }
26 |
--------------------------------------------------------------------------------
/src/apis/stylesheets.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 | import Stylesheet from '../models/stylesheet';
3 |
4 | export default class StylesheetsEndpoint extends BaseEndpoint {
5 | move = this.notImplemented('move');
6 | copy = this.notImplemented('copy');
7 | put = this.notImplemented('put');
8 | patch = this.notImplemented('patch');
9 | post = this.notImplemented('post');
10 | del = this.notImplemented('del');
11 |
12 | path (method, query={}) {
13 | if (query.subredditName) {
14 | return `r/${query.subredditName}/about/stylesheet.json`;
15 | }
16 |
17 | return 'api/subreddit_stylesheet.json';
18 | }
19 |
20 | formatBody (res) {
21 | const { body } = res;
22 | const { data } = body;
23 |
24 | if (data && data.images && data.stylesheet) {
25 | return new Stylesheet(data).toJSON();
26 | } else {
27 | return {};
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/apis/accounts.es6.js:
--------------------------------------------------------------------------------
1 | import apiRequest from '../apiBase/apiRequest';
2 | import Account from '../models2/Account';
3 |
4 | const getPath = (query) => {
5 | if (query.loggedOut) {
6 | return 'api/me.json';
7 | } else if (query.user === 'me') {
8 | return 'api/v1/me';
9 | }
10 |
11 | return `user/${query.user}/about.json`;
12 | };
13 |
14 | const parseGetBody = apiResponse => {
15 | const { body } = apiResponse.response;
16 |
17 | if (body) {
18 | const data = {
19 | name: 'me', // me is reserved, this should only stay me in the logged out case
20 | loid: body.loid,
21 | loid_created: body.loid_created,
22 | ...(body.data || body),
23 | };
24 |
25 | apiResponse.addResult(Account.fromJSON(data));
26 | }
27 |
28 | return apiResponse;
29 | };
30 |
31 | export default {
32 | get(apiOptions, query) {
33 | const path = getPath(query);
34 |
35 | return apiRequest(apiOptions, 'GET', path, { query }).then(parseGetBody);
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/models/message.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class Message extends Base {
4 | _type = 'Message';
5 | constructor(props) {
6 | if (props.replies === '') {
7 | props.replies = [];
8 | }
9 |
10 | super(props);
11 | }
12 |
13 | validators () {
14 | const text = this.textValidator.bind(this);
15 | const subject = this.subjectValidator.bind(this);
16 | const to = this.toValidator.bind(this);
17 |
18 | return {
19 | text,
20 | subject,
21 | to,
22 | };
23 | }
24 |
25 | textValidator () {
26 | return Base.validators.minLength(this.get('text'), 1) &&
27 | Base.validators.maxLength(this.get('text'), 10000);
28 | }
29 |
30 | subjectValidator () {
31 | return Base.validators.minLength(this.get('subject'), 1) &&
32 | Base.validators.maxLength(this.get('subject'), 100);
33 | }
34 |
35 | toValidator () {
36 | return Base.validators.minLength(this.get('to'), 1);
37 | }
38 | }
39 |
40 | export default Message;
41 |
--------------------------------------------------------------------------------
/src/apis/wikis.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery } from '../apiBase/APIRequestUtils';
2 | import Wiki from '../models2/Wiki';
3 |
4 | const getPath = (query) => {
5 | const { subredditName } = query;
6 | let { path } = query;
7 |
8 | // Default to the index
9 | if (!path) {
10 | path = 'index';
11 | }
12 |
13 | // Strip trailing slash from the path
14 | path = path.endsWith('/') ? path.slice(0, -1) : path;
15 |
16 | if (subredditName) {
17 | return `r/${subredditName}/wiki/${path}`;
18 | } else {
19 | return `wiki/${path}`;
20 | }
21 | };
22 |
23 | const parseGetBody = path => (res, apiResponse) => {
24 | const { body } = res;
25 | if (body) {
26 | const data = {
27 | path,
28 | ...(body.data || body),
29 | };
30 |
31 | apiResponse.addResult(Wiki.fromJSON(data));
32 | }
33 | };
34 |
35 | export default {
36 | get(apiOptions, query) {
37 | const path = getPath(query);
38 | const url = `${path}.json`;
39 |
40 | return runQuery(apiOptions, 'get', url, {}, query, parseGetBody(path));
41 | },
42 | };
43 |
--------------------------------------------------------------------------------
/src/apis/activities.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery } from '../apiBase/APIRequestUtils';
2 |
3 | import CommentModel from '../models2/CommentModel';
4 | import PostModel from '../models2/PostModel';
5 |
6 | const CONSTRUCTORS = {
7 | t1: CommentModel,
8 | t3: PostModel,
9 | };
10 |
11 | const getPath = query => (`user/${query.user}/${query.activity}.json`);
12 |
13 | const formatQuery = query => ({
14 | ...query,
15 | feature: 'link_preview',
16 | sr_detail: 'true',
17 | });
18 |
19 | const parseBody = (res, apiResponse) => {
20 | const { body } = res;
21 |
22 | if (body) {
23 | const activities = body.data.children;
24 |
25 | activities.forEach(function(a) {
26 | const constructor = CONSTRUCTORS[a.kind];
27 | apiResponse.addResult(constructor.fromJSON(a.data));
28 | });
29 | }
30 | };
31 |
32 | export default {
33 | get(apiOptions, query) {
34 | const path = getPath(query);
35 | const formattedQuery = formatQuery(query);
36 |
37 | return runQuery(apiOptions, 'get', path, formattedQuery, query, parseBody);
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/src/collections/CommentsPage.es6.js:
--------------------------------------------------------------------------------
1 | import Listing from './Listing';
2 | import CommentsEndpoint from '../apis/CommentsEndpoint';
3 | import NotImplementedError from '../apiBase/errors/NotImplementedError';
4 |
5 | export default class CommentsPage extends Listing {
6 | static endpoint = CommentsEndpoint
7 |
8 | static fetch(apiOptions, id) {
9 | if (typeof id === 'string') {
10 | id = { id };
11 | }
12 |
13 | return super.fetch(apiOptions, id);
14 | }
15 |
16 | static fetchMoreChildren(apiOptions, comment) {
17 | return super.fetch(apiOptions, { ids: comment.children });
18 | }
19 |
20 | get topLevelComments() {
21 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord);
22 | }
23 |
24 | replies(comment) {
25 | return comment.replies.map(this.apiResponse.getModelFromRecord);
26 | }
27 |
28 | async nextResponse() {
29 | throw new NotImplementedError('comments collection pageing not supported yet');
30 | }
31 |
32 | async prevResponse() {
33 | throw new NotImplementedError('comments collection pageing not supported yet');
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/models2/MessageModel.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 |
3 | const T = RedditModel.Types;
4 |
5 | export default class Message extends RedditModel {
6 | static PROPERTIES = {
7 | id: T.string,
8 | author: T.string,
9 | name: T.string,
10 | bodyHTML: T.string,
11 | isComment: T.bool,
12 | firstMessage: T.string,
13 | firstMessageName: T.string,
14 | createdUTC: T.number,
15 | subreddit: T.string,
16 | parentId: T.string,
17 | replies: T.arrayOf(T.string),
18 | distinguished: T.string,
19 | subject: T.string,
20 |
21 | // derived
22 | cleanPermalink: T.link,
23 | };
24 |
25 | static API_ALIASES = {
26 | was_comment: 'isComment',
27 | first_message: 'firstMessage',
28 | first_message_name: 'firstMessageName',
29 | created_utc: 'createdUTC',
30 | body_html: 'bodyHTML',
31 | parent_id: 'parentId',
32 | };
33 |
34 | static DERIVED_PROPERTIES = {
35 | cleanPermalink(data) {
36 | const { id } = data;
37 | if (!id) {
38 | return null;
39 | }
40 | return `/message/messages/${id}`;
41 | },
42 | };
43 | }
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Jack Lawson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/apiBase/errors/FakeError.es6.js:
--------------------------------------------------------------------------------
1 | import omit from 'lodash/omit';
2 |
3 | export default class FakeError {
4 | constructor (message) {
5 | Object.defineProperty(this, 'message', { value: message });
6 |
7 | if (Error.hasOwnProperty('captureStackTrace')) {
8 | Error.captureStackTrace(this, this.constructor);
9 | } else {
10 | Object.defineProperty(this, 'stack', { value: (new Error()).stack });
11 | }
12 | }
13 |
14 | /**
15 | * Use this when you want to merge properties from an object onto
16 | * an instance of FakeError. In other FakeError subclasses we used to
17 | * write things like `Object.assign(fakeErrorInstance, errorObject)`.
18 | * This code breaks because `errorObject`, an instnace of the Error class,
19 | * can have a property called `message` or `stack` that we assign
20 | * as read-only properties in the FakeError constructor.
21 | *
22 | * @param {Object} - the object we want to copy properties from
23 | * @returns {undefined} - used for the side-effect of copying key/values from input
24 | */
25 | safeAssignProps(obj) {
26 | Object.assign(this, omit(obj, ['message', 'stack']));
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/models2/mixins/votable.js:
--------------------------------------------------------------------------------
1 | import mixin from './mixin';
2 | import votes from '../../apis/VoteEndpoint';
3 |
4 | export function upvote (apiOptions) {
5 | // If already upvoted, cancel out the upvote.
6 | return this._vote(apiOptions, 1);
7 | }
8 |
9 | export function downvote (apiOptions) {
10 | // If already downvoted, cancel out the upvote.
11 | return this._vote(apiOptions, -1);
12 | }
13 |
14 | export function _vote (apiOptions, direction) {
15 | const oldModel = this;
16 |
17 | const undoingVote = direction === this.likes;
18 | const newLikes = undoingVote ? 0 : direction;
19 | const newScore = undoingVote
20 | ? this.score - direction
21 | : this.score - this.likes + direction;
22 |
23 | const stub = this.stub({
24 | likes: newLikes,
25 | score: newScore,
26 | }, async (resolve, reject) => {
27 | try {
28 | const endpoint = direction === 0 ? votes.del : votes.post;
29 | await endpoint(apiOptions, { thingId: oldModel.name, direction });
30 | return stub;
31 | } catch (e) {
32 | throw oldModel;
33 | }
34 | });
35 |
36 | return stub;
37 | }
38 |
39 | export default (cls) => mixin(cls, { upvote, downvote, _vote });
40 |
--------------------------------------------------------------------------------
/src/apis/modListing.es6.js:
--------------------------------------------------------------------------------
1 | import BaseContentEndpoint from './BaseContentEndpoint';
2 | import { has } from 'lodash/object';
3 |
4 | import PostModel from '../models2/PostModel';
5 | import Comment from '../models2/Comment';
6 |
7 | export default class ModListingEndpoint extends BaseContentEndpoint {
8 | move = this.notImplemented('move');
9 | copy = this.notImplemented('copy');
10 | put = this.notImplemented('put');
11 | patch = this.notImplemented('patch');
12 | del = this.notImplemented('del');
13 | post = this.notImplemented('post');
14 |
15 | path(method, query={}) {
16 | const { subreddit, modPath } = query;
17 | return `r/${subreddit}/about/${modPath}.json`;
18 | }
19 |
20 | formatQuery (options) {
21 | options.sr_detail = 'true';
22 |
23 | return options;
24 | }
25 |
26 | parseBody(res, apiResponse) {
27 | const { body } = res;
28 |
29 | if (has(body, 'data.children.0')) {
30 | body.data.children.forEach(c => {
31 | if (c.kind == 't3') {
32 | apiResponse.addResult(PostModel.fromJSON(c.data));
33 | } else if (c.kind === 't1') {
34 | apiResponse.addResult(Comment.fromJSON(c.data));
35 | }
36 | });
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/apiBase/APIResponsePaging.es6.js:
--------------------------------------------------------------------------------
1 | import { last } from 'lodash/array';
2 | import { MergedApiReponse } from './APIResponse';
3 |
4 | export const withQueryAndResult = (response, fn) => {
5 | let query;
6 | let results;
7 |
8 | if (response instanceof MergedApiReponse) {
9 | query = response.lastQuery;
10 | results = response.lastResponse.results;
11 | } else {
12 | query = response.query;
13 | results = response.results;
14 | }
15 |
16 | return fn(query, results);
17 | };
18 |
19 | export const afterResponse = (response) => withQueryAndResult(response, (query, results) => {
20 | const limit = query.limit || 25;
21 | return results.length >= limit ? last(results).paginationId : null;
22 | });
23 |
24 | export const beforeResponse = response => withQueryAndResult(response, (query, results) => {
25 | return query.after ? results[0].paginationId : null;
26 | });
27 |
28 | export const fetchAll = async (fetchFunction, apiOptions, initialParams, afterFn=afterResponse) => {
29 | let params = { ...initialParams };
30 | let response = await fetchFunction(apiOptions, params);
31 |
32 | let after = afterFn(response);
33 | while (after) {
34 | params = { ...params, after };
35 | response = response.appendResponse(await fetchFunction(apiOptions, params));
36 | after = afterResponse(response);
37 | }
38 |
39 | return response;
40 | };
41 |
--------------------------------------------------------------------------------
/src/apis/EditUserTextEndpoint.js:
--------------------------------------------------------------------------------
1 | import apiRequest from '../apiBase/apiRequest';
2 | import NoModelError from '../apiBase/errors/NoModelError';
3 | import ValidationError from '../apiBase/errors/ValidationError';
4 |
5 | import PostModel from '../models2/PostModel';
6 | import CommentModel from '../models2/CommentModel';
7 |
8 | const TYPE_MAP = {
9 | t1: CommentModel,
10 | t3: PostModel,
11 | };
12 |
13 | const ENDPOINT = '/api/editusertext';
14 |
15 | export default {
16 | post(apiOptions, data={}) {
17 | const { thingId, text } = data;
18 | if (!thingId || !text) {
19 | throw new NoModelError(ENDPOINT);
20 | }
21 |
22 | const options = {
23 | type: 'form',
24 | query: {
25 | raw_json: 1, // make sure html back from the server is un-escaped
26 | },
27 | body: {
28 | api_type: 'json',
29 | text,
30 | thing_id: thingId,
31 | },
32 | };
33 |
34 | return apiRequest(apiOptions, 'POST', ENDPOINT, options)
35 | .then(apiResponse => {
36 | const { body: { json }} = apiResponse.response;
37 | if (json.errors.length) {
38 | throw new ValidationError(ENDPOINT, json.errors, apiResponse.response.status);
39 | }
40 |
41 | const thing = json.data.things[0];
42 | return TYPE_MAP[thing.kind].fromJSON(thing.data);
43 | });
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/src/apis/subredditRelationships.es6.js:
--------------------------------------------------------------------------------
1 | import BaseContentEndpoint from './BaseContentEndpoint';
2 |
3 | const MODACTIONS = {
4 | contributor: 'friend',
5 | leaveContrib: 'leavecontributor',
6 | moderator_invite: 'friend',
7 | leaveMod: 'leavemoderator',
8 | acceptModInvite: 'accept_moderator_invite',
9 | banned: 'friend',
10 | muted: 'friend',
11 | wikibanned: 'friend',
12 | wikicontributor: 'friend',
13 | };
14 |
15 | export default class SubredditRelationshipsEndpoint extends BaseContentEndpoint {
16 | move = this.notImplemented('move');
17 | copy = this.notImplemented('copy');
18 | put = this.notImplemented('put');
19 | patch = this.notImplemented('patch');
20 |
21 | path(method, query={}) {
22 | const { subreddit, type, filter } = query;
23 | if (method === 'get') {
24 | return path = `r/${subreddit}/about/${filter}`;
25 | }
26 |
27 | const sub = subreddit ? `r/${subreddit}/` : '';
28 | let path = MODACTIONS[type];
29 |
30 | if (method === 'del' && path === 'friend') {
31 | path = 'unfriend';
32 | }
33 |
34 | return `${sub}api/${path}`;
35 | }
36 |
37 | get(data) {
38 | data.count = 25;
39 | return super.get(data);
40 | }
41 |
42 | post(data) {
43 | data.api_type = 'json';
44 | return super.post(data);
45 | }
46 |
47 | del(data) {
48 | data._method = 'post';
49 | return super.del(data);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/models2/SubredditRule.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 | import { SUBREDDIT_RULE } from './thingTypes';
3 |
4 | const T = RedditModel.Types;
5 |
6 | export default class SubredditRule extends RedditModel {
7 | static type = SUBREDDIT_RULE;
8 |
9 | /**
10 | * Valid types for rule targets.
11 | * @enum
12 | */
13 | static RULE_TARGET = {
14 | ALL: 'all',
15 | POST: 'link',
16 | COMMENT: 'comment',
17 | };
18 |
19 | static PROPERTIES = {
20 | createdUTC: T.number,
21 | description: T.string,
22 | descriptionHTML: T.string,
23 | kind: T.string,
24 | priority: T.number,
25 | shortName: T.string,
26 | violationReason: T.string,
27 |
28 | // The `subredditName` property is not returned from the API directly. It is
29 | // mixed into the response data by `SubredditRulesEndpoint.get` in order
30 | // to enable making unique UUIDs.
31 | subredditName: T.string,
32 | };
33 |
34 | static API_ALIASES = {
35 | short_name: 'shortName',
36 | created_utc: 'createdUTC',
37 | description_html: 'descriptionHTML',
38 | violation_reason: 'violationReason',
39 | };
40 |
41 | makeUUID(data) {
42 | // The actual rules model in r2 doesn't have a proper unique key, but
43 | // the `created_utc` timestamp should work since it shouldn't change.
44 | return `${data.subredditName}/${data.created_utc || data.createdUTC}`;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/models/report.es6.js:
--------------------------------------------------------------------------------
1 | import Base from './base';
2 |
3 | class Report extends Base {
4 | _type = 'Report';
5 |
6 | validators () {
7 | const reason = this.reasonValidator.bind(this);
8 | const otherReason = this.otherReasonValidator.bind(this);
9 | const thingId = this.thingIdValidator.bind(this);
10 |
11 | this.validators = {
12 | reason,
13 | otherReason,
14 | thingId,
15 | };
16 | }
17 |
18 | reasonValidator () {
19 | if (this.get('other_reason') && !(this.get('reason') === 'other')) {
20 | return false;
21 | }
22 |
23 | const reasonValid = Base.validators.minLength(this.get('reason'), 1) &&
24 | Base.validators.maxLength(this.get('reason'), 100);
25 |
26 | const otherReasonValid = !this.get('other_reason') || (
27 | Base.validators.minLength(this.get('other_reason'), 1) &&
28 | Base.validators.maxLength(this.get('other_reason'), 100)
29 | );
30 |
31 | return reasonValid && otherReasonValid;
32 | }
33 |
34 | otherReasonValidator () {
35 | if (this.get('reason') !== 'other') {
36 | if (this.get('other_reason')) {
37 | return false;
38 | }
39 | } else if (!this.get('reason')) {
40 | return false;
41 | }
42 |
43 | return Base.validators.maxLength(this.get('thing_id'), 100);
44 | }
45 |
46 | thingIdValidator () {
47 | return Base.validators.minLength(this.get('thing_id'), 6) &&
48 | Base.validators.maxLength(this.get('thing_id'), 10);
49 | }
50 | }
51 |
52 | export default Report;
53 |
--------------------------------------------------------------------------------
/src/models2/thingTypes.es6.js:
--------------------------------------------------------------------------------
1 | export const COMMENT = 'comment';
2 | export const COMMENT_TYPE = 't1';
3 | export const COMMENT_LOAD_MORE = 'comment_load_more';
4 |
5 | export const ACCOUNT = 'account';
6 | export const ACCOUNT_TYPE = 't2';
7 |
8 | export const POST = 'post';
9 | export const POST_TYPE = 't3';
10 |
11 | export const MESSAGE = 'message';
12 | export const MESSAGE_TYPE = 't4';
13 |
14 | export const SUBREDDIT = 'subreddit';
15 | export const SUBREDDIT_TYPE = 't5';
16 |
17 | export const TROPHIE = 'trophie';
18 | export const TROPHIE_TYPE = 't6';
19 |
20 | export const PROMOCAMPAIGN = 'promocampaign';
21 | export const PROMOCAMPAIGN_TYPE = 't8';
22 |
23 | // Honorary things
24 | export const WIKI = 'wiki';
25 | export const WIKI_TYPE = 'wiki';
26 |
27 | export const SUBREDDIT_RULE = 'subreddit_rule';
28 | export const SUBREDDIT_RULE_TYPE = 'subreddit_rule';
29 |
30 | const type_pairs = [
31 | [COMMENT, COMMENT_TYPE],
32 | [ACCOUNT, ACCOUNT_TYPE],
33 | [POST, POST_TYPE],
34 | [MESSAGE, MESSAGE_TYPE],
35 | [SUBREDDIT, SUBREDDIT_TYPE],
36 | [TROPHIE, TROPHIE_TYPE],
37 | [PROMOCAMPAIGN, PROMOCAMPAIGN_TYPE],
38 | [WIKI, WIKI_TYPE],
39 | [SUBREDDIT_RULE, SUBREDDIT_RULE_TYPE],
40 | ];
41 |
42 | export const TYPES = type_pairs.reduce((table, pair) => {
43 | table[pair[1]] = pair[0];
44 | return table;
45 | }, {});
46 |
47 | export const TYPE_TO_THING_TYPE = type_pairs.reduce((table, pair)=> {
48 | table[pair[0]] = pair[1];
49 | return table;
50 | }, {});
51 |
52 | export function thingType(id) {
53 | return TYPES[id.substring(0, 2)];
54 | }
55 |
--------------------------------------------------------------------------------
/src/collections/SubredditLists.js:
--------------------------------------------------------------------------------
1 | import { fetchAll, afterResponse, beforeResponse } from '../apiBase/APIResponsePaging';
2 | import Listing from './Listing';
3 | import SubredditEndpoint from '../apis/SubredditEndpoint';
4 |
5 | export class SubredditList extends Listing {
6 | static sortFromOptions = () => {}
7 | static sort = '';
8 | static limit = 100;
9 | static endpoint = SubredditEndpoint;
10 |
11 | static baseOptions(apiOptions) {
12 | return {
13 | sort: this.sortFromOptions(apiOptions) || this.sort,
14 | limit: this.limit,
15 | sr_detail: true,
16 | };
17 | }
18 |
19 | static async fetch(apiOptions, all=true) {
20 | if (all) {
21 | const { get } = SubredditEndpoint;
22 | const allMergedSubreddits = await fetchAll(get, apiOptions,
23 | this.baseOptions(apiOptions));
24 |
25 | return new this(allMergedSubreddits);
26 | }
27 |
28 | const firstPage = await this.getResponse(apiOptions);
29 | return new this(firstPage);
30 | }
31 |
32 | get subreddits() {
33 | return this.apiResponse.results.map(this.apiResponse.getModelFromRecord);
34 | }
35 | }
36 |
37 | export class SubscribedSubreddits extends SubredditList {
38 | static sortFromOptions = (apiOptions) => {
39 | if (apiOptions.token) {
40 | return 'mine/subscriber';
41 | }
42 |
43 | return 'default';
44 | }
45 | }
46 |
47 | export class ModeratingSubreddits extends SubredditList {
48 | static sort = 'mine/moderator';
49 | }
50 |
51 | export class ContributingSubreddits extends SubredditList {
52 | static sort = 'mine/contributor';
53 | }
54 |
--------------------------------------------------------------------------------
/src/models2/RedditModel.es6.js:
--------------------------------------------------------------------------------
1 | import Model from '../apiBase/Model';
2 |
3 | import { TYPES, thingType } from './thingTypes';
4 |
5 | import isThingID from '../lib/isThingID';
6 | import process from '../lib/markdown';
7 | import unredditifyLink from '../lib/unredditifyLink';
8 |
9 | import mockHTML from './mockgenerators/mockHTML';
10 | import mockLink from './mockgenerators/mockLink';
11 |
12 | // TYPES I'd like to add
13 | // mod: (type) => type
14 | // useage: bannedBy: T.mod(T.string),
15 | // purpose: Just to document that a field is only going to be there as a moderator
16 | //
17 | // record: val => val instanceOf Record ? val : new Record()
18 | // usage: replies: T.arrayOf(T.record)
19 | // purpose: Enforce that model relations are defined as records
20 | //
21 | // model: ModelClass => val => ModelClass.fromJSON(val)
22 | // usage: srDetail: T.model(SubredditDetailModel)
23 | // purpose: express nested model parsing for one off nested parts of your model
24 |
25 | export default class RedditModel extends Model {
26 |
27 | static Types = {
28 | ...Model.Types,
29 | html: val => Model.Types.string(val),
30 | link: val => unredditifyLink(Model.Types.string(val)),
31 | };
32 |
33 | static MockTypes = {
34 | ...Model.MockTypes,
35 | html: mockHTML,
36 | link: mockLink,
37 | };
38 |
39 | makeUUID(data) {
40 | if (isThingID(data.name)) { return data.name; }
41 | if (isThingID(data.id)) { return data.id; }
42 | return super.makeUUID(data);
43 | }
44 |
45 | getType(data, uuid) {
46 | return super.getType(data, uuid) || TYPES[data.kind] || thingType(uuid) || 'Unknown';
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/apiBase/apiRequest.js:
--------------------------------------------------------------------------------
1 | import superagent from 'superagent';
2 |
3 | import { APIResponse } from './APIResponse';
4 | import ResponseError from './errors/ResponseError';
5 |
6 |
7 | /* A thin helper function around our apis.
8 | *
9 | * @param {Object} apiOptions - fields required to hit our api
10 | * @param {String} method - the http method upper or lowercased, e.g. 'GET'
11 | * @param {String} path - the endpoint path
12 | * @param {Object} options - use to set query params, a body, or request type
13 | */
14 | export default (apiOptions, method, path, options={}) => {
15 | const { query={}, body={}, type=null } = options;
16 | const { origin, appName, env, token, headers={}, queryParams={}, } = apiOptions;
17 |
18 | const _method = method.toLowerCase();
19 | const _headers = token
20 | ? { ...headers, Authorization: `Bearer ${token}` }
21 | : headers;
22 | const _query = {
23 | ...queryParams,
24 | ...query,
25 | app: `${appName}-${env}`,
26 | };
27 |
28 | const _path = path.startsWith('/') ? path : `/${path}`;
29 | const endpoint = `${origin}${_path}`;
30 | const request = superagent[_method](endpoint).set(_headers).query(_query);
31 |
32 | if (type) {
33 | request.type(type);
34 | }
35 |
36 | if (_method === 'post') {
37 | request.send(body);
38 | }
39 |
40 | return new Promise((resolve, reject) => {
41 | request.end((err, res) => {
42 | if (!err) {
43 | resolve(new APIResponse(res));
44 | } else {
45 | if (err && err.timeout) {
46 | err.status = 504;
47 | }
48 |
49 | reject(new ResponseError(err, _path));
50 | }
51 | });
52 | });
53 | }
54 |
--------------------------------------------------------------------------------
/src/models2/Account.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 |
3 | import { ACCOUNT } from './thingTypes';
4 |
5 | const T = RedditModel.Types;
6 |
7 | export default class Subreddit extends RedditModel {
8 | static type = ACCOUNT;
9 |
10 | static PROPERTIES = {
11 | commentKarma: T.number,
12 | createdUTC: T.number,
13 | features: T.nop,
14 | goldCreddits: T.number,
15 | goldExpiration: T.number,
16 | hasMail: T.bool,
17 | hasModMail: T.bool,
18 | hasVerifiedEmail: T.bool,
19 | hideFromRobots: T.bool,
20 | id: T.string,
21 | inBeta: T.bool,
22 | inboxCount: T.number,
23 | isEmployee: T.bool,
24 | isGold: T.bool,
25 | isMod: T.bool,
26 | isSuspended: T.bool,
27 | linkKarma: T.number,
28 | loid: T.string,
29 | loidCreated: T.number,
30 | name: T.string,
31 | oauthClientId: T.string,
32 | over18: T.bool,
33 | suspensionExpirationUTC: T.number,
34 | }
35 |
36 | static API_ALIASES = {
37 | comment_karm: 'commentKarma',
38 | created_utc: 'createdUTC',
39 | gold_creddits: 'goldCreddits',
40 | gold_expiration: 'goldExpiration',
41 | has_mail: 'hasMail',
42 | has_mod_mail: 'hasModMail',
43 | has_verified_email: 'hasVerifiedEmail',
44 | hide_from_robots: 'hideFromRobots',
45 | in_beta: 'inBeta',
46 | is_employee: 'isEmployee',
47 | is_gold: 'isGold',
48 | is_mod: 'isMod',
49 | is_suspended: 'isSuspended',
50 | link_karma: 'linkKarma',
51 | loid_created: 'loidCreated',
52 | oauth_client_id: 'oauthClientId',
53 | over_18: 'over18',
54 | suspension_expiration_utc: 'suspensionExpirationUTC',
55 | }
56 |
57 | makeUUID(data) {
58 | return data.name;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@r/api-client",
3 | "version": "3.37.0",
4 | "description": "A wrapper for Reddit's API",
5 | "main": "build.js",
6 | "scripts": {
7 | "watch": "blueprints -w",
8 | "build": "blueprints",
9 | "prepublish": "npm run build",
10 | "lint": "eslint ./src",
11 | "repl": "./repl"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/reddit/node-api-client"
16 | },
17 | "keywords": [
18 | "reddit",
19 | "api"
20 | ],
21 | "author": "reddit.com",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/reddit/node-api-client/issues"
25 | },
26 | "peerDependencies": {
27 | "lodash": "4.x",
28 | "superagent": "1.x"
29 | },
30 | "devDependencies": {
31 | "@r/build": "~0.10.1",
32 | "babel-core": "^6.8.0",
33 | "babel-eslint": "^6.0.4",
34 | "babel-plugin-syntax-trailing-function-commas": "^6.8.0",
35 | "babel-plugin-transform-async-to-generator": "^6.8.0",
36 | "babel-plugin-transform-class-properties": "^6.8.0",
37 | "babel-plugin-transform-object-rest-spread": "^6.8.0",
38 | "babel-plugin-transform-react-constant-elements": "^6.8.0",
39 | "babel-plugin-transform-react-inline-elements": "^6.8.0",
40 | "babel-polyfill": "6.8.0",
41 | "babel-preset-es2015": "^6.5.0",
42 | "babel-preset-react": "^6.5.0",
43 | "babel-register": "6.5.1",
44 | "chai": "3.5.0",
45 | "eslint": "^2.9.0",
46 | "eslint-plugin-babel": "2.1.1",
47 | "eslint-plugin-react": "3.16.1",
48 | "lodash": "4.x",
49 | "mocha": "2.4.5",
50 | "sinon": "1.17.4",
51 | "sinon-chai": "2.8.0",
52 | "superagent": "1.x",
53 | "webpack": "2.1.0-beta.7"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/apis/SearchEndpoint.js:
--------------------------------------------------------------------------------
1 | import { runQuery } from '../apiBase/APIRequestUtils';
2 |
3 | import PostModel from '../models2/PostModel';
4 | import Subreddit from '../models2/Subreddit';
5 | import { POST_TYPE } from '../models2/thingTypes';
6 |
7 | const getPath = (query) => {
8 | let path = '';
9 |
10 | if (query.subreddit) {
11 | path = `r/${query.subreddit}/`;
12 | }
13 |
14 | return `${path}search.json`;
15 | };
16 |
17 | const formatQuery = (query) => {
18 | if (query.subreddit) {
19 | query.restrict_sr = 'on';
20 | delete query.subreddit;
21 | }
22 |
23 | return query;
24 | };
25 |
26 | const listsFromResponse = (res) => {
27 | const { body } = res;
28 | if (!body) { return []; }
29 |
30 | // If only one type is returned body will be an object;
31 | return Array.isArray(body) ? body : [body];
32 | };
33 |
34 | const parseBody = (res, apiResponse) => {
35 | const lists = listsFromResponse(res);
36 |
37 | lists.forEach((listing) => {
38 | if (listing.data.children.length) {
39 | if (listing.data.children[0].kind === POST_TYPE) {
40 | listing.data.children.forEach((link) => {
41 | apiResponse.addResult(PostModel.fromJSON(link.data));
42 | });
43 |
44 | apiResponse.meta.after = listing.data.after;
45 | apiResponse.meta.before = listing.data.before;
46 | } else {
47 | listing.data.children.forEach((subreddit) => {
48 | apiResponse.addResult(Subreddit.fromJSON(subreddit.data));
49 | });
50 | }
51 | }
52 | });
53 | };
54 |
55 | export default {
56 | get(apiOptions, query) {
57 | const path = getPath(query);
58 | const apiQuery = formatQuery({ ...query });
59 |
60 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody);
61 | },
62 | };
63 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 |
4 | "root": true,
5 |
6 | "env": {
7 | // I write for browser
8 | "browser": true,
9 | // in CommonJS
10 | "node": true
11 | },
12 |
13 | "extends": "eslint:recommended",
14 |
15 | // plugins let use use custom rules below
16 | "plugins": [
17 | "babel",
18 | "react"
19 | ],
20 |
21 | "ecmaFeatures": {
22 | "jsx": true,
23 | "es6": true,
24 | },
25 |
26 | "rules": {
27 | // 0 = off
28 | // 1 = warn
29 | // 2 = error
30 |
31 | // React specifc rules
32 | "react/jsx-boolean-value": 0,
33 | "react/jsx-closing-bracket-location": 1,
34 | "react/jsx-curly-spacing": [2, "always"],
35 | "react/jsx-indent-props": [1, 2],
36 | "react/jsx-no-undef": 1,
37 | "react/jsx-uses-react": 1,
38 | "react/jsx-uses-vars": 1,
39 | "react/wrap-multilines": 1,
40 | "react/react-in-jsx-scope": 1,
41 | "react/prefer-es6-class": 1,
42 | // no binding functions in render for perf
43 | "react/jsx-no-bind": 1,
44 |
45 | // handle async/await functions correctly
46 | // "babel/generator-star-spacing": 1,
47 | // handle object spread
48 | "babel/object-shorthand": 1,
49 |
50 | "indent": [2, 2, {"SwitchCase": 1}],
51 | "max-len": [1, 100, 2, {"ignoreComments": true}],
52 | "no-unused-vars": 1,
53 | "no-console": 0,
54 | // semicolons
55 | "semi": [2, "always"],
56 | "brace-style": [2, "1tbs", { "allowSingleLine": true }],
57 | "comma-dangle": [2, "always-multiline"],
58 | "consistent-return": 0,
59 | "no-underscore-dangle": 0,
60 | "quotes": [2, "single"],
61 | "keyword-spacing": [2],
62 | "space-before-blocks": [2, "always"],
63 | "space-before-function-parentheses": [0, "never"],
64 | "space-in-brackets": [0, "never"],
65 | "space-in-parens": [2, "never"],
66 | "space-unary-ops": [1, { "words": true, "nonwords": false }],
67 | "strict": [2, "never"],
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/apiBase/errors/ResponseError.js:
--------------------------------------------------------------------------------
1 | import FakeError from './FakeError';
2 |
3 | export class DisconnectedError extends FakeError {
4 | constructor(error, url) {
5 | super(`URL ${url} not reachable. You are probably disconnected from the internet.`);
6 | this.safeAssignProps(error);
7 | }
8 | }
9 |
10 | const codeMap = {
11 | ECONNREFUSED: DisconnectedError,
12 | ENOTFOUND: DisconnectedError,
13 | };
14 |
15 | export default class ResponseError extends FakeError {
16 | constructor (error, url) {
17 | // Make sure an error and url were actually passed in
18 | if (!error) { throw new Error('No error passed to ResponseError'); }
19 | if (!url) { throw new Error('No url passed to ResponseError'); }
20 |
21 | // HACK: technically, we should be able to skip right to the check for
22 | // `if (error.code && error.syscall) { ... }`, but there's a bug in babel
23 | // preventing us from doing so. Babel wants to make sure `super` is called
24 | // before we exit this constructor. This check is technically unneeded, because
25 | // we're returning a new instance of a separate class -- and aborting init
26 | // of this class. To workaround this, call super ahead of time
27 | // so babel's check passes.
28 | //
29 | // NOTE: If you're looking through compiled code, this fixes a bug where
30 | // babel added a call to `_possibleConstructorReturn` that was passed a var
31 | // named `_this2` which was declared but isn't initialized until `super` runs
32 | super(`Status ${error.status} returned from API request to ${url}`);
33 | this.safeAssignProps(error);
34 | this.name = 'ResponseError';
35 |
36 | // Check if it's a disconnection error or something else weird
37 | if (error.code && error.syscall) {
38 | return ResponseError.getSystemLevelError(error, url);
39 | }
40 | }
41 |
42 | static getSystemLevelError (error, url) {
43 | const E = codeMap[error.code] || Error;
44 | return new E(error, url);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/apis/SavedAndHiddenCommon.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils';
2 |
3 | import { has, omit } from 'lodash/object';
4 |
5 | import CommentModel from '../models2/CommentModel';
6 | import PostModel from '../models2/PostModel';
7 |
8 | const CONSTRUCTORS = {
9 | t1: CommentModel,
10 | t3: PostModel,
11 | };
12 |
13 | const parseBody = (res, apiResponse) => {
14 | const { body } = res;
15 | if (!has(body, 'data.children')) {
16 | return;
17 | }
18 |
19 | const things = body.data.children;
20 |
21 | things.forEach(function(t) {
22 | apiResponse.addResult(CONSTRUCTORS[t.kind].fromJSON(t.data));
23 | });
24 | };
25 |
26 | const formatQuery = (query) => {
27 | return omit(query, 'user');
28 | };
29 |
30 | const validator = (data) => {
31 | return !!data.id;
32 | };
33 |
34 | const dataFromQuery = (data) => {
35 | return {
36 | id: data.id,
37 | category: data.category,
38 | };
39 | };
40 |
41 | const get = (apiOptions, query, path) => {
42 | const apiQuery = formatQuery(query);
43 |
44 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody);
45 | };
46 |
47 | const del = (apiOptions, query, path) => {
48 | validateData(query, 'del', 'saved', validator);
49 | const postData = dataFromQuery(query);
50 |
51 | return runQuery(apiOptions, 'post', path, postData, query, parseBody);
52 | };
53 |
54 | const post = (apiOptions, query, path) => {
55 | validateData(query, 'post', 'saved', validator);
56 | const postData = formatQuery(query);
57 |
58 | return runQuery(apiOptions, 'post', path, postData, query, parseBody);
59 | };
60 |
61 | export default (getPathFn, delPath, postPath) => {
62 | return {
63 | get(apiOptions, query) {
64 | const path = getPathFn(query);
65 | return get(apiOptions, query, path);
66 | },
67 |
68 | del(apiOptions, query) {
69 | return del(apiOptions, query, delPath);
70 | },
71 |
72 | post(apiOptions, query) {
73 | return post(apiOptions, query, postPath);
74 | },
75 | };
76 | };
77 |
--------------------------------------------------------------------------------
/src/apis/BaseContentEndpoint.js:
--------------------------------------------------------------------------------
1 | import { pick } from 'lodash/object';
2 | import NoModelError from '../apiBase/errors/NoModelError';
3 |
4 | const MOD_ACTION_MAP = {
5 | approved: (t, data) => {
6 | return [
7 | t ? 'api/approve' : 'api/remove',
8 | pick(data, ['id', 'spam']),
9 | ];
10 | },
11 | removed: (t, data) => {
12 | return [
13 | t ? 'api/remove' : 'api/approve',
14 | pick(data, ['id', 'spam']),
15 | ];
16 | },
17 | distinguished: (_, data) => {
18 | return [
19 | 'api/distinguish',
20 | {
21 | id: data.id,
22 | how: data.distinguished,
23 | },
24 | ];
25 | },
26 | ignoreReports: (_, data) => {
27 | return [
28 | 'api/ignore_reports',
29 | {
30 | id: data.id,
31 | spam: data.isSpam,
32 | },
33 | ];
34 | },
35 | };
36 |
37 | export const formatBaseContentQuery = (query, method) => {
38 | if (method !== 'patch') {
39 | query.feature = 'link_preview';
40 | query.sr_detail = 'true';
41 | }
42 |
43 | if (method === 'del') {
44 | query._method = 'post';
45 | }
46 |
47 | return query;
48 | };
49 |
50 | export const patchPath = () => {
51 | return 'api/editusertext';
52 | };
53 |
54 | export const deletePath = () => {
55 | return 'api/del';
56 | };
57 |
58 | export const patch = (apiOptions, data) => {
59 | if (!data) {
60 | throw new NoModelError('/api/editusertext');
61 | }
62 |
63 | const promises = [];
64 |
65 | Object.keys(data).map(k => {
66 | const prop = MOD_ACTION_MAP[k];
67 | const val = data[k];
68 |
69 | if (prop) {
70 | const [api, json] = prop(val, data);
71 | promises.push(new Promise((r, x) => {
72 | this.rawSend('post', api, json, (err, res, req) => {
73 | if (err || !res.ok) {
74 | x(err || res);
75 | }
76 |
77 | r(res, req);
78 | });
79 | }));
80 | }
81 | });
82 |
83 | if (data.text) {
84 | const json = {
85 | api_type: 'json',
86 | thing_id: data.id,
87 | text: data.text,
88 | _method: 'post',
89 | };
90 |
91 | promises.push(this.save('patch', json));
92 | }
93 |
94 | return Promise.all(promises);
95 | };
96 |
--------------------------------------------------------------------------------
/src/lib/commentTreeUtils.es6.js:
--------------------------------------------------------------------------------
1 | import { COMMENT_LOAD_MORE } from '../models2/thingTypes';
2 |
3 | // All of these function rely on mutation, either for building the tree,
4 | // or for performance reasons (things like building dictionaryies), use/edit carefully
5 |
6 | export function treeifyComments(comments=[]) {
7 | const commentDict = {};
8 | comments.forEach(c => {
9 | commentDict[c.name] = c;
10 | });
11 |
12 | const topLevelComments = [];
13 |
14 | // build the tree. this relies on references, so mutability is important here
15 | comments.forEach(c => {
16 | const parent = commentDict[c.parent_id];
17 | if (!parent) {
18 | topLevelComments.push(c);
19 | return;
20 | }
21 |
22 | if (!parent.replies) { parent.replies = []; }
23 | parent.replies.push(c);
24 | });
25 |
26 | return topLevelComments;
27 | }
28 |
29 | export function parseCommentData(data) {
30 | if (data.kind === 'more') {
31 | return {
32 | type: COMMENT_LOAD_MORE,
33 | children: data.data.children,
34 | count: data.data.count,
35 | parent_id: data.data.parent_id
36 | }
37 | }
38 |
39 | const comment = data.data;
40 |
41 | if (comment.replies) {
42 | comment.replies = comment.replies.data.children.map(parseCommentData);
43 | } else {
44 | comment.replies = [];
45 | }
46 |
47 | return comment;
48 | }
49 |
50 | const COMMENT_DEFAULTS = {
51 | numReplies: 0,
52 | loadMoreIds: [],
53 | loadMore: false,
54 | };
55 |
56 | export function normalizeCommentReplies(comments, isTopLevel, visitComment) {
57 | return comments.map(comment => {
58 | if (comment.type === COMMENT_LOAD_MORE) { return; }
59 |
60 | // assign some helpful keys and their defaults to the comment
61 | Object.assign(comment, COMMENT_DEFAULTS);
62 |
63 | // Filter out if a comment is a "load more" type, set a property on the
64 | // parent comment, and then nuke the fake "reply"
65 | const loadMoreIdx = comment.replies.findIndex(c => c.type === COMMENT_LOAD_MORE);
66 | if (loadMoreIdx > -1) {
67 | const loadMoreStub = comment.replies[loadMoreIdx];
68 |
69 | comment.numReplies = loadMoreStub.count;
70 | comment.loadMoreIds = loadMoreStub.children;
71 | comment.loadMore = true;
72 | comment.replies = comment.replies.slice(0, loadMoreIdx);
73 | }
74 |
75 | comment.replies = normalizeCommentReplies(comment.replies, false, visitComment);
76 |
77 | return visitComment(comment, isTopLevel);
78 | }).filter(c => c);
79 | }
80 |
--------------------------------------------------------------------------------
/src/collections/SearchQuery.es6.js:
--------------------------------------------------------------------------------
1 | import Listing from './Listing';
2 | import SearchEndpoint from '../apis/SearchEndpoint';
3 |
4 | import { last } from 'lodash/array';
5 |
6 | import { withQueryAndResult } from '../apiBase/APIResponsePaging';
7 | import { POST, SUBREDDIT } from '../models2/thingTypes';
8 | const RESERVED_FOR_SUBBREDITS = 3; // api reserves 3 slots for subreddit results
9 |
10 | export default class SearchQuery extends Listing {
11 | static endpoint = SearchEndpoint;
12 |
13 | static fetch(apiOptions, queryOrOptions, options={}) {
14 | if (typeof queryOrOptions === 'string') {
15 | options.q = queryOrOptions;
16 | } else {
17 | options = { ...options, ...queryOrOptions };
18 | }
19 |
20 | return super.fetch(apiOptions, options);
21 | }
22 |
23 | static fetchPostsAndComments(apiOptions, queryOrOptions, options={}) {
24 | options = {
25 | ...options,
26 | include_facets: 'off',
27 | type: [ 'sr', 'link' ],
28 | sort: 'relevance',
29 | t: 'all',
30 | };
31 |
32 | return this.fetch(apiOptions, queryOrOptions, options);
33 | }
34 |
35 | static fetchPosts(apiOptions, queryOrOptions, options={}) {
36 | options = {
37 | ...options,
38 | include_facets: 'off',
39 | type: [ 'link' ],
40 | sort: 'relevance',
41 | t: 'all',
42 | };
43 |
44 | return this.fetch(apiOptions, queryOrOptions, options);
45 | }
46 |
47 | static fetchSubreddits(apiOptions, queryOrOptions, options={}) {
48 | options = {
49 | ...options,
50 | include_facets: 'off',
51 | type: [ 'sr' ],
52 | sort: 'relevance',
53 | t: 'all',
54 | };
55 |
56 | return this.fetch(apiOptions, queryOrOptions, options);
57 | }
58 |
59 | expectedNumberOfPosts(query) {
60 | return (query.limit || 25) - RESERVED_FOR_SUBBREDITS;
61 | }
62 |
63 | get afterId() {
64 | return withQueryAndResult(this.apiResponse, (query, results) => {
65 | const limit = this.expectedNumberOfPosts(query);
66 | const posts = results.filter(record => record.type === POST);
67 | return posts.length >= limit ? last(posts).uuid : null;
68 | });
69 | }
70 |
71 | get posts() {
72 | return this.apiResponse.results
73 | .filter(record => record.type === POST)
74 | .map(this.apiResponse.getModelFromRecord);
75 | }
76 |
77 | get subreddits() {
78 | return this.apiResponse.results
79 | .filter(record => record.type === SUBREDDIT)
80 | .map(this.apiResponse.getModelFromRecord);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/models/base.es6.js:
--------------------------------------------------------------------------------
1 | const THING_ID_REGEX = new RegExp('^t\\d_[0-9a-z]+', 'i');
2 |
3 | export default class Base {
4 | _type = 'Base';
5 |
6 | constructor (props={}) {
7 | this.props = {};
8 |
9 | for (let p in props) {
10 | this.props[p] = props[p];
11 | }
12 | }
13 |
14 | validators () {
15 | return;
16 | }
17 |
18 | get (name) {
19 | return this.props[name];
20 | }
21 |
22 | set (name, value) {
23 | if (typeof name === 'object') {
24 | Object.assign(this.props, name);
25 | } else {
26 | this.props[name] = value;
27 | }
28 | }
29 |
30 | validate (keys) {
31 | const validators = this.validators();
32 |
33 | if (!validators) {
34 | return true;
35 | }
36 |
37 | let invalid = [];
38 | let p;
39 |
40 | for (p in this.props) {
41 | // Optionally, send in an array of keys to validate
42 | if (!keys || keys.includes(p)) {
43 | if (validators[p] && !validators[p](this.props[p])) {
44 | invalid.push(p);
45 | }
46 | }
47 | }
48 |
49 | if (invalid.length === 0) {
50 | return true;
51 | }
52 |
53 | return invalid;
54 | }
55 |
56 | uuid(props) {
57 | if (Base.validators.thingId(props.name)) {
58 | return props.name;
59 | } else if (Base.validators.thingId(props.id)) {
60 | return props.id;
61 | }
62 | }
63 |
64 | toJSON (formatter=this.noopFormat) {
65 | let props = this.props;
66 | props._type = this._type;
67 | props.uuid = this.uuid(props);
68 |
69 | if (formatter && typeof formatter === 'function') {
70 | return formatter(props);
71 | }
72 |
73 | return props;
74 | }
75 |
76 | static validators = {
77 | integer: function(i) {
78 | return i === parseInt(i);
79 | },
80 |
81 | string: function(s) {
82 | return s === s.toString();
83 | },
84 |
85 | min: function (i, min) {
86 | return i >= min;
87 | },
88 |
89 | max: function (i, max) {
90 | return i <= max;
91 | },
92 |
93 | maxLength: function (s, l) {
94 | return Base.validators.max(s.length, l);
95 | },
96 |
97 | minLength: function (s, l) {
98 | return Base.validators.min(s.length, l);
99 | },
100 |
101 | regex: function(s, expr) {
102 | return expr.test(s);
103 | },
104 |
105 | thingId: function(id) {
106 | return id == null || Base.validators.regex(id, THING_ID_REGEX);
107 | },
108 | };
109 | }
110 |
--------------------------------------------------------------------------------
/src/collections/Listing.es6.js:
--------------------------------------------------------------------------------
1 | import { afterResponse, beforeResponse } from '../apiBase/APIResponsePaging';
2 | import { omit } from 'lodash/object';
3 |
4 | const identity = (id) => id;
5 |
6 | // Base class for paged collections
7 | // TODO: rethink base options a bit, whould base options just really make everytyhing?
8 | // think more about next page and etc, it should be easy to do paged requests
9 | // in the very first fetch call
10 | export default class Listing {
11 | static baseOptions() { return {}; }
12 |
13 | static endpoint = { get() {} };
14 |
15 | static async getResponse(apiOptions, options={}) {
16 | const res = await this.endpoint.get(apiOptions, {
17 | ...this.baseOptions(),
18 | ...options,
19 | });
20 |
21 | return res;
22 | }
23 |
24 | static async fetch(apiOptions, options={}) {
25 | return new this(await this.getResponse(apiOptions, options));
26 | }
27 |
28 | constructor(apiResponse) {
29 | this.apiResponse = apiResponse;
30 | this.nextResponse = this.nextResponse.bind(this);
31 | this.prevResponse = this.prevResponse.bind(this);
32 | }
33 |
34 | afterId(apiResponse) {
35 | return afterResponse(apiResponse);
36 | }
37 |
38 | hasNextPage() {
39 | return !!this.afterId;
40 | }
41 |
42 | prevId(apiResponse) {
43 | return beforeResponse(apiResponse);
44 | }
45 |
46 | hasPreviousPage() {
47 | return !!this.prevId;
48 | }
49 |
50 | async nextResponse(apiOptions) {
51 | const after = this.afterId(this.apiResponse);
52 | if (!after) { return ; }
53 | const options = omit({ ...this.apiResponse.query, after}, 'before');
54 | return await this.constructor.getResponse(apiOptions, options);
55 | }
56 |
57 | async prevResponse(apiOptions) {
58 | const before = this.prevId(this.apiResponse);
59 | if (!before) { return; }
60 | const options = omit({ ...this.apiResponse.query, before}, 'after');
61 | return await this.constructor.getResponse(apiOptions, options);
62 | }
63 |
64 | async fetchAndMakeInstance(fetchMethod, apiOptions, reduceResponse) {
65 | const response = await fetchMethod(apiOptions);
66 | if (response) {
67 | return new this.constructor(reduceResponse(response));
68 | }
69 | }
70 |
71 | async nextPage(apiOptions) {
72 | return this.fetchAndMakeInstance(this.nextResponse, apiOptions, identity);
73 | }
74 |
75 | async withNextPage(apiOptions) {
76 | const { nextResponse, apiResponse } = this;
77 | return this.fetchAndMakeInstance(nextResponse, apiOptions, apiResponse.appendResponse);
78 | }
79 |
80 | async prevPage(apiOptions) {
81 | return this.fetchAndMakeInstance(this.prevResponse, apiOptions, identity);
82 | }
83 |
84 | async withPrevPage(apiOptions) {
85 | return this.fetchAndMakeInstance(this.prevResponse, apiOptions, (prevResponse) => {
86 | return prevResponse.appendResponse(this.apiResponse);
87 | });
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/apis/PostsEndpoint.js:
--------------------------------------------------------------------------------
1 | import some from 'lodash/some';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import BadCaptchaError from '../apiBase/errors/BadCaptchaError';
5 | import ValidationError from '../apiBase/errors/ValidationError';
6 | import PostModel from '../models2/PostModel';
7 | import { formatBaseContentQuery } from './BaseContentEndpoint';
8 |
9 |
10 | const BAD_CAPTCHA = 'BAD_CAPTCHA';
11 |
12 | const getPath = (query) => {
13 | if (query.user) {
14 | return `user/${query.user}/submitted.json`;
15 | } else if (query.id) {
16 | return `by_id/${query.id}.json`;
17 | } else if (query.ids) {
18 | return `by_id/${query.ids.join(',')}.json`;
19 | } else if (query.subredditName) {
20 | if (query.sort) {
21 | return `r/${query.subredditName}/${query.sort}.json`;
22 | }
23 | return `r/${query.subredditName}.json`;
24 | } else if (query.multi) {
25 | return `user/${query.multiUser}/m/${query.multi}.json`;
26 | }
27 |
28 | return `${query.sort || 'hot'}.json`;
29 | };
30 |
31 | const formatQuery = (query, method) => {
32 | formatBaseContentQuery(query, method);
33 |
34 | if (method !== 'patch') {
35 | query.feature = 'link_preview';
36 | query.sr_detail = 'true';
37 | }
38 |
39 | if (method === 'del') {
40 | query._method = 'post';
41 | }
42 |
43 | return query;
44 | };
45 |
46 | const formatPostData = (data)=> {
47 | const postData = {
48 | api_type: 'json',
49 | thing_id: data.thingId,
50 | title: data.title,
51 | kind: data.kind,
52 | sendreplies: data.sendreplies,
53 | sr: data.sr,
54 | iden: data.iden,
55 | 'g-recaptcha-response': data.gRecaptchaResponse,
56 | resubmit: data.resubmit,
57 | };
58 |
59 | if (data.text) {
60 | postData.text = data.text;
61 | } else if (data.url) {
62 | postData.url = data.url;
63 | }
64 |
65 | return postData;
66 | };
67 |
68 | const handleGet = apiResponse => {
69 | const { body: { data } } = apiResponse.response;
70 |
71 | if (data && data.children && data.children[0]) {
72 | if (data.children.length === 1) {
73 | apiResponse.addResult(PostModel.fromJSON(data.children[0].data));
74 | } else {
75 | data.children.forEach(c => apiResponse.addResult(PostModel.fromJSON(c.data)));
76 | }
77 | }
78 |
79 | return apiResponse;
80 | };
81 |
82 | export default {
83 | get(apiOptions, _query) {
84 | const path = getPath(_query);
85 | const query = formatQuery({ raw_json: 1, ..._query }, 'get');
86 |
87 | return apiRequest(apiOptions, 'GET', path, { query }).then(handleGet);
88 | },
89 |
90 | post(apiOptions, data) {
91 | const path = 'api/submit';
92 | const query = formatPostData(data);
93 |
94 | return apiRequest(apiOptions, 'POST', path, { query })
95 | .then(apiResponse => {
96 | const { body: { json } } = apiResponse.response;
97 | if (json.errors.length && some(json.errors, e => e[0] === BAD_CAPTCHA)) {
98 | throw new BadCaptchaError(data.gRecaptchaResponse, json.captcha, json.errors);
99 | } else if (json.errors.length) {
100 | throw new ValidationError(path, json.errors, 200);
101 | } else {
102 | return apiResponse.response.body;
103 | }
104 | });
105 | },
106 | };
107 |
--------------------------------------------------------------------------------
/src/apis/MessagesEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import { has } from 'lodash/object';
2 |
3 | import apiRequest from '../apiBase/apiRequest';
4 | import ValidationError from '../apiBase/errors/ValidationError';
5 |
6 | import Comment from '../models2/CommentModel';
7 | import Post from '../models2/PostModel';
8 | import Message from '../models2/MessageModel';
9 |
10 | const TYPE_MAP = {
11 | 't1': Comment,
12 | 't3': Post,
13 | 't4': Message,
14 | };
15 |
16 | const parseGetBody = apiResponse => {
17 | const { body } = apiResponse.response;
18 | body.data.children.forEach(c => {
19 | const replies = [];
20 | if (c.data.replies) {
21 | c.data.replies.data.children.forEach(r => {
22 | apiResponse.addModel(TYPE_MAP[r.kind].fromJSON(r.data));
23 | replies.push(`${r.kind}_${r.data.id}`);
24 | });
25 | }
26 |
27 | c.data.replies = replies;
28 | apiResponse.addResult(TYPE_MAP[c.kind].fromJSON(c.data));
29 | });
30 |
31 | const { before, after } = body.data;
32 | apiResponse.meta = { before, after };
33 |
34 | return apiResponse;
35 | };
36 |
37 | const parsePostBody = apiResponse => {
38 | const { body } = apiResponse.response;
39 | if (has(body, 'json.errors') && body.json.errors.length) {
40 | // There's a problem -- return the errors
41 | const reqType = (apiResponse.request.url.indexOf('compose') !== -1) ? 'compose' : 'reply';
42 | throw new ValidationError(`Message ${reqType}`, body.json.errors, body.status);
43 | } else if (has(body, 'json.data.things.0.data')) {
44 | // We're replying to an existing thread -- the API gives us
45 | // that message, so we return the model.
46 | const message = body.json.data.things[0];
47 | const model = TYPE_MAP[message.kind].fromJSON(message.data);
48 | return model;
49 | }
50 |
51 | // New message thread -- API tells us nothing, so we return null
52 | return null;
53 | };
54 |
55 | const getPath = (data={}) => {
56 | const { subreddit, type, thread } = data;
57 | const sub = subreddit ? `r/${subreddit}/` : '';
58 | const id = thread ? `/${thread}` : '';
59 | return `${sub}message/${type}${id}.json`;
60 | };
61 |
62 | const postPath = (body={}) => {
63 | const { thingId } = body;
64 | if (!thingId) {
65 | return 'api/compose';
66 | }
67 |
68 | // `api/comment` is intentional; message replies are treated as comments.
69 | return 'api/comment';
70 | };
71 |
72 | const translateData = data => {
73 | const ret = {
74 | api_type: 'json',
75 | raw_json: 1,
76 | text: data.body,
77 | };
78 |
79 | if (data.thingId) {
80 | ret.thing_id = data.thingId;
81 | }
82 | if (data.to) {
83 | ret.to = data.to;
84 | }
85 | if (data.subreddit) {
86 | ret.from_sr = data.subreddit;
87 | }
88 | if (data.subject) {
89 | ret.subject = data.subject;
90 | }
91 |
92 | return ret;
93 | };
94 |
95 | export default {
96 | get(apiOptions, data) {
97 | const path = getPath(data);
98 | const query = { ...data.query, raw_json: 1 };
99 |
100 | return apiRequest(apiOptions, 'GET', path, { query }).then(parseGetBody);
101 | },
102 |
103 | post(apiOptions, data) {
104 | const path = postPath(data);
105 | const body = translateData(data);
106 |
107 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }).then(parsePostBody);
108 | },
109 | };
110 |
--------------------------------------------------------------------------------
/src/models2/CommentModel.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 | import Record from '../apiBase/Record';
3 | import { COMMENT, COMMENT_LOAD_MORE } from './thingTypes';
4 |
5 | import votable from './mixins/votable';
6 | import replyable from './mixins/replyable';
7 |
8 | const T = RedditModel.Types;
9 |
10 | export default class CommentModel extends RedditModel {
11 | static type = COMMENT;
12 |
13 | static PROPERTIES = {
14 | archived: T.bool,
15 | author: T.string,
16 | authorFlairCSSClass: T.string,
17 | authorFlairText: T.string,
18 | children: T.nop,
19 | controversiality: T.number,
20 | distinguished: T.string,
21 | downs: T.number,
22 | edited: T.bool,
23 | gilded: T.number,
24 | id: T.string,
25 | likes: T.likes,
26 | name: T.string,
27 | replies: T.array,
28 | numReplies: T.number,
29 | loadMore: T.bool,
30 | loadMoreIds: T.arrayOf(T.string),
31 | saved: T.bool,
32 | score: T.number,
33 | stickied: T.bool,
34 | subreddit: T.string,
35 | ups: T.number,
36 | removed: T.bool,
37 | approved: T.bool,
38 | spam: T.bool,
39 |
40 | // aliases
41 | approvedBy: T.string,
42 | bannedBy: T.string,
43 | bodyHTML: T.html,
44 | bodyMD: T.html,
45 | createdUTC: T.number,
46 | linkId: T.string,
47 | linkTitle: T.string,
48 | modReports: T.array,
49 | numReports: T.number,
50 | parentId: T.string,
51 | reportReasons: T.array,
52 | scoreHidden: T.bool,
53 | subredditId: T.string,
54 | userReports: T.array,
55 |
56 | // derived
57 | cleanPermalink: T.link,
58 | canContinueThread: T.bool,
59 | };
60 |
61 | static API_ALIASES = {
62 | approved_by: 'approvedBy',
63 | author_flair_css_class: 'authorFlairCSSClass',
64 | author_flair_text: 'authorFlairText',
65 | banned_by: 'bannedBy',
66 | body_html: 'bodyHTML',
67 | body: 'bodyMD',
68 | created_utc: 'createdUTC',
69 | link_id: 'linkId',
70 | link_title: 'linkTitle',
71 | mod_reports: 'modReports',
72 | num_reports: 'numReports',
73 | parent_id: 'parentId',
74 | report_reasons: 'reportReasons',
75 | score_hidden: 'scoreHidden',
76 | subreddit_id: 'subredditId',
77 | user_reports: 'userReports',
78 | };
79 |
80 | static DERIVED_PROPERTIES = {
81 | cleanPermalink(data) {
82 | // if we are re-instantiating for a stub (read when we vote or reply)
83 | // re-use the cleanPermalink we parsed before.
84 | if (data.cleanPermalink) { return data.cleanPermalink; }
85 |
86 | const { subreddit, link_id, id, context } = data;
87 |
88 | if (context) { return context; }
89 |
90 | return `/r/${subreddit}/comments/${link_id.substr(3)}/comment/${id}`;
91 | },
92 |
93 | canContinueThread(data) {
94 | // We derive this property to make the logic for rendering loadMore and
95 | // continue thread more explicit
96 | return data.loadMore && data.loadMoreIds.length === 0;
97 | },
98 | };
99 |
100 | makeUUID(data) {
101 | if (data.name === 't1__' && data.parent_id) {
102 | // This is a stub for load more, parentId is needed to fetch more
103 | return data.parent_id;
104 | }
105 |
106 | return data.name;
107 | }
108 |
109 | toRecord() {
110 | if (this.uuid === this.name) {
111 | return super.toRecord();
112 | }
113 |
114 | // otherwise its a load more stub for super nested comments
115 | return new Record(COMMENT_LOAD_MORE, this.parentId);
116 | }
117 | }
118 |
119 | votable(CommentModel);
120 | replyable(CommentModel);
121 |
--------------------------------------------------------------------------------
/src/apis/CommentsEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import apiRequest from '../apiBase/apiRequest';
2 | import { formatBaseContentQuery } from './BaseContentEndpoint';
3 |
4 | import { has } from 'lodash/object';
5 |
6 | import CommentModel from '../models2/CommentModel';
7 | import PostModel from '../models2/PostModel';
8 |
9 | import {
10 | treeifyComments,
11 | parseCommentData,
12 | normalizeCommentReplies,
13 | } from '../lib/commentTreeUtils';
14 |
15 | const formatQuery = (query, method) => {
16 | formatBaseContentQuery(query, method);
17 |
18 | if (query.commentIds) {
19 | query.children = query.commentIds.join(',');
20 | query.api_type = 'json';
21 | query.link_id = query.linkId;
22 |
23 | delete query.commentIds;
24 | delete query.linkId;
25 | } else if (has(query, 'query.comment')) {
26 | query.comment = query.query.comment;
27 | query.context = 1;
28 | }
29 |
30 | return query;
31 | };
32 |
33 | const getPath = (query) => {
34 | if (query.user) {
35 | return `user/${query.user}/comments.json`;
36 | } else if (query.commentIds) {
37 | return `api/morechildren.json`;
38 | } else {
39 | return `comments/${(query.id || query.linkId).replace(/^t3_/, '')}.json`;
40 | }
41 | };
42 |
43 | const parseGetBody = (apiResponse, hasChildren) => {
44 | const { body } = apiResponse.response;
45 | let comments = [];
46 |
47 | if (Array.isArray(body)) {
48 | // The first part of the response is a link
49 | const linkData = body[0].data;
50 | if (linkData && linkData.children && linkData.children.length) {
51 | linkData.children.forEach(link => {
52 | apiResponse.addModel(PostModel.fromJSON(link.data));
53 | });
54 | }
55 |
56 | comments = body[1].data.children.map(parseCommentData);
57 | } else if (body.json && body.json.data) {
58 |
59 | const { things } = body.json.data;
60 | comments = treeifyComments(things.map(parseCommentData));
61 | }
62 |
63 | normalizeCommentReplies(comments, true, (commentJSON, isTopLevel) => {
64 | // parsing is done bottom up, comment models are immutable
65 | // but they'll rely on the records
66 | const comment = CommentModel.fromJSON(commentJSON);
67 |
68 | if (isTopLevel) {
69 | apiResponse.addResult(comment);
70 | } else {
71 | apiResponse.addModel(comment);
72 | }
73 |
74 | // this sets replies to be records for consistency
75 | return comment.toRecord();
76 | });
77 |
78 | return apiResponse;
79 | };
80 |
81 | const parsePostBody = apiResponse => {
82 | const { body } = apiResponse.response;
83 |
84 | if (has(body, 'json.data.things.0.data')) {
85 | const comment = body.json.data.things[0].data;
86 | apiResponse.addResult(CommentModel.fromJSON(comment));
87 | }
88 |
89 | return apiResponse;
90 | };
91 |
92 | export default {
93 | get(apiOptions, _query) {
94 | const hasChildren = !!_query.children;
95 | const path = getPath(_query);
96 | const query = formatQuery({ raw_json: 1, ..._query });
97 |
98 | return apiRequest(apiOptions, 'GET', path, { query })
99 | .then(apiResponse => parseGetBody(apiResponse, hasChildren));
100 | },
101 |
102 | post(apiOptions, data) {
103 | const path = 'api/comment';
104 | const body = {
105 | api_type: 'json',
106 | thing_id: data.thingId,
107 | text: data.text,
108 | raw_json: 1,
109 | };
110 |
111 | return apiRequest(apiOptions, 'POST', path, { body, type: 'form' }).then(parsePostBody);
112 | },
113 |
114 | del(apiOptions, id) {
115 | const body = { id };
116 | return apiRequest(apiOptions, 'POST', 'api/del', { body, type: 'form' });
117 | },
118 | };
119 |
--------------------------------------------------------------------------------
/src/apis/SubredditEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import { runQuery, validateData } from '../apiBase/APIRequestUtils';
2 |
3 | import { pick } from 'lodash/object';
4 | import { isEmpty } from 'lodash/lang';
5 |
6 | import Subreddit from '../models2/Subreddit';
7 |
8 | const DEFAULT_SUBREDDIT_OPTIONS = {
9 | allow_top: true,
10 | collapse_deleted_comments: false,
11 | comment_score_hide_mins: 0,
12 | description: '',
13 | exclude_banned_modqueue: false,
14 | 'header-title': '',
15 | hide_ads: false,
16 | lang: 'en',
17 | link_type: 'any',
18 | name: '',
19 | over_18: false,
20 | public_description: '',
21 | public_traffic: false,
22 | show_media: true,
23 | spam_comments: 'low',
24 | spam_links: 'high',
25 | spam_selfposts: 'high',
26 | sr: '',
27 | submission_type: '',
28 | submit_link_label: '',
29 | submit_text: '',
30 | submit_text_label: '',
31 | suggested_comment_sort: 'confidence',
32 | title: '',
33 | type: 'public',
34 | wiki_edit_age: 0,
35 | wiki_edit_karma: 100,
36 | wikimode: 'disabled',
37 | };
38 |
39 | const requestPath = 'api/site_admin';
40 |
41 | const getPath = (query) => {
42 | if (query.id && query.view === 'mod') {
43 | return `r/${query.id}/about/edit.json`;
44 | }
45 |
46 | if (query.id) {
47 | return `r/${query.id}/about.json`;
48 | }
49 |
50 | return `subreddits/${query.sort || 'default'}.json`;
51 | };
52 |
53 | const formatQuery = (query, method) => {
54 | if (method !== 'get') {
55 | query.api_type = 'json';
56 | }
57 |
58 | return query;
59 | };
60 |
61 | const parseBody = (res, apiResponse) => {
62 | const { body } = res;
63 |
64 | if (body.data && Array.isArray(body.data.children)) {
65 | body.data.children.forEach(c => apiResponse.addResult(Subreddit.fromJSON(c.data)));
66 | // sometimes, we get back empty object and 200 for invalid sorts like
67 | // `mine` when logged out
68 | } else if (!isEmpty(body)) {
69 | apiResponse.addResult(Subreddit.fromJSON(body.data || body));
70 | }
71 | };
72 |
73 | const get = (apiOptions, query) => {
74 | const path = getPath(query);
75 | const apiQuery = formatQuery({ ...query });
76 |
77 | return runQuery(apiOptions, 'get', path, apiQuery, query, parseBody);
78 | };
79 |
80 | const patch = (apiOptions, data) => {
81 | // If the data doesn't have all of the keys, get the full subreddit data
82 | // and then merge in the changes and submit _that_. The API requires the
83 | // full object be sent.
84 | if (Object.keys(data).sort() !== Subreddit.fields) {
85 | return new Promise((resolve, reject) => {
86 | get(apiOptions, {
87 | id: data.id,
88 | view: 'mod',
89 | }).then(function(apiResponse) {
90 | if (!apiResponse.results.length === 1) { reject(); }
91 | const sub = apiResponse.getModelFromRecord(apiResponse.results[0]);
92 |
93 | const postData = pick({
94 | ...DEFAULT_SUBREDDIT_OPTIONS,
95 | ...sub,
96 | ...data,
97 | sr: sub.name,
98 | }, Object.keys(DEFAULT_SUBREDDIT_OPTIONS));
99 |
100 | return post(apiOptions, postData);
101 | }, reject);
102 | });
103 | }
104 |
105 | return post(apiOptions, data);
106 | };
107 |
108 | const post = (apiOptions, data) => {
109 | const postData = pick({
110 | ...DEFAULT_SUBREDDIT_OPTIONS,
111 | ...data,
112 | }, Object.keys(DEFAULT_SUBREDDIT_OPTIONS));
113 |
114 | return runQuery(apiOptions, 'post', requestPath, postData, data, parseBody);
115 | };
116 |
117 | const put = (apiOptions, data) => {
118 | const modifiedData = { ...data, name: data.id };
119 | return post(apiOptions, modifiedData);
120 | };
121 |
122 | export default {
123 | get,
124 | patch,
125 | post,
126 | put,
127 | };
128 |
--------------------------------------------------------------------------------
/repl:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | 'use strict';
4 |
5 | const EventEmitter = require('events').EventEmitter;
6 |
7 | const repl = require('repl');
8 | const q = require('querystring');
9 |
10 | const packageInfo = require('./package.json');
11 | console.log(`Starting snoode v${packageInfo.version} repl`);
12 | console.log('Enter `help()` for information.');
13 |
14 | require('babel-polyfill');
15 | const optionsWithAuth = require('./build').optionsWithAuth;
16 | const APIOptions = Object.assign({},
17 | optionsWithAuth(process.env.TOKEN),
18 | { eventEmitter: new EventEmitter() }
19 | );
20 |
21 | const local = repl.start('$> ');
22 |
23 | function help () {
24 | const help = [
25 | 'To try out the API, use the available instance of `api` to make requests.',
26 | ' For example, try writing: `SubredditEndpoint.get(APIOptions, { id: "coffee" })`',
27 | '\n',
28 | 'The latest API error can be accessed at `error`. Requests and responses',
29 | 'can be accessed at `request` and `response`.',
30 | 'The latest API result can be accessed at `apiResponse`',
31 | '\n',
32 | 'Also try out using Models directly, they"re all in scope',
33 | ' e.g. try typing `SubscribedSubreddits.fetch(APIOptions)` (assuming you have your token)',
34 | ' property configured',
35 | ' then type `apiResponse.results` and',
36 | ' `apiResponse.getModelFromRecord(apiResponse.results[0])`',
37 | ' if you want access to the SubredditList model that"s created in this example,',
38 | ' you"ll need to append a .then(function(res) { this.res = res; })',
39 | ' and then res will be available as a toplevel variable',
40 | ].join('\n');
41 |
42 | console.log(help);
43 | }
44 |
45 | APIOptions.eventEmitter.on('response', function printResponse (req, res) {
46 | console.log(`${req.method} ${req.url} returned ${res.statusCode}`);
47 | local.context.request = res;
48 | local.context.response = res;
49 | });
50 |
51 | APIOptions.eventEmitter.on('result', function (apiResponse) {
52 | local.context.apiResponse = apiResponse;
53 | });
54 |
55 | APIOptions.eventEmitter.on('error', function printResponse (e, req) {
56 | console.log(`${req.method} ${req.url} returned error:`);
57 | console.log(e.toString());
58 | local.context.error = e;
59 | });
60 |
61 | APIOptions.eventEmitter.on('request', function printRequest (req) {
62 | console.log(`Initiating ${req.method} to ${req.url}`);
63 | console.log(`query/data: ${JSON.stringify(req.query, null, 2)}`);
64 | });
65 |
66 | local.context.findLoadMoreStub = function findLoadMoreStub(r) {
67 | // r is a result
68 | r.results.forEach(topLevelRecord => {
69 | local.context.checkRecordForLoadMore(r, topLevelRecord);
70 | });
71 | };
72 |
73 | local.context.checkRecordForLoadMore = function checkRecordForLoadMore(r, record) {
74 | const comment = r.getModelFromRecord(record);
75 | if (!comment) { return; }
76 | comment.replies.forEach(replyRecord => {
77 | if (replyRecord.type === 'comment') {
78 | local.context.checkRecordForLoadMore(r, replyRecord);
79 | return;
80 | }
81 |
82 | console.log('load more?', replyRecord);
83 | });
84 | };
85 |
86 | function exportToContext(submoduleName) {
87 | const submodule = require('./build')[submoduleName];
88 | local.context[submoduleName] = submodule;
89 |
90 | Object.keys(submodule).forEach(function(subSubmoduleName) {
91 | local.context[subSubmoduleName] = submodule[subSubmoduleName];
92 | });
93 | }
94 |
95 | local.context.APIOptions = APIOptions;
96 |
97 | exportToContext('endpoints');
98 | exportToContext('models');
99 | exportToContext('collections');
100 | exportToContext('APIResponses');
101 | exportToContext('APIResponsePaging');
102 |
103 | local.context.help = help;
104 |
--------------------------------------------------------------------------------
/src/apis/multis.es6.js:
--------------------------------------------------------------------------------
1 | import BaseEndpoint from '../apiBase/BaseEndpoint';
2 | import Multi from '../models/multi';
3 |
4 | const ID_REGEX = /^user\/[^\/]+\/m\/[^\/]+$/;
5 |
6 | export default class MultisEndpoint extends BaseEndpoint {
7 | static mapSubreddits (subs) {
8 | return subs.map(s => s.name);
9 | }
10 |
11 | static formatData (data={}, method) {
12 | if (method === 'post' || method === 'put') {
13 | return {
14 | model: JSON.stringify({
15 | description_md: data.description,
16 | display_name: data.displayName,
17 | icon_name: data.iconName,
18 | key_color: data.keyColor,
19 | visibility: data.visibility,
20 | weighting_scheme: data.weightingScheme,
21 | subreddits: data.subreddits ? data.subreddits.map(s => ({ name: s })) : undefined,
22 | }),
23 | name: data.name,
24 | };
25 | }
26 | return data;
27 | }
28 |
29 | static buildId({ username, name, id }) {
30 | if (username && name) {
31 | return `user/${username}/m/${name}`;
32 | }
33 |
34 | if (id) { return id; }
35 | }
36 |
37 | model = Multi;
38 |
39 | path (method, query={}) {
40 | let id = MultisEndpoint.buildId(query);
41 |
42 | switch (method) {
43 | case 'get':
44 | if (query.username) {
45 | if (query.username === 'me') {
46 | return 'api/multi/mine';
47 | } else if (id) {
48 | return `api/multi/${id}`;
49 | }
50 |
51 | return `api/multi/user/${query.username}`;
52 | }
53 |
54 | return 'api/multi';
55 | case 'put':
56 | case 'patch':
57 | case 'post':
58 | case 'del':
59 | return `api/multi/${id}`;
60 | case 'copy':
61 | return 'api/multi/copy';
62 | case 'move':
63 | return 'api/multi/rename';
64 | }
65 | }
66 |
67 | formatQuery (query, method) {
68 | if (method === 'get') {
69 | if (query.user) {
70 | query.username = query.user;
71 | delete query.user;
72 | }
73 | }
74 |
75 | return query;
76 | }
77 |
78 | formatBody (res) {
79 | const { body } = res;
80 |
81 | if (body && Array.isArray(body)) {
82 | return body.map(m => {
83 | const multi = m.data;
84 |
85 | multi.subreddits = MultisEndpoint.mapSubreddits(multi.subreddits);
86 | return new Multi(multi).toJSON();
87 | });
88 | } else if (body) {
89 | const multi = body.data;
90 | multi.subreddits = MultisEndpoint.mapSubreddits(multi.subreddits);
91 | return new Multi(multi);
92 | }
93 | }
94 |
95 | formatData (data) {
96 | return MultisEndpoint.formatData(data);
97 | }
98 |
99 | copy (fromId, data) {
100 | if (!ID_REGEX.exec(fromId)) {
101 | throw new Error('ID did not match `user/{username}/m/{multiname}` format.');
102 | }
103 |
104 | data = {
105 | from: fromId,
106 | to: data.id,
107 | _method: 'post',
108 | };
109 |
110 | if (data && data.displayName) {
111 | data.display_name = data.displayName;
112 | }
113 |
114 | return this.save('copy', data);
115 | }
116 |
117 | move (fromId, toId, data) {
118 | if (!ID_REGEX.exec(fromId) || !ID_REGEX.exec(toId)) {
119 | throw new Error('ID did not match `user/{username}/m/{multiname}` format.');
120 | }
121 |
122 | const moveData = {
123 | _method: 'post',
124 | from: fromId,
125 | to: toId,
126 | };
127 |
128 | if (data && data.displayName) {
129 | moveData.display_name = data.displayName;
130 | }
131 |
132 | return this.save('move', moveData);
133 | }
134 |
135 | formatBody (res) {
136 | const { body } = res;
137 | if (body) {
138 | return new Multi(body.data || body).toJSON();
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/models2/Preferences.es6.js:
--------------------------------------------------------------------------------
1 | import RedditModel from './RedditModel';
2 | const T = RedditModel.Types;
3 |
4 | export default class PreferencesModel extends RedditModel {
5 | static type = 'preferences';
6 |
7 | static PROPERTIES = {
8 | affiliateLinks: T.bool,
9 | allowClicktracking: T.bool,
10 | beta: T.bool,
11 | clickgadget: T.bool,
12 | collapseReadMessages: T.bool,
13 | compress: T.bool,
14 | credditAutorenew: T.bool,
15 | defaultCommentSort: T.string, // It would be nice to have T.oneOf like react here
16 | // as it's one of 'confidence', 'old', 'top', 'qa', 'controversial', 'new',
17 | defaultThemeSr: T.string,
18 | domainDetails: T.bool,
19 | emailMessages: T.bool,
20 | enableDefaultThemes: T.bool,
21 | hideAds: T.bool,
22 | hideDowns: T.bool,
23 | hideFromRobots: T.bool,
24 | hideLocationbar: T.bool,
25 | hideUps: T.bool,
26 | highlightControversial: T.bool,
27 | highlightNewComments: T.bool,
28 | ignoreSuggestedSort: T.bool,
29 | labelNsfw: T.bool,
30 | lang: T.string,
31 | legacySearch: T.bool,
32 | markMessagesRead: T.bool,
33 | media: T.string, // Another case for T.oneOf,
34 | // 'on', 'off', 'subreddit'
35 | minCommentScore: T.number, // T.number should maybe have a T.range version
36 | // -- this can only be between -100 and 100
37 | minLinkScore: T.number, // same as above
38 | monitorMentions: T.bool,
39 | newWindow: T.bool,
40 | noProfanity: T.bool,
41 | numComments: T.number, // in range 1 and 500
42 | numsites: T.number, // in range 1 and 500
43 | organic: T.bool,
44 | otherTheme: T.string, // subreddit name
45 | over18: T.bool,
46 | privateFeeds: T.bool,
47 | publicVotes: T.bool,
48 | research: T.bool,
49 | showFlair: T.bool,
50 | showGoldExpiration: T.bool,
51 | showLinkFlair: T.bool,
52 | showPromote: T.bool,
53 | showStylesheets: T.bool,
54 | showTrending: T.bool,
55 | storeVisits: T.bool,
56 | themeSelector: T.string, // subreddit name
57 | threadedMessages: T.bool,
58 | threadedModmail: T.bool,
59 | useGlobalDefaults: T.bool,
60 | };
61 |
62 | static API_ALIASES = {
63 | affiliate_links: 'affiliateLinks',
64 | allow_clicktracking: 'allowClicktracking',
65 | beta: 'beta',
66 | clickgadget: 'clickgadget',
67 | collapse_read_messages: 'collapseReadMessages',
68 | compress: 'compress',
69 | creddit_autorenew: 'credditAutorenew',
70 | default_comment_sort: 'defaultCommentSort',
71 | default_theme_sr: 'defaultThemeSr',
72 | domain_details: 'domainDetails',
73 | email_messages: 'emailMessages',
74 | enable_default_themes: 'enableDefaultThemes',
75 | hide_ads: 'hideAds',
76 | hide_downs: 'hideDowns',
77 | hide_from_robots: 'hideFromRobots',
78 | hide_locationbar: 'hideLocationbar',
79 | hide_ups: 'hideUps',
80 | highlight_controversial: 'highlightControversial',
81 | highlight_new_comments: 'highlightNewComments',
82 | ignore_suggested_sort: 'ignoreSuggestedSort',
83 | label_nsfw: 'labelNsfw',
84 | lang: 'lang',
85 | legacy_search: 'legacySearch',
86 | mark_messages_read: 'markMessagesRead',
87 | media: 'media',
88 | min_comment_score: 'minCommentScore',
89 | min_link_score: 'minLinkScore',
90 | monitor_mentions: 'monitorMentions',
91 | newwindow: 'newWindow',
92 | no_profanity: 'noProfanity',
93 | num_comments: 'numComments',
94 | numsites: 'numsites',
95 | organic: 'organic',
96 | other_theme: 'otherTheme',
97 | over_18: 'over18',
98 | private_feeds: 'privateFeeds',
99 | public_votes: 'publicVotes',
100 | research: 'research',
101 | show_flair: 'showFlair',
102 | show_gold_expiration: 'showGoldExpiration',
103 | show_link_flair: 'showLinkFlair',
104 | show_promote: 'showPromote',
105 | show_stylesheets: 'showStylesheets',
106 | show_trending: 'showTrending',
107 | store_visits: 'storeVisits',
108 | themeSelector: 'themeSelector',
109 | threaded_messages: 'threadedMessages',
110 | threaded_modmail: 'threadedModmail',
111 | use_global_defaults: 'useGlobalDefaults',
112 | };
113 |
114 | makeUUID(data) {
115 | return 'preferences'; // there's only one preferences object for a user
116 | // so the id is constant. Probably shouldn't use this.
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/apiBase/APIResponse.es6.js:
--------------------------------------------------------------------------------
1 | import { forEach } from 'lodash/collection';
2 | import { last } from 'lodash/array';
3 |
4 | import {
5 | TYPES,
6 | thingType,
7 | COMMENT,
8 | ACCOUNT,
9 | POST,
10 | MESSAGE,
11 | SUBREDDIT,
12 | SUBREDDIT_RULE,
13 | WIKI,
14 | } from '../models2/thingTypes';
15 |
16 | export class APIResponseBase {
17 | constructor() {
18 | this.results = [];
19 |
20 | this.posts = {};
21 | this.comments = {};
22 | this.accounts = {};
23 | this.messages = {};
24 | this.subreddits = {};
25 | this.subreddit_rules = {};
26 | this.wikis = {};
27 |
28 | this.typeToTable = {
29 | [COMMENT]: this.comments,
30 | [POST]: this.posts,
31 | [ACCOUNT]: this.accounts,
32 | [MESSAGE]: this.messages,
33 | [SUBREDDIT]: this.subreddits,
34 | [SUBREDDIT_RULE]: this.subreddit_rules,
35 | [WIKI]: this.wikis,
36 | };
37 |
38 | this.addResult = this.addResult.bind(this);
39 | this.addModel = this.addModel.bind(this);
40 | this.makeRecord = this.makeRecord.bind(this);
41 | this.addToTable = this.addToTable.bind(this);
42 | this.getModelFromRecord = this.getModelFromRecord.bind(this);
43 | this.appendResponse = this.appendResponse.bind(this);
44 | }
45 |
46 | addResult(model) {
47 | if (!model) { return this; }
48 | const record = this.makeRecord(model);
49 | if (record) {
50 | this.results.push(record);
51 | this.addToTable(record, model);
52 | }
53 |
54 | return this;
55 | }
56 |
57 | addModel(model) {
58 | if (!model) { return this; }
59 | const record = this.makeRecord(model);
60 | if (record) {
61 | this.addToTable(record, model);
62 | }
63 |
64 | return this;
65 | }
66 |
67 | makeRecord(model) {
68 | if (model.toRecord) { return model.toRecord(); }
69 | const { uuid } = model;
70 | if (!uuid) { return; }
71 |
72 | const type = TYPES[model.kind] || thingType(uuid);
73 | if (!type) { return; }
74 | return { type, uuid };
75 | }
76 |
77 | addToTable(record, model) {
78 | const table = this.typeToTable[record.type];
79 | if (table) { table[record.uuid] = model; }
80 | return this;
81 | }
82 |
83 | getModelFromRecord(record) {
84 | const table = this.typeToTable[record.type];
85 | if (table) { return table[record.uuid]; }
86 | }
87 |
88 | appendResponse() { throw new Error('Not implemented in base class'); }
89 | }
90 |
91 | export class APIResponse extends APIResponseBase {
92 | constructor(response, meta={}, query={}) {
93 | super();
94 | this.request = response.req;
95 | this.response = response;
96 |
97 | // Left for backwards compatibility, you can use request and response directly
98 | this.meta = meta;
99 | this.query = query;
100 | }
101 |
102 | appendResponse(nextResponse) {
103 | return new MergedApiReponse([this, nextResponse]);
104 | }
105 | }
106 |
107 | export class MergedApiReponse extends APIResponseBase {
108 | constructor(apiResponses) {
109 | super();
110 | this.metas = apiResponses.map(response => response.meta);
111 | this.querys = apiResponses.map(response => response.query);
112 |
113 | this.apiResponses = apiResponses;
114 |
115 | const seenResults = new Set();
116 |
117 | const tableKeys = [
118 | COMMENT,
119 | ACCOUNT,
120 | POST,
121 | MESSAGE,
122 | SUBREDDIT,
123 | ];
124 |
125 | forEach(apiResponses, (apiResponse) => {
126 | forEach(apiResponse.results, (record) => {
127 | if (!seenResults.has(record.uuid)) {
128 | seenResults.add(record.uuid);
129 | this.results.push(record);
130 | }
131 | });
132 |
133 | forEach(tableKeys, (tableKey) => {
134 | const table = this.typeToTable[tableKey];
135 | Object.assign(table, apiResponse.typeToTable[tableKey]);
136 | });
137 | });
138 | }
139 |
140 | get lastResponse() {
141 | return last(this.apiResponses);
142 | }
143 |
144 | get lastQuery() {
145 | return last(this.querys);
146 | }
147 |
148 | get lastMeta() {
149 | return last(this.meta);
150 | }
151 |
152 | get query() { // shorthand convenience
153 | return this.latQuery;
154 | }
155 |
156 | appendResponse(response) {
157 | const newReponses = this.apiResponses.slice();
158 | newReponses.push(response);
159 |
160 | return new MergedApiReponse(newReponses);
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/apis/modTools.es6.js:
--------------------------------------------------------------------------------
1 | import apiRequest from '../apiBase/apiRequest';
2 |
3 | /**
4 | * Valid distinguish types.
5 | * Note that the API endpoint used to distinguish posts and comments accepts
6 | * 'yes' instead of 'moderator' and 'no' instead of ''. See #distinguish
7 | * @enum
8 | */
9 | const DISTINGUISH_TYPES = {
10 | NONE: '',
11 | MODERATOR: 'moderator',
12 | ADMIN: 'admin',
13 | };
14 |
15 | const remove = (apiOptions, fullname, spam) => {
16 | // Remove a link, comment, or modmail message.
17 | const body = {
18 | id: fullname,
19 | spam: spam
20 | };
21 |
22 | return apiRequest(apiOptions, 'POST', 'api/remove', { body, type: 'form' });
23 | }
24 |
25 | const approve = (apiOptions, fullname) => {
26 | // Approve a link or comment
27 | const body = { id: fullname };
28 | return apiRequest(apiOptions, 'POST', 'api/approve', { body, type: 'form' });
29 | }
30 |
31 | /**
32 | * Distinguish a link or comment
33 | * @function
34 | * @param {Object} apiOptions
35 | * @param {string} fullname The fullname of the target comment
36 | * @param {DISTINGUISH_TYPES} distinguishType What type of distinguish is being set
37 | * @param {?bool} [_sticky] For internal use by #setStickyComment
38 | */
39 | const distinguish = (apiOptions, fullname, distinguishType, _sticky=null) => {
40 | const distinguishTypeMap = {
41 | [DISTINGUISH_TYPES.MODERATOR]: 'yes',
42 | [DISTINGUISH_TYPES.NONE]: 'no',
43 | };
44 |
45 | const body = {
46 | id: fullname,
47 | };
48 | const how = distinguishTypeMap[distinguishType] || distinguishType;
49 |
50 | if (_sticky !== null) {
51 | body.sticky = _sticky;
52 | }
53 |
54 | return apiRequest(apiOptions, 'POST', `api/distinguish/${how}`, { body, type: 'form' });
55 | }
56 |
57 | const markNSFW = (apiOptions, id) => {
58 | // Mark a link as NSFW
59 | const body = { id };
60 | return apiRequest(apiOptions, 'POST', 'api/marknsfw', { body, type: 'form' });
61 | }
62 |
63 | const unmarkNSFW = (apiOptions, id) => {
64 | // Unmark a link as NSFW
65 | const body = { id };
66 | return apiRequest(apiOptions, 'POST', 'api/unmarknsfw', { body, type: 'form' });
67 | }
68 |
69 | const lock = (apiOptions, id) => {
70 | // Lock a link
71 | const body = { id };
72 | return apiRequest(apiOptions, 'POST', 'api/lock', { body, type: 'form' });
73 | }
74 |
75 | const unlock = (apiOptions, id) => {
76 | // Unlock a link
77 | const body = { id };
78 | return apiRequest(apiOptions, 'POST', 'api/unlock', { body, type: 'form' });
79 | }
80 |
81 | const spoiler = (apiOptions, id) => {
82 | // Spoiler a post
83 | const body = { id };
84 | return apiRequest(apiOptions, 'POST', 'api/spoiler', { body, type: 'form' });
85 | }
86 |
87 | const unspoiler = (apiOptions, id) => {
88 | // Unspoiler a post
89 | const body = { id };
90 | return apiRequest(apiOptions, 'POST', 'api/unspoiler', { body, type: 'form' });
91 | }
92 |
93 | /**
94 | * Set or unset a stickied post (AKA an "Annoucement").
95 | * See also: https://www.reddit.com/dev/api#POST_api_set_subreddit_sticky
96 | * @function
97 | * @param {Object} apiOptions
98 | * @param {string} fullname The fullname of the target post
99 | * @param {boolean} isStickied Whether to sticky or unsticky the post
100 | * @param {?number} [stickyNum] Allows for specifying the "slot" to sticky the post
101 | * into, or for specifying which post to unsticky.
102 | */
103 | const setSubredditSticky = (apiOptions, fullname, isStickied, stickyNum=null) => {
104 | const body = {
105 | id: fullname,
106 | state: isStickied,
107 | };
108 |
109 | if (stickyNum) {
110 | body.num = stickyNum;
111 | }
112 |
113 | return apiRequest(apiOptions, 'POST', 'api/set_subreddit_sticky', { body, type: 'form' });
114 | }
115 |
116 | /**
117 | * Sticky or unsticky a comment.
118 | * Sticky comments are a special case of distinguished comments, and are done
119 | * through the same API endpoint (api/distinguish). That endpoint also handles
120 | * distinguishing posts, but it does *not* handle sticky posts. To avoid
121 | * confusion, we'll keep sticky comments separated here.
122 | * @function
123 | * @param {Object} apiOptions
124 | * @param {string} fullname The fullname of the target comment
125 | * @param {boolean} isStickied Whether to sticky or unsticky the comment
126 | */
127 | const setStickyComment = (apiOptions, fullname, isStickied) => {
128 | const distinguishType = isStickied ? DISTINGUISH_TYPES.MODERATOR : DISTINGUISH_TYPES.NONE;
129 | return distinguish(apiOptions, fullname, distinguishType, isStickied);
130 | };
131 |
132 | export default {
133 | remove,
134 | approve,
135 | distinguish,
136 | markNSFW,
137 | unmarkNSFW,
138 | lock,
139 | unlock,
140 | spoiler,
141 | unspoiler,
142 | setStickyComment,
143 | setSubredditSticky,
144 | DISTINGUISH_TYPES,
145 | };
146 |
--------------------------------------------------------------------------------
/src/apis/SubredditRulesEndpoint.es6.js:
--------------------------------------------------------------------------------
1 | import apiRequest from '../apiBase/apiRequest';
2 | import SubredditRule from '../models2/SubredditRule';
3 |
4 | const ADD_RULE_PATH = 'api/add_subreddit_rule';
5 | const REMOVE_RULE_PATH = 'api/remove_subreddit_rule';
6 | const UPDATE_RULE_PATH = 'api/update_subreddit_rule';
7 |
8 | export default {
9 | /**
10 | * Get the rules for the given subreddit.
11 | * Models are added to the APIResponse object, and can be retrieved using
12 | * its `getModelFromRecord` method. E.g.
13 | *
14 | * const res = await SubredditRulesEndpoint.get(apiOptions, { id: 'beta' });
15 | * const rules = res.results.map(r => res.getModelFromRecord(r));
16 | *
17 | * @function
18 | * @param {Object} apiOptions
19 | * @param {string} subredditName The name of a subreddit
20 | * @returns {Promise