<commit_before>import { Inflect } from 'grind-support'

//
// Adapted from Laravel:
// https://github.com/laravel/framework/blob/b75aca6a203590068161835945213fd1a39c7080/src/Illuminate/Routing/ResourceRegistrar.php
//

export class ResourceRouteBuilder {
	routes = null
	resourceDefaults = [ 'index', 'create', 'store', 'show', 'edit', 'update', 'destroy' ]
	parameters = null

	static parameterMap = [ ]
	static singularParameters = true
	static verbs = {
		create: 'create',
		edit: 'edit',
	}

	constructor(routes) {
		this.routes = routes
	}

	buildRoutes(name, controller, options = { }, callback = null) {
		if(typeof options === 'function') {
			callback = options
			options = { }
		}

		if(!options.parameters.isNil && !this.parameters.isNil) {
			this.parameters = options.parameters
		}

		// If the resource name contains a slash, we will assume the developer wishes to
		// register these resource routes with a prefix so we will set that up out of
		// the box so they don't have to mess with it. Otherwise, we will continue.
		if(name.indexOf('/') >= 0) {
			return this._prefixedResource(name, controller, options, callback)
		}

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		const segments = name.split(/\./)
		const base = this.getResourceWildcard(segments[segments.length - 1])

		return this.routes.group({
			controller,
			prefix: this.getResourceUri(name),
			before: options.before,
			after: options.after
		}, routes => {
			if(typeof callback === 'function') {
				callback(routes, controller)
			}

			for(const m of this._getResourceMethods(this.resourceDefaults, options)) {
				const method = `_addResource${m.substring(0, 1).toUpperCase()}${m.substring(1)}`
				this[method](name, base, controller, options)
			}
		})
	}

	_prefixedResource(name, controller, options = { }, callback = null) {
		const { name: segment, prefix } = this._getResourcePrefix(name)

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		return this.routes.group({ prefix }, () => {
			this.buildRoutes(segment, controller, options, callback)
		})
	}

	_getResourcePrefix(name) {
		const segments = name.split(/\//)

		// To get the prefix, we will take all of the name segments and implode them on
		// a slash. This will generate a proper URI prefix fors us. Then we take this
		// last segment, which will be considered the final resources name we use.
		const prefix = segments.slice(0, -1).join('/')

		return {
			name: segments[segments.length - 1],
			prefix
		}
	}

	_getResourceMethods(defaults, options) {
		if(!options.only.isNil) {
			return defaults.filter(method => options.only.indexOf(method) >= 0)
		} else if(!options.except.isNil) {
			return defaults.filter(method => options.except.indexOf(method) === -1)
		}

		return defaults
	}

	_addResourceIndex(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'index', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get('/', action)
	}

	_addResourceCreate(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'create', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(this.constructor.verbs.create, action)
	}

	_addResourceStore(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'store', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.post('/', action)
	}

	_addResourceShow(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'show', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(`:${base}`, action)
	}

	_addResourceEdit(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'edit', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(`:${base}/${this.constructor.verbs.edit}`, action)
	}

	_addResourceUpdate(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'update', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.match([ 'put', 'patch' ], `:${base}`, action)
	}

	_addResourceDestroy(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'destroy', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.delete(`:${base}`, action)
	}

	getResourceUri(resource) {
		if(resource.indexOf('.') === -1) {
			return resource
		}

		// Once we have built the base URI, we'll remove the parameter holder for this
		// base resource name so that the individual route adders can suffix these
		// paths however they need to, as some do not have any parameters at all.
		const segments = resource.split(/\./)
		const uri = this._getNestedResourceUri(segments)

		return uri.replace(`/:${this.getResourceWildcard(segments[segments.length - 1])}`, '')
	}

	_getNestedResourceUri(segments) {
		// We will spin through the segments and create a placeholder for each of the
		// resource segments, as well as the resource itself. Then we should get an
		// entire string for the resource URI that contains all nested resources.
		return segments.map(s => `${s}/:${this.getResourceWildcard(s)}`).join('/')
	}

	getResourceWildcard(value) {
		if(!this.parameters.isNil && !this.parameters.isNil[value]) {
			value = this.parameters[value]
		} else if(!this.constructor.parameterMap.isNil && !this.constructor.parameterMap[value].isNil) {
			value = this.constructor.parameterMap[value]
		} else if(this.parameters === 'singular' || this.constructor.singularParameters) {
			value = Inflect.singularize(value)
		}

		return value.replace(/-/g, '_')
	}

	_getResourceAction(resource, controller, method, options) {
		return {
			as: this._getResourceRouteName(resource, method, options),
			method: method
		}
	}

	_getResourceRouteName(resource, method, options) {
		let name = resource

		// If the names array has been provided to us we will check for an entry in the
		// array first. We will also check for the specific method within this array
		// so the names may be specified on a more "granular" level using methods.
		if(!options.names.isNil) {
			if(typeof options.names === 'string') {
				name = options.names
			} else if(!options.names[method].isNil) {
				return options.names[method]
			}
		}

		// If a global prefix has been assigned to all names for this resource, we will
		// grab that so we can prepend it onto the name when we create this name for
		// the resource action. Otherwise we'll just use an empty string for here.
		const prefix = !options.as.isNil ? `${options.as}.` : ''
		return `${prefix}${name}.${method}`.replace(/(^\.|\.$)/g, '')
	}

	static singularParameters(singular = true) {
		this.singularParameters = singular
	}

	static getParameters() {
		return [ ...this.parameterMap ]
	}

	static setParameters(parameters = [ ]) {
		this.parameterMap = [ ...parameters ]
	}

	static verbs(verbs = { }) {
		if(Object.keys(verbs).length === 0) {
			return { ...this.verbs }
		}

		this.verbs = { ...this.verbs, ...verbs }
	}

}
<commit_msg>Stripped trailing slash in resource routes from index/create<commit_after>import { Inflect } from 'grind-support'

//
// Adapted from Laravel:
// https://github.com/laravel/framework/blob/b75aca6a203590068161835945213fd1a39c7080/src/Illuminate/Routing/ResourceRegistrar.php
//

export class ResourceRouteBuilder {
	routes = null
	resourceDefaults = [ 'index', 'create', 'store', 'show', 'edit', 'update', 'destroy' ]
	parameters = null

	static parameterMap = [ ]
	static singularParameters = true
	static verbs = {
		create: 'create',
		edit: 'edit',
	}

	constructor(routes) {
		this.routes = routes
	}

	buildRoutes(name, controller, options = { }, callback = null) {
		if(typeof options === 'function') {
			callback = options
			options = { }
		}

		if(!options.parameters.isNil && !this.parameters.isNil) {
			this.parameters = options.parameters
		}

		// If the resource name contains a slash, we will assume the developer wishes to
		// register these resource routes with a prefix so we will set that up out of
		// the box so they don't have to mess with it. Otherwise, we will continue.
		if(name.indexOf('/') >= 0) {
			return this._prefixedResource(name, controller, options, callback)
		}

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		const segments = name.split(/\./)
		const base = this.getResourceWildcard(segments[segments.length - 1])

		return this.routes.group({
			controller,
			prefix: this.getResourceUri(name),
			before: options.before,
			after: options.after
		}, routes => {
			if(typeof callback === 'function') {
				callback(routes, controller)
			}

			for(const m of this._getResourceMethods(this.resourceDefaults, options)) {
				const method = `_addResource${m.substring(0, 1).toUpperCase()}${m.substring(1)}`
				this[method](name, base, controller, options)
			}
		})
	}

	_prefixedResource(name, controller, options = { }, callback = null) {
		const { name: segment, prefix } = this._getResourcePrefix(name)

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		return this.routes.group({ prefix }, () => {
			this.buildRoutes(segment, controller, options, callback)
		})
	}

	_getResourcePrefix(name) {
		const segments = name.split(/\//)

		// To get the prefix, we will take all of the name segments and implode them on
		// a slash. This will generate a proper URI prefix fors us. Then we take this
		// last segment, which will be considered the final resources name we use.
		const prefix = segments.slice(0, -1).join('/')

		return {
			name: segments[segments.length - 1],
			prefix
		}
	}

	_getResourceMethods(defaults, options) {
		if(!options.only.isNil) {
			return defaults.filter(method => options.only.indexOf(method) >= 0)
		} else if(!options.except.isNil) {
			return defaults.filter(method => options.except.indexOf(method) === -1)
		}

		return defaults
	}

	_addResourceIndex(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'index', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get('', action)
	}

	_addResourceCreate(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'create', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(this.constructor.verbs.create, action)
	}

	_addResourceStore(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'store', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.post('', action)
	}

	_addResourceShow(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'show', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(`:${base}`, action)
	}

	_addResourceEdit(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'edit', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(`:${base}/${this.constructor.verbs.edit}`, action)
	}

	_addResourceUpdate(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'update', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.match([ 'put', 'patch' ], `:${base}`, action)
	}

	_addResourceDestroy(name, base, controller, options) {
		const action = this._getResourceAction(name, controller, 'destroy', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.delete(`:${base}`, action)
	}

	getResourceUri(resource) {
		if(resource.indexOf('.') === -1) {
			return resource
		}

		// Once we have built the base URI, we'll remove the parameter holder for this
		// base resource name so that the individual route adders can suffix these
		// paths however they need to, as some do not have any parameters at all.
		const segments = resource.split(/\./)
		const uri = this._getNestedResourceUri(segments)

		return uri.replace(`/:${this.getResourceWildcard(segments[segments.length - 1])}`, '')
	}

	_getNestedResourceUri(segments) {
		// We will spin through the segments and create a placeholder for each of the
		// resource segments, as well as the resource itself. Then we should get an
		// entire string for the resource URI that contains all nested resources.
		return segments.map(s => `${s}/:${this.getResourceWildcard(s)}`).join('/')
	}

	getResourceWildcard(value) {
		if(!this.parameters.isNil && !this.parameters.isNil[value]) {
			value = this.parameters[value]
		} else if(!this.constructor.parameterMap.isNil && !this.constructor.parameterMap[value].isNil) {
			value = this.constructor.parameterMap[value]
		} else if(this.parameters === 'singular' || this.constructor.singularParameters) {
			value = Inflect.singularize(value)
		}

		return value.replace(/-/g, '_')
	}

	_getResourceAction(resource, controller, method, options) {
		return {
			as: this._getResourceRouteName(resource, method, options),
			method: method
		}
	}

	_getResourceRouteName(resource, method, options) {
		let name = resource

		// If the names array has been provided to us we will check for an entry in the
		// array first. We will also check for the specific method within this array
		// so the names may be specified on a more "granular" level using methods.
		if(!options.names.isNil) {
			if(typeof options.names === 'string') {
				name = options.names
			} else if(!options.names[method].isNil) {
				return options.names[method]
			}
		}

		// If a global prefix has been assigned to all names for this resource, we will
		// grab that so we can prepend it onto the name when we create this name for
		// the resource action. Otherwise we'll just use an empty string for here.
		const prefix = !options.as.isNil ? `${options.as}.` : ''
		return `${prefix}${name}.${method}`.replace(/(^\.|\.$)/g, '')
	}

	static singularParameters(singular = true) {
		this.singularParameters = singular
	}

	static getParameters() {
		return [ ...this.parameterMap ]
	}

	static setParameters(parameters = [ ]) {
		this.parameterMap = [ ...parameters ]
	}

	static verbs(verbs = { }) {
		if(Object.keys(verbs).length === 0) {
			return { ...this.verbs }
		}

		this.verbs = { ...this.verbs, ...verbs }
	}

}
<|endoftext|>


<commit_before>import { Inflect } from 'grind-support'

//
// Adapted from Laravel:
// https://github.com/laravel/framework/blob/b75aca6a203590068161835945213fd1a39c7080/src/Illuminate/Routing/ResourceRegistrar.php
//

export class ResourceRouteBuilder {
	routes = null
	resourceDefaults = [ 'index', 'create', 'store', 'show', 'edit', 'update', 'destroy' ]
	parameters = null

	static parameterMap = [ ]
	static singularParameters = true
	static verbs = {
		create: 'create',
		edit: 'edit',
	}

	constructor(routes) {
		this.routes = routes
	}

	buildRoutes(name, controller, options = { }, callback = null) {
		if(typeof options === 'function') {
			callback = options
			options = { }
		}

		if(!options.parameters.isNil && !this.parameters.isNil) {
			this.parameters = options.parameters
		}

		// If the resource name contains a slash, we will assume the developer wishes to
		// register these resource routes with a prefix so we will set that up out of
		// the box so they don't have to mess with it. Otherwise, we will continue.
		if(name.indexOf('/') >= 0) {
			return this._prefixedResource(name, controller, options, callback)
		}

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		const segments = name.split(/\./)
		const base = this.getResourceWildcard(segments[segments.length - 1])

		return this.routes.group({ controller }, routes => {
			for(const m of this._getResourceMethods(this.resourceDefaults, options)) {
				const method = `_addResource${m.substring(0, 1).toUpperCase()}${m.substring(1)}`
				this[method](name, base, controller, options)
			}

			if(typeof callback === 'function') {
				callback(routes, controller)
			}
		})
	}

	_prefixedResource(name, controller, options = { }, callback = null) {
		const { name: segment, prefix } = this._getResourcePrefix(name)

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		return this.routes.group({ prefix }, () => {
			this.buildRoutes(segment, controller, options, callback)
		})
	}

	_getResourcePrefix(name) {
		const segments = name.split(/\//)

		// To get the prefix, we will take all of the name segments and implode them on
		// a slash. This will generate a proper URI prefix fors us. Then we take this
		// last segment, which will be considered the final resources name we use.
		const prefix = segments.slice(0, -1).join('/')

		return {
			name: segments[segments.length - 1],
			prefix
		}
	}

	_getResourceMethods(defaults, options) {
		if(!options.only.isNil) {
			return defaults.filter(method => options.only.indexOf(method) >= 0)
		} else if(!options.except.isNil) {
			return defaults.filter(method => options.except.indexOf(method) === -1)
		}

		return defaults
	}

	_addResourceIndex(name, base, controller, options) {
		const uri = this.getResourceUri(name)
		const action = this._getResourceAction(name, controller, 'index', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceCreate(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/${this.constructor.verbs.create}`
		const action = this._getResourceAction(name, controller, 'create', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceStore(name, base, controller, options) {
		const uri = this.getResourceUri(name)
		const action = this._getResourceAction(name, controller, 'store', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.post(uri, action)
	}

	_addResourceShow(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'show', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceEdit(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}/${this.constructor.verbs.edit}`
		const action = this._getResourceAction(name, controller, 'edit', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceUpdate(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'update', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.match([ 'put', 'patch' ], uri, action)
	}

	_addResourceDestroy(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'destroy', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.delete(uri, action)
	}

	getResourceUri(resource) {
		if(resource.indexOf('.') === -1) {
			return resource
		}

		// Once we have built the base URI, we'll remove the parameter holder for this
		// base resource name so that the individual route adders can suffix these
		// paths however they need to, as some do not have any parameters at all.
		const segments = resource.split(/\./)
		const uri = this._getNestedResourceUri(segments)

		return uri.replace(`/:${this.getResourceWildcard(segments[segments.length - 1])}`, '')
	}

	_getNestedResourceUri(segments) {
		// We will spin through the segments and create a placeholder for each of the
		// resource segments, as well as the resource itself. Then we should get an
		// entire string for the resource URI that contains all nested resources.
		return segments.map(s => `${s}/:${this.getResourceWildcard(s)}`).join('/')
	}

	getResourceWildcard(value) {
		if(!this.parameters.isNil && !this.parameters.isNil[value]) {
			value = this.parameters[value]
		} else if(!this.constructor.parameterMap.isNil && !this.constructor.parameterMap[value].isNil) {
			value = this.constructor.parameterMap[value]
		} else if(this.parameters === 'singular' || this.constructor.singularParameters) {
			value = Inflect.singularize(value)
		}

		return value.replace(/-/g, '_')
	}

	_getResourceAction(resource, controller, method, options) {
		const name = this._getResourceRouteName(resource, method, options)

		const action = {
			as: name,
			method: method
		}

		if(!options.before.isNil) {
			action.before = options.before
		}

		if(!options.after.isNil) {
			action.after = options.after
		}

		return action
	}

	_getResourceRouteName(resource, method, options) {
		let name = resource

		// If the names array has been provided to us we will check for an entry in the
		// array first. We will also check for the specific method within this array
		// so the names may be specified on a more "granular" level using methods.
		if(!options.names.isNil) {
			if(typeof options.names === 'string') {
				name = options.names
			} else if(!options.names[method].isNil) {
				return options.names[method]
			}
		}

		// If a global prefix has been assigned to all names for this resource, we will
		// grab that so we can prepend it onto the name when we create this name for
		// the resource action. Otherwise we'll just use an empty string for here.
		const prefix = !options.as.isNil ? `${options.as}.` : ''
		return `${prefix}${name}.${method}`.replace(/(^\.|\.$)/g, '')
	}

	static singularParameters(singular = true) {
		this.singularParameters = singular
	}

	static getParameters() {
		return [ ...this.parameterMap ]
	}

	static setParameters(parameters = [ ]) {
		this.parameterMap = [ ...parameters ]
	}

	static verbs(verbs = { }) {
		if(Object.keys(verbs).length === 0) {
			return { ...this.verbs }
		}

		this.verbs = { ...this.verbs, ...verbs }
	}

}
<commit_msg>ResourceRouteBuilder callback now executed _before_ routes are added This is important as any get routes added after would be eaten by /resource/:resource<commit_after>import { Inflect } from 'grind-support'

//
// Adapted from Laravel:
// https://github.com/laravel/framework/blob/b75aca6a203590068161835945213fd1a39c7080/src/Illuminate/Routing/ResourceRegistrar.php
//

export class ResourceRouteBuilder {
	routes = null
	resourceDefaults = [ 'index', 'create', 'store', 'show', 'edit', 'update', 'destroy' ]
	parameters = null

	static parameterMap = [ ]
	static singularParameters = true
	static verbs = {
		create: 'create',
		edit: 'edit',
	}

	constructor(routes) {
		this.routes = routes
	}

	buildRoutes(name, controller, options = { }, callback = null) {
		if(typeof options === 'function') {
			callback = options
			options = { }
		}

		if(!options.parameters.isNil && !this.parameters.isNil) {
			this.parameters = options.parameters
		}

		// If the resource name contains a slash, we will assume the developer wishes to
		// register these resource routes with a prefix so we will set that up out of
		// the box so they don't have to mess with it. Otherwise, we will continue.
		if(name.indexOf('/') >= 0) {
			return this._prefixedResource(name, controller, options, callback)
		}

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		const segments = name.split(/\./)
		const base = this.getResourceWildcard(segments[segments.length - 1])

		return this.routes.group({ controller }, routes => {
			if(typeof callback === 'function') {
				callback(routes, controller)
			}

			for(const m of this._getResourceMethods(this.resourceDefaults, options)) {
				const method = `_addResource${m.substring(0, 1).toUpperCase()}${m.substring(1)}`
				this[method](name, base, controller, options)
			}
		})
	}

	_prefixedResource(name, controller, options = { }, callback = null) {
		const { name: segment, prefix } = this._getResourcePrefix(name)

		// We need to extract the base resource from the resource name. Nested resources
		// are supported in the framework, but we need to know what name to use for a
		// placeholder on the route parameters, which should be the base resources.
		return this.routes.group({ prefix }, () => {
			this.buildRoutes(segment, controller, options, callback)
		})
	}

	_getResourcePrefix(name) {
		const segments = name.split(/\//)

		// To get the prefix, we will take all of the name segments and implode them on
		// a slash. This will generate a proper URI prefix fors us. Then we take this
		// last segment, which will be considered the final resources name we use.
		const prefix = segments.slice(0, -1).join('/')

		return {
			name: segments[segments.length - 1],
			prefix
		}
	}

	_getResourceMethods(defaults, options) {
		if(!options.only.isNil) {
			return defaults.filter(method => options.only.indexOf(method) >= 0)
		} else if(!options.except.isNil) {
			return defaults.filter(method => options.except.indexOf(method) === -1)
		}

		return defaults
	}

	_addResourceIndex(name, base, controller, options) {
		const uri = this.getResourceUri(name)
		const action = this._getResourceAction(name, controller, 'index', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceCreate(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/${this.constructor.verbs.create}`
		const action = this._getResourceAction(name, controller, 'create', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceStore(name, base, controller, options) {
		const uri = this.getResourceUri(name)
		const action = this._getResourceAction(name, controller, 'store', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.post(uri, action)
	}

	_addResourceShow(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'show', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceEdit(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}/${this.constructor.verbs.edit}`
		const action = this._getResourceAction(name, controller, 'edit', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.get(uri, action)
	}

	_addResourceUpdate(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'update', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.match([ 'put', 'patch' ], uri, action)
	}

	_addResourceDestroy(name, base, controller, options) {
		const uri = `${this.getResourceUri(name)}/:${base}`
		const action = this._getResourceAction(name, controller, 'destroy', options)

		if(typeof controller[action.method] !== 'function') {
			return null
		}

		return this.routes.delete(uri, action)
	}

	getResourceUri(resource) {
		if(resource.indexOf('.') === -1) {
			return resource
		}

		// Once we have built the base URI, we'll remove the parameter holder for this
		// base resource name so that the individual route adders can suffix these
		// paths however they need to, as some do not have any parameters at all.
		const segments = resource.split(/\./)
		const uri = this._getNestedResourceUri(segments)

		return uri.replace(`/:${this.getResourceWildcard(segments[segments.length - 1])}`, '')
	}

	_getNestedResourceUri(segments) {
		// We will spin through the segments and create a placeholder for each of the
		// resource segments, as well as the resource itself. Then we should get an
		// entire string for the resource URI that contains all nested resources.
		return segments.map(s => `${s}/:${this.getResourceWildcard(s)}`).join('/')
	}

	getResourceWildcard(value) {
		if(!this.parameters.isNil && !this.parameters.isNil[value]) {
			value = this.parameters[value]
		} else if(!this.constructor.parameterMap.isNil && !this.constructor.parameterMap[value].isNil) {
			value = this.constructor.parameterMap[value]
		} else if(this.parameters === 'singular' || this.constructor.singularParameters) {
			value = Inflect.singularize(value)
		}

		return value.replace(/-/g, '_')
	}

	_getResourceAction(resource, controller, method, options) {
		const name = this._getResourceRouteName(resource, method, options)

		const action = {
			as: name,
			method: method
		}

		if(!options.before.isNil) {
			action.before = options.before
		}

		if(!options.after.isNil) {
			action.after = options.after
		}

		return action
	}

	_getResourceRouteName(resource, method, options) {
		let name = resource

		// If the names array has been provided to us we will check for an entry in the
		// array first. We will also check for the specific method within this array
		// so the names may be specified on a more "granular" level using methods.
		if(!options.names.isNil) {
			if(typeof options.names === 'string') {
				name = options.names
			} else if(!options.names[method].isNil) {
				return options.names[method]
			}
		}

		// If a global prefix has been assigned to all names for this resource, we will
		// grab that so we can prepend it onto the name when we create this name for
		// the resource action. Otherwise we'll just use an empty string for here.
		const prefix = !options.as.isNil ? `${options.as}.` : ''
		return `${prefix}${name}.${method}`.replace(/(^\.|\.$)/g, '')
	}

	static singularParameters(singular = true) {
		this.singularParameters = singular
	}

	static getParameters() {
		return [ ...this.parameterMap ]
	}

	static setParameters(parameters = [ ]) {
		this.parameterMap = [ ...parameters ]
	}

	static verbs(verbs = { }) {
		if(Object.keys(verbs).length === 0) {
			return { ...this.verbs }
		}

		this.verbs = { ...this.verbs, ...verbs }
	}

}
<|endoftext|>


<commit_before>/* eslint no-useless-escape: 0 */
const mimelib = require('mimelib');
const encoding = require('encoding');
const he = require('he');
const os = require('os');
const fs = require('fs');
const path = require('path');
const mkdirp = require('mkdirp');
const {Errors: {APIError}} = require('isomorphic-core');
const {N1CloudAPI, RegExpUtils, Utils} = require('nylas-exports');

// Aiming for the former in length, but the latter is the hard db cutoff
const SNIPPET_SIZE = 100;
const SNIPPET_MAX_SIZE = 255;

// The input is the value of a to/cc/bcc/from header as parsed by the imap
// library we're using, but it currently parses them in a weird format. If an
// email is sent to a@example.com and b@example.com, the parsed output of the
// 'to' header is ['a@example.com, b@example.com']. (Note both emails are in
// the same string.) When fixed, this function will need to update accordingly.
function computeContacts(input) {
  if (!input || input.length === 0 || !input[0]) {
    return [];
  }
  const values = mimelib.parseAddresses(input[0]);
  if (!values || values.length === 0 || !input[0]) {
    return [];
  }
  return values.map(v => {
    if (!v || v.length === 0) {
      return null
    }
    const {name, address: email} = v;
    return {name, email};
  })
  .filter(c => c != null)
}

function computeSnippet(body) {
  const doc = new DOMParser().parseFromString(body, 'text/html')
  const skipTags = new Set(['TITLE', 'SCRIPT', 'STYLE', 'IMG']);
  const noSpaceTags = new Set(['B', 'I', 'STRONG', 'EM', 'SPAN']);

  const treeWalker = document.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => {
    if (skipTags.has(node.tagName)) {
      // skip this node and all its children
      return NodeFilter.FILTER_REJECT;
    }
    if (node.nodeType === Node.TEXT_NODE) {
      const nodeValue = node.nodeValue ? node.nodeValue.trim() : null;
      if (nodeValue) {
        return NodeFilter.FILTER_ACCEPT;
      }
      return NodeFilter.FILTER_SKIP;
    }
    return NodeFilter.FILTER_ACCEPT;
  });

  let extractedText = "";
  let lastNodeTag = "";
  while (treeWalker.nextNode()) {
    if (treeWalker.currentNode.nodeType === Node.ELEMENT_NODE) {
      lastNodeTag = treeWalker.currentNode.nodeName;
    } else {
      if (extractedText && !noSpaceTags.has(lastNodeTag)) {
        extractedText += " ";
      }
      extractedText += treeWalker.currentNode.nodeValue;
      if (extractedText.length > SNIPPET_MAX_SIZE) {
        break;
      }
    }
  }
  const snippetText = extractedText.trim();

  // clean up and trim snippet
  let trimmed = snippetText.replace(/[\n\r]/g, ' ').replace(/\s\s+/g, ' ').substr(0, SNIPPET_MAX_SIZE);
  if (trimmed) {
    // TODO: strip quoted text from snippets also
    // trim down to approx. SNIPPET_SIZE w/out cutting off words right in the
    // middle (if possible)
    const wordBreak = trimmed.indexOf(' ', SNIPPET_SIZE);
    if (wordBreak !== -1) {
      trimmed = trimmed.substr(0, wordBreak);
    }
  }
  return trimmed;
}

// Preserve whitespacing on plaintext emails -- has the side effect of
// monospacing, but that seems OK and perhaps sometimes even desired (for e.g.
// ascii art, alignment)
function htmlifyPlaintext(text) {
  const escapedText = he.escape(text);
  return `<pre class="nylas-plaintext">${escapedText}</pre>`;
}

function replaceMessageIdInBodyTrackingLinks(messageId, originalBody) {
  const regex = new RegExp(`(${N1CloudAPI.APIRoot}.+?)MESSAGE_ID`, 'g')
  return originalBody.replace(regex, `$1${messageId}`)
}

function stripTrackingLinksFromBody(originalBody) {
  let body = originalBody.replace(/<img class="n1-open"[^<]+src="([a-zA-Z0-9-_:/.]*)">/g, () => {
    return "";
  });
  body = body.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
    const param = url.split("?")[1];
    if (param) {
      const link = decodeURIComponent(param.split("=")[1]);
      return `${prefix}${link}${suffix}${content}${closingTag}`;
    }
    return match;
  });
  return body;
}

function buildTrackingBodyForRecipient({baseMessage, recipient, usesOpenTracking, usesLinkTracking} = {}) {
  const {id: messageId, body} = baseMessage
  const encodedEmail = btoa(recipient.email)
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
  let customBody = body
  if (usesOpenTracking) {
    customBody = customBody.replace(/<img class="n1-open"[^<]+src="([a-zA-Z0-9-_:/.]*)">/g, (match, url) => {
      return `<img class="n1-open" width="0" height="0" style="border:0; width:0; height:0;" src="${url}?r=${encodedEmail}">`;
    });
  }
  if (usesLinkTracking) {
    customBody = customBody.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
      return `${prefix}${url}&r=${encodedEmail}${suffix}${content}${closingTag}`;
    });
  }
  return replaceMessageIdInBodyTrackingLinks(messageId, customBody);
}

function getReplyHeaders(messageReplyingTo) {
  let inReplyTo;
  let references;
  if (messageReplyingTo.headerMessageId) {
    inReplyTo = messageReplyingTo.headerMessageId;
    if (messageReplyingTo.references) {
      references = messageReplyingTo.references.concat(messageReplyingTo.headerMessageId);
    } else {
      references = [messageReplyingTo.headerMessageId];
    }
  }
  return {inReplyTo, references}
}

function bodyFromParts(imapMessage, desiredParts) {
  let body = '';
  for (const {id, mimeType, transferEncoding, charset} of desiredParts) {
    let decoded = '';
    // see https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html
    if (!transferEncoding || new Set(['7bit', '8bit', 'binary']).has(transferEncoding.toLowerCase())) {
      // NO transfer encoding has been performed --- how to decode to a string
      // depends ONLY on the charset, which defaults to 'ascii' according to
      // https://tools.ietf.org/html/rfc2045#section-5.2
      decoded = encoding.convert(imapMessage.parts[id], 'utf-8', charset || 'ascii').toString('utf-8');
    } else if (transferEncoding.toLowerCase() === 'quoted-printable') {
      decoded = mimelib.decodeQuotedPrintable(imapMessage.parts[id], charset || 'ascii');
    } else if (transferEncoding.toLowerCase() === 'base64') {
      decoded = mimelib.decodeBase64(imapMessage.parts[id], charset || 'ascii');
    } else {
      // custom x-token content-transfer-encodings
      return Promise.reject(new Error(`Unsupported Content-Transfer-Encoding ${transferEncoding}, mimetype ${mimeType}`))
    }
    // desiredParts are in order of the MIME tree walk, e.g. 1.1, 1.2, 2...,
    // and for multipart/alternative arrays, we have already pulled out the
    // highest fidelity part (generally HTML).
    //
    // Therefore, the correct way to display multiple parts is to simply
    // concatenate later ones with the body of the previous MIME parts.
    //
    // This may seem kind of weird, but some MUAs _do_ send out whack stuff
    // like an HTML body followed by a plaintext footer.
    if (mimeType === 'text/plain') {
      body += htmlifyPlaintext(decoded);
    } else {
      body += decoded;
    }
  }
  // sometimes decoding results in a NUL-terminated body string, which makes
  // SQLite blow up with an 'unrecognized token' error
  body = body.replace(/\0/g, '');

  return body;
}

// Since we only fetch the MIME structure and specific desired MIME parts from
// IMAP, we unfortunately can't use an existing library like mailparser to parse
// the message, and have to do fun stuff like deal with character sets and
// content-transfer-encodings ourselves.
async function parseFromImap(imapMessage, desiredParts, {db, accountId, folder}) {
  const {Message, Label} = db;
  const {attributes} = imapMessage;

  // this key name can change depending on which subset of headers we're downloading,
  // so to prevent having to update this code every time we change the set,
  // dynamically look up the key instead
  const headerKey = Object.keys(imapMessage.parts).filter(k => k.startsWith('HEADER'))[0]
  const headers = imapMessage.parts[headerKey].toString('ascii')
  const parsedHeaders = mimelib.parseHeaders(headers);
  for (const key of ['x-gm-thrid', 'x-gm-msgid', 'x-gm-labels']) {
    parsedHeaders[key] = attributes[key];
  }

  const parsedMessage = {
    to: computeContacts(parsedHeaders.to),
    cc: computeContacts(parsedHeaders.cc),
    bcc: computeContacts(parsedHeaders.bcc),
    from: computeContacts(parsedHeaders.from),
    replyTo: computeContacts(parsedHeaders['reply-to']),
    accountId: accountId,
    body: bodyFromParts(imapMessage, desiredParts),
    snippet: null,
    unread: !attributes.flags.includes('\\Seen'),
    starred: attributes.flags.includes('\\Flagged'),
    // Make sure we use the date from the headers because we use the header date
    // for generating message ids.
    // `attributes.date` is the server generated date and might differ from the
    // header across accounts
    // TODO: how to exclude the date header from the hash if there is no
    // Date: header and we have to use the IMAP server date for message sort
    // & display? seems like it should be OK within an account, but might
    // generate different message IDs across different accounts (which I
    // don't think is a problem we're intending to solve...)
    date: parsedHeaders.date ? parsedHeaders.date[0] : imapMessage.attributes.date,
    folderImapUID: attributes.uid,
    folderId: folder.id,
    folder: null,
    labels: [],
    headerMessageId: parsedHeaders['message-id'] ? parsedHeaders['message-id'][0] : '',
    gMsgId: parsedHeaders['x-gm-msgid'],
    gThrId: parsedHeaders['x-gm-thrid'],
    subject: parsedHeaders.subject ? parsedHeaders.subject[0] : '(no subject)',
  }
  // Inversely to `buildForSend`, we leave the date header as it is so that the
  // format is consistent for the generative IDs, then convert it to a Date object
  parsedMessage.id = Message.hash(parsedMessage)
  parsedMessage.date = new Date(Date.parse(parsedMessage.date))

  parsedMessage.snippet = computeSnippet(parsedMessage.body);
  parsedMessage.folder = folder;

  // TODO: unclear if this is necessary given we already have parsed labels
  const xGmLabels = attributes['x-gm-labels']
  if (xGmLabels) {
    parsedMessage.folderImapXGMLabels = JSON.stringify(xGmLabels)
    parsedMessage.labels = await Label.findXGMLabels(xGmLabels)
  }

  if (process.env.NYLAS_DEBUG) {
    const outJSON = JSON.stringify({imapMessage, desiredParts, result: parsedMessage});
    const outDir = path.join(os.tmpdir(), "k2-parse-output", folder.name)
    const outFile = path.join(outDir, imapMessage.attributes.uid.toString());
    mkdirp.sync(outDir);
    fs.writeFileSync(outFile, outJSON);
  }

  return parsedMessage;
}

async function buildForSend(db, json) {
  const {Thread, Message} = db
  let replyToThread;
  let replyToMessage;

  if (json.thread_id != null) {
    replyToThread = await Thread.find({
      where: {id: json.thread_id},
      include: [{
        model: Message,
        as: 'messages',
        attributes: ['id'],
      }],
    });
  }

  if (json.reply_to_message_id != null) {
    replyToMessage = await Message.findById(json.reply_to_message_id);
  }

  if (replyToThread && replyToMessage) {
    if (!replyToThread.messages.find((msg) => msg.id === replyToMessage.id)) {
      throw new APIError(`Message ${replyToMessage.id} is not in thread ${replyToThread.id}`, 400)
    }
  }

  let thread;
  let replyHeaders = {};
  if (replyToMessage) {
    replyHeaders = getReplyHeaders(replyToMessage);
    thread = await replyToMessage.getThread();
  } else if (replyToThread) {
    thread = replyToThread;
    const previousMessages = thread.messages.filter(msg => !msg.isDraft);
    if (previousMessages.length > 0) {
      const lastMessage = previousMessages[previousMessages.length - 1]
      replyHeaders = getReplyHeaders(lastMessage);
    }
  }

  const {inReplyTo, references} = replyHeaders
  const date = new Date()
  const message = {
    accountId: json.account_id,
    threadId: thread ? thread.id : null,
    headerMessageId: Message.buildHeaderMessageId(json.client_id),
    from: json.from,
    to: json.to,
    cc: json.cc,
    bcc: json.bcc,
    references,
    inReplyTo,
    replyTo: json.reply_to,
    subject: json.subject,
    body: json.body,
    unread: true,
    isDraft: json.draft,
    isSent: false,
    version: 0,
    date: date,
    uploads: json.uploads,
  }
  // We have to clone the message and change the date for hashing because the
  // date we get later when we parse from IMAP is a different format, per the
  // nodemailer buildmail function that gives us the raw message and replaces
  // the date header with this modified UTC string
  // https://github.com/nodemailer/buildmail/blob/master/lib/buildmail.js#L470
  const messageForHashing = Utils.deepClone(message)
  messageForHashing.date = date.toUTCString().replace(/GMT/, '+0000')
  message.id = Message.hash(messageForHashing)
  message.body = replaceMessageIdInBodyTrackingLinks(message.id, message.body)
  return Message.build(message)
}

module.exports = {
  buildForSend,
  parseFromImap,
  computeSnippet,
  computeContacts,
  stripTrackingLinksFromBody,
  buildTrackingBodyForRecipient,
  replaceMessageIdInBodyTrackingLinks,
}
<commit_msg>[local-sync] Make comment about Date/INTERNALDATE & message hashes better<commit_after>/* eslint no-useless-escape: 0 */
const mimelib = require('mimelib');
const encoding = require('encoding');
const he = require('he');
const os = require('os');
const fs = require('fs');
const path = require('path');
const mkdirp = require('mkdirp');
const {Errors: {APIError}} = require('isomorphic-core');
const {N1CloudAPI, RegExpUtils, Utils} = require('nylas-exports');

// Aiming for the former in length, but the latter is the hard db cutoff
const SNIPPET_SIZE = 100;
const SNIPPET_MAX_SIZE = 255;

// The input is the value of a to/cc/bcc/from header as parsed by the imap
// library we're using, but it currently parses them in a weird format. If an
// email is sent to a@example.com and b@example.com, the parsed output of the
// 'to' header is ['a@example.com, b@example.com']. (Note both emails are in
// the same string.) When fixed, this function will need to update accordingly.
function computeContacts(input) {
  if (!input || input.length === 0 || !input[0]) {
    return [];
  }
  const values = mimelib.parseAddresses(input[0]);
  if (!values || values.length === 0 || !input[0]) {
    return [];
  }
  return values.map(v => {
    if (!v || v.length === 0) {
      return null
    }
    const {name, address: email} = v;
    return {name, email};
  })
  .filter(c => c != null)
}

function computeSnippet(body) {
  const doc = new DOMParser().parseFromString(body, 'text/html')
  const skipTags = new Set(['TITLE', 'SCRIPT', 'STYLE', 'IMG']);
  const noSpaceTags = new Set(['B', 'I', 'STRONG', 'EM', 'SPAN']);

  const treeWalker = document.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => {
    if (skipTags.has(node.tagName)) {
      // skip this node and all its children
      return NodeFilter.FILTER_REJECT;
    }
    if (node.nodeType === Node.TEXT_NODE) {
      const nodeValue = node.nodeValue ? node.nodeValue.trim() : null;
      if (nodeValue) {
        return NodeFilter.FILTER_ACCEPT;
      }
      return NodeFilter.FILTER_SKIP;
    }
    return NodeFilter.FILTER_ACCEPT;
  });

  let extractedText = "";
  let lastNodeTag = "";
  while (treeWalker.nextNode()) {
    if (treeWalker.currentNode.nodeType === Node.ELEMENT_NODE) {
      lastNodeTag = treeWalker.currentNode.nodeName;
    } else {
      if (extractedText && !noSpaceTags.has(lastNodeTag)) {
        extractedText += " ";
      }
      extractedText += treeWalker.currentNode.nodeValue;
      if (extractedText.length > SNIPPET_MAX_SIZE) {
        break;
      }
    }
  }
  const snippetText = extractedText.trim();

  // clean up and trim snippet
  let trimmed = snippetText.replace(/[\n\r]/g, ' ').replace(/\s\s+/g, ' ').substr(0, SNIPPET_MAX_SIZE);
  if (trimmed) {
    // TODO: strip quoted text from snippets also
    // trim down to approx. SNIPPET_SIZE w/out cutting off words right in the
    // middle (if possible)
    const wordBreak = trimmed.indexOf(' ', SNIPPET_SIZE);
    if (wordBreak !== -1) {
      trimmed = trimmed.substr(0, wordBreak);
    }
  }
  return trimmed;
}

// Preserve whitespacing on plaintext emails -- has the side effect of
// monospacing, but that seems OK and perhaps sometimes even desired (for e.g.
// ascii art, alignment)
function htmlifyPlaintext(text) {
  const escapedText = he.escape(text);
  return `<pre class="nylas-plaintext">${escapedText}</pre>`;
}

function replaceMessageIdInBodyTrackingLinks(messageId, originalBody) {
  const regex = new RegExp(`(${N1CloudAPI.APIRoot}.+?)MESSAGE_ID`, 'g')
  return originalBody.replace(regex, `$1${messageId}`)
}

function stripTrackingLinksFromBody(originalBody) {
  let body = originalBody.replace(/<img class="n1-open"[^<]+src="([a-zA-Z0-9-_:/.]*)">/g, () => {
    return "";
  });
  body = body.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
    const param = url.split("?")[1];
    if (param) {
      const link = decodeURIComponent(param.split("=")[1]);
      return `${prefix}${link}${suffix}${content}${closingTag}`;
    }
    return match;
  });
  return body;
}

function buildTrackingBodyForRecipient({baseMessage, recipient, usesOpenTracking, usesLinkTracking} = {}) {
  const {id: messageId, body} = baseMessage
  const encodedEmail = btoa(recipient.email)
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
  let customBody = body
  if (usesOpenTracking) {
    customBody = customBody.replace(/<img class="n1-open"[^<]+src="([a-zA-Z0-9-_:/.]*)">/g, (match, url) => {
      return `<img class="n1-open" width="0" height="0" style="border:0; width:0; height:0;" src="${url}?r=${encodedEmail}">`;
    });
  }
  if (usesLinkTracking) {
    customBody = customBody.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
      return `${prefix}${url}&r=${encodedEmail}${suffix}${content}${closingTag}`;
    });
  }
  return replaceMessageIdInBodyTrackingLinks(messageId, customBody);
}

function getReplyHeaders(messageReplyingTo) {
  let inReplyTo;
  let references;
  if (messageReplyingTo.headerMessageId) {
    inReplyTo = messageReplyingTo.headerMessageId;
    if (messageReplyingTo.references) {
      references = messageReplyingTo.references.concat(messageReplyingTo.headerMessageId);
    } else {
      references = [messageReplyingTo.headerMessageId];
    }
  }
  return {inReplyTo, references}
}

function bodyFromParts(imapMessage, desiredParts) {
  let body = '';
  for (const {id, mimeType, transferEncoding, charset} of desiredParts) {
    let decoded = '';
    // see https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html
    if (!transferEncoding || new Set(['7bit', '8bit', 'binary']).has(transferEncoding.toLowerCase())) {
      // NO transfer encoding has been performed --- how to decode to a string
      // depends ONLY on the charset, which defaults to 'ascii' according to
      // https://tools.ietf.org/html/rfc2045#section-5.2
      decoded = encoding.convert(imapMessage.parts[id], 'utf-8', charset || 'ascii').toString('utf-8');
    } else if (transferEncoding.toLowerCase() === 'quoted-printable') {
      decoded = mimelib.decodeQuotedPrintable(imapMessage.parts[id], charset || 'ascii');
    } else if (transferEncoding.toLowerCase() === 'base64') {
      decoded = mimelib.decodeBase64(imapMessage.parts[id], charset || 'ascii');
    } else {
      // custom x-token content-transfer-encodings
      return Promise.reject(new Error(`Unsupported Content-Transfer-Encoding ${transferEncoding}, mimetype ${mimeType}`))
    }
    // desiredParts are in order of the MIME tree walk, e.g. 1.1, 1.2, 2...,
    // and for multipart/alternative arrays, we have already pulled out the
    // highest fidelity part (generally HTML).
    //
    // Therefore, the correct way to display multiple parts is to simply
    // concatenate later ones with the body of the previous MIME parts.
    //
    // This may seem kind of weird, but some MUAs _do_ send out whack stuff
    // like an HTML body followed by a plaintext footer.
    if (mimeType === 'text/plain') {
      body += htmlifyPlaintext(decoded);
    } else {
      body += decoded;
    }
  }
  // sometimes decoding results in a NUL-terminated body string, which makes
  // SQLite blow up with an 'unrecognized token' error
  body = body.replace(/\0/g, '');

  return body;
}

// Since we only fetch the MIME structure and specific desired MIME parts from
// IMAP, we unfortunately can't use an existing library like mailparser to parse
// the message, and have to do fun stuff like deal with character sets and
// content-transfer-encodings ourselves.
async function parseFromImap(imapMessage, desiredParts, {db, accountId, folder}) {
  const {Message, Label} = db;
  const {attributes} = imapMessage;

  // this key name can change depending on which subset of headers we're downloading,
  // so to prevent having to update this code every time we change the set,
  // dynamically look up the key instead
  const headerKey = Object.keys(imapMessage.parts).filter(k => k.startsWith('HEADER'))[0]
  const headers = imapMessage.parts[headerKey].toString('ascii')
  const parsedHeaders = mimelib.parseHeaders(headers);
  for (const key of ['x-gm-thrid', 'x-gm-msgid', 'x-gm-labels']) {
    parsedHeaders[key] = attributes[key];
  }

  const parsedMessage = {
    to: computeContacts(parsedHeaders.to),
    cc: computeContacts(parsedHeaders.cc),
    bcc: computeContacts(parsedHeaders.bcc),
    from: computeContacts(parsedHeaders.from),
    replyTo: computeContacts(parsedHeaders['reply-to']),
    accountId: accountId,
    body: bodyFromParts(imapMessage, desiredParts),
    snippet: null,
    unread: !attributes.flags.includes('\\Seen'),
    starred: attributes.flags.includes('\\Flagged'),
    // We prefer the date from the message headers because the date is one of
    // the fields we use for generating unique message IDs, and the server
    // INTERNALDATE, `attributes.date`, may differ across accounts for the same
    // message. If the Date header is not included in the message, we fall
    // back to the INTERNALDATE and it's possible we'll generate different IDs
    // for the same message delivered to different accounts (which is better
    // than having message ID collisions for different messages, which could
    // happen if we did not include the date).
    date: parsedHeaders.date ? parsedHeaders.date[0] : imapMessage.attributes.date,
    folderImapUID: attributes.uid,
    folderId: folder.id,
    folder: null,
    labels: [],
    headerMessageId: parsedHeaders['message-id'] ? parsedHeaders['message-id'][0] : '',
    gMsgId: parsedHeaders['x-gm-msgid'],
    gThrId: parsedHeaders['x-gm-thrid'],
    subject: parsedHeaders.subject ? parsedHeaders.subject[0] : '(no subject)',
  }
  // Inversely to `buildForSend`, we leave the date header as it is so that the
  // format is consistent for the generative IDs, then convert it to a Date object
  parsedMessage.id = Message.hash(parsedMessage)
  parsedMessage.date = new Date(Date.parse(parsedMessage.date))

  parsedMessage.snippet = computeSnippet(parsedMessage.body);
  parsedMessage.folder = folder;

  // TODO: unclear if this is necessary given we already have parsed labels
  const xGmLabels = attributes['x-gm-labels']
  if (xGmLabels) {
    parsedMessage.folderImapXGMLabels = JSON.stringify(xGmLabels)
    parsedMessage.labels = await Label.findXGMLabels(xGmLabels)
  }

  if (process.env.NYLAS_DEBUG) {
    const outJSON = JSON.stringify({imapMessage, desiredParts, result: parsedMessage});
    const outDir = path.join(os.tmpdir(), "k2-parse-output", folder.name)
    const outFile = path.join(outDir, imapMessage.attributes.uid.toString());
    mkdirp.sync(outDir);
    fs.writeFileSync(outFile, outJSON);
  }

  return parsedMessage;
}

async function buildForSend(db, json) {
  const {Thread, Message} = db
  let replyToThread;
  let replyToMessage;

  if (json.thread_id != null) {
    replyToThread = await Thread.find({
      where: {id: json.thread_id},
      include: [{
        model: Message,
        as: 'messages',
        attributes: ['id'],
      }],
    });
  }

  if (json.reply_to_message_id != null) {
    replyToMessage = await Message.findById(json.reply_to_message_id);
  }

  if (replyToThread && replyToMessage) {
    if (!replyToThread.messages.find((msg) => msg.id === replyToMessage.id)) {
      throw new APIError(`Message ${replyToMessage.id} is not in thread ${replyToThread.id}`, 400)
    }
  }

  let thread;
  let replyHeaders = {};
  if (replyToMessage) {
    replyHeaders = getReplyHeaders(replyToMessage);
    thread = await replyToMessage.getThread();
  } else if (replyToThread) {
    thread = replyToThread;
    const previousMessages = thread.messages.filter(msg => !msg.isDraft);
    if (previousMessages.length > 0) {
      const lastMessage = previousMessages[previousMessages.length - 1]
      replyHeaders = getReplyHeaders(lastMessage);
    }
  }

  const {inReplyTo, references} = replyHeaders
  const date = new Date()
  const message = {
    accountId: json.account_id,
    threadId: thread ? thread.id : null,
    headerMessageId: Message.buildHeaderMessageId(json.client_id),
    from: json.from,
    to: json.to,
    cc: json.cc,
    bcc: json.bcc,
    references,
    inReplyTo,
    replyTo: json.reply_to,
    subject: json.subject,
    body: json.body,
    unread: true,
    isDraft: json.draft,
    isSent: false,
    version: 0,
    date: date,
    uploads: json.uploads,
  }
  // We have to clone the message and change the date for hashing because the
  // date we get later when we parse from IMAP is a different format, per the
  // nodemailer buildmail function that gives us the raw message and replaces
  // the date header with this modified UTC string
  // https://github.com/nodemailer/buildmail/blob/master/lib/buildmail.js#L470
  const messageForHashing = Utils.deepClone(message)
  messageForHashing.date = date.toUTCString().replace(/GMT/, '+0000')
  message.id = Message.hash(messageForHashing)
  message.body = replaceMessageIdInBodyTrackingLinks(message.id, message.body)
  return Message.build(message)
}

module.exports = {
  buildForSend,
  parseFromImap,
  computeSnippet,
  computeContacts,
  stripTrackingLinksFromBody,
  buildTrackingBodyForRecipient,
  replaceMessageIdInBodyTrackingLinks,
}
<|endoftext|>


<commit_before>/*
 * Copyright (c) 2015, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 * WSO2 Inc. licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

function onRequest(context) {
    // var log = new Log("device-view.js");
    var deviceType = context["uriParams"]["deviceType"];
    var deviceId = request.getParameter("id");
    var deviceViewData = {};

    if (deviceType && deviceId) {
        var deviceModule = require("/app/modules/business-controllers/device.js")["deviceModule"];
        var response = deviceModule.viewDevice(deviceType, deviceId);
        if (response["status"] == "success") {
            deviceViewData["deviceFound"] = true;
            deviceViewData["isAuthorized"] = true;

            var filteredDeviceData = response["content"];

            // creating deviceView information model from filtered device data
            var viewModel = {};
            if (filteredDeviceData["type"]) {
                viewModel["type"] = filteredDeviceData["type"];
                viewModel.isNotWindows = true;
            }
            if (filteredDeviceData["deviceIdentifier"]) {
                viewModel["deviceIdentifier"] = filteredDeviceData["deviceIdentifier"];
            }
            if (filteredDeviceData["name"]) {
                viewModel["name"] = filteredDeviceData["name"];
            }
            if (filteredDeviceData["enrolmentInfo"]) {
                if (filteredDeviceData["enrolmentInfo"]["status"]) {
                    viewModel["status"] = filteredDeviceData["enrolmentInfo"]["status"];
                    viewModel.isActive = false ;
                    viewModel.isNotRemoved = true;
                    if (filteredDeviceData["enrolmentInfo"]["status"]== "ACTIVE") {
                        viewModel.isActive = true ;
                    }
                    if (filteredDeviceData["enrolmentInfo"]["status"]== "REMOVED") {
                        viewModel.isNotRemoved = false ;
                    }
                }
                if (filteredDeviceData["enrolmentInfo"]["owner"]) {
                    viewModel["owner"] = filteredDeviceData["enrolmentInfo"]["owner"];
                }
                if (filteredDeviceData["enrolmentInfo"]["ownership"]) {
                    viewModel["ownership"] = filteredDeviceData["enrolmentInfo"]["ownership"];
                }
            }
            if (filteredDeviceData["latestDeviceInfo"]) {
                viewModel["deviceInfoAvailable"] = true;
                if (filteredDeviceData["latestDeviceInfo"]["osBuildDate"]) {
                    if (filteredDeviceData["latestDeviceInfo"]["osBuildDate"] != "0") {
                        viewModel["osBuildDate"] = new Date(filteredDeviceData["latestDeviceInfo"]["osBuildDate"] * 1000);
                    }
                }
                if (filteredDeviceData["latestDeviceInfo"]["location"]["latitude"] &&
                    filteredDeviceData["latestDeviceInfo"]["location"]["longitude"]) {
                    viewModel["location"] = {};
                    viewModel["location"]["latitude"] = filteredDeviceData["latestDeviceInfo"]["location"]["latitude"];
                    viewModel["location"]["longitude"] = filteredDeviceData["latestDeviceInfo"]["location"]["longitude"];
                    viewModel["location"]["updatedTime"] = filteredDeviceData["latestDeviceInfo"]["location"]["updatedTime"];
                }
                if (filteredDeviceData["latestDeviceInfo"]["vendor"] && filteredDeviceData["latestDeviceInfo"]["deviceModel"]) {
                    viewModel["vendor"] = filteredDeviceData["latestDeviceInfo"]["vendor"];
                    viewModel["model"] = filteredDeviceData["latestDeviceInfo"]["deviceModel"];
                }
                if (filteredDeviceData["latestDeviceInfo"]["updatedTime"]) {
                    viewModel["lastUpdatedTime"] = filteredDeviceData["latestDeviceInfo"]["updatedTime"].
                        substr(0, filteredDeviceData["latestDeviceInfo"]["updatedTime"].indexOf("+"));
                }
                viewModel["BatteryLevel"] = {};
                viewModel["BatteryLevel"]["value"] = filteredDeviceData["latestDeviceInfo"]["batteryLevel"];

                viewModel["ramUsage"] = {};
                viewModel["ramUsage"]["value"] = (filteredDeviceData["latestDeviceInfo"]["availableRAMMemory"]);

                viewModel["internalMemory"] = {};
                viewModel["internalMemory"]["value"] = Math.round((filteredDeviceData["latestDeviceInfo"]["internalAvailableMemory"]) / 1024);

            }
            if (!filteredDeviceData["initialDeviceInfo"] && !filteredDeviceData["latestDeviceInfo"]) {
                viewModel["deviceInfoAvailable"] = false;
            }
            viewModel.locationHistory = stringify(filteredDeviceData["locationHistory"]);
            deviceViewData["device"] = viewModel;
        } else if (response["status"] == "unauthorized") {
            deviceViewData["deviceFound"] = true;
            deviceViewData["isAuthorized"] = false;
        } else if (response["status"] == "notFound") {
            deviceViewData["deviceFound"] = false;
        }
    } else {
        deviceViewData["deviceFound"] = false;
    }

    var autoCompleteParams = [
        {"name" : "deviceId", "value" : deviceId}
    ];

    var userModule = require("/app/modules/business-controllers/user.js")["userModule"];
    var permissions = userModule.getUIPermissions();
    deviceViewData["autoCompleteParams"] = autoCompleteParams;
    deviceViewData["permissions"] = permissions;
    return deviceViewData;
}<commit_msg>Fix for windows NaN value<commit_after>/*
 * Copyright (c) 2015, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 * WSO2 Inc. licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

function onRequest(context) {
    // var log = new Log("device-view.js");
    var deviceType = context["uriParams"]["deviceType"];
    var deviceId = request.getParameter("id");
    var deviceViewData = {};

    if (deviceType && deviceId) {
        var deviceModule = require("/app/modules/business-controllers/device.js")["deviceModule"];
        var response = deviceModule.viewDevice(deviceType, deviceId);
        if (response["status"] == "success") {
            deviceViewData["deviceFound"] = true;
            deviceViewData["isAuthorized"] = true;

            var filteredDeviceData = response["content"];

            // creating deviceView information model from filtered device data
            var viewModel = {};
            if (filteredDeviceData["type"]) {
                viewModel["type"] = filteredDeviceData["type"];
                viewModel.isNotWindows = true;
            }
            if (filteredDeviceData["deviceIdentifier"]) {
                viewModel["deviceIdentifier"] = filteredDeviceData["deviceIdentifier"];
            }
            if (filteredDeviceData["name"]) {
                viewModel["name"] = filteredDeviceData["name"];
            }
            if (filteredDeviceData["enrolmentInfo"]) {
                if (filteredDeviceData["enrolmentInfo"]["status"]) {
                    viewModel["status"] = filteredDeviceData["enrolmentInfo"]["status"];
                    viewModel.isActive = false ;
                    viewModel.isNotRemoved = true;
                    if (filteredDeviceData["enrolmentInfo"]["status"]== "ACTIVE") {
                        viewModel.isActive = true ;
                    }
                    if (filteredDeviceData["enrolmentInfo"]["status"]== "REMOVED") {
                        viewModel.isNotRemoved = false ;
                    }
                }
                if (filteredDeviceData["enrolmentInfo"]["owner"]) {
                    viewModel["owner"] = filteredDeviceData["enrolmentInfo"]["owner"];
                }
                if (filteredDeviceData["enrolmentInfo"]["ownership"]) {
                    viewModel["ownership"] = filteredDeviceData["enrolmentInfo"]["ownership"];
                }
            }
            if (filteredDeviceData["latestDeviceInfo"]) {
                viewModel["deviceInfoAvailable"] = true;
                if (filteredDeviceData["latestDeviceInfo"]["osBuildDate"]) {
                    if (filteredDeviceData["latestDeviceInfo"]["osBuildDate"] != "0") {
                        viewModel["osBuildDate"] = new Date(filteredDeviceData["latestDeviceInfo"]["osBuildDate"] * 1000);
                    }
                }
                if (filteredDeviceData["latestDeviceInfo"]["location"]["latitude"] &&
                    filteredDeviceData["latestDeviceInfo"]["location"]["longitude"]) {
                    viewModel["location"] = {};
                    viewModel["location"]["latitude"] = filteredDeviceData["latestDeviceInfo"]["location"]["latitude"];
                    viewModel["location"]["longitude"] = filteredDeviceData["latestDeviceInfo"]["location"]["longitude"];
                    viewModel["location"]["updatedTime"] = filteredDeviceData["latestDeviceInfo"]["location"]["updatedTime"];
                }
                if (filteredDeviceData["latestDeviceInfo"]["vendor"] && filteredDeviceData["latestDeviceInfo"]["deviceModel"]) {
                    viewModel["vendor"] = filteredDeviceData["latestDeviceInfo"]["vendor"];
                    viewModel["model"] = filteredDeviceData["latestDeviceInfo"]["deviceModel"];
                }
                if (filteredDeviceData["latestDeviceInfo"]["updatedTime"]) {
                    viewModel["lastUpdatedTime"] = filteredDeviceData["latestDeviceInfo"]["updatedTime"].
                        substr(0, filteredDeviceData["latestDeviceInfo"]["updatedTime"].indexOf("+"));
                }
                viewModel["BatteryLevel"] = {};
                viewModel["BatteryLevel"]["value"] = filteredDeviceData["latestDeviceInfo"]["batteryLevel"];

                viewModel["ramUsage"] = {};
                viewModel["ramUsage"]["value"] = (filteredDeviceData["latestDeviceInfo"]["availableRAMMemory"]);

                viewModel["internalMemory"] = {};
                viewModel["internalMemory"]["value"] = replaceNaNVal(Math.round((filteredDeviceData["latestDeviceInfo"]["internalAvailableMemory"]) / 1024));

            }
            if (!filteredDeviceData["initialDeviceInfo"] && !filteredDeviceData["latestDeviceInfo"]) {
                viewModel["deviceInfoAvailable"] = false;
            }
            viewModel.locationHistory = stringify(filteredDeviceData["locationHistory"]);
            deviceViewData["device"] = viewModel;
        } else if (response["status"] == "unauthorized") {
            deviceViewData["deviceFound"] = true;
            deviceViewData["isAuthorized"] = false;
        } else if (response["status"] == "notFound") {
            deviceViewData["deviceFound"] = false;
        }
    } else {
        deviceViewData["deviceFound"] = false;
    }

    var autoCompleteParams = [
        {"name" : "deviceId", "value" : deviceId}
    ];

    var userModule = require("/app/modules/business-controllers/user.js")["userModule"];
    var permissions = userModule.getUIPermissions();
    deviceViewData["autoCompleteParams"] = autoCompleteParams;
    deviceViewData["permissions"] = permissions;
    return deviceViewData;

    function replaceNaNVal(val) {
        if (isNaN(val)) {
            return "N/A";
        }
        return val;
    }
}<|endoftext|>


<commit_before>// ==UserScript==
// @name        WaniKani: I Don't know button
// @namespace   wk-wakaran
// @version     1.0
// @author Niklas Barsk
// @description Adds an "I don't know the answer" button to reviews. When clicked it will submit an incorrect answer.
// @include     https://www.wanikani.com/review/session*
// @run-at      document-end
// @updateURL https://raw.githubusercontent.com/nibarius/wk-wakaran/master/wk-wakaran.user.js
// ==/UserScript==

function onDontKnowClick(e) {
    if (isMeaning()) {
    	document.getElementById("user-response").value="I have no clue T_T";
    }
    else {
        document.getElementById("user-response").value="えぇぇ！。。。さっぱりわからないぃぃぃ。・ﾟﾟ･（≧д≦）･ﾟﾟ･｡";
    }
}

function isMeaning() {
    return document.getElementById("question-type").className == "meaning";
}

function addDontKnowButton() {
  	var dontKnowButton = document.createElement("b");
  	dontKnowButton.innerHTML = "?";
  	dontKnowButton.setAttribute("style", "margin-left: 0.5em");
    dontKnowButton.onclick = onDontKnowClick;
  	document.getElementById("answer-form").getElementsByTagName("button")[0].appendChild(dontKnowButton);
}

addDontKnowButton();
<commit_msg>Improve English message.<commit_after>// ==UserScript==
// @name        WaniKani: I Don't know button
// @namespace   wk-wakaran
// @version     1.0
// @author Niklas Barsk
// @description Adds an "I don't know the answer" button to reviews. When clicked it will submit an incorrect answer.
// @include     https://www.wanikani.com/review/session*
// @run-at      document-end
// @updateURL https://raw.githubusercontent.com/nibarius/wk-wakaran/master/wk-wakaran.user.js
// ==/UserScript==

function onDontKnowClick(e) {
    if (isMeaning()) {
        document.getElementById("user-response").value="Aargh! What does that even mean? (╯°□°)╯︵ ┻━┻";
    }
    else {
        document.getElementById("user-response").value="えぇぇ！。。。さっぱりわからないぃぃぃ。・ﾟﾟ･（≧д≦）･ﾟﾟ･｡";
    }
}

function isMeaning() {
    return document.getElementById("question-type").className == "meaning";
}

function addDontKnowButton() {
  	var dontKnowButton = document.createElement("b");
  	dontKnowButton.innerHTML = "?";
  	dontKnowButton.setAttribute("style", "margin-left: 0.5em");
    dontKnowButton.onclick = onDontKnowClick;
  	document.getElementById("answer-form").getElementsByTagName("button")[0].appendChild(dontKnowButton);
}

addDontKnowButton();
<|endoftext|>


<commit_before>
/** Note: This file doesn't contain any unit tests. It has utility functions and
 * logic for performing common things required in unit tests. An example is
 * creating a level and adding a list of actors automatically there.
 */

const RG = require('../client/src/battles');
const expect = require('chai').expect;
const Screen = require('../client/gui/screen');

const RGTest = {};

RGTest.rng = new RG.Random();

/* Creates a mock-level for unit tests. */
RGTest.createMockLevel = function(cols, rows) {
    const level = {cols: cols, rows: rows,
        map: {
            getCell: function(x, y) {
                return { x: x, y: y };
            },
            hasXY: function() {
                return true;
            },
            isPassable: function(x, y) {
                return x > -1 && y > -1;
            }
        },
        getMap: function() {return this.map;},

        addActor: function(actor, x, y) {
            actor.setXY(x, y);
            actor.setLevel(this);
        }
    };
    return level;
};

RGTest.createLevel = function(type, cols, rows) {
    return RG.FACT.createLevel('arena', cols, rows);
};

RGTest.equipItem = function(actor, item) {
    const invEq = actor.getInvEq();
    invEq.addItem(item);
    expect(invEq.equipItem(item)).to.equal(true);
};

/* Wraps an object into a cell for later use. Some functions require a map cell
* instead of taking the object directly, so this is useful. */
RGTest.wrapObjWithCell = function(obj) {
    const cell = RG.FACT.createFloorCell();
    cell.setExplored(true); // Otherwise returns darkness
    const propType = obj.getPropType();
    cell.setProp(propType, obj);
    return cell;
};

RGTest.getMeAWizard = function(conf = {}) {
    const wizard = new RG.Actor.Rogue('wizard');
    wizard.setType(conf.type || 'human');
    const brain = new RG.Brain.SpellCaster(wizard);
    brain.setCastProbability(1.1);
    wizard.setBrain(brain);

    wizard._spellbook = new RG.Spell.SpellBook(wizard);
    const spell = RG.FACT.createSpell('FrostBolt');
    spell.setPower(conf.power || 11);
    spell.setRange(conf.range || 7);
    spell.setDice([RG.FACT.createDie([1, 2, 3])]);
    wizard._spellbook.addSpell(spell);

    const spellPower = new RG.Component.SpellPower();
    spellPower.setPP(30);
    spellPower.setMaxPP(40);
    wizard.add('SpellPower', spellPower);
    return wizard;

};

RGTest.checkActorXY = function(actor, x, y) {
    expect(actor.getX(), `X must be ${x}`).to.equal(x);
    expect(actor.getY(), `Y must be ${y}`).to.equal(y);
};

RGTest.checkChar = function(obj, expChar) {
    const cell = RGTest.wrapObjWithCell(obj);
    expect(RG.getCellChar(cell)).to.equal(expChar);
};

RGTest.checkCSSClassName = function(obj, expClass) {
    const cell = RGTest.wrapObjWithCell(obj);
    expect(RG.getStyleClassForCell(cell)).to.equal(expClass);

};

RGTest.expectEqualHealth = function(o1, o2) {
    expect(o1.get('Health').getHP()).to.equal(o2.get('Health').getHP());
};

RGTest.verifyStairsConnectivity = function(stairs) {
    let connVerified = 0;
    stairs.forEach(s => {
        expect(s.getTargetStairs()).not.to.be.empty;
        expect(s.getTargetLevel()).not.to.be.empty;
        expect(s.getSrcLevel()).not.to.be.empty;
        ++connVerified;
    });
    console.log(`verifyStairsConnectivity ${connVerified} connections OK`);
};

// Expect that branches b1 and b2 are connected by number of connections
// given by nConns.
RGTest.expectConnected = function(b1, b2, nConns) {
    let connFound = 0;
    const b1Stairs = b1.getStairsOther();
    const b2Stairs = b2.getStairsOther();
    expect(b1Stairs, 'B1 must have stairs').to.have.length.above(0);
    expect(b2Stairs, 'B2 must have stairs').to.have.length.above(0);

    b1Stairs.forEach(stair1 => {
        const s1TargetID = stair1.getTargetLevel().getID();
        expect(stair1.getTargetStairs()).not.to.be.empty;
        b2Stairs.forEach(stair2 => {
            const s2SourceID = stair2.getSrcLevel().getID();
            if (s1TargetID === s2SourceID) {
                // stair1 should be the target of stair2
                if (stair2.getTargetStairs() === stair1) {
                    ++connFound;
                }
                expect(stair2.getTargetStairs()).not.to.be.empty;
            }
        });
    });
    expect(connFound, `Connections between branches must be ${nConns}`)
        .to.equal(nConns);
};

/* Adds each entity into the level into a random location. */
RGTest.wrapIntoLevel = function(arr) {
    const level = RG.FACT.createLevel('empty', 20, 20);
    arr.forEach(ent => {
        const x = RGTest.rng.getUniformInt(0, 19);
        const y = RGTest.rng.getUniformInt(0, 19);
        if (ent.getPropType() === RG.TYPE_ACTOR) {
            level.addActor(ent, x, y);
        }
        if (ent.getPropType() === RG.TYPE_ITEM) {
            level.addItem(ent, x, y);
        }
    });
    return level;
};

/* Moves entity from its current position to x,y. */
RGTest.moveEntityTo = function(ent, x, y) {
    const map = ent.getLevel().getMap();
    const xOld = ent.getX();
    const yOld = ent.getY();
    const propType = ent.getPropType();
    if (map.removeProp(xOld, yOld, propType, ent)) {
        map.setProp(x, y, propType, ent);
        ent.setXY(x, y);
        return true;
    }
    throw new Error(`Cannot move entity to ${x}, ${y}`);
};

/* Equips all given items for the given actor, and checks that everything
 * succeeds. */
RGTest.equipItems = function(ent, items) {
    const inv = ent.getInvEq();
    items.forEach(item => {
        inv.addItem(item);
        if (!inv.equipItem(item)) {
            throw new Error(`Cannot equip item ${item}`);
        }
    });
};

/* Can be used to catch the emitted game messages. */
RGTest.MsgCatcher = function() {
    this.filters = [];

    this.hasNotify = true;
    this.enabled = true;
    this.notify = (evtName, msgObj) => {
        if (!this.enabled) {return;}

        const {msg, cell} = msgObj;
        let hasMatch = false;

        if (this.filters.length > 0) {
            this.filters.forEach(filter => {
                if (filter.test(msg)) {
                    hasMatch = true;
                }
            });
        }
        else {
            hasMatch = true;
        }

        if (hasMatch) {
            console.log('\tMsg: |' + msg + '|');
        }
        if (cell) {
            console.log('\tFrom cell: |' + JSON.stringify(cell) + '|');
        }
    };

    RG.POOL.listenEvent(RG.EVT_MSG, this);

    /* Adds a message filter to select which messages to print. */
    this.addFilter = filter => {
        if (typeof filter === 'string') {
            this.filters.push(new RegExp(filter));
        }
        else {
            this.filters.push(filter);
        }

    };

    this.disable = () => {this.enabled = false;};
};

RGTest.createBoundGem = function() {
    const gem = new RG.Item.SpiritGem('Great gem');
    const spirit = new RG.Actor.Spirit('Legend spirit');
    spirit.get('Stats').setStrength(100);
    spirit.get('Stats').setAgility(100);
    gem.setSpirit(spirit);
    return gem;
};

RGTest.printScreen = function(actor) {
    const screen = new Screen(30, 14);
    const visible = actor.getBrain().getSeenCells();
    const map = actor.getLevel().getMap();
    actor.getLevel().exploreCells(actor);
    const [pX, pY] = actor.getXY();
    screen.render(pX, pY, map, visible);
    screen.printRenderedChars();
};

/* A function to run game simulation using a list of actors and a list of
 * systems. */
RGTest.updateGame = (actors, systems, nTurns = 1, cb) => {
    let simActors = actors;
    if (!Array.isArray(actors)) {
        simActors = [actors];
    }
    for (let i = 0; i < nTurns; i++) {
        simActors.forEach(actor => {
            const action = actor.nextAction();
            action.doAction();
            systems.forEach(sys => {sys.update();});
        });
        if (typeof cb === 'function') {
            cb();
        }
    }
};

/* Prints the given level as ASCII. */
RGTest.printLevel = level => {
    const [cols, rows] = [level.getMap().cols, level.getMap().rows];
    const screen = new Screen(cols / 2, rows / 2);
    level.getMap()._optimizeForRowAccess();
    screen.renderFullMap(level.getMap());
    screen.printRenderedChars();
};

RGTest.updateSystems = systems => {
    for (let i = 0; i < systems.length; i++) {
        systems[i].update();
    }
};

module.exports = RGTest;
<commit_msg>Added verifyConnectivity().<commit_after>
/** Note: This file doesn't contain any unit tests. It has utility functions and
 * logic for performing common things required in unit tests. An example is
 * creating a level and adding a list of actors automatically there.
 */

const RG = require('../client/src/battles');
const expect = require('chai').expect;
const Screen = require('../client/gui/screen');

const RGTest = {};

RGTest.rng = new RG.Random();

/* Creates a mock-level for unit tests. */
RGTest.createMockLevel = function(cols, rows) {
    const level = {cols: cols, rows: rows,
        map: {
            getCell: function(x, y) {
                return { x: x, y: y };
            },
            hasXY: function() {
                return true;
            },
            isPassable: function(x, y) {
                return x > -1 && y > -1;
            }
        },
        getMap: function() {return this.map;},

        addActor: function(actor, x, y) {
            actor.setXY(x, y);
            actor.setLevel(this);
        }
    };
    return level;
};

RGTest.createLevel = function(type, cols, rows) {
    return RG.FACT.createLevel('arena', cols, rows);
};

RGTest.equipItem = function(actor, item) {
    const invEq = actor.getInvEq();
    invEq.addItem(item);
    expect(invEq.equipItem(item)).to.equal(true);
};

/* Wraps an object into a cell for later use. Some functions require a map cell
* instead of taking the object directly, so this is useful. */
RGTest.wrapObjWithCell = function(obj) {
    const cell = RG.FACT.createFloorCell();
    cell.setExplored(true); // Otherwise returns darkness
    const propType = obj.getPropType();
    cell.setProp(propType, obj);
    return cell;
};

RGTest.getMeAWizard = function(conf = {}) {
    const wizard = new RG.Actor.Rogue('wizard');
    wizard.setType(conf.type || 'human');
    const brain = new RG.Brain.SpellCaster(wizard);
    brain.setCastProbability(1.1);
    wizard.setBrain(brain);

    wizard._spellbook = new RG.Spell.SpellBook(wizard);
    const spell = RG.FACT.createSpell('FrostBolt');
    spell.setPower(conf.power || 11);
    spell.setRange(conf.range || 7);
    spell.setDice([RG.FACT.createDie([1, 2, 3])]);
    wizard._spellbook.addSpell(spell);

    const spellPower = new RG.Component.SpellPower();
    spellPower.setPP(30);
    spellPower.setMaxPP(40);
    wizard.add('SpellPower', spellPower);
    return wizard;

};

RGTest.checkActorXY = function(actor, x, y) {
    expect(actor.getX(), `X must be ${x}`).to.equal(x);
    expect(actor.getY(), `Y must be ${y}`).to.equal(y);
};

RGTest.checkChar = function(obj, expChar) {
    const cell = RGTest.wrapObjWithCell(obj);
    expect(RG.getCellChar(cell)).to.equal(expChar);
};

RGTest.checkCSSClassName = function(obj, expClass) {
    const cell = RGTest.wrapObjWithCell(obj);
    expect(RG.getStyleClassForCell(cell)).to.equal(expClass);

};

RGTest.expectEqualHealth = function(o1, o2) {
    expect(o1.get('Health').getHP()).to.equal(o2.get('Health').getHP());
};

RGTest.verifyStairsConnectivity = function(stairs) {
    let connVerified = 0;
    stairs.forEach(s => {
        expect(s.getTargetStairs()).not.to.be.empty;
        expect(s.getTargetLevel()).not.to.be.empty;
        expect(s.getSrcLevel()).not.to.be.empty;
        ++connVerified;
    });
    console.log(`verifyStairsConnectivity ${connVerified} connections OK`);
};

RGTest.verifyConnectivity = function(stairs) {
    let connVerified = 0;
    stairs.forEach(s => {
        let str = `x, y: ${s.getX()}, ${s.getY()} `;
        str += JSON.stringify(s);
        expect(s.getTargetStairs(), str).to.exist;
        expect(s.getTargetLevel(), str).to.exist;
        expect(s.getSrcLevel(), str).to.exist;
        ++connVerified;
    });
    console.log(`verifyConnectivity ${connVerified} connections OK`);
    expect(connVerified, 'At least one connection exists').to.be.above(0);
};

// Expect that subzones b1 and b2 are connected by number of connections
// given by nConns.
RGTest.expectConnected = function(b1, b2, nConns) {
    let connFound = 0;
    const b1Stairs = b1.getStairsOther();
    const b2Stairs = b2.getStairsOther();
    expect(b1Stairs, 'B1 must have stairs').to.have.length.above(0);
    expect(b2Stairs, 'B2 must have stairs').to.have.length.above(0);

    b1Stairs.forEach(stair1 => {
        const s1TargetID = stair1.getTargetLevel().getID();
        expect(stair1.getTargetStairs()).not.to.be.empty;
        b2Stairs.forEach(stair2 => {
            const s2SourceID = stair2.getSrcLevel().getID();
            if (s1TargetID === s2SourceID) {
                // stair1 should be the target of stair2
                if (stair2.getTargetStairs() === stair1) {
                    ++connFound;
                }
                expect(stair2.getTargetStairs()).not.to.be.empty;
            }
        });
    });
    expect(connFound, `Connections between branches must be ${nConns}`)
        .to.equal(nConns);
};

/* Adds each entity into the level into a random location. */
RGTest.wrapIntoLevel = function(arr) {
    const level = RG.FACT.createLevel('empty', 20, 20);
    arr.forEach(ent => {
        const x = RGTest.rng.getUniformInt(0, 19);
        const y = RGTest.rng.getUniformInt(0, 19);
        if (ent.getPropType() === RG.TYPE_ACTOR) {
            level.addActor(ent, x, y);
        }
        if (ent.getPropType() === RG.TYPE_ITEM) {
            level.addItem(ent, x, y);
        }
    });
    return level;
};

/* Moves entity from its current position to x,y. */
RGTest.moveEntityTo = function(ent, x, y) {
    const map = ent.getLevel().getMap();
    const xOld = ent.getX();
    const yOld = ent.getY();
    const propType = ent.getPropType();
    if (map.removeProp(xOld, yOld, propType, ent)) {
        map.setProp(x, y, propType, ent);
        ent.setXY(x, y);
        return true;
    }
    throw new Error(`Cannot move entity to ${x}, ${y}`);
};

/* Equips all given items for the given actor, and checks that everything
 * succeeds. */
RGTest.equipItems = function(ent, items) {
    const inv = ent.getInvEq();
    items.forEach(item => {
        inv.addItem(item);
        if (!inv.equipItem(item)) {
            throw new Error(`Cannot equip item ${item}`);
        }
    });
};

/* Can be used to catch the emitted game messages. */
RGTest.MsgCatcher = function() {
    this.filters = [];

    this.hasNotify = true;
    this.enabled = true;
    this.notify = (evtName, msgObj) => {
        if (!this.enabled) {return;}

        const {msg, cell} = msgObj;
        let hasMatch = false;

        if (this.filters.length > 0) {
            this.filters.forEach(filter => {
                if (filter.test(msg)) {
                    hasMatch = true;
                }
            });
        }
        else {
            hasMatch = true;
        }

        if (hasMatch) {
            console.log('\tMsg: |' + msg + '|');
        }
        if (cell) {
            console.log('\tFrom cell: |' + JSON.stringify(cell) + '|');
        }
    };

    RG.POOL.listenEvent(RG.EVT_MSG, this);

    /* Adds a message filter to select which messages to print. */
    this.addFilter = filter => {
        if (typeof filter === 'string') {
            this.filters.push(new RegExp(filter));
        }
        else {
            this.filters.push(filter);
        }

    };

    this.disable = () => {this.enabled = false;};
};

RGTest.createBoundGem = function() {
    const gem = new RG.Item.SpiritGem('Great gem');
    const spirit = new RG.Actor.Spirit('Legend spirit');
    spirit.get('Stats').setStrength(100);
    spirit.get('Stats').setAgility(100);
    gem.setSpirit(spirit);
    return gem;
};

RGTest.printScreen = function(actor) {
    const screen = new Screen(30, 14);
    const visible = actor.getBrain().getSeenCells();
    const map = actor.getLevel().getMap();
    actor.getLevel().exploreCells(actor);
    const [pX, pY] = actor.getXY();
    screen.render(pX, pY, map, visible);
    screen.printRenderedChars();
};

/* A function to run game simulation using a list of actors and a list of
 * systems. */
RGTest.updateGame = (actors, systems, nTurns = 1, cb) => {
    let simActors = actors;
    if (!Array.isArray(actors)) {
        simActors = [actors];
    }
    for (let i = 0; i < nTurns; i++) {
        simActors.forEach(actor => {
            const action = actor.nextAction();
            action.doAction();
            systems.forEach(sys => {sys.update();});
        });
        if (typeof cb === 'function') {
            cb();
        }
    }
};

/* Prints the given level as ASCII. */
RGTest.printLevel = level => {
    const [cols, rows] = [level.getMap().cols, level.getMap().rows];
    const screen = new Screen(cols / 2, rows / 2);
    level.getMap()._optimizeForRowAccess();
    screen.renderFullMap(level.getMap());
    screen.printRenderedChars();
};

RGTest.updateSystems = systems => {
    for (let i = 0; i < systems.length; i++) {
        systems[i].update();
    }
};

module.exports = RGTest;
<|endoftext|>