<commit_before>const path = require("path");
const url = require("url");
const fs = require("fs");
const promisify = require("util.promisify");
const color = require("tinycolor2");
const colors = require("colors");
const jsonxml = require("jsontoxml");
const sizeOf = require("image-size");
const Jimp = require("jimp");
const sharp = require("sharp");
const PLATFORM_OPTIONS = require("./config/platform-options.json");

module.exports = function(options) {
  function directory(path) {
    return path.substr(-1) === "/" ? path : `${path}/`;
  }

  function relative(path, relativeToPath = false) {
    return url.resolve(
      (!relativeToPath && options.path && directory(options.path)) || "",
      path
    );
  }

  function log(context, message) {
    if (options.logging) {
      const { magenta, green, yellow } = colors;

      message = message.replace(/ \d+(x\d+)?/g, item => magenta(item));
      message = message.replace(/#([0-9a-f]{3}){1,2}/g, item => magenta(item));
      console.log(`${green("[Favicons]")} ${yellow(context)}: ${message}...`);
    }
  }

  function parseColor(hex) {
    const { r, g, b, a } = color(hex).toRgb();

    return Jimp.rgbaToInt(r, g, b, a * 255);
  }

  return {
    General: {
      source(src) {
        log("General:source", `Source type is ${typeof src}`);

        if (Buffer.isBuffer(src)) {
          try {
            return Promise.resolve([{ size: sizeOf(src), file: src }]);
          } catch (error) {
            return Promise.reject(new Error("Invalid image buffer"));
          }
        } else if (typeof src === "string") {
          return promisify(fs.readFile)(src).then(this.source.bind(this));
        } else if (Array.isArray(src) && !src.some(Array.isArray)) {
          if (!src.length) {
            return Promise.reject(new Error("No source provided"));
          }

          return Promise.all(src.map(this.source.bind(this))).then(results =>
            [].concat(...results)
          );
        } else {
          return Promise.reject(new Error("Invalid source type provided"));
        }
      },

      preparePlatformOptions(platform) {
        const parameters =
          typeof options.icons[platform] === "object"
            ? options.icons[platform]
            : {};

        for (const key of Object.keys(parameters)) {
          if (
            !(key in PLATFORM_OPTIONS) ||
            !PLATFORM_OPTIONS[key].platforms.includes(platform)
          ) {
            throw new Error(
              `Unsupported option '${key}' on platform '${platform}'`
            );
          }
        }

        for (const key of Object.keys(PLATFORM_OPTIONS)) {
          const platformOption = PLATFORM_OPTIONS[key];
          const { platforms, defaultTo } = platformOption;

          if (!(key in parameters) && platforms.includes(platform)) {
            parameters[key] =
              platform in platformOption ? platformOption[platform] : defaultTo;
          }
        }

        if (parameters.background === true) {
          parameters.background = options.background;
        }

        return parameters;
      }
    },

    HTML: {
      render(htmlTemplate) {
        return htmlTemplate(Object.assign({}, options, { relative }));
      }
    },

    Files: {
      create(properties, name, isHtml) {
        return new Promise((resolve, reject) => {
          log("Files:create", `Creating file: ${name}`);
          if (name === "manifest.json") {
            properties.name = options.appName;
            properties.short_name = options.appShortName || options.appName;
            properties.description = options.appDescription;
            properties.dir = options.dir;
            properties.lang = options.lang;
            properties.display = options.display;
            properties.orientation = options.orientation;
            properties.scope = options.scope;
            properties.start_url = options.start_url;
            properties.background_color = options.background;
            properties.theme_color = options.theme_color;
            properties.icons.map(
              icon =>
                (icon.src = relative(icon.src, options.manifestRelativePaths))
            );
            properties = JSON.stringify(properties, null, 2);
          } else if (name === "manifest.webapp") {
            properties.version = options.version;
            properties.name = options.appName;
            properties.description = options.appDescription;
            properties.developer.name = options.developerName;
            properties.developer.url = options.developerURL;
            properties.icons = Object.keys(properties.icons).reduce(
              (obj, key) =>
                Object.assign(obj, {
                  [key]: relative(
                    properties.icons[key],
                    options.manifestRelativePaths
                  )
                }),
              {}
            );
            properties = JSON.stringify(properties, null, 2);
          } else if (name === "browserconfig.xml") {
            properties[0].children[0].children[0].children.map(property => {
              if (property.name === "TileColor") {
                property.text = options.background;
              } else {
                property.attrs.src = relative(
                  property.attrs.src,
                  options.manifestRelativePaths
                );
              }
            });
            properties = jsonxml(properties, {
              prettyPrint: true,
              xmlHeader: true,
              indent: "  "
            });
          } else if (name === "yandex-browser-manifest.json") {
            properties.version = options.version;
            properties.api_version = 1;
            properties.layout.logo = relative(
              properties.layout.logo,
              options.manifestRelativePaths
            );
            properties.layout.color = options.background;
            properties = JSON.stringify(properties, null, 2);
          } else if (isHtml) {
            properties = properties.join("\n");
          } else {
            reject(`Unknown format of file ${name}.`);
          }
          resolve({ name, contents: properties });
        });
      }
    },

    Images: {
      create(properties) {
        return new Promise((resolve, reject) => {
          log(
            "Image:create",
            `Creating empty ${properties.width}x${
              properties.height
            } canvas with ${
              properties.transparent ? "transparent" : properties.background
            } background`
          );

          this.jimp = new Jimp(
            properties.width,
            properties.height,
            properties.transparent ? 0 : parseColor(properties.background),
            (error, canvas) => (error ? reject(error) : resolve(canvas))
          );
        });
      },

      render(sourceset, properties, offset) {
        log(
          "Image:render",
          `Find nearest icon to ${properties.width}x${
            properties.height
          } with offset ${offset}`
        );

        const width = properties.width - offset * 2;
        const height = properties.height - offset * 2;
        const svgSource = sourceset.find(source => source.size.type === "svg");

        let promise = null;

        if (svgSource) {
          const background = { r: 0, g: 0, b: 0, alpha: 0 };

          log("Image:render", `Rendering SVG to ${width}x${height}`);
          promise = sharp(svgSource.file)
            .resize({
              background,
              width,
              height,
              fit: sharp.fit.contain
            })
            .toBuffer()
            .then(Jimp.read);
        } else {
          const sideSize = Math.max(width, height);

          let nearestIcon = sourceset[0];
          let nearestSideSize = Math.max(
            nearestIcon.size.width,
            nearestIcon.size.height
          );

          for (const icon of sourceset) {
            const max = Math.max(icon.size.width, icon.size.height);

            if (
              (nearestSideSize > max || nearestSideSize < sideSize) &&
              max >= sideSize
            ) {
              nearestIcon = icon;
              nearestSideSize = max;
            }
          }

          log("Images:render", `Resizing PNG to ${width}x${height}`);

          promise = Jimp.read(nearestIcon.file).then(image =>
            image.contain(
              width,
              height,
              Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE,
              options.pixel_art &&
                width >= image.bitmap.width &&
                height >= image.bitmap.height
                ? Jimp.RESIZE_NEAREST_NEIGHBOR
                : null
            )
          );
        }

        return promise.then(image => image);
      },

      mask: Jimp.read(path.join(__dirname, "mask.png")),
      overlayGlow: Jimp.read(path.join(__dirname, "overlay-glow.png")),
      // Gimp drop shadow filter: input: mask.png, config: X: 2, Y: 5, Offset: 5, Color: black, Opacity: 20
      overlayShadow: Jimp.read(path.join(__dirname, "overlay-shadow.png")),

      composite(canvas, image, properties, offset, max) {
        if (properties.mask) {
          log("Images:composite", "Masking composite image on circle");
          return Promise.all([
            this.mask,
            this.overlayGlow,
            this.overlayShadow
          ]).then(([mask, glow, shadow]) => {
            canvas.mask(mask.clone().resize(max, Jimp.AUTO), 0, 0);
            if (properties.overlayGlow) {
              canvas.composite(glow.clone().resize(max, Jimp.AUTO), 0, 0);
            }
            if (properties.overlayShadow) {
              canvas.composite(shadow.clone().resize(max, Jimp.AUTO), 0, 0);
            }
            properties = Object.assign({}, properties, {
              mask: false
            });

            return this.composite(canvas, image, properties, offset, max);
          });
        }

        log(
          "Images:composite",
          `Compositing favicon on ${properties.width}x${
            properties.height
          } canvas with offset ${offset}`
        );

        return new Promise((resolve, reject) => {
          canvas.composite(image, offset, offset);
          if (properties.rotate) {
            const degrees = 90;

            log("Images:render", `Rotating image by ${degrees}`);
            canvas.rotate(degrees, false);
          }
          return canvas.getBuffer(Jimp.MIME_PNG, (error, result) =>
            error ? reject(error) : resolve(result)
          );
        });
      }
    }
  };
};
<commit_msg>Use Jimp methods returning promises<commit_after>const path = require("path");
const url = require("url");
const fs = require("fs");
const promisify = require("util.promisify");
const color = require("tinycolor2");
const colors = require("colors");
const jsonxml = require("jsontoxml");
const sizeOf = require("image-size");
const Jimp = require("jimp");
const sharp = require("sharp");
const PLATFORM_OPTIONS = require("./config/platform-options.json");

module.exports = function(options) {
  function directory(path) {
    return path.substr(-1) === "/" ? path : `${path}/`;
  }

  function relative(path, relativeToPath = false) {
    return url.resolve(
      (!relativeToPath && options.path && directory(options.path)) || "",
      path
    );
  }

  function log(context, message) {
    if (options.logging) {
      const { magenta, green, yellow } = colors;

      message = message.replace(/ \d+(x\d+)?/g, item => magenta(item));
      message = message.replace(/#([0-9a-f]{3}){1,2}/g, item => magenta(item));
      console.log(`${green("[Favicons]")} ${yellow(context)}: ${message}...`);
    }
  }

  function parseColor(hex) {
    const { r, g, b, a } = color(hex).toRgb();

    return Jimp.rgbaToInt(r, g, b, a * 255);
  }

  return {
    General: {
      source(src) {
        log("General:source", `Source type is ${typeof src}`);

        if (Buffer.isBuffer(src)) {
          try {
            return Promise.resolve([{ size: sizeOf(src), file: src }]);
          } catch (error) {
            return Promise.reject(new Error("Invalid image buffer"));
          }
        } else if (typeof src === "string") {
          return promisify(fs.readFile)(src).then(this.source.bind(this));
        } else if (Array.isArray(src) && !src.some(Array.isArray)) {
          if (!src.length) {
            return Promise.reject(new Error("No source provided"));
          }

          return Promise.all(src.map(this.source.bind(this))).then(results =>
            [].concat(...results)
          );
        } else {
          return Promise.reject(new Error("Invalid source type provided"));
        }
      },

      preparePlatformOptions(platform) {
        const parameters =
          typeof options.icons[platform] === "object"
            ? options.icons[platform]
            : {};

        for (const key of Object.keys(parameters)) {
          if (
            !(key in PLATFORM_OPTIONS) ||
            !PLATFORM_OPTIONS[key].platforms.includes(platform)
          ) {
            throw new Error(
              `Unsupported option '${key}' on platform '${platform}'`
            );
          }
        }

        for (const key of Object.keys(PLATFORM_OPTIONS)) {
          const platformOption = PLATFORM_OPTIONS[key];
          const { platforms, defaultTo } = platformOption;

          if (!(key in parameters) && platforms.includes(platform)) {
            parameters[key] =
              platform in platformOption ? platformOption[platform] : defaultTo;
          }
        }

        if (parameters.background === true) {
          parameters.background = options.background;
        }

        return parameters;
      }
    },

    HTML: {
      render(htmlTemplate) {
        return htmlTemplate(Object.assign({}, options, { relative }));
      }
    },

    Files: {
      create(properties, name, isHtml) {
        return new Promise((resolve, reject) => {
          log("Files:create", `Creating file: ${name}`);
          if (name === "manifest.json") {
            properties.name = options.appName;
            properties.short_name = options.appShortName || options.appName;
            properties.description = options.appDescription;
            properties.dir = options.dir;
            properties.lang = options.lang;
            properties.display = options.display;
            properties.orientation = options.orientation;
            properties.scope = options.scope;
            properties.start_url = options.start_url;
            properties.background_color = options.background;
            properties.theme_color = options.theme_color;
            properties.icons.map(
              icon =>
                (icon.src = relative(icon.src, options.manifestRelativePaths))
            );
            properties = JSON.stringify(properties, null, 2);
          } else if (name === "manifest.webapp") {
            properties.version = options.version;
            properties.name = options.appName;
            properties.description = options.appDescription;
            properties.developer.name = options.developerName;
            properties.developer.url = options.developerURL;
            properties.icons = Object.keys(properties.icons).reduce(
              (obj, key) =>
                Object.assign(obj, {
                  [key]: relative(
                    properties.icons[key],
                    options.manifestRelativePaths
                  )
                }),
              {}
            );
            properties = JSON.stringify(properties, null, 2);
          } else if (name === "browserconfig.xml") {
            properties[0].children[0].children[0].children.map(property => {
              if (property.name === "TileColor") {
                property.text = options.background;
              } else {
                property.attrs.src = relative(
                  property.attrs.src,
                  options.manifestRelativePaths
                );
              }
            });
            properties = jsonxml(properties, {
              prettyPrint: true,
              xmlHeader: true,
              indent: "  "
            });
          } else if (name === "yandex-browser-manifest.json") {
            properties.version = options.version;
            properties.api_version = 1;
            properties.layout.logo = relative(
              properties.layout.logo,
              options.manifestRelativePaths
            );
            properties.layout.color = options.background;
            properties = JSON.stringify(properties, null, 2);
          } else if (isHtml) {
            properties = properties.join("\n");
          } else {
            reject(`Unknown format of file ${name}.`);
          }
          resolve({ name, contents: properties });
        });
      }
    },

    Images: {
      create(properties) {
        log(
          "Image:create",
          `Creating empty ${properties.width}x${
            properties.height
          } canvas with ${
            properties.transparent ? "transparent" : properties.background
          } background`
        );

        return Jimp.create(
          properties.width,
          properties.height,
          properties.transparent ? 0 : parseColor(properties.background)
        );
      },

      render(sourceset, properties, offset) {
        log(
          "Image:render",
          `Find nearest icon to ${properties.width}x${
            properties.height
          } with offset ${offset}`
        );

        const width = properties.width - offset * 2;
        const height = properties.height - offset * 2;
        const svgSource = sourceset.find(source => source.size.type === "svg");

        let promise = null;

        if (svgSource) {
          const background = { r: 0, g: 0, b: 0, alpha: 0 };

          log("Image:render", `Rendering SVG to ${width}x${height}`);
          promise = sharp(svgSource.file)
            .resize({
              background,
              width,
              height,
              fit: sharp.fit.contain
            })
            .toBuffer()
            .then(Jimp.read);
        } else {
          const sideSize = Math.max(width, height);

          let nearestIcon = sourceset[0];
          let nearestSideSize = Math.max(
            nearestIcon.size.width,
            nearestIcon.size.height
          );

          for (const icon of sourceset) {
            const max = Math.max(icon.size.width, icon.size.height);

            if (
              (nearestSideSize > max || nearestSideSize < sideSize) &&
              max >= sideSize
            ) {
              nearestIcon = icon;
              nearestSideSize = max;
            }
          }

          log("Images:render", `Resizing PNG to ${width}x${height}`);

          promise = Jimp.read(nearestIcon.file).then(image =>
            image.contain(
              width,
              height,
              Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE,
              options.pixel_art &&
                width >= image.bitmap.width &&
                height >= image.bitmap.height
                ? Jimp.RESIZE_NEAREST_NEIGHBOR
                : null
            )
          );
        }

        return promise.then(image => image);
      },

      mask: Jimp.read(path.join(__dirname, "mask.png")),
      overlayGlow: Jimp.read(path.join(__dirname, "overlay-glow.png")),
      // Gimp drop shadow filter: input: mask.png, config: X: 2, Y: 5, Offset: 5, Color: black, Opacity: 20
      overlayShadow: Jimp.read(path.join(__dirname, "overlay-shadow.png")),

      composite(canvas, image, properties, offset, max) {
        if (properties.mask) {
          log("Images:composite", "Masking composite image on circle");
          return Promise.all([
            this.mask,
            this.overlayGlow,
            this.overlayShadow
          ]).then(([mask, glow, shadow]) => {
            canvas.mask(mask.clone().resize(max, Jimp.AUTO), 0, 0);
            if (properties.overlayGlow) {
              canvas.composite(glow.clone().resize(max, Jimp.AUTO), 0, 0);
            }
            if (properties.overlayShadow) {
              canvas.composite(shadow.clone().resize(max, Jimp.AUTO), 0, 0);
            }
            properties = Object.assign({}, properties, {
              mask: false
            });

            return this.composite(canvas, image, properties, offset, max);
          });
        }

        log(
          "Images:composite",
          `Compositing favicon on ${properties.width}x${
            properties.height
          } canvas with offset ${offset}`
        );

        canvas.composite(image, offset, offset);
        if (properties.rotate) {
          const degrees = 90;

          log("Images:render", `Rotating image by ${degrees}`);
          canvas.rotate(degrees, false);
        }
        return canvas.getBufferAsync(Jimp.MIME_PNG);
      }
    }
  };
};
<|endoftext|>


<commit_before>var Datastore = require('nedb'),
    inflection = require('inflection'),
    Settings = require('../settings');

var db = new Datastore({ filename: Settings.storagePath(), autoload: true });

class Bot {
    constructor(slack, logger, settings) {
        this.slack = slack;
        this.logger = logger;
        this.settings = settings;
        this.looping = false;
        this.quietLoop = true;
        this.queue = [];
        this.channels = settings.channels.map(c => c.toLowerCase());
        db.ensureIndex({ fieldName: 'ts', unique: true}, function(err) { });

        logger.info('Bot', `Connected to Slack as @${slack.self.name} on ${slack.team.name}`);
        var slackChannels = Object.keys(slack.channels)
            .map(c => slack.channels[c]);
        slackChannels = slackChannels.concat(Object.keys(slack.groups)
            .map(g => slack.groups[g])
            .map(g => {
                g.is_member = true;
                return g;
            })
        );

        var unwatchableChannels = slackChannels
            .filter(c => settings.channels.indexOf(c.name) > -1)
            .filter(c => !c.is_member)
            .map(c => c.name);
        var forgottenChannels = slackChannels
            .filter(c => c.is_member)
            .filter(c => settings.channels.indexOf(c.name) == -1)
            .map(c => c.name);
        var validChannels = settings.channels
            .filter(c => unwatchableChannels.indexOf(c) == -1 && forgottenChannels.indexOf(c) == -1);

        this.channels = validChannels;

        if(unwatchableChannels.length > 0) {
            logger.warn('Bot', `Whoa! I'm trying to watch ${inflection.inflect('', unwatchableChannels.length, 'a channel', 'channels')}${inflection.inflect('', unwatchableChannels.length, '', ' in')} which I'm not a member of: ${unwatchableChannels.join(', ')}`);
        }

        if(forgottenChannels.length > 0) {
            logger.warn('Bot', `Hey! I belong to ${inflection.inflect('an', forgottenChannels.length, null, 'some')} unwatched ${inflection.inflect('channel', forgottenChannels.length)}: ${forgottenChannels.join(', ')}`);
        }

        if(validChannels.length === 0) {
            logger.error('Bot', 'Hmm. Looks like I have nothing to watch! Nothing to do! Yay! See u later, alligator.');
            process.exit(1);
            return;
        } else {
            logger.info('Bot', `Okay, I will watch ${inflection.inflect('', validChannels.length, 'this', 'these')} ${inflection.inflect('channel', validChannels.length)}: ${validChannels.join(', ')}`);
        }

        slack
            .on('raw_message', msg => this.onRawMessage(msg))
            .on('message', msg => this.enqueue(msg));
    }

    onRawMessage(msg) {
        if(msg.type.startsWith('reaction_') || msg.type === 'group_joined') {
            this.enqueue(msg);
        }
    }

    enqueue(msg) {
        this.queue.push(msg);
        if(!this.looping) {
            this.looping = true;
            this.loop();
        }
    }

    loop() {
        var msg = this.queue.shift();
        if(!msg) {
            if(!this.quietLoop) {
                this.logger.verbose('Loopr', 'Queue is empty. Pfew! Breaking loop.');
            }
            this.looping = false;
            this.quietLoop = true;
            return;
        }
        this.quietLoop = false;
        if(!!msg.subtype) {
            msg.type = msg.subtype;
        }

        var channelCheck = (chn) => {
            var channel = this.slack.getChannelGroupOrDMByID(chn);
            var exists = this.channels.indexOf(channel.name) > -1;
            this.logger.verbose('Loopr', `Checking ${channel.name} against ${this.channels.join(', ')}...`);
            if(!exists) {
                this.quietLoop = true;
            }
            this.logger.verbose('Loopr', `Result: ${exists}`);
            return exists;
        };

        switch(msg.type) {
            case 'reaction_added':
            case 'reaction_removed':
                if(!channelCheck(msg.item.channel)) {
                    break;
                }
                var delta = msg.type === 'reaction_added' ? 1 : -1;
                this.logger.verbose('Loopr', `Message TS ${msg.item.ts} updating reactions index with delta ${delta}`);
                db.update({ ts: msg.item.ts }, { $inc: { reactions: delta } }, {}, () => this.loop());
                break;
            case 'message_deleted':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                db.remove({ ts: msg.deleted_ts }, {}, (err, num) => {
                    if(num === 1) {
                        this.logger.verbose('Loopr', `Message TS ${msg.deleted_ts} removed`);
                    } else {
                        this.quietLoop = true;
                    }
                    this.loop();
                });
                break;
            case 'message_changed':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                this.logger.verbose('Loopr', `Message TS ${msg.message.ts} was edited.`);
                var matches = msg.message.text.match(this.settings.messageMatcherRegex);
                if(matches && matches.length > 0) {
                    this.logger.verbose('Loopr', `Message TS ${msg.message.ts} still is eligible.`);
                    // Insert or update.
                    db.find({ ts: msg.message.ts }, (err, docs) => {
                        if(!!docs && docs.length == 1) {
                            this.logger.verbose('Loopr', `Message TS ${msg.message.ts} found on storage. Updating text...`);
                            // update.
                            db.update({ ts: msg.message.ts }, { text: msg.message.text }, {}, _ => this.loop());
                        } else {
                            // insert.
                            this.logger.verbose('Loopr', `Message TS ${msg.message.ts} not found on storage. Inserting a new one...`);
                            var channel = this.slack.getChannelGroupOrDMByID(msg.channel);
                            db.insert({ ts: msg.message.ts, text: msg.message.text, channel: channel.name, reactions: 0, date: Date.now() }, _ => this.loop());
                        }
                    });
                } else {
                    // delete.
                    this.logger.verbose('Loopr', `Message TS ${msg.message.ts} is not eligible anymore and will be removed.`);
                    db.remove({ ts: msg.message.ts }, {}, _ => this.loop());
                }
                break;
            case 'message':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                if(msg.text) {
                    var matches = msg.text.match(this.settings.messageMatcherRegex);
                    if(matches && matches.length > 0) {
                        db.find({ ts: msg.ts }, (err, docs) => {
                            if(!!docs && docs.length < 1) {
                                this.logger.verbose('Loopr', `Message TS ${msg.ts} is eligible and does not exist on storage. Inserting now...`);
                                var channel = this.slack.getChannelGroupOrDMByID(msg.channel);
                                db.insert({ ts: msg.ts, text: msg.text, channel: channel.name, reactions: 0, date: Date.now() }, _ => this.loop());
                            } else {
                                this.logger.verbose('Loopr', `Message TS ${msg.ts} is eligible and already exists on storage. Skipping...`);
                                this.loop();
                            }
                        });
                    } else {
                        this.quietLoop = true;
                        this.loop();
                    }
                }
                break;
            case 'group_joined':
                if(this.settings.autoWatch) {
                    this.logger.info('Loopr', `Yay! I've been invited to ${msg.channel.name}! Updating settings...`);
                    var channels = this.settings.channels;
                    channels.push(msg.channel.name);
                    this.settings.channels = channels;
                    if(this.channels.indexOf(msg.channel.name) === -1) {
                        this.channels.push(msg.channel.name);
                    }
                }
                break;
            default:
                this.logger.warn('Loopr', `Unknown event caught: ${msg.type}. Ignoring...`);
                this.loop();
                break;
        }
    }
}

module.exports = Bot;
<commit_msg>Update watcher to keep track of reactions count<commit_after>var Datastore = require('nedb'),
    inflection = require('inflection'),
    Settings = require('../settings');

var db = new Datastore({ filename: Settings.storagePath(), autoload: true });

class Bot {
    constructor(slack, logger, settings) {
        this.slack = slack;
        this.logger = logger;
        this.settings = settings;
        this.looping = false;
        this.quietLoop = true;
        this.queue = [];
        this.channels = settings.channels.map(c => c.toLowerCase());
        db.ensureIndex({ fieldName: 'ts', unique: true}, function(err) { });

        logger.info('Bot', `Connected to Slack as @${slack.self.name} on ${slack.team.name}`);
        var slackChannels = Object.keys(slack.channels)
            .map(c => slack.channels[c]);
        slackChannels = slackChannels.concat(Object.keys(slack.groups)
            .map(g => slack.groups[g])
            .map(g => {
                g.is_member = true;
                return g;
            })
        );

        var unwatchableChannels = slackChannels
            .filter(c => settings.channels.indexOf(c.name) > -1)
            .filter(c => !c.is_member)
            .map(c => c.name);
        var forgottenChannels = slackChannels
            .filter(c => c.is_member)
            .filter(c => settings.channels.indexOf(c.name) == -1)
            .map(c => c.name);
        var validChannels = settings.channels
            .filter(c => unwatchableChannels.indexOf(c) == -1 && forgottenChannels.indexOf(c) == -1);

        this.channels = validChannels;

        if(unwatchableChannels.length > 0) {
            logger.warn('Bot', `Whoa! I'm trying to watch ${inflection.inflect('', unwatchableChannels.length, 'a channel', 'channels')}${inflection.inflect('', unwatchableChannels.length, '', ' in')} which I'm not a member of: ${unwatchableChannels.join(', ')}`);
        }

        if(forgottenChannels.length > 0) {
            logger.warn('Bot', `Hey! I belong to ${inflection.inflect('an', forgottenChannels.length, null, 'some')} unwatched ${inflection.inflect('channel', forgottenChannels.length)}: ${forgottenChannels.join(', ')}`);
        }

        if(validChannels.length === 0) {
            logger.error('Bot', 'Hmm. Looks like I have nothing to watch! Nothing to do! Yay! See u later, alligator.');
            process.exit(1);
            return;
        } else {
            logger.info('Bot', `Okay, I will watch ${inflection.inflect('', validChannels.length, 'this', 'these')} ${inflection.inflect('channel', validChannels.length)}: ${validChannels.join(', ')}`);
        }

        slack
            .on('raw_message', msg => this.onRawMessage(msg))
            .on('message', msg => this.enqueue(msg));
    }

    onRawMessage(msg) {
        if(msg.type.startsWith('reaction_') || msg.type === 'group_joined') {
            this.enqueue(msg);
        }
    }

    enqueue(msg) {
        this.queue.push(msg);
        if(!this.looping) {
            this.looping = true;
            this.loop();
        }
    }

    loop() {
        var msg = this.queue.shift();
        if(!msg) {
            if(!this.quietLoop) {
                this.logger.verbose('Loopr', 'Queue is empty. Pfew! Breaking loop.');
            }
            this.looping = false;
            this.quietLoop = true;
            return;
        }
        this.quietLoop = false;
        if(!!msg.subtype) {
            msg.type = msg.subtype;
        }

        var channelCheck = (chn) => {
            var channel = this.slack.getChannelGroupOrDMByID(chn);
            var exists = this.channels.indexOf(channel.name) > -1;
            this.logger.verbose('Loopr', `Checking ${channel.name} against ${this.channels.join(', ')}...`);
            if(!exists) {
                this.quietLoop = true;
            }
            this.logger.verbose('Loopr', `Result: ${exists}`);
            return exists;
        };

        switch(msg.type) {
            case 'reaction_added':
            case 'reaction_removed':
                if(!channelCheck(msg.item.channel)) {
                    break;
                }
                var delta = msg.type === 'reaction_added' ? 1 : -1,
                    reaction = msg.reaction;
                this.logger.verbose('Loopr', `Message TS ${msg.item.ts} updating reactions index with delta ${delta}`);
                db.find({ ts: msg.item.ts }, (err, docs) => {
                    if(docs && docs.length === 1) {
                        var d = docs[0];
                        if(!d.reactions.hasOwnProperty(reaction)) {
                            d.reactions[reaction] = 0;
                        }
                        d.reactions[reaction] = Math.max(0, d.reactions[reaction] + delta);
                        if(d.reactions[reaction] === 0) {
                            delete d.reactions[reaction]
                        }
                        db.update({ ts: d.ts }, d, {}, () => this.loop());
                    } else {
                        this.loop();
                    }
                });
                break;
            case 'message_deleted':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                db.remove({ ts: msg.deleted_ts }, {}, (err, num) => {
                    if(num === 1) {
                        this.logger.verbose('Loopr', `Message TS ${msg.deleted_ts} removed`);
                    } else {
                        this.quietLoop = true;
                    }
                    this.loop();
                });
                break;
            case 'message_changed':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                this.logger.verbose('Loopr', `Message TS ${msg.message.ts} was edited.`);
                var matches = msg.message.text.match(this.settings.messageMatcherRegex);
                if(matches && matches.length > 0) {
                    this.logger.verbose('Loopr', `Message TS ${msg.message.ts} still is eligible.`);
                    // Insert or update.
                    db.find({ ts: msg.message.ts }, (err, docs) => {
                        if(!!docs && docs.length == 1) {
                            this.logger.verbose('Loopr', `Message TS ${msg.message.ts} found on storage. Updating text...`);
                            // update.
                            db.update({ ts: msg.message.ts }, { text: msg.message.text }, {}, _ => this.loop());
                        } else {
                            // insert.
                            this.logger.verbose('Loopr', `Message TS ${msg.message.ts} not found on storage. Inserting a new one...`);
                            var channel = this.slack.getChannelGroupOrDMByID(msg.channel);
                            db.insert({ ts: msg.message.ts, text: msg.message.text, channel: channel.name, reactions: {}, date: Date.now() }, _ => this.loop());
                        }
                    });
                } else {
                    // delete.
                    this.logger.verbose('Loopr', `Message TS ${msg.message.ts} is not eligible anymore and will be removed.`);
                    db.remove({ ts: msg.message.ts }, {}, _ => this.loop());
                }
                break;
            case 'message':
                if(!channelCheck(msg.channel)) {
                    break;
                }
                if(msg.text) {
                    var matches = msg.text.match(this.settings.messageMatcherRegex);
                    if(matches && matches.length > 0) {
                        db.find({ ts: msg.ts }, (err, docs) => {
                            if(!!docs && docs.length < 1) {
                                this.logger.verbose('Loopr', `Message TS ${msg.ts} is eligible and does not exist on storage. Inserting now...`);
                                var channel = this.slack.getChannelGroupOrDMByID(msg.channel);
                                db.insert({ ts: msg.ts, text: msg.text, channel: channel.name, reactions: {}, date: Date.now() }, _ => this.loop());
                            } else {
                                this.logger.verbose('Loopr', `Message TS ${msg.ts} is eligible and already exists on storage. Skipping...`);
                                this.loop();
                            }
                        });
                    } else {
                        this.quietLoop = true;
                        this.loop();
                    }
                }
                break;
            case 'group_joined':
                if(this.settings.autoWatch) {
                    this.logger.info('Loopr', `Yay! I've been invited to ${msg.channel.name}! Updating settings...`);
                    var channels = this.settings.channels;
                    channels.push(msg.channel.name);
                    this.settings.channels = channels;
                    if(this.channels.indexOf(msg.channel.name) === -1) {
                        this.channels.push(msg.channel.name);
                    }
                }
                break;
            default:
                this.logger.warn('Loopr', `Unknown event caught: ${msg.type}. Ignoring...`);
                this.loop();
                break;
        }
    }
}

module.exports = Bot;
<|endoftext|>


<commit_before>/* ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **
Filename: api.jake
Date: 3/26/17
Author: Chris Mendez http://chrisjmendez.com
Description: API requests made to an AWS API Gateway.
It is assumed that within your AWS API Gateway, you
might have a single function written for three stages:
dev, staging, and prod.

Example:

//Submit a CURL request to the Productin API
jake api:getAll[prod]

* ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **/
var util    = require('util')
var request = require('request')
var apiConfig = {
	development: process.env.API_DEV,
	staging:     process.env.API_STAGING,
	production:  process.env.API_PROD
}

namespace('api', function () {
	desc('GET All Orders. Ex: jake api:getAll[prod]');
	task('getAll', { async: true }, function(env) {
		//A. Get the URL path based on your dev environment: dev, stating, prod
		var url  = getURLPath(env);
		var path = url + "guitars/";
		//B. Use NPM Request to construct a GET request
		request.get({ url: path, json: true }, function(err, res){
			if(err) throw err
			console.log(res.body)
		});

	});

	desc('GET item detail based on orderId.');
	task('getItemDetail', { async: true }, function(env) {
		//A. Create URL
		var url  = getURLPath(env);
		var orderId = "123456";
		var path = url + "guitars/" + orderId;
		//B. Use NPM Request to construct a GET request
		request.get({ url: path, json: true }, function(err, res){
			if(err) throw err
			console.log(res.body)
		});
	});

	desc('POST order');
	task('getOrder', { async: true }, function(env) {
		//A. Create URL
		var orderId = "abcd123";
		//B. Use NPM Request to construct a POST request
		request.post({
			//headers: {'content-type' : 'application/x-www-form-urlencoded'},
			url:     getURLPath(env) + 'order/',
			body:    JSON.stringify({ orderId: orderId})
		}, function(err, res, data){
			if(err) throw err;
			if( data ){
				console.log(JSON.parse(data))
			}
		});
	});
});

//Return the URL based on the staging environment
function getURLPath(env){
	var url = "";

  if(!apiConfig) throw Error("apiConfig " + apiConfig + ". Please review .env.");

	switch(env){
	case "stag":
	case "staging":
		url = apiConfig.staging
		break;
	case "prod":
	case "production":
		url = apiConfig.production
		break;
	case "dev":
	case "development":
	default:
		url = apiConfig.development
	}
	console.log("Environment:", env, " URL:", url );
	return url
}

<commit_msg>Added ability to accept API Keys<commit_after>/* ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **
Filename: api.jake
Date: 3/26/17
Author: Chris Mendez http://chrisjmendez.com
Description: API requests made to an AWS API Gateway.
It is assumed that within your AWS API Gateway, you
might have a single function written for three stages:
dev, staging, and prod.

Example:

//Submit a CURL request to the Productin API
jake api:getAll[prod]

* ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **/
var util    = require('util')
var request = require('request')
var apiConfig = {
  development: process.env.API_DEV,
  staging:     process.env.API_STAGING,
  production:  process.env.API_PROD,
  key:         process.env.API_KEY
}

namespace('api', function () {
  desc('GET All Orders. Requires API_KEY. Ex: jake api:getAll[prod]');
  task('getAll', { async: true }, function(env) {
    //A. Get the URL path based on your dev environment: dev, stating, prod
    var url  = getURLPath(env);
    var path = url + "guitars/";
    //B. Use NPM Request to construct a GET request
    request.get({ url: path, json: true }, function(err, res){
      if(err) throw err
        console.log(res.body)
    });

  });

  desc('GET item detail based on id. Requires API_KEY. Ex: jake api:getItemDetail[dev,0000]');
  task('getItemDetail', { async: true }, function(env, id) {
    //A. Create URL
    var url  = getURLPath(env);
    var path = url + "guitars/" + id;

    //B. Use NPM Request to construct a GET request
    if(!apiConfig.key) throw Error("Error: API_KEY required within .env.")
    request.get({
      headers: {'x-api-key' : apiConfig.key },
      url: path,
      json: true
    }, function(err, res){
      if(err) throw err
        console.log(res.body)
    });
  });

  desc('POST order');
  task('getOrder', { async: true }, function(env, id) {
    var id = id
    //B. Use NPM Request to construct a POST request
    if(!apiConfig.key) throw Error("Error: API_KEY required within .env.");
    request.post({
      headers: {'x-api-key' : apiConfig.key },
      url:     getURLPath(env) + 'order/',
      body:    JSON.stringify({ orderId: id})
    }, function(err, res, data){
      if(err) throw err;
      if( data ){
        console.log(JSON.parse(data))
      }
    });
  });
});

//Return the URL based on the staging environment
function getURLPath(env){
  var url = "";

  if(!apiConfig) throw Error("apiConfig " + apiConfig + ". Please review .env.");

  switch(env){
    case "stag":
    case "staging":
      url = apiConfig.staging
      break;
    case "prod":
    case "production":
      url = apiConfig.production
      break;
    case "dev":
    case "development":
    default:
      url = apiConfig.development
  }
  console.log("Environment:", env, " URL:", url );
  return url
}

<|endoftext|>


<commit_before>// @flow
/**
 * @module withControl
 */

import React from 'react'
import ReactDOM from 'react-dom'
import hoistNonReactStatics from 'hoist-non-react-statics'

import type { Ref, Element, ComponentType } from 'react'
import type { Styles } from './internal/flow-types'

import ContextTypes from './internal/context-types'

import {
  IS_DEVELOPMENT,
} from './internal/constants'

import {
  isReactComponent,
  isStatelessComponent,
  makeError,
} from './internal/utils'

type Options = {
  useStringRefs?: boolean,
  mergeProps?: Function,
};

type Props = {};

type State = {
  style: Object,
};

const withControl = (
  name: string,
  {
    mergeProps,
    useStringRefs = false,
  }: Options = {}
) => (BaseComponent: ComponentType<{}>): ComponentType<Props> => {

  type BaseRef = Element<typeof BaseComponent>;

  if (IS_DEVELOPMENT) {
    if (!isReactComponent(BaseComponent)) {
      throw makeError(
        `withControl() must be used to wrap a React component`
         + ` Make sure that you're passing in either a component class or a`
         + ` function that returns an element. A common mistake is to pass in`
         + ` the React element itself. For example, passing in "<Component/>"`
         + ` instead of "Component", but you should be passing in the second form.`
      );
    }
    if (mergeProps && typeof mergeProps !== 'function') {
      throw makeError(
        `The second argument to withControl must be a function with two arguments:`
        + ` the first is the props of the controlled component, and the second is`
        + ` the animatronicStyles object. The function must return an object that`
        + ` will be spread into the wrapped component as props.`
      );
    }
  }

  class ControlledComponent extends React.Component<Props, State> {
    _ref: ?BaseRef
    _onRef: Function
    _setComponentStyle: Function
    _resetComponentStyle: Function

    constructor(props: Props) {
      super(props);
      this.state = { style: {} };
      this._ref = null;
      this._onRef = this._onRef.bind(this);
      this._setComponentStyle = this._setComponentStyle.bind(this);
      this._resetComponentStyle = this._resetComponentStyle.bind(this);
    }

    componentDidMount() {
      const { animatronics } = this.context;

      const ref: ?BaseRef = useStringRefs ? this.refs[name] : this._ref;

      // $FlowFixMe: flow thinks the ref is an object type for some reason
      const domNode = ReactDOM.findDOMNode(ref);

      if (IS_DEVELOPMENT) {
        if (!animatronics) {
          throw makeError(
            `Can't find the right context in the following controlled component: ${ name }.`,
            `This likely means you forgot to use an animatronics component. Check to see that`,
            `you're using either <Animatronics/> or withAnimatronics() and that your controlled`,
            `component is a descendant of it.`
          );
        }
      }

      animatronics.registerComponent(
        name,
        domNode,
        this._setComponentStyle,
        this._resetComponentStyle,
      );
    }

    componentDidUpdate() {
      const { animatronics } = this.context;

      const ref: ?BaseRef = useStringRefs ? this.refs[name] : this._ref;

      // $FlowFixMe: flow thinks the ref is an object type for some reason
      const domNode = ReactDOM.findDOMNode(ref);

      animatronics.registerComponent(
        name,
        domNode,
        this._setComponentStyle,
        this._resetComponentStyle,
      );
    }

    componentWillUnmount() {
      const { animatronics } = this.context;
      animatronics.unregisterComponent(name);
    }

    _setComponentStyle(updatedStyles: Styles) {
      this.setState(state => ({
        style: {
          ...state.style,
          ...updatedStyles,
        },
      }));
    }

    _resetComponentStyle() {
      this.setState({ style: {} })
    }

    _onRef(ref: ?BaseRef) {
      this._ref = ref;
    }

    render() {
      const { ...props } = this.props;
      const { style } = this.state;

      const baseProps = mergeProps ? (
        mergeProps(props, style)
      ) : ({
        animatronicStyles: style,
        ...props,
      });

      if (!isStatelessComponent(BaseComponent)) {
        baseProps.ref = useStringRefs ? name : this._onRef;
      }

      return <BaseComponent { ...baseProps }/>;
    }
  }

  ControlledComponent.contextTypes = ContextTypes;

  return hoistNonReactStatics(ControlledComponent, BaseComponent);
};

export default withControl;
<commit_msg>:children_crossing: [withControl] Updated error message<commit_after>// @flow
/**
 * @module withControl
 */

import React from 'react'
import ReactDOM from 'react-dom'
import hoistNonReactStatics from 'hoist-non-react-statics'

import type { Ref, Element, ComponentType } from 'react'
import type { Styles } from './internal/flow-types'

import ContextTypes from './internal/context-types'

import {
  IS_DEVELOPMENT,
} from './internal/constants'

import {
  isReactComponent,
  isStatelessComponent,
  makeError,
} from './internal/utils'

type Options = {
  useStringRefs?: boolean,
  mergeProps?: Function,
};

type Props = {};

type State = {
  style: Object,
};

const withControl = (
  name: string,
  {
    mergeProps,
    useStringRefs = false,
  }: Options = {}
) => (BaseComponent: ComponentType<{}>): ComponentType<Props> => {

  type BaseRef = Element<typeof BaseComponent>;

  if (IS_DEVELOPMENT) {
    if (!isReactComponent(BaseComponent)) {
      throw makeError(
        `withControl() must be used to wrap a React component`
         + ` Make sure that you're passing in either a component class or a`
         + ` function that returns an element. A common mistake is to pass in`
         + ` the React element itself. For example, passing in "<Component/>"`
         + ` instead of "Component", but you should be passing in the second form.`
      );
    }
    if (mergeProps && typeof mergeProps !== 'function') {
      throw makeError(
        `The "mergeProps" option to withControl must be a function with two arguments:`
        + ` the first is the props of the controlled component, and the second is`
        + ` the animatronicStyles object. The function must return an object that`
        + ` will be spread into the wrapped component as props.`
      );
    }
  }

  class ControlledComponent extends React.Component<Props, State> {
    _ref: ?BaseRef
    _onRef: Function
    _setComponentStyle: Function
    _resetComponentStyle: Function

    constructor(props: Props) {
      super(props);
      this.state = { style: {} };
      this._ref = null;
      this._onRef = this._onRef.bind(this);
      this._setComponentStyle = this._setComponentStyle.bind(this);
      this._resetComponentStyle = this._resetComponentStyle.bind(this);
    }

    componentDidMount() {
      const { animatronics } = this.context;

      const ref: ?BaseRef = useStringRefs ? this.refs[name] : this._ref;

      // $FlowFixMe: flow thinks the ref is an object type for some reason
      const domNode = ReactDOM.findDOMNode(ref);

      if (IS_DEVELOPMENT) {
        if (!animatronics) {
          throw makeError(
            `Can't find the right context in the following controlled component: ${ name }.`,
            `This likely means you forgot to use an animatronics component. Check to see that`,
            `you're using either <Animatronics/> or withAnimatronics() and that your controlled`,
            `component is a descendant of it.`
          );
        }
      }

      animatronics.registerComponent(
        name,
        domNode,
        this._setComponentStyle,
        this._resetComponentStyle,
      );
    }

    componentDidUpdate() {
      const { animatronics } = this.context;

      const ref: ?BaseRef = useStringRefs ? this.refs[name] : this._ref;

      // $FlowFixMe: flow thinks the ref is an object type for some reason
      const domNode = ReactDOM.findDOMNode(ref);

      animatronics.registerComponent(
        name,
        domNode,
        this._setComponentStyle,
        this._resetComponentStyle,
      );
    }

    componentWillUnmount() {
      const { animatronics } = this.context;
      animatronics.unregisterComponent(name);
    }

    _setComponentStyle(updatedStyles: Styles) {
      this.setState(state => ({
        style: {
          ...state.style,
          ...updatedStyles,
        },
      }));
    }

    _resetComponentStyle() {
      this.setState({ style: {} })
    }

    _onRef(ref: ?BaseRef) {
      this._ref = ref;
    }

    render() {
      const { ...props } = this.props;
      const { style } = this.state;

      const baseProps = mergeProps ? (
        mergeProps(props, style)
      ) : ({
        animatronicStyles: style,
        ...props,
      });

      if (!isStatelessComponent(BaseComponent)) {
        baseProps.ref = useStringRefs ? name : this._onRef;
      }

      return <BaseComponent { ...baseProps }/>;
    }
  }

  ControlledComponent.contextTypes = ContextTypes;

  return hoistNonReactStatics(ControlledComponent, BaseComponent);
};

export default withControl;
<|endoftext|>


<commit_before>'use strict';

function WorldState() {
    this.handlers = [];
    this.mutators = [];
    this.tickers = [];
}

/*
 * THIS IS THE MESSAGE SCHEMA
key: key,
values: patch
*/

// this is a singleton, so this is ok
// we want it to be private
var worldState = {};

WorldState.prototype = {
    reset: function() {
        worldState = {};
    },
    getHack: function() {
        return worldState;
    },
    get: function(key) {
        if (key !== undefined) {
            return worldState[key.toString()];
        }
    },
    registerTicker: function(fn) {
        this.tickers.push(fn);
    },
    registerHandler: function(fn) {
        this.handlers.push({
            fn: fn
        });
    },
    registerMutator: function(list, fn) {
        this.mutators.push({
            list: list,
            fn: fn
        });
    },
    initialState: function(currentTick, timestamp, msg) {
        worldState[msg.key] = {
            key: msg.key,
            values: msg.values,
            type: msg.values.type,
        };
    },
    updateState: function(currentTick, timestamp, msg) {
        var current = worldState[msg.key];

        if (current === undefined) {
            console.log({
                type: "missingState",
                msg: msg
            });

            throw "missingState";
        }

        for (var attrname in msg.values) {
            current.values[attrname] = msg.values[attrname];
        }
    },
    notifyMutators: function(key, values) {
        this.mutators.forEach(function(o) {
            // Test that this change message has all the required fields
            var gonogo = o.list.reduce(
                function(previousValue, currentValue, index, array) {
                    return previousValue && values.hasOwnProperty(currentValue);
                },
                true
            );

            if (gonogo) {
                //try {
                    o.fn(key, values);
                /*} catch (err) {
                    console.log(err);
                } */
            }
        });
    },
    notifyHandlers: function(key, values) {
        this.handlers.forEach(function(o) {
            //try {
                o.fn(key, values);
            /*} catch (err) {
                console.log(err);
            } */
        });
    },
    asyncMutation: function(key) {
        var obj = this.get(key);

        this.notifyMutators(key, obj.values);
    },
    onStateChange: function(currentTick, timestamp, list) {
        var self = this;
        list.forEach(function(msg) {
            // TODO messages that update things can come before the
            // messages to create those things. deal with it
            // TODO this method needs to handle all timestamp
            if (worldState[msg.key] === undefined) {
                if (msg.values.tombstone !== true) {
                    self.initialState(currentTick, timestamp, msg);

                    self.notifyHandlers(msg.key, msg.values);
                }
            } else {
                self.notifyMutators(msg.key, msg.values);

                self.updateState(currentTick, timestamp, msg);
            }
        })
    },
    tickInterval: 80,
    currentTick: function() {
        var ms = new Date().getTime();
        var tickNumber = ms - (ms % this.tickInterval);

        return tickNumber;
    },
    worldTick: function(currentTick) {
        this.tickers.forEach(function(fn) {
            fn(currentTick);
        });
    }
};

var bob = new WorldState();
window.worldState = bob;

module.exports = bob;

<commit_msg>tons of fixes<commit_after>'use strict';

function WorldState() {
    this.handlers = [];
    this.mutators = [];
    this.tickers = [];
}

/*
 * THIS IS THE MESSAGE SCHEMA
key: key,
values: patch
*/

// this is a singleton, so this is ok
// we want it to be private
var worldState = {};

WorldState.prototype = {
    reset: function() {
        worldState = {};
    },
    getHack: function() {
        return worldState;
    },
    get: function(key) {
        if (key !== undefined) {
            return worldState[key.toString()];
        }
    },
    registerTicker: function(fn) {
        this.tickers.push(fn);
    },
    registerHandler: function(fn) {
        this.handlers.push({
            fn: fn
        });
    },
    registerMutator: function(list, fn) {
        this.mutators.push({
            list: list,
            fn: fn
        });
    },
    initialState: function(currentTick, timestamp, msg) {
        worldState[msg.key] = {
            key: msg.key,
            values: msg.values,
            type: msg.values.type,
        };
    },
    updateState: function(currentTick, timestamp, msg) {
        var current = worldState[msg.key];

        if (current === undefined) {
            console.log({
                type: "missingState",
                msg: msg
            });

            throw "missingState";
        }

        for (var attrname in msg.values) {
            current.values[attrname] = msg.values[attrname];
        }
    },
    notifyMutators: function(key, values) {
        this.mutators.forEach(function(o) {
            // Test that this change message has all the required fields
            var gonogo = o.list.reduce(
                function(previousValue, currentValue, index, array) {
                    return previousValue && values.hasOwnProperty(currentValue);
                },
                true
            );

            if (gonogo) {
                //try {
                    o.fn(key, values);
                /*} catch (err) {
                    console.log(err);
                } */
            }
        });
    },
    notifyHandlers: function(key, values) {
        this.handlers.forEach(function(o) {
            //try {
                o.fn(key, values);
            /*} catch (err) {
                console.log(err);
            } */
        });
    },
    asyncMutation: function(key) {
        var obj = this.get(key);

        this.notifyMutators(key, obj.values);
    },
    onStateChange: function(currentTick, timestamp, list) {
        var self = this;
        list.forEach(function(msg) {
            // TODO messages that update things can come before the
            // messages to create those things. deal with it
            // TODO this method needs to handle all timestamp
            if (worldState[msg.key] === undefined) {
                if (msg.values.tombstone !== true) {
                    self.initialState(currentTick, timestamp, msg);

                    self.notifyHandlers(msg.key, msg.values);
                }
            } else {
                self.notifyMutators(msg.key, msg.values);

                self.updateState(currentTick, timestamp, msg);
            }
        })
    },
    tickInterval: 80,
    currentTick: function() {
        var ms = Date.now();
        var tickNumber = ms - (ms % this.tickInterval);

        return tickNumber;
    },
    worldTick: function(currentTick) {
        this.tickers.forEach(function(fn) {
            fn(currentTick);
        });
    }
};

var bob = new WorldState();
window.worldState = bob;

module.exports = bob;

<|endoftext|>


<commit_before>define(['js/core/Component', 'srv/core/Context', 'srv/core/Handlers', 'srv/core/EndPoints', 'srv/handler/ExceptionHandler', 'flow'],
    function(Component, Context, Handlers, EndPoints, ExceptionHandler, flow) {

    return Component.inherit('srv.core.Server', {

        ctor: function() {
            this.$handlers = null;
            this.$endPoints = null;

            this.callBase();
        },

        addChild: function(child) {
            if (child instanceof Handlers) {
                this.$handlers = child;
            } else if (child instanceof EndPoints) {
                this.$endPoints = child;
            }

            this.callBase();
        },

        start: function (parameter, callback) {

            if (!this.$endPoints) {
                callback(new Error("No endPoints specified"));
                return;
            }

            if (!this.$handlers) {
                callback(new Error("No handlers found"));
                return;
            }

            var self = this;

            flow()
                .seq(function(cb) {
                    // start all end points
                    self.$endPoints.start(self, cb);
                })
                .seq(function(cb) {
                    // handlers starts also asynchronous to load e.g. classes
                    self.$handlers.start(self, cb);
                })
                .exec(function(err) {
                    if (err) {
                        self.shutdown(function(e){
                            callback(err);
                        });
                    } else {
                        callback();
                    }
                })

        },

        /***
         * closes all connections and shuts down the server
         * @param callback
         */
        shutdown: function(callback) {
            var self = this;
            flow()
                .seq(function(cb){
                    self.$handlers.stop(function(){
                        // ignore errors during stop
                       cb();
                    });
                })
                .seq(function(cb){
                    self.$endPoints.shutdown(function(){
                        // ignore errors during shutdown
                        cb();
                    });
                })
                .exec(callback);
        },

        /***
         * determinate the request handler and routes a request through the server pipeline
         *
         * @param request
         * @param response
         */
        handleRequest: function(endPoint, request, response) {
            var context,
                requestHandler;

            try {
                // create the new context object
                context = new Context(endPoint, request, response);
                // and set the chosen handler
                requestHandler = this.$handlers.getRequestHandler(context);

                context.handler = requestHandler;

                requestHandler.handleRequest(context, function(err) {
                    if (err) {
                        handleErrorWithErrorHandler(err);
                    }
                });

            } catch (e) {
                handleErrorWithErrorHandler(e);
            }

            function handleErrorWithErrorHandler(e) {
                try {
                    requestHandler = new ExceptionHandler(e);
                    requestHandler.handleRequest(context, function(err) {
                        if (err) {
                            handleErrorIntern(err);
                        }
                    });
                } catch (e) {
                    handleErrorIntern(e);
                }

                function handleErrorIntern(e) {
                    // TODO: log something here
                    console.error(e);
                }
            }

        }

    });
});<commit_msg>added application domain exception handling. requires nodejs > 0.8.0<commit_after>define(['js/core/Component', 'srv/core/Context', 'srv/core/Handlers', 'srv/core/EndPoints', 'srv/handler/ExceptionHandler', 'flow', 'domain'],
    function (Component, Context, Handlers, EndPoints, ExceptionHandler, flow, Domain) {

        return Component.inherit('srv.core.Server', {

            ctor: function () {
                this.$handlers = null;
                this.$endPoints = null;

                this.callBase();
            },

            addChild: function (child) {
                if (child instanceof Handlers) {
                    this.$handlers = child;
                } else if (child instanceof EndPoints) {
                    this.$endPoints = child;
                }

                this.callBase();
            },

            start: function (parameter, callback) {

                if (!this.$endPoints) {
                    callback(new Error("No endPoints specified"));
                    return;
                }

                if (!this.$handlers) {
                    callback(new Error("No handlers found"));
                    return;
                }

                var self = this;

                flow()
                    .seq(function (cb) {
                        // start all end points
                        self.$endPoints.start(self, cb);
                    })
                    .seq(function (cb) {
                        // handlers starts also asynchronous to load e.g. classes
                        self.$handlers.start(self, cb);
                    })
                    .exec(function (err) {
                        if (err) {
                            self.shutdown(function (e) {
                                callback(err);
                            });
                        } else {
                            callback();
                        }
                    })

            },

            /***
             * closes all connections and shuts down the server
             * @param callback
             */
            shutdown: function (callback) {
                var self = this;
                flow()
                    .seq(function (cb) {
                        self.$handlers.stop(function () {
                            // ignore errors during stop
                            cb();
                        });
                    })
                    .seq(function (cb) {
                        self.$endPoints.shutdown(function () {
                            // ignore errors during shutdown
                            cb();
                        });
                    })
                    .exec(function(err) {
                        callback && callback(err);

                        process.stdin.end();
                    });
            },

            /***
             * determinate the request handler and routes a request through the server pipeline
             *
             * @param request
             * @param response
             */
            handleRequest: function (endPoint, request, response) {
                var self = this,
                    context,
                    requestHandler,
                    handledWithErrorHandler = false;

                // create a new application domain for the request
                var domain = Domain.create();

                domain.add(request);
                domain.add(response);

                response.on('end', function () {
                    domain.dispose();
                });

                domain.on('error', handleError);

                domain.run(function () {

                    // create the new context object
                    context = new Context(endPoint, request, response);
                    // and set the chosen handler
                    requestHandler = self.$handlers.getRequestHandler(context);

                    context.handler = requestHandler;

                    requestHandler.handleRequest(context, function(err) {
                        if (err) {
                            handleError(err);
                        }
                    });

                });

                function handleError(err) {

                    if (!handledWithErrorHandler) {
                        handledWithErrorHandler = true;

                        var exceptionHandler = new ExceptionHandler(err);
                        try {
                            exceptionHandler.handleRequest(context, function (err) {
                                err && handleErrorTheHardWay(err);
                            });
                        } catch (e) {
                            handleErrorTheHardWay(err);
                        }

                    } else {
                        // error handler didn't do the job well
                        handleErrorTheHardWay(err);
                    }
                }

                function handleErrorTheHardWay(error) {

                    console.error(error);

                    try {
                        response.writeHead(500);
                        response.end('Internal Server Error');
                    } catch (error) {
                        console.error('Error sending 500', error, request.url);
                        domain.dispose();
                    }
                }

            }

        });
    });<|endoftext|>


<commit_before>const ghostBookshelf = require('./base');
const uuid = require('uuid');
const _ = require('lodash');
const config = require('../../shared/config');
const {gravatar} = require('../lib/image');

const Member = ghostBookshelf.Model.extend({
    tableName: 'members',

    defaults() {
        return {
            status: 'free',
            uuid: uuid.v4(),
            email_count: 0,
            email_opened_count: 0
        };
    },

    filterExpansions() {
        return [{
            key: 'label',
            replacement: 'labels.slug'
        }, {
            key: 'labels',
            replacement: 'labels.slug'
        }, {
            key: 'product',
            replacement: 'products.slug'
        }, {
            key: 'products',
            replacement: 'products.slug'
        }, {
            key: 'tier',
            replacement: 'products.slug'
        }, {
            key: 'tiers',
            replacement: 'products.slug'
        }, {
            key: 'newsletters',
            replacement: 'newsletters.slug'
        }];
    },

    filterRelations() {
        return {
            labels: {
                tableName: 'labels',
                type: 'manyToMany',
                joinTable: 'members_labels',
                joinFrom: 'member_id',
                joinTo: 'label_id'
            },
            products: {
                tableName: 'products',
                type: 'manyToMany',
                joinTable: 'members_products',
                joinFrom: 'member_id',
                joinTo: 'product_id'
            },
            newsletters: {
                tableName: 'newsletters',
                type: 'manyToMany',
                joinTable: 'members_newsletters',
                joinFrom: 'member_id',
                joinTo: 'newsletter_id'
            },
            subscriptions: {
                tableName: 'members_stripe_customers_subscriptions',
                tableNameAs: 'subscriptions',
                type: 'manyToMany',
                joinTable: 'members_stripe_customers',
                joinFrom: 'member_id',
                joinTo: 'customer_id',
                joinToForeign: 'customer_id'
            }
        };
    },

    relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients', 'newsletters'],

    // do not delete email_recipients records when a member is destroyed. Recipient
    // records are used for analytics and historical records
    relationshipConfig: {
        email_recipients: {
            destroyRelated: false
        }
    },

    relationshipBelongsTo: {
        products: 'products',
        newsletters: 'newsletters',
        labels: 'labels',
        stripeCustomers: 'members_stripe_customers',
        email_recipients: 'email_recipients'
    },

    productEvents() {
        return this.hasMany('MemberProductEvent', 'member_id', 'id')
            .query('orderBy', 'created_at', 'DESC');
    },

    products() {
        return this.belongsToMany('Product', 'members_products', 'member_id', 'product_id')
            .withPivot('sort_order')
            .query('orderBy', 'sort_order', 'ASC')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('products.*');
            });
    },

    newsletters() {
        return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('newsletters.*');
            });
    },

    offerRedemptions() {
        return this.hasMany('OfferRedemption', 'member_id', 'id')
            .query('orderBy', 'created_at', 'DESC');
    },

    labels: function labels() {
        return this.belongsToMany('Label', 'members_labels', 'member_id', 'label_id')
            .withPivot('sort_order')
            .query('orderBy', 'sort_order', 'ASC')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('labels.*');
            });
    },

    stripeCustomers() {
        return this.hasMany('MemberStripeCustomer', 'member_id', 'id');
    },

    stripeSubscriptions() {
        return this.belongsToMany(
            'StripeCustomerSubscription',
            'members_stripe_customers',
            'member_id',
            'customer_id',
            'id',
            'customer_id'
        );
    },

    email_recipients() {
        return this.hasMany('EmailRecipient', 'member_id', 'id');
    },

    serialize(options) {
        const defaultSerializedObject = ghostBookshelf.Model.prototype.serialize.call(this, options);

        if (defaultSerializedObject.stripeSubscriptions) {
            defaultSerializedObject.subscriptions = defaultSerializedObject.stripeSubscriptions;
            delete defaultSerializedObject.stripeSubscriptions;
        }

        return defaultSerializedObject;
    },

    emitChange: function emitChange(event, options) {
        const eventToTrigger = 'member' + '.' + event;
        ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
    },

    onCreated: function onCreated(model, options) {
        ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);

        model.emitChange('added', options);
    },

    onUpdated: function onUpdated(model, options) {
        ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);

        model.emitChange('edited', options);
    },

    onDestroyed: function onDestroyed(model, options) {
        ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);

        model.emitChange('deleted', options);
    },

    onDestroying: function onDestroyed(model) {
        ghostBookshelf.Model.prototype.onDestroying.apply(this, arguments);

        this.handleAttachedModels(model);
    },

    onSaving: function onSaving(model, attr, options) {
        let labelsToSave = [];

        if (_.isUndefined(this.get('labels'))) {
            this.unset('labels');
            return;
        }

        // CASE: detect lowercase/uppercase label slugs
        if (!_.isUndefined(this.get('labels')) && !_.isNull(this.get('labels'))) {
            labelsToSave = [];

            //  and deduplicate upper/lowercase tags
            _.each(this.get('labels'), function each(item) {
                item.name = item.name && item.name.trim();
                for (let i = 0; i < labelsToSave.length; i = i + 1) {
                    if (labelsToSave[i].name && item.name && labelsToSave[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
                        return;
                    }
                }

                labelsToSave.push(item);
            });

            this.set('labels', labelsToSave);
        }

        this.handleAttachedModels(model);

        // CASE: Detect existing labels with same case-insensitive name and replace
        return ghostBookshelf.model('Label')
            .findAll(Object.assign({
                columns: ['id', 'name']
            }, _.pick(options, 'transacting')))
            .then((labels) => {
                labelsToSave.forEach((label) => {
                    let existingLabel = labels.find((lab) => {
                        return label.name.toLowerCase() === lab.get('name').toLowerCase();
                    });
                    label.name = (existingLabel && existingLabel.get('name')) || label.name;
                    label.id = (existingLabel && existingLabel.id) || label.id;
                });

                model.set('labels', labelsToSave);
            });
    },

    handleAttachedModels: function handleAttachedModels(model) {
        /**
         * @NOTE:
         * Bookshelf only exposes the object that is being detached on `detaching`.
         * For the reason above, `detached` handler is using the scope of `detaching`
         * to access the models that are not present in `detached`.
         */
        model.related('labels').once('detaching', function onDetaching(collection, label) {
            model.related('labels').once('detached', function onDetached(detachedCollection, response, options) {
                label.emitChange('detached', options);
                model.emitChange('label.detached', options);
            });
        });

        model.related('labels').once('attaching', function onDetaching(collection, labels) {
            model.related('labels').once('attached', function onDetached(detachedCollection, response, options) {
                labels.forEach((label) => {
                    label.emitChange('attached', options);
                    model.emitChange('label.attached', options);
                });
            });
        });
    },

    /**
     * The base model keeps only the columns, which are defined in the schema.
     * We have to add the relations on top, otherwise bookshelf-relations
     * has no access to the nested relations, which should be updated.
     */
    permittedAttributes: function permittedAttributes() {
        let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments);

        this.relationships.forEach((key) => {
            filteredKeys.push(key);
        });

        return filteredKeys;
    },

    /**
     * We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always
     * receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid
     * that event listeners have to re-fetch a resource. This function is used in the context of inserting
     * and updating resources. We won't return the relations by default for now.
     */
    defaultRelations: function defaultRelations(methodName, options) {
        if (['edit', 'add', 'destroy'].indexOf(methodName) !== -1) {
            options.withRelated = _.union(['labels'], options.withRelated || []);
        }

        return options;
    },

    searchQuery: function searchQuery(queryBuilder, query) {
        queryBuilder.where('members.name', 'like', `%${query}%`);
        queryBuilder.orWhere('members.email', 'like', `%${query}%`);
    },

    orderRawQuery(field, direction) {
        if (field === 'email_open_rate') {
            return {
                orderByRaw: `members.email_open_rate IS NOT NULL DESC, members.email_open_rate ${direction}`
            };
        }
    },

    toJSON(unfilteredOptions) {
        const options = Member.filterOptions(unfilteredOptions, 'toJSON');
        const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);

        // Inject a computed avatar url. Uses gravatar's default ?d= query param
        // to serve a blank image if there is no gravatar for the member's email.
        // Will not use gravatar if privacy.useGravatar is false in config
        attrs.avatar_image = null;
        if (attrs.email && !config.isPrivacyDisabled('useGravatar')) {
            attrs.avatar_image = gravatar.url(attrs.email, {size: 250, default: 'blank'});
        }

        return attrs;
    }
}, {
    /**
     * Returns an array of keys permitted in a method's `options` hash, depending on the current method.
     * @param {String} methodName The name of the method to check valid options for.
     * @return {Array} Keys allowed in the `options` hash of the model's method.
     */
    permittedOptions: function permittedOptions(methodName) {
        let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);

        if (['findPage', 'findAll'].includes(methodName)) {
            options = options.concat(['search']);
        }

        return options;
    },

    add(data, unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.add(data, Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.add.call(this, data, unfilteredOptions);
    },

    edit(data, unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.edit(data, Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.edit.call(this, data, unfilteredOptions);
    },

    destroy(unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.destroy(Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.destroy.call(this, unfilteredOptions);
    },

    getLabelRelations(data, unfilteredOptions = {}) {
        const query = ghostBookshelf.knex('members_labels')
            .select('id')
            .where('label_id', data.labelId)
            .whereIn('member_id', data.memberIds);

        if (unfilteredOptions.transacting) {
            query.transacting(unfilteredOptions.transacting);
        }

        return query;
    },

    getNewsletterRelations(data, unfilteredOptions = {}) {
        const query = ghostBookshelf.knex('members_newsletters')
            .select('id')
            .whereIn('member_id', data.memberIds);

        if (unfilteredOptions.transacting) {
            query.transacting(unfilteredOptions.transacting);
        }

        return query;
    },

    fetchAllSubscribed(unfilteredOptions = {}) {
        // we use raw queries instead of model relationships because model hydration is expensive
        const query = ghostBookshelf.knex('members_newsletters')
            .join('newsletters', 'members_newsletters.newsletter_id', '=', 'newsletters.id')
            .where('newsletters.status', 'active')
            .distinct('member_id as id');

        if (unfilteredOptions.transacting) {
            query.transacting(unfilteredOptions.transacting);
        }

        return query;
    }
});

const Members = ghostBookshelf.Collection.extend({
    model: Member
});

module.exports = {
    Member: ghostBookshelf.model('Member', Member),
    Members: ghostBookshelf.collection('Members', Members)
};
<commit_msg>Removed unused getNewsletterRelations method<commit_after>const ghostBookshelf = require('./base');
const uuid = require('uuid');
const _ = require('lodash');
const config = require('../../shared/config');
const {gravatar} = require('../lib/image');

const Member = ghostBookshelf.Model.extend({
    tableName: 'members',

    defaults() {
        return {
            status: 'free',
            uuid: uuid.v4(),
            email_count: 0,
            email_opened_count: 0
        };
    },

    filterExpansions() {
        return [{
            key: 'label',
            replacement: 'labels.slug'
        }, {
            key: 'labels',
            replacement: 'labels.slug'
        }, {
            key: 'product',
            replacement: 'products.slug'
        }, {
            key: 'products',
            replacement: 'products.slug'
        }, {
            key: 'tier',
            replacement: 'products.slug'
        }, {
            key: 'tiers',
            replacement: 'products.slug'
        }, {
            key: 'newsletters',
            replacement: 'newsletters.slug'
        }];
    },

    filterRelations() {
        return {
            labels: {
                tableName: 'labels',
                type: 'manyToMany',
                joinTable: 'members_labels',
                joinFrom: 'member_id',
                joinTo: 'label_id'
            },
            products: {
                tableName: 'products',
                type: 'manyToMany',
                joinTable: 'members_products',
                joinFrom: 'member_id',
                joinTo: 'product_id'
            },
            newsletters: {
                tableName: 'newsletters',
                type: 'manyToMany',
                joinTable: 'members_newsletters',
                joinFrom: 'member_id',
                joinTo: 'newsletter_id'
            },
            subscriptions: {
                tableName: 'members_stripe_customers_subscriptions',
                tableNameAs: 'subscriptions',
                type: 'manyToMany',
                joinTable: 'members_stripe_customers',
                joinFrom: 'member_id',
                joinTo: 'customer_id',
                joinToForeign: 'customer_id'
            }
        };
    },

    relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients', 'newsletters'],

    // do not delete email_recipients records when a member is destroyed. Recipient
    // records are used for analytics and historical records
    relationshipConfig: {
        email_recipients: {
            destroyRelated: false
        }
    },

    relationshipBelongsTo: {
        products: 'products',
        newsletters: 'newsletters',
        labels: 'labels',
        stripeCustomers: 'members_stripe_customers',
        email_recipients: 'email_recipients'
    },

    productEvents() {
        return this.hasMany('MemberProductEvent', 'member_id', 'id')
            .query('orderBy', 'created_at', 'DESC');
    },

    products() {
        return this.belongsToMany('Product', 'members_products', 'member_id', 'product_id')
            .withPivot('sort_order')
            .query('orderBy', 'sort_order', 'ASC')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('products.*');
            });
    },

    newsletters() {
        return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('newsletters.*');
            });
    },

    offerRedemptions() {
        return this.hasMany('OfferRedemption', 'member_id', 'id')
            .query('orderBy', 'created_at', 'DESC');
    },

    labels: function labels() {
        return this.belongsToMany('Label', 'members_labels', 'member_id', 'label_id')
            .withPivot('sort_order')
            .query('orderBy', 'sort_order', 'ASC')
            .query((qb) => {
                // avoids bookshelf adding a `DISTINCT` to the query
                // we know the result set will already be unique and DISTINCT hurts query performance
                qb.columns('labels.*');
            });
    },

    stripeCustomers() {
        return this.hasMany('MemberStripeCustomer', 'member_id', 'id');
    },

    stripeSubscriptions() {
        return this.belongsToMany(
            'StripeCustomerSubscription',
            'members_stripe_customers',
            'member_id',
            'customer_id',
            'id',
            'customer_id'
        );
    },

    email_recipients() {
        return this.hasMany('EmailRecipient', 'member_id', 'id');
    },

    serialize(options) {
        const defaultSerializedObject = ghostBookshelf.Model.prototype.serialize.call(this, options);

        if (defaultSerializedObject.stripeSubscriptions) {
            defaultSerializedObject.subscriptions = defaultSerializedObject.stripeSubscriptions;
            delete defaultSerializedObject.stripeSubscriptions;
        }

        return defaultSerializedObject;
    },

    emitChange: function emitChange(event, options) {
        const eventToTrigger = 'member' + '.' + event;
        ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
    },

    onCreated: function onCreated(model, options) {
        ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);

        model.emitChange('added', options);
    },

    onUpdated: function onUpdated(model, options) {
        ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);

        model.emitChange('edited', options);
    },

    onDestroyed: function onDestroyed(model, options) {
        ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);

        model.emitChange('deleted', options);
    },

    onDestroying: function onDestroyed(model) {
        ghostBookshelf.Model.prototype.onDestroying.apply(this, arguments);

        this.handleAttachedModels(model);
    },

    onSaving: function onSaving(model, attr, options) {
        let labelsToSave = [];

        if (_.isUndefined(this.get('labels'))) {
            this.unset('labels');
            return;
        }

        // CASE: detect lowercase/uppercase label slugs
        if (!_.isUndefined(this.get('labels')) && !_.isNull(this.get('labels'))) {
            labelsToSave = [];

            //  and deduplicate upper/lowercase tags
            _.each(this.get('labels'), function each(item) {
                item.name = item.name && item.name.trim();
                for (let i = 0; i < labelsToSave.length; i = i + 1) {
                    if (labelsToSave[i].name && item.name && labelsToSave[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
                        return;
                    }
                }

                labelsToSave.push(item);
            });

            this.set('labels', labelsToSave);
        }

        this.handleAttachedModels(model);

        // CASE: Detect existing labels with same case-insensitive name and replace
        return ghostBookshelf.model('Label')
            .findAll(Object.assign({
                columns: ['id', 'name']
            }, _.pick(options, 'transacting')))
            .then((labels) => {
                labelsToSave.forEach((label) => {
                    let existingLabel = labels.find((lab) => {
                        return label.name.toLowerCase() === lab.get('name').toLowerCase();
                    });
                    label.name = (existingLabel && existingLabel.get('name')) || label.name;
                    label.id = (existingLabel && existingLabel.id) || label.id;
                });

                model.set('labels', labelsToSave);
            });
    },

    handleAttachedModels: function handleAttachedModels(model) {
        /**
         * @NOTE:
         * Bookshelf only exposes the object that is being detached on `detaching`.
         * For the reason above, `detached` handler is using the scope of `detaching`
         * to access the models that are not present in `detached`.
         */
        model.related('labels').once('detaching', function onDetaching(collection, label) {
            model.related('labels').once('detached', function onDetached(detachedCollection, response, options) {
                label.emitChange('detached', options);
                model.emitChange('label.detached', options);
            });
        });

        model.related('labels').once('attaching', function onDetaching(collection, labels) {
            model.related('labels').once('attached', function onDetached(detachedCollection, response, options) {
                labels.forEach((label) => {
                    label.emitChange('attached', options);
                    model.emitChange('label.attached', options);
                });
            });
        });
    },

    /**
     * The base model keeps only the columns, which are defined in the schema.
     * We have to add the relations on top, otherwise bookshelf-relations
     * has no access to the nested relations, which should be updated.
     */
    permittedAttributes: function permittedAttributes() {
        let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments);

        this.relationships.forEach((key) => {
            filteredKeys.push(key);
        });

        return filteredKeys;
    },

    /**
     * We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always
     * receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid
     * that event listeners have to re-fetch a resource. This function is used in the context of inserting
     * and updating resources. We won't return the relations by default for now.
     */
    defaultRelations: function defaultRelations(methodName, options) {
        if (['edit', 'add', 'destroy'].indexOf(methodName) !== -1) {
            options.withRelated = _.union(['labels'], options.withRelated || []);
        }

        return options;
    },

    searchQuery: function searchQuery(queryBuilder, query) {
        queryBuilder.where('members.name', 'like', `%${query}%`);
        queryBuilder.orWhere('members.email', 'like', `%${query}%`);
    },

    orderRawQuery(field, direction) {
        if (field === 'email_open_rate') {
            return {
                orderByRaw: `members.email_open_rate IS NOT NULL DESC, members.email_open_rate ${direction}`
            };
        }
    },

    toJSON(unfilteredOptions) {
        const options = Member.filterOptions(unfilteredOptions, 'toJSON');
        const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);

        // Inject a computed avatar url. Uses gravatar's default ?d= query param
        // to serve a blank image if there is no gravatar for the member's email.
        // Will not use gravatar if privacy.useGravatar is false in config
        attrs.avatar_image = null;
        if (attrs.email && !config.isPrivacyDisabled('useGravatar')) {
            attrs.avatar_image = gravatar.url(attrs.email, {size: 250, default: 'blank'});
        }

        return attrs;
    }
}, {
    /**
     * Returns an array of keys permitted in a method's `options` hash, depending on the current method.
     * @param {String} methodName The name of the method to check valid options for.
     * @return {Array} Keys allowed in the `options` hash of the model's method.
     */
    permittedOptions: function permittedOptions(methodName) {
        let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);

        if (['findPage', 'findAll'].includes(methodName)) {
            options = options.concat(['search']);
        }

        return options;
    },

    add(data, unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.add(data, Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.add.call(this, data, unfilteredOptions);
    },

    edit(data, unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.edit(data, Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.edit.call(this, data, unfilteredOptions);
    },

    destroy(unfilteredOptions = {}) {
        if (!unfilteredOptions.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                return this.destroy(Object.assign({transacting}, unfilteredOptions));
            });
        }
        return ghostBookshelf.Model.destroy.call(this, unfilteredOptions);
    },

    getLabelRelations(data, unfilteredOptions = {}) {
        const query = ghostBookshelf.knex('members_labels')
            .select('id')
            .where('label_id', data.labelId)
            .whereIn('member_id', data.memberIds);

        if (unfilteredOptions.transacting) {
            query.transacting(unfilteredOptions.transacting);
        }

        return query;
    },

    fetchAllSubscribed(unfilteredOptions = {}) {
        // we use raw queries instead of model relationships because model hydration is expensive
        const query = ghostBookshelf.knex('members_newsletters')
            .join('newsletters', 'members_newsletters.newsletter_id', '=', 'newsletters.id')
            .where('newsletters.status', 'active')
            .distinct('member_id as id');

        if (unfilteredOptions.transacting) {
            query.transacting(unfilteredOptions.transacting);
        }

        return query;
    }
});

const Members = ghostBookshelf.Collection.extend({
    model: Member
});

module.exports = {
    Member: ghostBookshelf.model('Member', Member),
    Members: ghostBookshelf.collection('Members', Members)
};
<|endoftext|>