1. (function() {
  2. 'use strict';
  3. // capture injected services for access throughout this module
  4. var $q = null,
  5. $location = null,
  6. mlRest = null,
  7. qb = null;
  8. angular.module('ml.search')
  9. .factory('MLSearchFactory', MLSearchFactory);
  10. MLSearchFactory.$inject = ['$q', '$location', 'MLRest', 'MLQueryBuilder'];
  11. /**
  12. * @class MLSearchFactory
  13. * @classdesc angular factory for creating instances of {@link MLSearchContext}
  14. *
  15. * @param {Object} $q - angular promise service
  16. * @param {Object} $location - angular location service
  17. * @param {MLRest} MLRest - low-level ML REST API wrapper (from {@link https://github.com/joemfb/ml-common-ng})
  18. * @param {MLQueryBuilder} MLQueryBuilder - structured query builder (from {@link https://github.com/joemfb/ml-common-ng})
  19. */
  20. // jscs:disable checkParamNames
  21. function MLSearchFactory($injectQ, $injectLocation, $injectMlRest, $injectQb) {
  22. $q = $injectQ;
  23. $location = $injectLocation;
  24. mlRest = $injectMlRest;
  25. qb = $injectQb;
  26. return {
  27. /**
  28. * returns a new instance of {@link MLSearchContext}
  29. * @method MLSearchFactory#newContext
  30. *
  31. * @param {Object} options (to override {@link MLSearchContext.defaults})
  32. * @returns {MLSearchContext}
  33. */
  34. newContext: function newContext(options) {
  35. return new MLSearchContext(options);
  36. }
  37. };
  38. }
  39. /**
  40. * @class MLSearchContext
  41. * @classdesc class for maintaining and manipulating the state of a search context
  42. *
  43. * @param {Object} options - provided object properties will override {@link MLSearchContext.defaults}
  44. *
  45. * @prop {MLQueryBuilder} qb - query builder service from `ml.common`
  46. * @prop {Object} results - search results
  47. * @prop {Object} storedOptions - cache stored search options by name
  48. * @prop {Object} activeFacets - active facet selections
  49. * @prop {Object} namespaces - namespace prefix-to-URI mappings
  50. * @prop {Object[]} boostQueries - boosting queries to be added to the current query
  51. * @prop {Object[]} additionalQueries - additional queries to be added to the current query
  52. * @prop {String} searchTransform - search results transformation name
  53. * @prop {String} qtext - current search phrase
  54. * @prop {Number} start - current pagination offset
  55. * @prop {Object} options - configuration options
  56. */
  57. function MLSearchContext(options) {
  58. this.qb = qb;
  59. this.results = {};
  60. this.storedOptions = {};
  61. this.activeFacets = {};
  62. this.namespaces = {};
  63. this.boostQueries = [];
  64. this.additionalQueries = [];
  65. this.searchTransform = null;
  66. this.qtext = null;
  67. this.start = 1;
  68. // TODO: validate options
  69. this.options = _.merge( _.cloneDeep(this.defaults), options );
  70. }
  71. angular.extend(MLSearchContext.prototype, {
  72. /* ******************************************************** */
  73. /* ************** MLSearchContext properties ************** */
  74. /* ******************************************************** */
  75. /**
  76. * pass an object to `new MLSearchContext()` or {@link MLSearchFactory#newContext}
  77. * with any of these properties to override their values
  78. *
  79. * @type object
  80. * @memberof MLSearchContext
  81. * @static
  82. *
  83. * @prop {String} defaults.queryOptions - stored search options name (`'all'`)
  84. * @prop {String} defaults.suggestOptions - stored search options name for suggestions (`same as queryOptions`)
  85. * @prop {Number} defaults.pageLength - results page length (`10`)
  86. * @prop {String} defaults.snippet - results transform operator state-name (`'compact'`)
  87. * @prop {String} defaults.sort - sort operator state-name (`null`)
  88. * @prop {String} defaults.facetMode - determines if facets are combined in an `and-query` or an `or-query` (`and`)
  89. * @prop {Boolean} defaults.includeProperties - include document properties in queries (`false`)
  90. * @prop {Boolean} defaults.includeAggregates - automatically get aggregates for facets (`false`)
  91. * @prop {Object} defaults.params - URL params settings
  92. * @prop {String} defaults.params.separator - constraint-name and value separator (`':'`)
  93. * @prop {String} defaults.params.qtext - qtext parameter name (`'qtext'`)
  94. * @prop {String} defaults.params.facets - facets parameter name (`'f'`)
  95. * @prop {String} defaults.params.sort - sort parameter name (`'s'`)
  96. * @prop {String} defaults.params.page - page parameter name (`'p'`)
  97. * @prop {String} defaults.params.prefix - optional string prefix for each parameter name (`null`)
  98. * @prop {String} defaults.params.prefixSeparator - separator for prefix and parameter name. (`null`) <br>if `null`, `options.params.separator` is used as the prefix separator
  99. */
  100. defaults: {
  101. queryOptions: 'all',
  102. suggestOptions: null,
  103. pageLength: 10,
  104. snippet: 'compact',
  105. sort: null,
  106. facetMode: 'and',
  107. includeProperties: false,
  108. includeAggregates: false,
  109. params: {
  110. separator: ':',
  111. qtext: 'q',
  112. facets: 'f',
  113. negatedFacets: 'n',
  114. sort: 's',
  115. page: 'p',
  116. prefix: null,
  117. prefixSeparator: null
  118. // TODO: queryOptions?
  119. }
  120. },
  121. /* ******************************************************** */
  122. /* ****** MLSearchContext instance getters/setters ******** */
  123. /* ******************************************************** */
  124. /**
  125. * Gets the object repesenting active facet selections
  126. * @method MLSearchContext#getActiveFacets
  127. *
  128. * @return {Object} `this.activeFacets`
  129. */
  130. getActiveFacets: function getActiveFacets() {
  131. return this.activeFacets;
  132. },
  133. /**
  134. * Get namspace prefix by URI
  135. * @method MLSearchContext#getNamespacePrefix
  136. *
  137. * @param {String} uri
  138. * @return {String} prefix
  139. */
  140. getNamespacePrefix: function getNamespacePrefix(uri) {
  141. return this.namespaces[ uri ];
  142. },
  143. /**
  144. * Get namspace URI by prefix
  145. * @method MLSearchContext#getNamespaceUri
  146. *
  147. * @param {String} prefix
  148. * @return {String} uri
  149. */
  150. getNamespaceUri: function getNamespacePrefix(prefix) {
  151. return _.chain(this.namespaces)
  152. .map(function(nsPrefix, uri) {
  153. if (prefix === nsPrefix) {
  154. return uri;
  155. }
  156. })
  157. .compact()
  158. .first()
  159. .value();
  160. },
  161. /**
  162. * Gets namespace prefix-to-URI mappings
  163. * @method MLSearchContext#getNamespaces
  164. *
  165. * @return {Object[]} namespace prefix-to-URI mapping objects
  166. */
  167. getNamespaces: function getNamespaces() {
  168. var namespaces = [];
  169. _.forIn(this.namespaces, function(prefix, uri) {
  170. namespaces.push({ prefix: prefix, uri: uri });
  171. });
  172. return namespaces;
  173. },
  174. /**
  175. * Sets namespace prefix->URI mappings
  176. * @method MLSearchContext#setNamespaces
  177. *
  178. * @param {Object[]} namespaces - objects with `uri` and `prefix` properties
  179. * @return {MLSearchContext} `this`
  180. */
  181. setNamespaces: function setNamespaces(namespaces) {
  182. // TODO: this.clearNamespaces() first?
  183. _.each(namespaces, this.addNamespace, this);
  184. return this;
  185. },
  186. /**
  187. * Adds a namespace prefix->URI mapping
  188. * @method MLSearchContext#addNamespace
  189. *
  190. * @param {Object} namespace object with `uri` and `prefix` properties
  191. * @return {MLSearchContext} `this`
  192. */
  193. addNamespace: function addNamespace(namespace) {
  194. this.namespaces[ namespace.uri ] = namespace.prefix;
  195. return this;
  196. },
  197. /**
  198. * Clears namespace prefix->URI mappings
  199. * @method MLSearchContext#clearNamespaces
  200. *
  201. * @return {MLSearchContext} `this`
  202. */
  203. clearNamespaces: function clearNamespaces() {
  204. this.namespaces = {};
  205. return this;
  206. },
  207. /**
  208. * Gets the boost queries
  209. * @method MLSearchContext#getBoostQueries
  210. *
  211. * @return {Array} `this.boostQueries`
  212. */
  213. getBoostQueries: function getBoostQueries() {
  214. return this.boostQueries;
  215. },
  216. /**
  217. * Adds a boost query to `this.boostQueries`
  218. * @method MLSearchContext#addBoostQuery
  219. *
  220. * @param {Object} query - boost query
  221. * @return {MLSearchContext} `this`
  222. */
  223. addBoostQuery: function addBoostQuery(query) {
  224. this.boostQueries.push(query);
  225. return this;
  226. },
  227. /**
  228. * Clears the boost queries
  229. * @method MLSearchContext#clearBoostQueries
  230. *
  231. * @return {MLSearchContext} `this`
  232. */
  233. clearBoostQueries: function clearBoostQueries() {
  234. this.boostQueries = [];
  235. return this;
  236. },
  237. /**
  238. * Gets the additional queries
  239. * @method MLSearchContext#getAdditionalQueries
  240. *
  241. * @return {Object} `this.additionalQueries`
  242. */
  243. getAdditionalQueries: function getAdditionalQueries() {
  244. return this.additionalQueries;
  245. },
  246. /**
  247. * Adds an additional query to `this.additionalQueries`
  248. * @method MLSearchContext#addAdditionalQuery
  249. *
  250. * @param {Object} query - additional query
  251. * @return {MLSearchContext} `this`
  252. */
  253. addAdditionalQuery: function addAdditionalQuery(query) {
  254. this.additionalQueries.push(query);
  255. return this;
  256. },
  257. /**
  258. * Clears the additional queries
  259. * @method MLSearchContext#clearAdditionalQueries
  260. *
  261. * @return {MLSearchContext} `this`
  262. */
  263. clearAdditionalQueries: function clearAdditionalQueries() {
  264. this.additionalQueries = [];
  265. return this;
  266. },
  267. /**
  268. * Gets the search transform name
  269. * @method MLSearchContext#getTransform
  270. *
  271. * @return {String} transform name
  272. */
  273. getTransform: function getTransform(transform) {
  274. return this.searchTransform;
  275. },
  276. /**
  277. * Sets the search transform name
  278. * @method MLSearchContext#setTransform
  279. *
  280. * @param {String} transform - transform name
  281. * @return {MLSearchContext} `this`
  282. */
  283. setTransform: function setTransform(transform) {
  284. this.searchTransform = transform;
  285. return this;
  286. },
  287. /**
  288. * Gets the current search phrase
  289. * @method MLSearchContext#getText
  290. *
  291. * @return {String} search phrase
  292. */
  293. getText: function getText() {
  294. return this.qtext;
  295. },
  296. /**
  297. * Sets the current search phrase
  298. * @method MLSearchContext#setText
  299. *
  300. * @param {String} text - search phrase
  301. * @return {MLSearchContext} `this`
  302. */
  303. setText: function setText(text) {
  304. if (text !== '') {
  305. this.qtext = text;
  306. } else {
  307. this.qtext = null;
  308. }
  309. return this;
  310. },
  311. /**
  312. * Gets the current search page
  313. * @method MLSearchContext#getPage
  314. *
  315. * @return {Number} search page
  316. */
  317. getPage: function getPage() {
  318. // TODO: $window.Math
  319. var page = Math.floor(this.start / this.options.pageLength) + 1;
  320. return page;
  321. },
  322. /**
  323. * Sets the search results page
  324. * @method MLSearchContext#setPage
  325. *
  326. * @param {Number} page - the desired search results page
  327. * @return {MLSearchContext} `this`
  328. */
  329. setPage: function setPage(page) {
  330. page = parseInt(page, 10) || 1;
  331. this.start = 1 + (page - 1) * this.options.pageLength;
  332. return this;
  333. },
  334. /* ******************************************************** */
  335. /* ******* MLSearchContext options getters/setters ******** */
  336. /* ******************************************************** */
  337. /**
  338. * Gets the current queryOptions (name of stored params)
  339. * @method MLSearchContext#getQueryOptions
  340. *
  341. * @return {String} queryOptions
  342. */
  343. getQueryOptions: function getQueryOptions() {
  344. return this.options.queryOptions;
  345. },
  346. /**
  347. * Gets the current suggestOptions (name of stored params for suggestions)
  348. * @method MLSearchContext#getSuggestOptions
  349. *
  350. * @return {String} suggestOptions
  351. */
  352. getSuggestOptions: function getSuggestOptions() {
  353. return this.options.suggestOptions;
  354. },
  355. /**
  356. * Gets the current page length
  357. * @method MLSearchContext#getPageLength
  358. *
  359. * @return {Number} page length
  360. */
  361. getPageLength: function getPageLength() {
  362. return this.options.pageLength;
  363. },
  364. /**
  365. * Sets the current page length
  366. * @method MLSearchContext#setPageLength
  367. *
  368. * @param {Number} pageLength - page length
  369. * @return {MLSearchContext} `this`
  370. */
  371. setPageLength: function setPageLength(pageLength) {
  372. this.options.pageLength = pageLength;
  373. return this;
  374. },
  375. /**
  376. * Gets the current results transform operator state name
  377. * @method MLSearchContext#getSnippet
  378. *
  379. * @return {String} operator state name
  380. */
  381. getSnippet: function getSnippet() {
  382. return this.options.snippet;
  383. },
  384. /**
  385. * Sets the current results transform operator state name
  386. * @method MLSearchContext#setSnippet
  387. *
  388. * @param {String} snippet - operator state name
  389. * @return {MLSearchContext} `this`
  390. */
  391. setSnippet: function setSnippet(snippet) {
  392. this.options.snippet = snippet;
  393. return this;
  394. },
  395. /**
  396. * Clears the results transform operator (resets it to its default value)
  397. * @method MLSearchContext#clearSnippet
  398. *
  399. * @return {MLSearchContext} `this`
  400. */
  401. clearSnippet: function clearSnippet() {
  402. this.options.snippet = this.defaults.snippet;
  403. return this;
  404. },
  405. /**
  406. * Gets the current sort operator state name
  407. * @method MLSearchContext#getSort
  408. *
  409. * @return {String} sort operator state name
  410. */
  411. getSort: function getSort() {
  412. return this.options.sort;
  413. },
  414. /**
  415. * Sets the current sort operator state name
  416. * @method MLSearchContext#setSort
  417. *
  418. * @param {String} sort - sort operator state name
  419. * @return {MLSearchContext} `this`
  420. */
  421. setSort: function setSort(sort) {
  422. this.options.sort = sort;
  423. return this;
  424. },
  425. /**
  426. * Clears the sort operator state name (resets it to its default value)
  427. * @method MLSearchContext#clearSort
  428. *
  429. * @return {MLSearchContext} `this`
  430. */
  431. clearSort: function clearSort() {
  432. this.options.sort = this.defaults.sort;
  433. return this;
  434. },
  435. /**
  436. * Gets the current facet mode (determines if facet values are combined in an `and-query` or an `or-query`)
  437. * @method MLSearchContext#getFacetMode
  438. *
  439. * @return {String} facet mode
  440. */
  441. getFacetMode: function getFacetMode() {
  442. return this.options.facetMode;
  443. },
  444. /**
  445. * Sets the current facet mode (`and`|`or`). (determines if facet values are combined in an `and-query` or an `or-query`)
  446. * @method MLSearchContext#setFacetMode
  447. *
  448. * @param {String} facetMode - 'and' or 'or'
  449. * @return {MLSearchContext} `this`
  450. */
  451. setFacetMode: function setFacetMode(facetMode) {
  452. // TODO: validate facetMode
  453. this.options.facetMode = facetMode;
  454. return this;
  455. },
  456. /**
  457. * Gets the current URL params config object
  458. * @method MLSearchContext#getParamsConfig
  459. *
  460. * @return {Object} params config
  461. */
  462. getParamsConfig: function getParamsConfig() {
  463. return this.options.params;
  464. },
  465. /**
  466. * Gets the key of the enabled URL params
  467. * @method MLSearchContext#getParamsKeys
  468. *
  469. * @return {Array<String>} URL params keys
  470. */
  471. getParamsKeys: function getParamsKeys() {
  472. var prefix = this.getParamsPrefix();
  473. return _.chain( this.options.params )
  474. .omit(['separator', 'prefix', 'prefixSeparator'])
  475. .map(function(value) {
  476. return prefix + value;
  477. })
  478. .compact()
  479. .value();
  480. },
  481. /**
  482. * Gets the URL params prefix
  483. * @method MLSearchContext#getParamsPrefix
  484. *
  485. * @return {String} the defined params prefix + separator
  486. */
  487. getParamsPrefix: function getParamsPrefix() {
  488. var prefix = '';
  489. if ( this.options.params.prefix !== null ) {
  490. prefix = this.options.params.prefix + (
  491. this.options.params.prefixSeparator ||
  492. this.options.params.separator
  493. );
  494. }
  495. return prefix;
  496. },
  497. // TODO: setParamsConfig ?
  498. /* ******************************************************** */
  499. /* ************ MLSearchContext query builders ************ */
  500. /* ******************************************************** */
  501. /**
  502. * Constructs a structured query from the current state
  503. * @method MLSearchContext#getQuery
  504. *
  505. * @return {Object} a structured query object
  506. */
  507. getQuery: function getQuery() {
  508. var query = qb.and();
  509. if ( _.keys(this.activeFacets).length ) {
  510. query = this.getFacetQuery();
  511. }
  512. if ( this.boostQueries.length ) {
  513. query = qb.boost(query, this.boostQueries);
  514. }
  515. if ( this.additionalQueries.length ) {
  516. query = qb.and(query, this.additionalQueries);
  517. }
  518. if ( this.options.includeProperties ) {
  519. query = qb.or(query, qb.propertiesFragment(query));
  520. }
  521. query = qb.where(query);
  522. if ( this.options.sort ) {
  523. // TODO: this assumes that the sort operator is called "sort", but
  524. // that isn't necessarily true. Properly done, we'd get the options
  525. // from the server and find the operator that contains sort-order
  526. // elements
  527. query.query.queries.push( qb.ext.operatorState('sort', this.options.sort) );
  528. }
  529. if ( this.options.snippet ) {
  530. // same problem as `sort`
  531. query.query.queries.push( qb.ext.operatorState('results', this.options.snippet) );
  532. }
  533. return query;
  534. },
  535. /**
  536. * constructs a structured query from the current active facets
  537. * @method MLSearchContext#getFacetQuery
  538. *
  539. * @return {Object} a structured query object
  540. */
  541. getFacetQuery: function getFacetQuery() {
  542. var self = this,
  543. queries = [],
  544. query = {},
  545. constraintFn;
  546. _.forIn( self.activeFacets, function(facet, facetName) {
  547. if ( facet.values.length ) {
  548. constraintFn = function(facetValueObject) {
  549. var constraintQuery = qb.ext.constraint( facet.type )( facetName, facetValueObject.value );
  550. if (facetValueObject.negated === true) {
  551. constraintQuery = qb.not(constraintQuery);
  552. }
  553. return constraintQuery;
  554. };
  555. queries = queries.concat( _.map(facet.values, constraintFn) );
  556. }
  557. });
  558. if ( self.options.facetMode === 'or' ) {
  559. query = qb.or(queries);
  560. } else {
  561. query = qb.and(queries);
  562. }
  563. return query;
  564. },
  565. /**
  566. * Construct a combined query from the current state (excluding stored options)
  567. * @method MLSearchContext#getCombinedQuerySync
  568. *
  569. * @param {Object} [options] - optional search options object
  570. *
  571. * @return {Object} - combined query
  572. */
  573. getCombinedQuerySync: function getCombinedQuerySync(options) {
  574. return qb.ext.combined( this.getQuery(), this.getText(), options );
  575. },
  576. /**
  577. * Construct a combined query from the current state
  578. * @method MLSearchContext#getCombinedQuery
  579. *
  580. * @param {Boolean} [includeOptions] - if `true`, get and include the stored search options (defaults to `false`)
  581. *
  582. * @return {Promise} - a promise resolved with the combined query
  583. */
  584. getCombinedQuery: function getCombinedQuery(includeOptions) {
  585. var combined = this.getCombinedQuerySync();
  586. if ( !includeOptions ) {
  587. return $q.resolve(combined);
  588. }
  589. return this.getStoredOptions()
  590. .then(function(data) {
  591. combined.search.options = data.options;
  592. return combined;
  593. });
  594. },
  595. /* ******************************************************** */
  596. /* ************ MLSearchContext facet methods ************* */
  597. /* ******************************************************** */
  598. /**
  599. * Check if the facet/value combination is already selected
  600. * @method MLSearchContext#isFacetActive
  601. *
  602. * @param {String} name - facet name
  603. * @param {String} value - facet value
  604. * @return {Boolean} isSelected
  605. */
  606. isFacetActive: function isFacetActive(name, value) {
  607. var active = this.activeFacets[name];
  608. return !!active && !!_.find(active.values, { value: value });
  609. },
  610. /**
  611. * Check if the facet/value combination selected & negated
  612. * @method MLSearchContext#isFacetNegated
  613. *
  614. * @param {String} name - facet name
  615. * @param {String} value - facet value
  616. * @return {Boolean} isNegated
  617. */
  618. isFacetNegated: function isFacetNegated(name, value) {
  619. var active = this.activeFacets[name];
  620. if (!active) {
  621. return false;
  622. }
  623. var facet = _.find(active.values, { value: value });
  624. if (facet) {
  625. return facet.negated;
  626. } else {
  627. return false;
  628. }
  629. },
  630. /**
  631. * Add the facet/value/type combination to the activeFacets list
  632. * @method MLSearchContext#selectFacet
  633. *
  634. * @param {String} name - facet name
  635. * @param {String} value - facet value
  636. * @param {String} type - facet type
  637. * @param {Boolean} isNegated - facet negated (default to false)
  638. * @return {MLSearchContext} `this`
  639. */
  640. selectFacet: function selectFacet(name, value, type, isNegated) {
  641. if (/^"(.*)"$/.test(value)) {
  642. value = value.replace(/^"(.*)"$/, '$1');
  643. }
  644. var active = this.activeFacets[name],
  645. negated = isNegated || false,
  646. valueObject = { value: value, negated: negated };
  647. if (active && !this.isFacetActive(name, value) ) {
  648. active.values.push(valueObject);
  649. } else {
  650. this.activeFacets[name] = { type: type, values: [valueObject] };
  651. }
  652. return this;
  653. },
  654. /**
  655. * Removes the facet/value combination from the activeFacets list
  656. * @method MLSearchContext#clearFacet
  657. *
  658. * @param {String} name - facet name
  659. * @param {String} value - facet value
  660. * @return {MLSearchContext} `this`
  661. */
  662. clearFacet: function clearFacet(name, value) {
  663. var active = this.activeFacets[name];
  664. active.values = _.filter( active.values, function(facetValueObject) {
  665. return facetValueObject.value !== value;
  666. });
  667. if ( !active.values.length ) {
  668. delete this.activeFacets[name];
  669. }
  670. return this;
  671. },
  672. /**
  673. * If facet/value combination is active, remove it from the activeFacets list
  674. * otherwise, find it's type, and add it.
  675. * @method MLSearchContext#toggleFacet
  676. *
  677. * @param {String} name - facet name
  678. * @param {String} value - facet value
  679. * @param {Boolean} isNegated - facet negated
  680. * @return {MLSearchContext} `this`
  681. */
  682. toggleFacet: function toggleFacet(name, value, isNegated) {
  683. var config;
  684. if ( this.isFacetActive(name, value) ) {
  685. this.clearFacet(name, value);
  686. } else {
  687. config = this.getFacetConfig(name);
  688. this.selectFacet(name, value, config.type, isNegated);
  689. }
  690. return this;
  691. },
  692. /**
  693. * Clears the activeFacets list
  694. * @method MLSearchContext#clearAllFacets
  695. *
  696. * @return {MLSearchContext} `this`
  697. */
  698. clearAllFacets: function clearAllFacets() {
  699. this.activeFacets = {};
  700. return this;
  701. },
  702. /**
  703. * Retrieve additional values for the provided `facet` object,
  704. * appending them to the facet's `facetValues` array. Sets `facet.displayingAll = true`
  705. * once no more values are available.
  706. *
  707. * @method MLSearchContext#showMoreFacets
  708. *
  709. * @param {Object} facet - a facet object returned from {@link MLSearchContext#search}
  710. * @param {String} facetName - facet name
  711. * @param {Number} [step] - the number of additional facet values to retrieve (defaults to `5`)
  712. *
  713. * @return {Promise} a promise resolved once additional facets have been retrieved
  714. */
  715. showMoreFacets: function showMoreFacets(facet, facetName, step) {
  716. if (facet.displayingAll) {
  717. return $q.resolve();
  718. }
  719. step = step || 5;
  720. var start = facet.facetValues.length + 1;
  721. var limit = start + step - 1;
  722. return this.valuesFromConstraint(facetName, { start: start, limit: limit })
  723. .then(function(resp) {
  724. var newFacets = resp && resp['values-response'] && resp['values-response']['distinct-value'];
  725. facet.displayingAll = (!newFacets || newFacets.length < (limit - start));
  726. _.each(newFacets, function(newFacetValue) {
  727. facet.facetValues.push({
  728. name: newFacetValue._value,
  729. value: newFacetValue._value,
  730. count: newFacetValue.frequency
  731. });
  732. });
  733. return facet;
  734. });
  735. },
  736. /* ******************************************************** */
  737. /* ********** MLSearchContext URL params methods ********** */
  738. /* ******************************************************** */
  739. /**
  740. * Construct a URL query params object from the current state
  741. * @method MLSearchContext#getParams
  742. *
  743. * @return {Object} params - a URL query params object
  744. */
  745. getParams: function getParams() {
  746. var page = this.getPage(),
  747. facetParams = this.getFacetParams(),
  748. facets = facetParams.facets,
  749. negated = facetParams.negatedFacets,
  750. params = {},
  751. prefix = this.getParamsPrefix();
  752. if ( facets.length && this.options.params.facets !== null ) {
  753. params[ prefix + this.options.params.facets ] = facets;
  754. }
  755. if ( negated.length && this.options.params.negatedFacets !== null ) {
  756. params[ prefix + this.options.params.negatedFacets ] = negated;
  757. }
  758. if ( page > 1 && this.options.params.page !== null ) {
  759. params[ prefix + this.options.params.page ] = page;
  760. }
  761. if ( this.qtext && this.options.params.qtext !== null ) {
  762. params[ prefix + this.options.params.qtext ] = this.qtext;
  763. }
  764. if ( this.options.sort && this.options.params.sort !== null ) {
  765. params[ prefix + this.options.params.sort ] = this.options.sort;
  766. }
  767. return params;
  768. },
  769. /**
  770. * Construct an array of facet selections (`name` `separator` `value`) from `this.activeFacets` for use in a URL query params object
  771. * @method MLSearchContext#getFacetParams
  772. *
  773. * @return {Array<String>} an array of facet URL query param values
  774. */
  775. getFacetParams: function getFacetParams() {
  776. var self = this,
  777. facetQuery = self.getFacetQuery(),
  778. queries = [],
  779. facets = { facets: [], negatedFacets: [] };
  780. queries = ( facetQuery['or-query'] || facetQuery['and-query'] ).queries;
  781. _.each(queries, function(query) {
  782. var queryType = _.keys(query)[0],
  783. constraint,
  784. name,
  785. arrayToPushTo;
  786. if (queryType === 'not-query') {
  787. constraint = query[queryType][_.keys(query[queryType])[0]];
  788. arrayToPushTo = facets.negatedFacets;
  789. } else {
  790. constraint = query[ queryType ];
  791. arrayToPushTo = facets.facets;
  792. }
  793. name = constraint['constraint-name'];
  794. _.each( constraint.value || constraint.uri || constraint.text, function(value) {
  795. // quote values with spaces
  796. if (/\s+/.test(value) && !/^"(.*)"$/.test(value)) {
  797. value = '"' + value + '"';
  798. }
  799. arrayToPushTo.push( name + self.options.params.separator + value );
  800. });
  801. });
  802. return facets;
  803. },
  804. /**
  805. * Gets the current search related URL params (excluding any params not controlled by {@link MLSearchContext})
  806. * @method MLSearchContext#getCurrentParams
  807. *
  808. * @param {Object} [params] - URL params (defaults to `$location.search()`)
  809. * @return {Object} search-related URL params
  810. */
  811. getCurrentParams: function getCurrentParams(params) {
  812. var prefix = this.getParamsPrefix();
  813. params = _.pick(
  814. params || $location.search(),
  815. this.getParamsKeys()
  816. );
  817. _.chain(this.options.params)
  818. .pick(['facets', 'negatedFacets'])
  819. .values()
  820. .each(function(key) {
  821. var name = prefix + key;
  822. if ( params[ name ] ) {
  823. params[ name ] = asArray(params[ name ]);
  824. }
  825. })
  826. .value();
  827. return params;
  828. },
  829. /**
  830. * Updates the current state based on the URL query params
  831. * @method MLSearchContext#fromParams
  832. *
  833. * @param {Object} [params] - a URL query params object (defaults to `$location.search()`)
  834. * @return {Promise} a promise resolved once the params have been applied
  835. */
  836. fromParams: function fromParams(params) {
  837. var self = this,
  838. paramsConf = this.options.params,
  839. facets = null,
  840. negatedFacets = null,
  841. optionPromise = null;
  842. params = this.getCurrentParams( params );
  843. this.fromParam( paramsConf.qtext, params,
  844. this.setText.bind(this),
  845. this.setText.bind(this, null)
  846. );
  847. this.fromParam( paramsConf.page, params,
  848. this.setPage.bind(this),
  849. this.setPage.bind(this, 1)
  850. );
  851. this.fromParam( paramsConf.sort, params,
  852. this.setSort.bind(this)
  853. );
  854. self.clearAllFacets();
  855. // _.identity returns it's argument, fromParam returns the callback result
  856. facets = this.fromParam( paramsConf.facets, params, _.identity );
  857. negatedFacets = this.fromParam( paramsConf.negatedFacets, params, _.identity );
  858. if ( !(facets || negatedFacets) ) {
  859. return $q.resolve();
  860. }
  861. // if facet type information is available, options can be undefined
  862. optionPromise = self.results.facets ?
  863. $q.resolve(undefined) :
  864. self.getStoredOptions();
  865. return optionPromise.then(function(options) {
  866. if ( facets ) {
  867. self.fromFacetParam(facets, options);
  868. }
  869. if ( negatedFacets ) {
  870. self.fromFacetParam(negatedFacets, options, true);
  871. }
  872. });
  873. },
  874. /**
  875. * Get the value for the given type of URL param, handling prefixes
  876. * @method MLSearchContext#fromParam
  877. * @private
  878. *
  879. * @param {String} name - URL param name
  880. * @param {Object} params - URL params
  881. * @param {Function} callback - callback invoked with the value of the URL param
  882. * @param {Function} defaultCallback - callback invoked if params are un-prefix'd and no value is provided
  883. */
  884. fromParam: function fromParam(name, params, callback, defaultCallback) {
  885. var prefixedName = this.getParamsPrefix() + name,
  886. value = params[ prefixedName ];
  887. if ( name === null ) {
  888. return;
  889. }
  890. if ( !value ) {
  891. if ( defaultCallback ) {
  892. defaultCallback.call(this);
  893. }
  894. return;
  895. }
  896. if ( _.isString(value) ) {
  897. value = decodeParam(value);
  898. }
  899. return callback.call( this, value );
  900. },
  901. /**
  902. * Updates the current active facets based on the provided facet URL query params
  903. * @method MLSearchContext#fromFacetParam
  904. * @private
  905. *
  906. * @param {Array|String} param - facet URL query params
  907. * @param {Object} [storedOptions] - a searchOptions object
  908. * @param {Boolean} isNegated - whether the facet should be negated (defaults to false)
  909. */
  910. fromFacetParam: function fromFacetParam(param, storedOptions, isNegated) {
  911. var self = this,
  912. values = _.map( asArray(param), decodeParam ),
  913. negated = isNegated || false;
  914. _.each(values, function(value) {
  915. var tokens = value.split( self.options.params.separator ),
  916. facetName = tokens[0],
  917. facetValue = tokens[1],
  918. facetInfo = self.getFacetConfig( facetName, storedOptions ) || {};
  919. if ( !facetInfo.type ) {
  920. console.error('don\'t have facets or options for \'' + facetName +
  921. '\', falling back to un-typed range queries');
  922. }
  923. self.selectFacet( facetName, facetValue, facetInfo.type, negated );
  924. });
  925. },
  926. /**
  927. * Gets the "facet config": either a facet response or a constraint definition object
  928. *
  929. * (this function is called in a tight loop, so loading the options async won't work)
  930. *
  931. * @method MLSearchContext#getFacetConfig
  932. * @private
  933. *
  934. * @param {String} name - facet name
  935. * @param {Object} [storedOptions] - a searchOptions object
  936. * @return {Object} facet config
  937. */
  938. getFacetConfig: function getFacetConfig(name, storedOptions) {
  939. var config = null;
  940. if ( storedOptions ) {
  941. config = _.chain( storedOptions.options.constraint )
  942. .where({ name: name })
  943. .first()
  944. .clone()
  945. .value();
  946. config.type = config.collection ? 'collection' :
  947. config.custom ? 'custom' :
  948. config.range.type;
  949. } else if ( !!this.results.facets && this.results.facets[ name ] ) {
  950. config = this.results.facets[ name ];
  951. }
  952. return config;
  953. },
  954. /**
  955. * Examines the current state, and determines if a new search is needed.
  956. * (intended to be triggered on `$locationChangeSuccess`)
  957. * @method MLSearchContext#locationChange
  958. *
  959. * @param {String} newUrl - the target URL of a location change
  960. * @param {String} oldUrl - the original URL of a location change
  961. * @param {Object} params - the search params of the target URL
  962. *
  963. * @return {Promise} a promise resolved after calling {@link MLSearchContext#fromParams} (if a new search is needed)
  964. */
  965. locationChange: function locationChange(newUrl, oldUrl, params) {
  966. params = this.getCurrentParams( params );
  967. // still on the search page, but there's a new query
  968. var shouldUpdate = pathsEqual(newUrl, oldUrl) &&
  969. !_.isEqual( this.getParams(), params );
  970. if ( !shouldUpdate ) {
  971. return $q.reject();
  972. }
  973. return this.fromParams(params);
  974. },
  975. /* ******************************************************** */
  976. /* ******** MLSearchContext data retrieval methods ******** */
  977. /* ******************************************************** */
  978. /**
  979. * Retrieves stored search options, caching the result in `this.storedOptions`
  980. * @method MLSearchContext#getStoredOptions
  981. *
  982. * @param {String} [name] - the name of the options to retrieve (defaults to `this.getQueryOptions()`)
  983. * @return {Promise} a promise resolved with the stored options
  984. */
  985. getStoredOptions: function getStoredOptions(name) {
  986. var self = this;
  987. name = name || this.getQueryOptions();
  988. if ( this.storedOptions[name] ) {
  989. return $q.resolve( this.storedOptions[name] );
  990. }
  991. return mlRest.queryConfig(name)
  992. .then(function(response) {
  993. // TODO: transform?
  994. self.storedOptions[name] = response.data;
  995. return self.storedOptions[name];
  996. });
  997. },
  998. /**
  999. * Retrieves stored search options, caching the result in `this.storedOptions`
  1000. * @method MLSearchContext#getAllStoredOptions
  1001. *
  1002. * @param {String[]} names - the names of the options to retrieve
  1003. * @return {Promise} a promise resolved with an object containing the requested search options, keyed by name
  1004. */
  1005. getAllStoredOptions: function getAllStoredOptions(names) {
  1006. var self = this,
  1007. result = {};
  1008. // cache any options not already loaded
  1009. return $q.all( _.map(names, self.getStoredOptions.bind(self)) ).then(function() {
  1010. // return only the names requested
  1011. _.each(names, function(name) {
  1012. result[name] = self.storedOptions[name];
  1013. });
  1014. return result;
  1015. });
  1016. },
  1017. /**
  1018. * Retrieves search phrase suggestions based on the current state
  1019. * @method MLSearchContext#suggest
  1020. *
  1021. * @param {String} qtext - the partial-phrase to match
  1022. * @param {String|Object} [options] - string options name (to override suggestOptions), or object for adhoc combined query options
  1023. * @return {Promise} a promise resolved with search phrase suggestions
  1024. */
  1025. suggest: function suggest(qtext, options) {
  1026. var params = {
  1027. 'partial-q': qtext,
  1028. format: 'json',
  1029. options: (_.isString(options) && options) || this.getSuggestOptions() || this.getQueryOptions()
  1030. };
  1031. var combined = this.getCombinedQuerySync();
  1032. if ( _.isObject(options) ) {
  1033. combined.search.options = options;
  1034. }
  1035. return mlRest.suggest(params, combined)
  1036. .then(function(response) {
  1037. return response.data;
  1038. });
  1039. },
  1040. /**
  1041. * Retrieves values from a lexicon (based on a constraint definition)
  1042. * @method MLSearchContext#valuesFromConstraint
  1043. *
  1044. * @param {String} name - the name of a search `constraint` definition
  1045. * @param {Object} [params] - URL params
  1046. * @return {Promise} a promise resolved with values
  1047. */
  1048. valuesFromConstraint: function valuesFromConstraint(name, params) {
  1049. var self = this;
  1050. return this.getStoredOptions()
  1051. .then(function(storedOptions) {
  1052. var constraint = getConstraint(storedOptions, name);
  1053. if ( !constraint ) {
  1054. return $q.reject(new Error('No constraint exists matching ' + name));
  1055. }
  1056. if (constraint.range && (constraint.range.bucket || constraint.range['computed-bucket'])) {
  1057. return $q.reject(new Error('Can\'t get values for bucketed constraint ' + name));
  1058. }
  1059. if (constraint.custom) {
  1060. return $q.reject(new Error('Can\'t get values for custom constraint ' + name));
  1061. }
  1062. var newOptions = valueOptionsFromConstraint(constraint);
  1063. return self.values(name, params, newOptions);
  1064. });
  1065. },
  1066. /**
  1067. * Retrieves values or tuples from 1-or-more lexicons
  1068. * @method MLSearchContext#values
  1069. *
  1070. * @param {String} name - the name of a `value-option` definition
  1071. * @param {Object} [params] - URL params
  1072. * @param {Object} [options] - search options, used in a combined query
  1073. * @return {Promise} a promise resolved with values
  1074. */
  1075. values: function values(name, params, options) {
  1076. var self = this,
  1077. combined = this.getCombinedQuerySync();
  1078. if ( !options && params && params.options && !(params.start || params.limit) ) {
  1079. options = params;
  1080. params = null;
  1081. }
  1082. params = params || {};
  1083. params.start = params.start !== undefined ? params.start : 1;
  1084. params.limit = params.limit !== undefined ? params.limit : 20;
  1085. params.options = self.getQueryOptions();
  1086. if ( _.isObject(options) ) {
  1087. combined.search.options = options;
  1088. }
  1089. return mlRest.values(name, params, combined)
  1090. .then(function(response) {
  1091. return response.data;
  1092. });
  1093. },
  1094. /**
  1095. * Retrieves search results based on the current state
  1096. *
  1097. * If an object is passed as the `adhoc` parameter, the search will be run as a `POST`
  1098. * with a combined query, and the results will not be saved to `MLSearchContext.results`.
  1099. *
  1100. * @method MLSearchContext#search
  1101. *
  1102. * @param {Object} [adhoc] - structured query || combined query || partial search options object
  1103. * @return {Promise} a promise resolved with search results
  1104. */
  1105. search: function search(adhoc) {
  1106. var self = this;
  1107. var params = {
  1108. start: this.start,
  1109. pageLength: this.getPageLength(),
  1110. transform: this.getTransform(),
  1111. options: this.getQueryOptions()
  1112. };
  1113. if ( adhoc ) {
  1114. var combined = this.getCombinedQuerySync();
  1115. if ( adhoc.search ) {
  1116. combined.search = adhoc.search;
  1117. } else if ( adhoc.options ) {
  1118. combined.search.options = adhoc.options;
  1119. } else if ( adhoc.query ) {
  1120. combined.search.query = adhoc.query;
  1121. } else {
  1122. combined.search.options = adhoc;
  1123. }
  1124. } else {
  1125. params.structuredQuery = this.getQuery();
  1126. params.q = this.getText();
  1127. }
  1128. return mlRest.search(params, combined)
  1129. .then(function(response) {
  1130. var results = response.data;
  1131. // the results of adhoc queries aren't preserved
  1132. if ( !combined ) {
  1133. self.results = results;
  1134. }
  1135. self.transformMetadata(results.results);
  1136. self.annotateActiveFacets(results.facets);
  1137. if (self.options.includeAggregates) {
  1138. return self.getAggregates(results.facets)
  1139. .then(function() {
  1140. return results;
  1141. });
  1142. }
  1143. return results;
  1144. });
  1145. },
  1146. /**
  1147. * Annotates facets (from a search response object) with the selections from `this.activeFacets`
  1148. * @method MLSearchContext#annotateActiveFacets
  1149. *
  1150. * @param {Object} facets - facets object from a search response
  1151. */
  1152. annotateActiveFacets: function annotateActiveFacets(facets) {
  1153. var self = this;
  1154. _.forIn( facets, function(facet, name) {
  1155. var selected = self.activeFacets[name];
  1156. if ( selected ) {
  1157. _.chain(facet.facetValues)
  1158. .filter(function(value) {
  1159. return self.isFacetActive(name, value.name);
  1160. })
  1161. .each(function(value) {
  1162. facet.selected = value.selected = true;
  1163. value.negated = self.isFacetNegated(name, value.name);
  1164. })
  1165. .value(); // thwart lazy evaluation
  1166. }
  1167. if ( facet.type === 'bucketed' || facet.type === 'custom' ) {
  1168. facet.displayingAll = true;
  1169. }
  1170. });
  1171. },
  1172. /**
  1173. * Gets aggregates for facets (from a search response object) based on facet type
  1174. * @method MLSearchContext#getAggregates
  1175. *
  1176. * @param {Object} facets - facets object from a search response
  1177. * @return {Promise} a promise resolved once facet aggregates have been retrieved
  1178. */
  1179. getAggregates: function getAggregates(facets) {
  1180. var self = this;
  1181. return self.getStoredOptions()
  1182. .then(function(storedOptions) {
  1183. var promises = [];
  1184. try {
  1185. _.forIn( facets, function(facet, facetName) {
  1186. var facetType = facet.type,
  1187. constraint = getConstraint(storedOptions, facetName);
  1188. if ( !constraint ) {
  1189. throw new Error('No constraint exists matching ' + facetName);
  1190. }
  1191. var newOptions = valueOptionsFromConstraint(constraint);
  1192. // TODO: update facetType from constraint ?
  1193. // TODO: make the choice of aggregates configurable
  1194. // these work for all index types
  1195. newOptions.values.aggregate = [
  1196. { apply: 'count' },
  1197. { apply: 'min' },
  1198. { apply: 'max' }
  1199. ];
  1200. // TODO: move the scalar-type -> aggregate mappings to MLRest (see https://gist.github.com/joemfb/b682504c7c19cd6fae11)
  1201. var numberTypes = [
  1202. 'xs:int',
  1203. 'xs:unsignedInt',
  1204. 'xs:long',
  1205. 'xs:unsignedLong',
  1206. 'xs:float',
  1207. 'xs:double',
  1208. 'xs:decimal'
  1209. ];
  1210. if ( _.contains(numberTypes, facetType) ) {
  1211. newOptions.values.aggregate = newOptions.values.aggregate.concat([
  1212. { apply: 'sum' },
  1213. { apply: 'avg' }
  1214. // TODO: allow enabling these from config?
  1215. // { apply: 'median' },
  1216. // { apply: 'stddev' },
  1217. // { apply: 'stddev-population' },
  1218. // { apply: 'variance' },
  1219. // { apply: 'variance-population' }
  1220. ]);
  1221. }
  1222. promises.push(
  1223. self.values(facetName, { start: 1, limit: 0 }, newOptions)
  1224. .then(function(resp) {
  1225. var aggregates = resp && resp['values-response'] && resp['values-response']['aggregate-result'];
  1226. _.each( aggregates, function(aggregate) {
  1227. facet[aggregate.name] = aggregate._value;
  1228. });
  1229. })
  1230. );
  1231. });
  1232. } catch (err) {
  1233. return $q.reject(err);
  1234. }
  1235. return $q.all(promises);
  1236. });
  1237. },
  1238. /**
  1239. * Transforms the metadata array in each search response result object to an object, key'd by `metadata-type`
  1240. * @method MLSearchContext#transformMetadata
  1241. *
  1242. * @param {Object} result - results array from a search response (or one result object from the array)
  1243. */
  1244. transformMetadata: function transformMetadata(result) {
  1245. var self = this,
  1246. metadata;
  1247. if ( _.isArray(result) ) {
  1248. _.each(result, this.transformMetadata, self);
  1249. return;
  1250. }
  1251. metadata = result.metadata;
  1252. result.metadata = {};
  1253. _.each(metadata, function(obj) {
  1254. var key = _.without(_.keys(obj), 'metadata-type')[0],
  1255. type = obj[ 'metadata-type' ],
  1256. value = obj[ key ],
  1257. shortKey = null,
  1258. prefix = null,
  1259. ns = null;
  1260. ns = key.replace(/^\{([^}]+)\}.*$/, '$1');
  1261. prefix = self.getNamespacePrefix(ns);
  1262. if ( prefix ) {
  1263. shortKey = key.replace(/\{[^}]+\}/, prefix + ':');
  1264. } else {
  1265. shortKey = key;
  1266. }
  1267. if ( !result.metadata[ shortKey ] ) {
  1268. result.metadata[ shortKey ] = { 'metadata-type': type, values: [] };
  1269. }
  1270. result.metadata[ shortKey ].values.push(value);
  1271. });
  1272. },
  1273. /**
  1274. * @method MLSearchContext#getStructuredQuery
  1275. * @deprecated
  1276. *
  1277. * @see MLSearchContext#getQuery
  1278. */
  1279. getStructuredQuery: function getStructuredQuery() {
  1280. console.log(
  1281. 'Warning, MLSearchContext.getStructuredQuery is deprecated, and will be removed in the next release!\n' +
  1282. 'Use MLSearchContext.getQuery in it\'s place'
  1283. );
  1284. return this.getQuery.apply(this, arguments);
  1285. },
  1286. /**
  1287. * @method MLSearchContext#serializeStructuredQuery
  1288. * @deprecated
  1289. *
  1290. * @see MLSearchContext#getParams
  1291. */
  1292. serializeStructuredQuery: function serializeStructuredQuery() {
  1293. console.log(
  1294. 'Warning, MLSearchContext.serializeStructuredQuery is deprecated, and will be removed in the next release!\n' +
  1295. 'Use MLSearchContext.getParams in it\'s place'
  1296. );
  1297. return this.getParams.apply(this, arguments);
  1298. }
  1299. });
  1300. function decodeParam(value) {
  1301. return decodeURIComponent(value.replace(/\+/g, '%20'));
  1302. }
  1303. function pathsEqual(newUrl, oldUrl) {
  1304. // TODO: use $$urlUtils.urlResolve(), once it's available
  1305. // see: https://github.com/angular/angular.js/pull/3302
  1306. // from: https://stackoverflow.com/questions/21516891
  1307. function pathName(href) {
  1308. var x = document.createElement('a');
  1309. x.href = href;
  1310. return x.pathname;
  1311. }
  1312. return pathName(newUrl) === pathName(oldUrl);
  1313. }
  1314. function getConstraint(storedOptions, name) {
  1315. return storedOptions && storedOptions.options && storedOptions.options.constraint &&
  1316. _.where(asArray(storedOptions.options.constraint), { name: name })[0];
  1317. }
  1318. function valueOptionsFromConstraint(constraint) {
  1319. var options = { constraint: asArray(constraint), values: asArray(_.cloneDeep(constraint)) };
  1320. // TODO: error if constraint.custom || constraint.range.bucket
  1321. options.values[0]['values-option'] = constraint.range && constraint.range['facet-option'];
  1322. return options;
  1323. }
  1324. // TODO: move to util module
  1325. function asArray() {
  1326. var args;
  1327. /* istanbul ignore else */
  1328. if ( arguments.length === 1) {
  1329. if (Array.isArray( arguments[0] )) {
  1330. args = arguments[0];
  1331. } else {
  1332. args = [ arguments[0] ];
  1333. }
  1334. } else {
  1335. args = [].slice.call(arguments);
  1336. }
  1337. return args;
  1338. }
  1339. })();