<commit_before>var app = angular.module("idiotic", []);
app.factory("Api", ["$http", "Item", "Scene", function($http, Item, Scene) {
    function Api(api_root, refresh_callback) {
        var api = new Object();
        api.api_url = api_root + 'api/';
        if(refresh_callback) {
            api.refresh_callback = refresh_callback;
        } else {
            api.refresh_callback = function() {};
        }

        api.items = [];

        api.get = function(endpoint) {
            return $http.get(encodeURI(api.api_url + endpoint)).then(function(resp) {
                if(resp.data.status == "success") {
                    console.log('GET', api.api_url + endpoint + ' successful');
                    return resp.data.result;
                } else {
                    console.log('GET', api.api_url + endpoint + ' FAILED')
                    throw resp.data;
                }
            });
        };
        api.post = function(endpoint, data) {
            return $http.post(encodeURI(api.api_url + endpoint), data).then(function(resp) {
                if(resp.data.status == "success") {
                    console.log('POST', api.api_url + endpoint + ' successful');
                    return resp.data.result;
                } else {
                    console.log('POST', api.api_url + endpoint + ' FAILED')
                    throw resp.data;
                }
            });
        };

        api.refresh = function() {
            console.log('Refreshing API from', api.api_url);
            // TODO: Parallelize
            return api.get('items').then(function(items_json) {
                        api.items = [];
                        angular.forEach(items_json, function(item_json) {
                            api.items.push(Item(api, item_json));
                        });
                    }).then(function() {
                        return api.get('scenes').then(function(scenes_json) {
                            api.scenes = [];
                            angular.forEach(scenes_json, function(scene_json) {
                                api.scenes.push(Scene(api, scene_json));
                            });
                        })
                    }).then(function() {
                        return api.get('version').then(function(version) {
                            api.version = version.VERSION;
                        });
                    }).then(api.refresh_callback);
        };

        api.refresh();
        return api;
    };
    return Api;
}]);

app.factory("Item", ["$http", function($http) {
    function Item(api, itemData) {
        var item = new Object();
        item.api = api;

        item.refresh = function(data) {
            item.type = data.type;
            item.id = data.id;
            item.name = data.name;
            item.state = data.state;
            item.tags = data.tags;
            item.enabled = data.enabled;

            // Construct commands from dictionary returned by API
            item.buttons = [];
            item.commands = [];
            for (name in data.commands) {
                command = data.commands[name];
                command['name'] = name;
                if (command.arguments.length > 0) {
                    item.commands.push(command);
                } else {
                    item.buttons.push(command);
                }
            }
        };

        item.default_action = function() {};

        item.send_state = function() {
            if(item.state === undefined) {
                console.log("Item", item.id, "refusing to send undefined state");
                return;
            }
            return item.send_command("set?val=" + item.state);
        }

        item.send_command = function(command) {
            return api.get("item/" + item.id + "/command/" + command.name)
                .then(function(result) {
                    // Update our models.
                    item.refresh(result.item);
                });
        }

        item.enable_graph = function() {
            return item.tags.indexOf("webui.show_sparkline") >= 0;
        }

        item.disabled = function(disabled) {
            if(disabled === undefined) {
                return !item.enabled;
            } else {
                item.enabled = !disabled;
            }
        };
        item.show_disable = function() {
            return item.tags.indexOf("webui.show_disable") >= 0;
        }
        item.send_disable = function() {
          if(item.enabled) {
            return api.get("item/" + item.id + "/enable");
          } else {
            return api.get("item/" + item.id + "/disable");
          }
        }

        item.refresh(itemData);
        return item;
    };

    return Item;
}]);

app.factory("Scene", ["$http", function($http) {
    function Scene(api, sceneData) {
        var scene = new Object();
        angular.extend(scene, sceneData);
        scene.api = api;

        scene.id = scene.name.toLowerCase().replace(/ /g, '_');
        scene.state = scene.active;

        scene.send_activity = function() {
            var enterexit = scene.state ? "enter" : "exit";
            return api.get("scene/" + scene.id + "/command/" + enterexit);
        };

        scene.toggle_activity = function() {
            scene.state = !scene.state;
        };

        scene.default_action = scene.toggle_activity;

        return scene;
    };

    return Scene;
}]);

app.controller("idioticController", ["$scope", "$http", "Api", function($scope, $http, Api) {
    var idiotic = this;

    // Give empty array until API is loaded.
    idiotic.items = function() { return []; }
    idiotic.scenes = function() { return []; }
    idiotic.conf = new Object();

    idiotic.refresh = function() {
        // Run all refresh functions, and broadcast when complete.
        Promise.all([
                idiotic.refresh_version(),
                idiotic.refresh_sections()
        ]).then(function() {
                $scope.$broadcast('idioticLoaded');
            });
    }
    idiotic.refresh_version = function() {
        console.log('loaded', idiotic.api.version);
        idiotic.conf.version = idiotic.api.version;
    }
    idiotic.refresh_sections = function () {
        var sections = [];
        for (section_index in idiotic.conf.sections) {
            var section = idiotic.conf.sections[section_index];
            // Prepare a new section object with an items() method.
            var s = new Object();
            s.include_tags = [];
            s.exclude_tags = [];
            angular.extend(s, section);

            // TODO: this is a hack and awful
            if (section.include_tags.indexOf("_scene") >= 0) {
                s.items = idiotic.scenes;
                sections.push(s);
                continue;
            }

            s.items = function() {
                var items = [];
                for (item_index in idiotic.items()) {
                    var item = idiotic.items()[item_index];
                    // For each item, check its tags against our lists. Include
                    // it only if there is at least one tag matching
                    // include_tags, and exactly zero tags matching
                    // exclude_tags.
                    var include = false;
                    for(tag_index in item.tags) {
                        var tag = item.tags[tag_index];
                        // If the tag is excluded, reject immediately.
                        if(this.exclude_tags.indexOf(tag) >= 0) {
                            include = false;
                            break;
                        }
                        // If the tag is included, mark for inclusion and
                        // continue to look for excluded tags.
                        if(this.include_tags.indexOf(tag) >= 0) {
                            include = true;
                        }
                    }
                    if(include) {
                        items.push(item);
                    }
                }
                return items;
            }

            sections.push(s);
        }

        idiotic.sections = sections;
    }

    $http.get("/webui/conf.json").then(function(resp) {
        console.log('WebUI configuration loaded');
        idiotic.conf = resp.data.result;

        idiotic.api = Api(idiotic.conf.api_base, idiotic.refresh);
        idiotic.items = function() { return idiotic.api.items; };
        idiotic.scenes = function() { return idiotic.api.scenes; };
    });

    $scope.slug = function(s) {
        return s.toLowerCase().replace(/ /g, '_');
    };
}]);

app.directive('scrollspy', ['$timeout', function($timeout) {
    return {
        link: function($scope, element, attrs) {
            $timeout(function() {
                $(element).scrollSpy();
            }, 0);
        }
    };
}]);
<commit_msg>Fix card touch failing to send activity<commit_after>var app = angular.module("idiotic", []);
app.factory("Api", ["$http", "Item", "Scene", function($http, Item, Scene) {
    function Api(api_root, refresh_callback) {
        var api = new Object();
        api.api_url = api_root + 'api/';
        if(refresh_callback) {
            api.refresh_callback = refresh_callback;
        } else {
            api.refresh_callback = function() {};
        }

        api.items = [];

        api.get = function(endpoint) {
            return $http.get(encodeURI(api.api_url + endpoint)).then(function(resp) {
                if(resp.data.status == "success") {
                    console.log('GET', api.api_url + endpoint + ' successful');
                    return resp.data.result;
                } else {
                    console.log('GET', api.api_url + endpoint + ' FAILED')
                    throw resp.data;
                }
            });
        };
        api.post = function(endpoint, data) {
            return $http.post(encodeURI(api.api_url + endpoint), data).then(function(resp) {
                if(resp.data.status == "success") {
                    console.log('POST', api.api_url + endpoint + ' successful');
                    return resp.data.result;
                } else {
                    console.log('POST', api.api_url + endpoint + ' FAILED')
                    throw resp.data;
                }
            });
        };

        api.refresh = function() {
            console.log('Refreshing API from', api.api_url);
            // TODO: Parallelize
            return api.get('items').then(function(items_json) {
                        api.items = [];
                        angular.forEach(items_json, function(item_json) {
                            api.items.push(Item(api, item_json));
                        });
                    }).then(function() {
                        return api.get('scenes').then(function(scenes_json) {
                            api.scenes = [];
                            angular.forEach(scenes_json, function(scene_json) {
                                api.scenes.push(Scene(api, scene_json));
                            });
                        })
                    }).then(function() {
                        return api.get('version').then(function(version) {
                            api.version = version.VERSION;
                        });
                    }).then(api.refresh_callback);
        };

        api.refresh();
        return api;
    };
    return Api;
}]);

app.factory("Item", ["$http", function($http) {
    function Item(api, itemData) {
        var item = new Object();
        item.api = api;

        item.refresh = function(data) {
            item.type = data.type;
            item.id = data.id;
            item.name = data.name;
            item.state = data.state;
            item.tags = data.tags;
            item.enabled = data.enabled;

            // Construct commands from dictionary returned by API
            item.buttons = [];
            item.commands = [];
            for (name in data.commands) {
                command = data.commands[name];
                command['name'] = name;
                if (command.arguments.length > 0) {
                    item.commands.push(command);
                } else {
                    item.buttons.push(command);
                }
            }
        };

        item.default_action = function() {};

        item.send_state = function() {
            if(item.state === undefined) {
                console.log("Item", item.id, "refusing to send undefined state");
                return;
            }
            return item.send_command("set?val=" + item.state);
        }

        item.send_command = function(command) {
            return api.get("item/" + item.id + "/command/" + command.name)
                .then(function(result) {
                    // Update our models.
                    item.refresh(result.item);
                });
        }

        item.enable_graph = function() {
            return item.tags.indexOf("webui.show_sparkline") >= 0;
        }

        item.disabled = function(disabled) {
            if(disabled === undefined) {
                return !item.enabled;
            } else {
                item.enabled = !disabled;
            }
        };
        item.show_disable = function() {
            return item.tags.indexOf("webui.show_disable") >= 0;
        }
        item.send_disable = function() {
          if(item.enabled) {
            return api.get("item/" + item.id + "/enable");
          } else {
            return api.get("item/" + item.id + "/disable");
          }
        }

        item.refresh(itemData);
        return item;
    };

    return Item;
}]);

app.factory("Scene", ["$http", function($http) {
    function Scene(api, sceneData) {
        var scene = new Object();
        angular.extend(scene, sceneData);
        scene.api = api;

        scene.id = scene.name.toLowerCase().replace(/ /g, '_');
        scene.state = scene.active;

        scene.send_activity = function() {
            var enterexit = scene.state ? "enter" : "exit";
            return api.get("scene/" + scene.id + "/command/" + enterexit);
        };

        scene.toggle_activity = function() {
            scene.state = !scene.state;
        };

        scene.default_action = function() {
            scene.toggle_activity();
            scene.send_activity();
        };

        return scene;
    };

    return Scene;
}]);

app.controller("idioticController", ["$scope", "$http", "Api", function($scope, $http, Api) {
    var idiotic = this;

    // Give empty array until API is loaded.
    idiotic.items = function() { return []; }
    idiotic.scenes = function() { return []; }
    idiotic.conf = new Object();

    idiotic.refresh = function() {
        // Run all refresh functions, and broadcast when complete.
        Promise.all([
                idiotic.refresh_version(),
                idiotic.refresh_sections()
        ]).then(function() {
                $scope.$broadcast('idioticLoaded');
            });
    }
    idiotic.refresh_version = function() {
        console.log('loaded', idiotic.api.version);
        idiotic.conf.version = idiotic.api.version;
    }
    idiotic.refresh_sections = function () {
        var sections = [];
        for (section_index in idiotic.conf.sections) {
            var section = idiotic.conf.sections[section_index];
            // Prepare a new section object with an items() method.
            var s = new Object();
            s.include_tags = [];
            s.exclude_tags = [];
            angular.extend(s, section);

            // TODO: this is a hack and awful
            if (section.include_tags.indexOf("_scene") >= 0) {
                s.items = idiotic.scenes;
                sections.push(s);
                continue;
            }

            s.items = function() {
                var items = [];
                for (item_index in idiotic.items()) {
                    var item = idiotic.items()[item_index];
                    // For each item, check its tags against our lists. Include
                    // it only if there is at least one tag matching
                    // include_tags, and exactly zero tags matching
                    // exclude_tags.
                    var include = false;
                    for(tag_index in item.tags) {
                        var tag = item.tags[tag_index];
                        // If the tag is excluded, reject immediately.
                        if(this.exclude_tags.indexOf(tag) >= 0) {
                            include = false;
                            break;
                        }
                        // If the tag is included, mark for inclusion and
                        // continue to look for excluded tags.
                        if(this.include_tags.indexOf(tag) >= 0) {
                            include = true;
                        }
                    }
                    if(include) {
                        items.push(item);
                    }
                }
                return items;
            }

            sections.push(s);
        }

        idiotic.sections = sections;
    }

    $http.get("/webui/conf.json").then(function(resp) {
        console.log('WebUI configuration loaded');
        idiotic.conf = resp.data.result;

        idiotic.api = Api(idiotic.conf.api_base, idiotic.refresh);
        idiotic.items = function() { return idiotic.api.items; };
        idiotic.scenes = function() { return idiotic.api.scenes; };
    });

    $scope.slug = function(s) {
        return s.toLowerCase().replace(/ /g, '_');
    };
}]);

app.directive('scrollspy', ['$timeout', function($timeout) {
    return {
        link: function($scope, element, attrs) {
            $timeout(function() {
                $(element).scrollSpy();
            }, 0);
        }
    };
}]);
<|endoftext|>


<commit_before>//>>excludeStart("exclude", pragmas.exclude);
define([ "methods" ], function( methods ) {
//>>excludeEnd("exclude");

	methods.addClass = function( elem, value ) {
		var classNames = [value], setClass, c, cl;

		if ( elem.nodeType === 1 ) {
			if ( !elem.className ) {
				elem.className = value;
			} else {
				setClass = " " + elem.className + " ";

				for ( c = 0, cl = classNames.length; c < cl; c++ ) {
					if ( !~setClass.indexOf( " " + classNames[ c ] + " " ) ) {
						setClass += classNames[ c ] + " ";
					}
				}

				// elem.className = jQuery.trim( setClass );
				elem.className = methods.trim(setClass);
			}
		}

		return elem;
	};

//>>excludeStart("exclude", pragmas.exclude);
});
//>>excludeEnd("exclude");
<commit_msg>only one class value permitted at this method level<commit_after>//>>excludeStart("exclude", pragmas.exclude);
define([ "methods" ], function( methods ) {
//>>excludeEnd("exclude");

	methods.addClass = function( elem, value ) {
		var classNames = [value], setClass, c, cl;

		if ( elem.nodeType === 1 ) {
			if ( !elem.className ) {
				elem.className = value;
			} else {
				setClass = " " + elem.className + " ";

				if ( !~setClass.indexOf( " " + value + " " ) ) {
					setClass += value + " ";
				}

				elem.className = methods.trim(setClass);
			}
		}

		return elem;
	};

//>>excludeStart("exclude", pragmas.exclude);
});
//>>excludeEnd("exclude");
<|endoftext|>


<commit_before>/**
 * The job, cron job, to be runned.
 * @module sms-library-helper/backend/job
 * @author Holi0317 <holliswuhollis@gmail.com>
 * @license MIT
 *
 * @requires googleapis
 * @requires bluebird
 */

'use strict';

let google = require('googleapis');
let Promise = require('bluebird');
let LibraryApi = require('./api');
let models = require('./models');
let config = require('../config');
let utils = require('./utils');

/**
 * One day in milisecond.
 * @const {number} ONE_DAY
 * @default 8.64e+7
 */
const ONE_DAY = 8.64e+7;
/**
 * Timezone that library system is at.
 * @const {string} TIMEZONE
 * @default 'Asia/Hong_Kong'
 */
const TIMEZONE = 'Asia/Hong_Kong';
/**
 * Maximum log to be kept.
 * @const {number} MAX_LOG_RECORD
 * @default 100
 */
const MAX_LOG_RECORD = 100;

Promise.promisifyAll(google.auth.OAuth2.prototype);

// Google apis
let calendar = google.calendar('v3');
Promise.promisifyAll(calendar.calendarList);
Promise.promisifyAll(calendar.calendars);
Promise.promisifyAll(calendar.events);

/**
 * A collection of promises that bind to a user's library and google account.
 * Use UserFunctionFactory for batch execution.
 *
 * @prop {backend/models.user} user - User object queried from database.
 * @prop {google.auth.OAuth2} oauth2client - Google OAuth2 bounded to user token.
 * @prop {backend/api.User} library - Library API bound to user.
 * @prop {Number} celdnarID - Google calendar ID that matches the name of user defined.
 * @prop {bool} failed - Trye if any operation failed.
 *
 * @see {@link sms-library-helper/backend/job~UserFunctionFactory}
 */
class UserFunctions {

  /**
   * Construct a new UserFunctions object.
   *
   * @param {backend/models.user} user - User (database record) that this object will be bounded to.
   *
   * @constructor
   */
  constructor(user) {
    this.user = user;
    this.oauth2client = new google.auth.OAuth2(config.clientId, config.clientSecret, config.redirectUrl);
    this.oauth2client.setCredentials(this.user.tokens);
    this.library = new LibraryApi();
    this.calendarID = null;
    this.failed = false;
  }

  /**
   * Log message into user log.
   * Just a wrapper for creating {@link backend/models.Log} object and pushing it into
   * user.logs.
   *
   * @param {object} a - First option to be passed into Log constructor.
   * @param {object} b - Second option to be passed into Log constructor.
   * @see backend/models.Log
   */
  log(a, b) {
    this.user.logs.push(new models.Log(a, b));
  }

  /**
   * Refresh user's Google OAuth2 token and write to database.
   *
   * @throws {Error} - Cannot refresh OAuth2 tokens.
   * @returns {Proimse} - Promise for refreshing token then writes into database.
   */
  refreshToken() {
    return this.oauth2client.refreshAccessTokenAsync()
    .catch(err => {
      console.error('Error when refreshing access token. Error: ', err);
      this.log('Cannot refresh Google token. Aborting.', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(newTokens => {
      this.log('Refreshed Google tokens.', 'DEBUG')
      // OAuth2 does not pass back refresh_token.
      newTokens.refresh_token = this.user.tokens.refresh_token;
      this.user.tokens = newTokens;
      return this.user.save();
    });
  }

  /**
   * Login library system, then renew books if needed.
   *
   * @throws {Error} - Login into library system failed.
   * @returns {Promise} - Promise for renew login and renew book, if needed.
   * Or empty promise if failed or not enabled.
   */
  renewBooks() {
    if (!this.user.renewEnabled || this.failed) {
      return Promise.resolve();
    }

    return this.library.login(this.user.libraryLogin, this.user.libraryPassword)
    .catch(err => {
      console.error('Error when logging into library system. Error: ', err);
      this.log('Cannot login library system. Is password changed?', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(() => {
      let now = new Date();
      let promises = [];  // Promises for renewing book to be returned.
      let borrowedBooks = [];   // Book name for all borrowed books. For logging purpose.
      let renewBooks = [];    // Book name for all books requires to be renewed. For logging purpose.

      for (let book of this.library.borrowedBooks) {  // Each borrowed books.

        borrowedBooks.push(book.name);  // Logging.

        let diff = book.dueDate - now;
        if (diff <= this.user.renewDate * ONE_DAY && diff > 0 && book.id) {
          // If Logic: Less than defined date, more than 0 day and have book ID.
          promises.push(this.library.renew(book));    // Create promise.
          renewBooks.push(book.name);    // Logging.
        }
      }

      if (borrowedBooks.length) {
        this.log(`You have borrowed the followings books: ${borrowedBooks.join(', ')}`);
      } else {
        this.log('No borrowed books detected.');
      }

      if (renewBooks.length) {
        this.log(`The following books will be renewed: ${renewBooks.join(', ')}`);
      }

      return Promise.all(promises);
    });

  }

  /**
   * List calendars and see if there is one equals to name user defined (user.calendarName).
   * If yes, Save the calendar ID to property and return empty Promise.
   * Otherwise, return a promise that creates calenar (_createCalendar).
   *
   * @see {@link sms-library-helper/backend/job~UserFunctions~_createCalendar}
   * @returns {Promise} - See above description.
   * @throws {Error} - Cannot list calendar.
   * @private
   */
  _getCalendar() {
    if (this.failed) {
      return Promise.resolve();
    }

    return calendar.calendarList.listAsync({
      auth: this.oauth2client,
      maxResults: 250,
      showHidden: true,
      minAccessRole: 'writer'
    })
    .catch(err => {
      console.error('Error when trying to list calendar. Error: ', err);
      this.log('Cannot list Google Calendar. Aborting.', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(res => {

      if (res.nextPageToken) {
        this.log('Found there is more than 250 items in calendar list. A new calendar may be created.', 'WARN');
      }

      for (let item of res.items) {
        if (item.summary === this.user.calendarName) {

          // Got calendar that I want.
          this.calendarID = item.id;
          this.log(`Found Google Calendar. ID: ${item.id}.`, 'DEBUG');
          return Promise.resolve();

        }
        // Else, continue.
      }

      // Calendar not found. Lets create one.
      this.log(`Cannot find Google Calendar with name "${this.user.calendarName}". Creating one.`);
      return this._createCalendar();

    });
  }

  /**
   * Create new calendar, where summary is user.calendarName and handle stuffs.
   *
   * @returns {Promise} - Create new calendar.
   * @throws {Error} - Cannot create calendar/insert it into CalendarList.
   * @private
   */
  _createCalendar() {
    if (this.failed) {
      return Promise.resolve();
    }

    return calendar.calendars.insertAsync({
      auth: this.oauth2client,
      resource: {
        summary: this.user.calendarName,
        timeZone: TIMEZONE
      }
    })
    .then(cal => {
      this.log(`Calendar created. ID: ${cal.id}.`, 'DEBUG');
      this.calendarID = cal.id;
      return Promise.resolve();
    })
    .catch(err => {
      this.calendarID = null;
      console.error('Error when creating new calendar. Error: ', err);
      this.log('Cannot create calendar / Insert calendar to CalendarList. Aborting', 'FATAL');
      this.failed = true;
      throw new utils.BreakSignal();
    })
  }

  /**
   * Create calendar event resource by using book object passed in.
   *
   * @param {backend/api.Book} book - Book object that need to be converted into event resource.
   * @returns {Object} - Event resource (Google calendar) of this book.
   * @static
   * @private
   */
  _createEventResource (book) {
    return {
      summary: `Due date for book ${book.name}. ID: ${book.id}`,
      start: {
        date: `${book.dueDate.getFullYear()}-${book.dueDate.getMonth()+1}-${book.dueDate.getDate()}`,  // JS use 0 as January and 11 as December.
        timeZone: TIMEZONE
      },
      end: {
        date: `${book.dueDate.getFullYear()}-${book.dueDate.getMonth()+1}-${book.dueDate.getDate()}`,
        timeZone: TIMEZONE
      }
    };
  }

  /**
   * Reload library content and write corresponding due date to Google calendar.
   *
   * @returns {Promise} - Promise for the above action.
   * @throws {Errors} - Cannot reload library content.
   * @throws {Errors} - Cannot get Google Calendar ID.
   * @throws {Errors} - Cannot insert Calendar event.
   * @FIXME Remove returned books.
   */
  refreshCalendar() {
    // Refresh user's google calendar
    // If user.renewEnabled is false, this will return an empty promise.
    // Return Promise
    if (!this.user.renewEnabled || this.failed) {
      return Promise.resolve();
    }

    return this._getCalendar()
    .then(this.library.reload.bind(this.library))
    .catch(err => {
      if (err instanceof utils.BreakSignal) {
        throw err;
      }
      console.error('Error when reloading library contents. Error: ', err);
      this.failed = true;
      this.log('Cannot reloading library contents. WTF? This should not happen. Aborting', 'FATAL');
      throw new utils.BreakSignal();
    })
    .then(() => {
      // List events in calendar
      return calendar.events.listAsync({
        auth: this.oauth2client,
        calendarId: this.calendarID,
        timeZone: TIMEZONE
      });
    })
    .catch(err => {
      if (err instanceof utils.BreakSignal) {
        throw err;
      }
      console.error('Error when listing events in Google Calendar. Error: ', err);
      this.failed = true;
      this.log('Cannot listing events in Google Calendar. Aborting.', 'FATAL');
      throw new utils.BreakSignal();
    })
    .then(events => {

      let promises = [];
      let logUpdated = [];
      let logCreated = [];
      let touchedEvents = [];

      for (let book of this.library.borrowedBooks) {  // Fore each book.
        if (book.id === null) {
          // Book does not have id. Probably overdued.
          continue;
        }

        // Event resource for requesting
        let resource = this._createEventResource(book);

        for (let event of events.items) {   // Fore each event.

          if (event.summary === resource.summary) {   // Is this event the one I want for this book?
            if (event.end.date !== resource.end.date) {  // Is due date unchanged? (renewed?)
              promises.push(calendar.events.updateAsync({
                auth: this.oauth2client,
                calendarId: this.calendarID,
                eventId: event.id,
                resource: resource
              }));

              logUpdated.push(book.name);

            }
            touchedEvents.push(event.id);
          }
          // Not the one I want. Continue looping.

        }
        // No event that I am interested.
        promises.push(calendar.events.insertAsync({
          auth: this.oauth2client,
          calendarId: this.calendarID,
          resource: resource
        }));

        logCreated.push(book.name);

      }

      // Cleanup events that has not been touched(Illegal events or returned books)
      let allEventsID = events.items.map(item => {
        return item.id;
      });
      let removes = utils.diff(allEventsID, touchedEvents);
      for (let eventID of removes) {
        promises.push(calendar.events.deleteAsync({
          auth: this.oauth2client,
          calendarId: this.calendarID,
          eventId: eventID
        }));
      }

      if (logUpdated.length) {
        this.log(`Calendar events of the following books will be updated: ${logUpdated.join(', ')}`);
      }
      if (logCreated.length) {
        this.log(`Calendar events of the following books will be created: ${logCreated.join(', ')}`);
      }
      if (removes.length) {
        this.log(`The following events will be removed: ${removes.join(', ')}`, 'DEBUG');
      }

      return Promise.all(promises);

    });

  }

  /**
   * Limit log message to 100 and write into database.
   *
   * @returns {Promise} - Write optration to database.
   */
  saveProfile() {
    if (this.failed) {
      this.log('Cron job failed.', 'FATAL');
    } else {
      this.log('Cron job succeed.', 'SUCCESS');
    }

    this.user.logs.sort((a, b) => {
      return b.time - a.time;
    });
    this.user.logs = this.user.logs.slice(-MAX_LOG_RECORD);
    return this.user.save();
  }

}

/**
 * Batcher for executing mass amount of UserFunctions.
 * Methods are identical to {@link UserFunctions}, but errors will be supressed.
 *
 * @prop {UserFunctions[]} users - Users that this batcher is bounded to.
 */
class UserFunctionFactory {

  /**
   * @constructor
   */
  constructor() {
    this.users = [];
  }

  /**
   * Call name and apply catchIgnore and chain all of them together.
   *
   * @param {string} name - Name of the method that will be called.
   * @returns {Promise} - Promise of that object.
   * @private
   */
  _method(name) {
    let promises = [];
    this.users.forEach(user => {
      promises.push(user[name]().catch(utils.catchIgnore));
    })
    return Promise.all(promises);
  }

  /**
   * @param {backend/models.users[]} users - Users that will be bounded to.
   */
  refreshToken(users) {
    // Add users to this.users and refresh their google tokens
    // Return: Promise for the above actoion
    this.users = [];
    let promises = [];

    users.forEach(user => {
      let uf = new UserFunctions(user);
      this.users.push(uf);
      uf.log('Started cron job.');
      promises.push(uf.refreshToken().catch(utils.catchIgnore));
    })

    return Promise.all(promises);
  }

  renewBooks() {
    return this._method('renewBooks');
  }

  refreshCalendar() {
    return this._method('refreshCalendar');
  }

  saveProfile() {
    return this._method('saveProfile');
  }
}

/**
 * @summary Regular job for the system.
 * @desc This job will, first, query all users. Then renew books for them. Finally,
 * refresh their Google calendar and do some database cleanup. Please run this job once
 * per day.
 *
 * @returns {Promise} - Promise for the job.
 */
function execute() {
  console.log('Started cron job.');
  let factory = new UserFunctionFactory();

  models.user.find()
  .then(factory.refreshToken.bind(factory))
  .then(factory.renewBooks.bind(factory))
  .then(factory.refreshCalendar.bind(factory))
  .then(factory.saveProfile.bind(factory))
  .then(() => {
    console.log('Cron job ended');
  })
  .catch(err => {
    console.error('Cron Job errored.');
    throw err;
  });
}

module.exports = execute;
module.exports._UserFunctions = UserFunctions;
module.exports._UserFunctionFactory = UserFunctionFactory;
<commit_msg>Add warning for over 2500 events.<commit_after>/**
 * The job, cron job, to be runned.
 * @module sms-library-helper/backend/job
 * @author Holi0317 <holliswuhollis@gmail.com>
 * @license MIT
 *
 * @requires googleapis
 * @requires bluebird
 */

'use strict';

let google = require('googleapis');
let Promise = require('bluebird');
let LibraryApi = require('./api');
let models = require('./models');
let config = require('../config');
let utils = require('./utils');

/**
 * One day in milisecond.
 * @const {number} ONE_DAY
 * @default 8.64e+7
 */
const ONE_DAY = 8.64e+7;
/**
 * Timezone that library system is at.
 * @const {string} TIMEZONE
 * @default 'Asia/Hong_Kong'
 */
const TIMEZONE = 'Asia/Hong_Kong';
/**
 * Maximum log to be kept.
 * @const {number} MAX_LOG_RECORD
 * @default 100
 */
const MAX_LOG_RECORD = 100;

Promise.promisifyAll(google.auth.OAuth2.prototype);

// Google apis
let calendar = google.calendar('v3');
Promise.promisifyAll(calendar.calendarList);
Promise.promisifyAll(calendar.calendars);
Promise.promisifyAll(calendar.events);

/**
 * A collection of promises that bind to a user's library and google account.
 * Use UserFunctionFactory for batch execution.
 *
 * @prop {backend/models.user} user - User object queried from database.
 * @prop {google.auth.OAuth2} oauth2client - Google OAuth2 bounded to user token.
 * @prop {backend/api.User} library - Library API bound to user.
 * @prop {Number} celdnarID - Google calendar ID that matches the name of user defined.
 * @prop {bool} failed - Trye if any operation failed.
 *
 * @see {@link sms-library-helper/backend/job~UserFunctionFactory}
 */
class UserFunctions {

  /**
   * Construct a new UserFunctions object.
   *
   * @param {backend/models.user} user - User (database record) that this object will be bounded to.
   *
   * @constructor
   */
  constructor(user) {
    this.user = user;
    this.oauth2client = new google.auth.OAuth2(config.clientId, config.clientSecret, config.redirectUrl);
    this.oauth2client.setCredentials(this.user.tokens);
    this.library = new LibraryApi();
    this.calendarID = null;
    this.failed = false;
  }

  /**
   * Log message into user log.
   * Just a wrapper for creating {@link backend/models.Log} object and pushing it into
   * user.logs.
   *
   * @param {object} a - First option to be passed into Log constructor.
   * @param {object} b - Second option to be passed into Log constructor.
   * @see backend/models.Log
   */
  log(a, b) {
    this.user.logs.push(new models.Log(a, b));
  }

  /**
   * Refresh user's Google OAuth2 token and write to database.
   *
   * @throws {Error} - Cannot refresh OAuth2 tokens.
   * @returns {Proimse} - Promise for refreshing token then writes into database.
   */
  refreshToken() {
    return this.oauth2client.refreshAccessTokenAsync()
    .catch(err => {
      console.error('Error when refreshing access token. Error: ', err);
      this.log('Cannot refresh Google token. Aborting.', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(newTokens => {
      this.log('Refreshed Google tokens.', 'DEBUG')
      // OAuth2 does not pass back refresh_token.
      newTokens.refresh_token = this.user.tokens.refresh_token;
      this.user.tokens = newTokens;
      return this.user.save();
    });
  }

  /**
   * Login library system, then renew books if needed.
   *
   * @throws {Error} - Login into library system failed.
   * @returns {Promise} - Promise for renew login and renew book, if needed.
   * Or empty promise if failed or not enabled.
   */
  renewBooks() {
    if (!this.user.renewEnabled || this.failed) {
      return Promise.resolve();
    }

    return this.library.login(this.user.libraryLogin, this.user.libraryPassword)
    .catch(err => {
      console.error('Error when logging into library system. Error: ', err);
      this.log('Cannot login library system. Is password changed?', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(() => {
      let now = new Date();
      let promises = [];  // Promises for renewing book to be returned.
      let borrowedBooks = [];   // Book name for all borrowed books. For logging purpose.
      let renewBooks = [];    // Book name for all books requires to be renewed. For logging purpose.

      for (let book of this.library.borrowedBooks) {  // Each borrowed books.

        borrowedBooks.push(book.name);  // Logging.

        let diff = book.dueDate - now;
        if (diff <= this.user.renewDate * ONE_DAY && diff > 0 && book.id) {
          // If Logic: Less than defined date, more than 0 day and have book ID.
          promises.push(this.library.renew(book));    // Create promise.
          renewBooks.push(book.name);    // Logging.
        }
      }

      if (borrowedBooks.length) {
        this.log(`You have borrowed the followings books: ${borrowedBooks.join(', ')}`);
      } else {
        this.log('No borrowed books detected.');
      }

      if (renewBooks.length) {
        this.log(`The following books will be renewed: ${renewBooks.join(', ')}`);
      }

      return Promise.all(promises);
    });

  }

  /**
   * List calendars and see if there is one equals to name user defined (user.calendarName).
   * If yes, Save the calendar ID to property and return empty Promise.
   * Otherwise, return a promise that creates calenar (_createCalendar).
   *
   * @see {@link sms-library-helper/backend/job~UserFunctions~_createCalendar}
   * @returns {Promise} - See above description.
   * @throws {Error} - Cannot list calendar.
   * @private
   */
  _getCalendar() {
    if (this.failed) {
      return Promise.resolve();
    }

    return calendar.calendarList.listAsync({
      auth: this.oauth2client,
      maxResults: 250,
      showHidden: true,
      minAccessRole: 'writer'
    })
    .catch(err => {
      console.error('Error when trying to list calendar. Error: ', err);
      this.log('Cannot list Google Calendar. Aborting.', 'FATAL');
      this.failed = true;
      throw err;
    })
    .then(res => {

      if (res.nextPageToken) {
        this.log('Found there is more than 250 items in calendar list. A new calendar may be created.', 'WARN');
      }

      for (let item of res.items) {
        if (item.summary === this.user.calendarName) {

          // Got calendar that I want.
          this.calendarID = item.id;
          this.log(`Found Google Calendar. ID: ${item.id}.`, 'DEBUG');
          return Promise.resolve();

        }
        // Else, continue.
      }

      // Calendar not found. Lets create one.
      this.log(`Cannot find Google Calendar with name "${this.user.calendarName}". Creating one.`);
      return this._createCalendar();

    });
  }

  /**
   * Create new calendar, where summary is user.calendarName and handle stuffs.
   *
   * @returns {Promise} - Create new calendar.
   * @throws {Error} - Cannot create calendar/insert it into CalendarList.
   * @private
   */
  _createCalendar() {
    if (this.failed) {
      return Promise.resolve();
    }

    return calendar.calendars.insertAsync({
      auth: this.oauth2client,
      resource: {
        summary: this.user.calendarName,
        timeZone: TIMEZONE
      }
    })
    .then(cal => {
      this.log(`Calendar created. ID: ${cal.id}.`, 'DEBUG');
      this.calendarID = cal.id;
      return Promise.resolve();
    })
    .catch(err => {
      this.calendarID = null;
      console.error('Error when creating new calendar. Error: ', err);
      this.log('Cannot create calendar / Insert calendar to CalendarList. Aborting', 'FATAL');
      this.failed = true;
      throw new utils.BreakSignal();
    })
  }

  /**
   * Create calendar event resource by using book object passed in.
   *
   * @param {backend/api.Book} book - Book object that need to be converted into event resource.
   * @returns {Object} - Event resource (Google calendar) of this book.
   * @static
   * @private
   */
  _createEventResource (book) {
    return {
      summary: `Due date for book ${book.name}. ID: ${book.id}`,
      start: {
        date: `${book.dueDate.getFullYear()}-${book.dueDate.getMonth()+1}-${book.dueDate.getDate()}`,  // JS use 0 as January and 11 as December.
        timeZone: TIMEZONE
      },
      end: {
        date: `${book.dueDate.getFullYear()}-${book.dueDate.getMonth()+1}-${book.dueDate.getDate()}`,
        timeZone: TIMEZONE
      }
    };
  }

  /**
   * Reload library content and write corresponding due date to Google calendar.
   *
   * @returns {Promise} - Promise for the above action.
   * @throws {Errors} - Cannot reload library content.
   * @throws {Errors} - Cannot get Google Calendar ID.
   * @throws {Errors} - Cannot insert Calendar event.
   * @FIXME Remove returned books.
   */
  refreshCalendar() {
    // Refresh user's google calendar
    // If user.renewEnabled is false, this will return an empty promise.
    // Return Promise
    if (!this.user.renewEnabled || this.failed) {
      return Promise.resolve();
    }

    return this._getCalendar()
    .then(this.library.reload.bind(this.library))
    .catch(err => {
      if (err instanceof utils.BreakSignal) {
        throw err;
      }
      console.error('Error when reloading library contents. Error: ', err);
      this.failed = true;
      this.log('Cannot reloading library contents. WTF? This should not happen. Aborting', 'FATAL');
      throw new utils.BreakSignal();
    })
    .then(() => {
      // List events in calendar
      return calendar.events.listAsync({
        auth: this.oauth2client,
        calendarId: this.calendarID,
        timeZone: TIMEZONE,
        maxResults: 2500
      });
    })
    .catch(err => {
      if (err instanceof utils.BreakSignal) {
        throw err;
      }
      console.error('Error when listing events in Google Calendar. Error: ', err);
      this.failed = true;
      this.log('Cannot listing events in Google Calendar. Aborting.', 'FATAL');
      throw new utils.BreakSignal();
    })
    .then(events => {

      let promises = [];
      let logUpdated = [];
      let logCreated = [];
      let touchedEvents = [];

      if (events.nextSyncToken) {
        this.log('More than 2500 events found in calendar (Seriously?). Some event may be missed out.', 'WARN');
      }

      for (let book of this.library.borrowedBooks) {  // Fore each book.
        if (book.id === null) {
          // Book does not have id. Probably overdued.
          continue;
        }

        // Event resource for requesting
        let resource = this._createEventResource(book);

        for (let event of events.items) {   // Fore each event.

          if (event.summary === resource.summary) {   // Is this event the one I want for this book?
            if (event.end.date !== resource.end.date) {  // Is due date unchanged? (renewed?)
              promises.push(calendar.events.updateAsync({
                auth: this.oauth2client,
                calendarId: this.calendarID,
                eventId: event.id,
                resource: resource
              }));

              logUpdated.push(book.name);

            }
            touchedEvents.push(event.id);
          }
          // Not the one I want. Continue looping.

        }
        // No event that I am interested.
        promises.push(calendar.events.insertAsync({
          auth: this.oauth2client,
          calendarId: this.calendarID,
          resource: resource
        }));

        logCreated.push(book.name);

      }

      // Cleanup events that has not been touched(Illegal events or returned books)
      let allEventsID = events.items.map(item => {
        return item.id;
      });
      let removes = utils.diff(allEventsID, touchedEvents);
      for (let eventID of removes) {
        promises.push(calendar.events.deleteAsync({
          auth: this.oauth2client,
          calendarId: this.calendarID,
          eventId: eventID
        }));
      }

      if (logUpdated.length) {
        this.log(`Calendar event(s) of the following books will be updated: ${logUpdated.join(', ')}`);
      }
      if (logCreated.length) {
        this.log(`Calendar event(s) of the following books will be created: ${logCreated.join(', ')}`);
      }
      if (removes.length) {
        this.log(`The following event(s) will be removed: ${removes.join(', ')}`, 'DEBUG');
      }

      return Promise.all(promises);

    });

  }

  /**
   * Limit log message to 100 and write into database.
   *
   * @returns {Promise} - Write optration to database.
   */
  saveProfile() {
    if (this.failed) {
      this.log('Cron job failed.', 'FATAL');
    } else {
      this.log('Cron job succeed.', 'SUCCESS');
    }

    this.user.logs.sort((a, b) => {
      return b.time - a.time;
    });
    this.user.logs = this.user.logs.slice(-MAX_LOG_RECORD);
    return this.user.save();
  }

}

/**
 * Batcher for executing mass amount of UserFunctions.
 * Methods are identical to {@link UserFunctions}, but errors will be supressed.
 *
 * @prop {UserFunctions[]} users - Users that this batcher is bounded to.
 */
class UserFunctionFactory {

  /**
   * @constructor
   */
  constructor() {
    this.users = [];
  }

  /**
   * Call name and apply catchIgnore and chain all of them together.
   *
   * @param {string} name - Name of the method that will be called.
   * @returns {Promise} - Promise of that object.
   * @private
   */
  _method(name) {
    let promises = [];
    this.users.forEach(user => {
      promises.push(user[name]().catch(utils.catchIgnore));
    })
    return Promise.all(promises);
  }

  /**
   * @param {backend/models.users[]} users - Users that will be bounded to.
   */
  refreshToken(users) {
    // Add users to this.users and refresh their google tokens
    // Return: Promise for the above actoion
    this.users = [];
    let promises = [];

    users.forEach(user => {
      let uf = new UserFunctions(user);
      this.users.push(uf);
      uf.log('Started cron job.');
      promises.push(uf.refreshToken().catch(utils.catchIgnore));
    })

    return Promise.all(promises);
  }

  renewBooks() {
    return this._method('renewBooks');
  }

  refreshCalendar() {
    return this._method('refreshCalendar');
  }

  saveProfile() {
    return this._method('saveProfile');
  }
}

/**
 * @summary Regular job for the system.
 * @desc This job will, first, query all users. Then renew books for them. Finally,
 * refresh their Google calendar and do some database cleanup. Please run this job once
 * per day.
 *
 * @returns {Promise} - Promise for the job.
 */
function execute() {
  console.log('Started cron job.');
  let factory = new UserFunctionFactory();

  models.user.find()
  .then(factory.refreshToken.bind(factory))
  .then(factory.renewBooks.bind(factory))
  .then(factory.refreshCalendar.bind(factory))
  .then(factory.saveProfile.bind(factory))
  .then(() => {
    console.log('Cron job ended');
  })
  .catch(err => {
    console.error('Cron Job errored.');
    throw err;
  });
}

module.exports = execute;
module.exports._UserFunctions = UserFunctions;
module.exports._UserFunctionFactory = UserFunctionFactory;
<|endoftext|>


<commit_before>
/**
 * "Don't Get Data" - Special flag for query to return the model/collection instead
 * of calling toJSON() on it.
 * @const
 */
var DONT_GET_DATA = 1;

/**
 * "Iterate Over Models" - Special flag for query to return an iterator over the
 * models of the collection, enabling iteration over models, which is what we want
 * to do when using _.each(collection ...) in a template, as this allows us to
 * use model.query(...) and properly bind references to the models.
 * @const
 */
var ITERATE_OVER_MODELS = 2;

/**
 * @const
 */
var QUERY_PUSH = 3;

/**
 * @const
 */
var QUERY_UNSHIFT = 4;

/**
 * @const
 */
var QUERY_REMOVE_FIRST = 5;

/**
 * @const
 */
var QUERY_REMOVE_LAST = 6;

/**
 * @const
 */
var QUERY_TOGGLE = 7;

/**
 * @const
 */
var QUERY_UNSET = 8;

/**
 * @const
 */
var QUERY_INCREMENT = 9;

/**
 * If you want to select the root, you can either pass __self__ or just an empty
 * string; __self__ is converted to an empty string and this "flag" is used to
 * check for whether we are selecting either.
 * @const
 */
var QUERY_SELF = '';

/**
 * @const
 */
var MAX_RECURSIVE_DIFF_DEPTH = 8;

function recursiveDiff (self, evs, curr, prev, exhaustive, depth, fireAll) {
    // Kludge alert: if the objects are too deep, just assume there is
    // a change.
    if (depth > MAX_RECURSIVE_DIFF_DEPTH) {
        log(WARN, self, 'recurseLimit', 'hit recursion depth limit of <%=limit%>', {
            limit: MAX_RECURSIVE_DIFF_DEPTH
        });
        return true;
    }
    evs = evs || {};
    curr = curr;
    prev = prev;
    if (isQueryable(prev) || isQueryable(curr)) {
        // The only reason either prev or curr should be queryable is if
        // we're setting a model where there previous was none (or vice versa).
        // In this case, *all* descendant events must be rebound to the new
        // model by firing them all immediately.
        fireAll = true;
    }
    var changed = fireAll;
    var k, i, n;
    for (k in evs) {
        if (k === QUERY_SELF) {
            if (prev !== curr) {
                // If prev and curr are both "object" types (but not null),
                // then we need to search recursively for "real" changes.
                // We want to avoid firing change events when the user sets
                // something to a deep copy of itself.
                if (isObject(prev) && isObject(curr)) {
                    exhaustive = true;
                } else {
                    changed = true;
                }
            }
        } else {
            changed = recursiveDiff(
                self, evs[k], curr && curr[k], prev && prev[k], false, depth + 1, fireAll) || changed;
        }
    }
    if (exhaustive && !changed) {
        // If exhaustive specified, and we haven't yet found a change, search
        // through all keys until we find one (note that this could duplicate
        // some searching done while searching the event tree)
        // This may not be super-efficient to call recursiveDiff all the time.
        if (isObject(prev) && isObject(curr)) {
            // prev and curr are both objects/arrays
            // search through them recursively for any differences
            var searched = {};
            var objs = [prev, curr];
            for (i = 0; i < 2 && !changed; i++) {
                var obj = objs[i];
                if (isArray(obj)) {
                    if (prev.length !== curr.length) {
                        changed = true;
                    }
                    for (k = 0; k < obj.length && !changed; k++) {
                        if (!searched[k]) {
                            searched[k] = true;
                            if (recursiveDiff(self, evs[k], curr[k], prev[k], true, depth + 1, false)) {
                                changed = true;
                            }
                        }
                    }
                } else {
                    for (k in obj) {
                        if (!searched[k]) {
                            searched[k] = true;
                            if (recursiveDiff(self, evs[k], curr[k], prev[k], true, depth + 1, false)) {
                                changed = true;
                                break;
                            }
                        }
                    }
                }
            }
        } else if (prev !== curr) {
            // at least one of prev and curr is a primitive (i.e. not arrays/objects)
            // and they are different.  thus, we've found a change and will pass this
            // outward so that we know to fire all parent callbacks
            changed = true;
        }
    }
    if (changed) {
        var callbacks = evs[QUERY_SELF] || [];
        for (n = 0; n < callbacks.length; n++) {
            callbacks[n].callback.call(callbacks[n].context);
        }
    }
    return changed;
}

/**
 * serialize the model in a semi-destructive way.  We don't really care
 * about the result as long as we can use it to test for anything that
 * gets changed behind TBone's back (i.e. by changing arrays/objects that
 * TBone has stored).
 *
 * This is only ever called if TBONE_DEBUG is true.
 */
function serializeForComparison(model) {
    if (opts['aliasCheck']) {
        try {
            var attributes = model.attributes;
            return JSON.stringify(attributes === undefined ? null : attributes, function (key, value) {
                // If value is an array or object, screen its keys for queryables.
                // Queryables track their own changes, so we don't care to
                // check that they haven't changed without this model knowing.
                if (isObject(value)) {
                    // This is not a way to serialize correctly, but
                    // we just want to show that the original structures
                    // were the same, minus queryables.
                    var localized = {};
                    for (var k in value) {
                        if (!isQueryable(value[k])) {
                            localized[k] = value[k];
                        }
                    }
                    return localized;
                } else {
                    return value;
                }
            });
        } catch (e) {
            log(WARN, model, 'aliascheck', 'Failed to serialize attributes to JSON');
        }
    }
    return "null";
}

function listDiffs(curr, prev, accum) {
    var diffs = {};
    if (isObject(prev) && isObject(curr)) {
        var searched = {};
        var objs = [prev, curr];
        for (var i = 0; i < 2; i++) {
            var obj = objs[i];
            for (var k in obj) {
                if (!searched[k]) {
                    searched[k] = true;
                    _.extend(diffs, listDiffs(prev[k], curr[k], accum.concat(k)));
                }
            }
        }
    } else {
        if (prev !== curr) {
            diffs[accum.join('.')] = prev + ' -> ' + curr;
        }
    }
    return diffs;
}

function query(flag, prop, value) {
    var self = this;
    var dontGetData = flag === DONT_GET_DATA;
    var iterateOverModels = flag === ITERATE_OVER_MODELS;
    var isPush = flag === QUERY_PUSH;
    var isUnshift = flag === QUERY_UNSHIFT;
    var isRemoveFirst = flag === QUERY_REMOVE_FIRST;
    var isRemoveLast = flag === QUERY_REMOVE_LAST;
    var isToggle = flag === QUERY_TOGGLE;
    var isIncrement = flag === QUERY_INCREMENT;
    var isListOp = isPush || isUnshift || isRemoveFirst || isRemoveLast;
    var isUnset = flag === QUERY_UNSET;
    var hasValue = arguments.length === 3;
    var isSet = isListOp || isToggle || isUnset || hasValue || isIncrement;
    if (typeof flag !== 'number') {
        /**
         * If no flag provided, shift the prop and value over.  We do it this way instead
         * of having flag last so that we can type-check flag and discern optional flags
         * from optional values.  And flag should only be used internally, anyway.
         */
        value = prop;
        prop = flag;
        flag = 0;
        /**
         * Use arguments.length to switch to set mode in order to properly support
         * setting undefined.
         */
        if (arguments.length === 2) {
            isSet = true;
            hasValue = true;
        }
    }

    /**
     * Remove a trailing dot and __self__ references, if any, from the prop.
     **/
    prop = (prop || '').replace('__self__', '');
    var argParts = prop.split('.');
    var args = [];
    var i;
    for (i = 0; i < argParts.length; i++) {
        // Ignore empty string arguments.
        if (argParts[i]) {
            args.push(argParts[i]);
        }
    }

    /**
     * For set operations, we only want to look up the parent of the property we
     * are modifying; pop the final property we're setting from args and save it
     * for later.
     * @type {string}
     */
    var setprop = args[args.length - 1] || 'attributes';

    /**
     * If this function was called with a bindable context (i.e. a Model or Collection),
     * then use that as the root data object instead of the global tbone.data.
     */
    var last_data = self;

    /**
     * If DONT_GET_DATA, and there's no prop, then this is a self-reference.
     */
    var _data = dontGetData && !prop ? self : self.attributes;

    var name_parts = [];
    var id;
    var arg;
    var doSubQuery;
    var parentCallbacks = [];
    var events = isSet && self._events['change'];

    while (true) {
        if (_data == null && !isSet) {
            // Couldn't even get to the level of the value we're trying to look up.
            // Concat the rest of args onto name_parts so that we record the full
            // path in the event binding.
            name_parts = name_parts.concat(args);
            break;
        } else if (_data !== self && isQueryable(_data)) {
            /**
             * To avoid duplicating the recentLookups code here, we set a flag and do
             * the sub-query after recording queries.
             *
             * Always do the subquery if there are more args.
             * If there are no more args...
             * - and this is a set...
             *   - (but really an unset): Don't do the sub-query regardless.
             *   -        to a queryable: Don't sub-query.  Set property to new queryable.
             *   -    to a non-queryable: Do the sub-query.  Push the value to the
             *                            other model (don't overwrite the model).  This
             *                            is kind of magical?
             * - and this is a get...
             *   -    with DONT_GET_DATA: Don't do sub-query.  Get the model itself.
             *   - without DONT_GET_DATA: Do the sub-query.  Delegate getting that model's
             *                            data to the other model.
             */
            doSubQuery = args.length || (isSet ? !isUnset && !isQueryable(value) : !dontGetData);
            break;
        } else if (isSet && !isObject(_data) && (args.length || isListOp)) {
            /**
             * Don't do implicit mkdir -p if we're just trying to unset something
             * that doesn't exist.
             */
            if (isUnset) {
                return;
            }
            /**
             * When doing an implicit mkdir -p while setting a deep-nested property
             * for the first time, we peek at the next arg and create either an array
             * for a numeric index and an object for anything else.  We set the
             * property via query() so as to fire change events appropriately.
             */
            if (_data != null) {
                log(WARN, this, 'mkdir', 'while writing <%=prop%>, had to overwrite ' +
                    'primitive value <%=primitive%> at <%=partial%>', {
                        prop: prop,
                        primitive: _data,
                        partial: name_parts.join('.')
                    });
            }
            /**
             * Decide whether to implicitly create an array or an object.
             *
             * If there are args remaining, then use the next arg to determine;
             * for a number, create an array - anything else, an object.
             *
             * If there are no more args, then create an array if this is a list
             * operation; otherwise, an object.
             */
            _data = (args.length ? rgxNumber.exec(args[0]) : isListOp) ? [] : {};
            self['query'](name_parts.join('.'), _data);
        }

        arg = args.shift();
        if (arg == null) { break; }

        name_parts.push(arg);
        last_data = _data;

        _data = _data[arg];
        if (events) {
            parentCallbacks = parentCallbacks.concat(events[QUERY_SELF] || []);
            events = events[arg];
        }
    }

    if (!isSet && recentLookups) {
        id = uniqueId(self);
        if (!recentLookups[id]) {
            recentLookups[id] = {
                '__obj__': self
            };
        }
        recentLookups[id][name_parts.join('.')] = _data;
    }

    if (doSubQuery) {
        return hasValue ? _data['query'](flag, args.join('.'), value) : _data['query'](flag, args.join('.'));
    }

    if (isSet) {
        if (TBONE_DEBUG) {
            if (self.prevJson) {
                var json = serializeForComparison(self);
                if (json !== self.prevJson) {
                    var before = JSON.parse(self.prevJson);
                    var after = JSON.parse(json);
                    var diffs = listDiffs(after, before, []);
                    log(ERROR, self, 'aliascheck', 'aliased change detected', {}, {
                        before: before,
                        after: after,
                        diffs: diffs
                    });
                }
            }
        }

        if (isPush) {
            _data.push(value);
        } else if (isUnshift) {
            _data.unshift(value);
        } else if (isRemoveFirst) {
            _data.shift(value);
        } else if (isRemoveLast) {
            _data.pop(value);
        } else if (isUnset) {
            delete last_data[setprop];
        } else if (isToggle) {
            value = last_data[setprop] = !_data;
        } else if (isIncrement) {
            value = last_data[setprop] = (_data || 0) + value;
        } else {
            last_data[setprop] = value;
            // XXX Kludge Alert.  In practice, gives many models a Name that otherwise
            // wouldn't have one by using the first prop name it is set to.  Works for
            // the typical T('modelName', model.make()) case.
            if (TBONE_DEBUG && isQueryable(value) && value.Name == null) {
                value.Name = prop;
            }
        }

        if (parentCallbacks.length) {
            // If there are any changes at all, then we need to fire one or more
            // callbacks for things we searched for.  Note that "parent" only includes
            // things from this model; change events don't bubble out to parent models.
            if (recursiveDiff(self, events, _data, value, true, 0, false)) {
                for (i = 0; i < parentCallbacks.length; i++) {
                    parentCallbacks[i].callback.call(parentCallbacks[i].context);
                }
            }
        } else {
            recursiveDiff(self, events, _data, value, false, 0, false);
        }

        if (TBONE_DEBUG) {
            self.prevJson = serializeForComparison(self);
        }
        return value;
    } else if (!iterateOverModels && self.isCollection && prop === '') {
        /**
         * If iterateOverModels is not set and _data is a collection, return the
         * raw data of each model in a list.  XXX is this ideal?  or too magical?
         */
        if (isArray(_data)) {
            _data = _.map(_data, function (d) { return d['query'](); });
        } else if (_data) {
            _data = _.reduce(_.keys(_data), function (memo, k) {
                if (isQueryable(_data[k])) {
                    memo[k] = _data[k]['query']();
                }
                return memo;
            }, {});
        }
    }
    return _data;
}

function queryText(flag, prop) {
    return denullText(prop == null ? this['query'](flag) : this['query'](flag, prop));
}
<commit_msg>Increase recursiveDiff recursion limit to 16.<commit_after>
/**
 * "Don't Get Data" - Special flag for query to return the model/collection instead
 * of calling toJSON() on it.
 * @const
 */
var DONT_GET_DATA = 1;

/**
 * "Iterate Over Models" - Special flag for query to return an iterator over the
 * models of the collection, enabling iteration over models, which is what we want
 * to do when using _.each(collection ...) in a template, as this allows us to
 * use model.query(...) and properly bind references to the models.
 * @const
 */
var ITERATE_OVER_MODELS = 2;

/**
 * @const
 */
var QUERY_PUSH = 3;

/**
 * @const
 */
var QUERY_UNSHIFT = 4;

/**
 * @const
 */
var QUERY_REMOVE_FIRST = 5;

/**
 * @const
 */
var QUERY_REMOVE_LAST = 6;

/**
 * @const
 */
var QUERY_TOGGLE = 7;

/**
 * @const
 */
var QUERY_UNSET = 8;

/**
 * @const
 */
var QUERY_INCREMENT = 9;

/**
 * If you want to select the root, you can either pass __self__ or just an empty
 * string; __self__ is converted to an empty string and this "flag" is used to
 * check for whether we are selecting either.
 * @const
 */
var QUERY_SELF = '';

/**
 * @const
 */
var MAX_RECURSIVE_DIFF_DEPTH = 16;

function recursiveDiff (self, evs, curr, prev, exhaustive, depth, fireAll) {
    // Kludge alert: if the objects are too deep, just assume there is
    // a change.
    if (depth > MAX_RECURSIVE_DIFF_DEPTH) {
        log(WARN, self, 'recurseLimit', 'hit recursion depth limit of <%=limit%>', {
            limit: MAX_RECURSIVE_DIFF_DEPTH
        }, {
            curr: curr,
            prev: prev
        });
        return true;
    }
    evs = evs || {};
    curr = curr;
    prev = prev;
    if (isQueryable(prev) || isQueryable(curr)) {
        // The only reason either prev or curr should be queryable is if
        // we're setting a model where there previous was none (or vice versa).
        // In this case, *all* descendant events must be rebound to the new
        // model by firing them all immediately.
        fireAll = true;
    }
    var changed = fireAll;
    var k, i, n;
    for (k in evs) {
        if (k === QUERY_SELF) {
            if (prev !== curr) {
                // If prev and curr are both "object" types (but not null),
                // then we need to search recursively for "real" changes.
                // We want to avoid firing change events when the user sets
                // something to a deep copy of itself.
                if (isObject(prev) && isObject(curr)) {
                    exhaustive = true;
                } else {
                    changed = true;
                }
            }
        } else {
            changed = recursiveDiff(
                self, evs[k], curr && curr[k], prev && prev[k], false, depth + 1, fireAll) || changed;
        }
    }
    if (exhaustive && !changed) {
        // If exhaustive specified, and we haven't yet found a change, search
        // through all keys until we find one (note that this could duplicate
        // some searching done while searching the event tree)
        // This may not be super-efficient to call recursiveDiff all the time.
        if (isObject(prev) && isObject(curr)) {
            // prev and curr are both objects/arrays
            // search through them recursively for any differences
            var searched = {};
            var objs = [prev, curr];
            for (i = 0; i < 2 && !changed; i++) {
                var obj = objs[i];
                if (isArray(obj)) {
                    if (prev.length !== curr.length) {
                        changed = true;
                    }
                    for (k = 0; k < obj.length && !changed; k++) {
                        if (!searched[k]) {
                            searched[k] = true;
                            if (recursiveDiff(self, evs[k], curr[k], prev[k], true, depth + 1, false)) {
                                changed = true;
                            }
                        }
                    }
                } else {
                    for (k in obj) {
                        if (!searched[k]) {
                            searched[k] = true;
                            if (recursiveDiff(self, evs[k], curr[k], prev[k], true, depth + 1, false)) {
                                changed = true;
                                break;
                            }
                        }
                    }
                }
            }
        } else if (prev !== curr) {
            // at least one of prev and curr is a primitive (i.e. not arrays/objects)
            // and they are different.  thus, we've found a change and will pass this
            // outward so that we know to fire all parent callbacks
            changed = true;
        }
    }
    if (changed) {
        var callbacks = evs[QUERY_SELF] || [];
        for (n = 0; n < callbacks.length; n++) {
            callbacks[n].callback.call(callbacks[n].context);
        }
    }
    return changed;
}

/**
 * serialize the model in a semi-destructive way.  We don't really care
 * about the result as long as we can use it to test for anything that
 * gets changed behind TBone's back (i.e. by changing arrays/objects that
 * TBone has stored).
 *
 * This is only ever called if TBONE_DEBUG is true.
 */
function serializeForComparison(model) {
    if (opts['aliasCheck']) {
        try {
            var attributes = model.attributes;
            return JSON.stringify(attributes === undefined ? null : attributes, function (key, value) {
                // If value is an array or object, screen its keys for queryables.
                // Queryables track their own changes, so we don't care to
                // check that they haven't changed without this model knowing.
                if (isObject(value)) {
                    // This is not a way to serialize correctly, but
                    // we just want to show that the original structures
                    // were the same, minus queryables.
                    var localized = {};
                    for (var k in value) {
                        if (!isQueryable(value[k])) {
                            localized[k] = value[k];
                        }
                    }
                    return localized;
                } else {
                    return value;
                }
            });
        } catch (e) {
            log(WARN, model, 'aliascheck', 'Failed to serialize attributes to JSON');
        }
    }
    return "null";
}

function listDiffs(curr, prev, accum) {
    var diffs = {};
    if (isObject(prev) && isObject(curr)) {
        var searched = {};
        var objs = [prev, curr];
        for (var i = 0; i < 2; i++) {
            var obj = objs[i];
            for (var k in obj) {
                if (!searched[k]) {
                    searched[k] = true;
                    _.extend(diffs, listDiffs(prev[k], curr[k], accum.concat(k)));
                }
            }
        }
    } else {
        if (prev !== curr) {
            diffs[accum.join('.')] = prev + ' -> ' + curr;
        }
    }
    return diffs;
}

function query(flag, prop, value) {
    var self = this;
    var dontGetData = flag === DONT_GET_DATA;
    var iterateOverModels = flag === ITERATE_OVER_MODELS;
    var isPush = flag === QUERY_PUSH;
    var isUnshift = flag === QUERY_UNSHIFT;
    var isRemoveFirst = flag === QUERY_REMOVE_FIRST;
    var isRemoveLast = flag === QUERY_REMOVE_LAST;
    var isToggle = flag === QUERY_TOGGLE;
    var isIncrement = flag === QUERY_INCREMENT;
    var isListOp = isPush || isUnshift || isRemoveFirst || isRemoveLast;
    var isUnset = flag === QUERY_UNSET;
    var hasValue = arguments.length === 3;
    var isSet = isListOp || isToggle || isUnset || hasValue || isIncrement;
    if (typeof flag !== 'number') {
        /**
         * If no flag provided, shift the prop and value over.  We do it this way instead
         * of having flag last so that we can type-check flag and discern optional flags
         * from optional values.  And flag should only be used internally, anyway.
         */
        value = prop;
        prop = flag;
        flag = 0;
        /**
         * Use arguments.length to switch to set mode in order to properly support
         * setting undefined.
         */
        if (arguments.length === 2) {
            isSet = true;
            hasValue = true;
        }
    }

    /**
     * Remove a trailing dot and __self__ references, if any, from the prop.
     **/
    prop = (prop || '').replace('__self__', '');
    var argParts = prop.split('.');
    var args = [];
    var i;
    for (i = 0; i < argParts.length; i++) {
        // Ignore empty string arguments.
        if (argParts[i]) {
            args.push(argParts[i]);
        }
    }

    /**
     * For set operations, we only want to look up the parent of the property we
     * are modifying; pop the final property we're setting from args and save it
     * for later.
     * @type {string}
     */
    var setprop = args[args.length - 1] || 'attributes';

    /**
     * If this function was called with a bindable context (i.e. a Model or Collection),
     * then use that as the root data object instead of the global tbone.data.
     */
    var last_data = self;

    /**
     * If DONT_GET_DATA, and there's no prop, then this is a self-reference.
     */
    var _data = dontGetData && !prop ? self : self.attributes;

    var name_parts = [];
    var id;
    var arg;
    var doSubQuery;
    var parentCallbacks = [];
    var events = isSet && self._events['change'];

    while (true) {
        if (_data == null && !isSet) {
            // Couldn't even get to the level of the value we're trying to look up.
            // Concat the rest of args onto name_parts so that we record the full
            // path in the event binding.
            name_parts = name_parts.concat(args);
            break;
        } else if (_data !== self && isQueryable(_data)) {
            /**
             * To avoid duplicating the recentLookups code here, we set a flag and do
             * the sub-query after recording queries.
             *
             * Always do the subquery if there are more args.
             * If there are no more args...
             * - and this is a set...
             *   - (but really an unset): Don't do the sub-query regardless.
             *   -        to a queryable: Don't sub-query.  Set property to new queryable.
             *   -    to a non-queryable: Do the sub-query.  Push the value to the
             *                            other model (don't overwrite the model).  This
             *                            is kind of magical?
             * - and this is a get...
             *   -    with DONT_GET_DATA: Don't do sub-query.  Get the model itself.
             *   - without DONT_GET_DATA: Do the sub-query.  Delegate getting that model's
             *                            data to the other model.
             */
            doSubQuery = args.length || (isSet ? !isUnset && !isQueryable(value) : !dontGetData);
            break;
        } else if (isSet && !isObject(_data) && (args.length || isListOp)) {
            /**
             * Don't do implicit mkdir -p if we're just trying to unset something
             * that doesn't exist.
             */
            if (isUnset) {
                return;
            }
            /**
             * When doing an implicit mkdir -p while setting a deep-nested property
             * for the first time, we peek at the next arg and create either an array
             * for a numeric index and an object for anything else.  We set the
             * property via query() so as to fire change events appropriately.
             */
            if (_data != null) {
                log(WARN, this, 'mkdir', 'while writing <%=prop%>, had to overwrite ' +
                    'primitive value <%=primitive%> at <%=partial%>', {
                        prop: prop,
                        primitive: _data,
                        partial: name_parts.join('.')
                    });
            }
            /**
             * Decide whether to implicitly create an array or an object.
             *
             * If there are args remaining, then use the next arg to determine;
             * for a number, create an array - anything else, an object.
             *
             * If there are no more args, then create an array if this is a list
             * operation; otherwise, an object.
             */
            _data = (args.length ? rgxNumber.exec(args[0]) : isListOp) ? [] : {};
            self['query'](name_parts.join('.'), _data);
        }

        arg = args.shift();
        if (arg == null) { break; }

        name_parts.push(arg);
        last_data = _data;

        _data = _data[arg];
        if (events) {
            parentCallbacks = parentCallbacks.concat(events[QUERY_SELF] || []);
            events = events[arg];
        }
    }

    if (!isSet && recentLookups) {
        id = uniqueId(self);
        if (!recentLookups[id]) {
            recentLookups[id] = {
                '__obj__': self
            };
        }
        recentLookups[id][name_parts.join('.')] = _data;
    }

    if (doSubQuery) {
        return hasValue ? _data['query'](flag, args.join('.'), value) : _data['query'](flag, args.join('.'));
    }

    if (isSet) {
        if (TBONE_DEBUG) {
            if (self.prevJson) {
                var json = serializeForComparison(self);
                if (json !== self.prevJson) {
                    var before = JSON.parse(self.prevJson);
                    var after = JSON.parse(json);
                    var diffs = listDiffs(after, before, []);
                    log(ERROR, self, 'aliascheck', 'aliased change detected', {}, {
                        before: before,
                        after: after,
                        diffs: diffs
                    });
                }
            }
        }

        if (isPush) {
            _data.push(value);
        } else if (isUnshift) {
            _data.unshift(value);
        } else if (isRemoveFirst) {
            _data.shift(value);
        } else if (isRemoveLast) {
            _data.pop(value);
        } else if (isUnset) {
            delete last_data[setprop];
        } else if (isToggle) {
            value = last_data[setprop] = !_data;
        } else if (isIncrement) {
            value = last_data[setprop] = (_data || 0) + value;
        } else {
            last_data[setprop] = value;
            // XXX Kludge Alert.  In practice, gives many models a Name that otherwise
            // wouldn't have one by using the first prop name it is set to.  Works for
            // the typical T('modelName', model.make()) case.
            if (TBONE_DEBUG && isQueryable(value) && value.Name == null) {
                value.Name = prop;
            }
        }

        if (parentCallbacks.length) {
            // If there are any changes at all, then we need to fire one or more
            // callbacks for things we searched for.  Note that "parent" only includes
            // things from this model; change events don't bubble out to parent models.
            if (recursiveDiff(self, events, _data, value, true, 0, false)) {
                for (i = 0; i < parentCallbacks.length; i++) {
                    parentCallbacks[i].callback.call(parentCallbacks[i].context);
                }
            }
        } else {
            recursiveDiff(self, events, _data, value, false, 0, false);
        }

        if (TBONE_DEBUG) {
            self.prevJson = serializeForComparison(self);
        }
        return value;
    } else if (!iterateOverModels && self.isCollection && prop === '') {
        /**
         * If iterateOverModels is not set and _data is a collection, return the
         * raw data of each model in a list.  XXX is this ideal?  or too magical?
         */
        if (isArray(_data)) {
            _data = _.map(_data, function (d) { return d['query'](); });
        } else if (_data) {
            _data = _.reduce(_.keys(_data), function (memo, k) {
                if (isQueryable(_data[k])) {
                    memo[k] = _data[k]['query']();
                }
                return memo;
            }, {});
        }
    }
    return _data;
}

function queryText(flag, prop) {
    return denullText(prop == null ? this['query'](flag) : this['query'](flag, prop));
}
<|endoftext|>