ICIJ/datashare-client

View on GitHub
src/store/modules/search.js

Summary

Maintainability
F
3 days
Test Coverage
import {
  castArray,
  concat,
  cloneDeep,
  each,
  endsWith,
  flatten,
  filter as filterCollection,
  find,
  findIndex,
  get,
  has,
  includes,
  isString,
  join,
  keys,
  map,
  omit,
  orderBy,
  range,
  random,
  reduce,
  toString,
  uniq
} from 'lodash'
import lucene from 'lucene'
import Vue from 'vue'

import EsDocList from '@/api/resources/EsDocList'
import filters from '@/store/filters'
import * as filterTypes from '@/store/filters'
import { isNarrowScreen } from '@/utils/screen'
import settings from '@/utils/settings'

export const TAB_NAME = {
  EXTRACTED_TEXT: 'extracted-text',
  PREVIEW: 'preview',
  DETAILS: 'details',
  NAMED_ENTITIES: 'named-entities'
}

export function initialState() {
  return cloneDeep({
    error: null,
    field: settings.defaultSearchField,
    filters,
    from: 0,
    index: '',
    indices: [],
    isReady: true,
    // Different default layout for narrow screen
    layout: isNarrowScreen() ? 'table' : 'list',
    query: '',
    response: EsDocList.none(),
    reversedFilters: [],
    contextualizedFilters: [],
    sortedFilters: {},
    showFilters: true,
    size: 25,
    sort: settings.defaultSearchSort,
    values: {},
    tab: TAB_NAME.EXTRACTED_TEXT
  })
}

export const state = initialState()

export const getters = {
  instantiateFilter(state, getters, rootState) {
    return ({ type = 'FilterText', options } = {}) => {
      const Type = filterTypes[type]
      const filter = new Type(options)
      // Bind current state to be able to retrieve its values
      filter.bindRootState(rootState)
      // Return the instance
      return filter
    }
  },
  instantiatedFilters(state, getters) {
    return orderBy(
      state.filters.map((filter) => getters.instantiateFilter(filter)),
      'order',
      'asc'
    )
  },
  getFilter(state, getters) {
    return (predicate) => find(getters.instantiatedFilters, predicate)
  },
  getFields(state) {
    return () => find(settings.searchFields, { key: state.field }).fields
  },
  hasFilterValue(state, getters) {
    return (item) =>
      !!find(getters.instantiatedFilters, ({ name, values }) => {
        return name === item.name && values.indexOf(item.value) > -1
      })
  },
  hasFilterValues(state, getters) {
    return (name) => !!find(getters.instantiatedFilters, (filter) => filter.name === name && filter.values.length > 0)
  },
  isFilterContextualized(state, getters) {
    return (name) => {
      return !!find(getters.instantiatedFilters, (filter) => {
        return filter.name === name && filter.contextualized
      })
    }
  },
  isFilterExcluded(state, getters) {
    return (name) => {
      return !!find(getters.instantiatedFilters, (filter) => {
        return filter.name === name && filter.reverse
      })
    }
  },
  filterSorted(state, getters) {
    return (name) => {
      return getters.getFilter({ name })
    }
  },
  filterSortedBy(state, getters) {
    return (name) => {
      return getters.filterSorted(name).sortBy
    }
  },
  filterSortedByOrder(state, getters) {
    return (name) => {
      return getters.filterSorted(name).sortByOrder
    }
  },
  activeFilters(state, getters) {
    return filterCollection(getters.instantiatedFilters, (f) => f.hasValues())
  },
  filterValuesAsRouteQuery(state, getters) {
    return () => {
      return reduce(
        keys(state.values),
        (memo, name) => {
          // We need to look for the filter's definition in order to us its `id`
          // as key for the URL params. This was we track configured filter instead
          // of arbitrary values provided by the user. This allow to retrieve special
          // behaviors depending on the filter definition.
          const filter = find(getters.instantiatedFilters, { name })
          // We don't add filterValue that match with any existing filters
          // defined in the `aggregation` store.
          if (filter && filter.values.length > 0) {
            const key = filter.reverse ? `f[-${filter.name}]` : `f[${filter.name}]`
            memo[key] = filter.values
          }
          return memo
        },
        {}
      )
    }
  },
  toRouteQuery(state, getters) {
    return () => ({
      q: state.query,
      from: state.from,
      size: state.size,
      sort: state.sort,
      indices: state.indices.join(','),
      field: state.field,
      tab: state.tab,
      ...getters.filterValuesAsRouteQuery()
    })
  },
  toRouteQueryWithStamp(state, getters) {
    return () => ({
      ...getters.toRouteQuery(),
      // A random string of 6 chars
      stamp: String.fromCharCode.apply(
        null,
        range(6).map(() => random(97, 122))
      )
    })
  },
  retrieveQueryTerms(state) {
    let terms = []
    function getTerm(query, path, start, operator, isFuzzyNumber = false) {
      const term = get(query, join([path, 'term'], '.'), '')
      const field = get(query, join([path, 'field'], '.'), '')
      const prefix = get(query, join([path, 'prefix'], '.'), '')
      const regex = get(query, join([path, 'regex'], '.'), false)
      const negation = ['-', '!'].includes(prefix) || start === 'NOT' || endsWith(operator, 'NOT')
      if (term !== '*' && term !== '' && !includes(map(terms, 'label'), term) && !isFuzzyNumber) {
        terms = concat(terms, {
          field: field === '<implicit>' ? '' : field,
          label: term.replace('\\', ''),
          negation,
          regex
        })
      }
      if (term === '' && has(query, join([path, 'left'], '.'))) {
        retTerms(get(query, 'left'))
      }
    }
    function retTerms(query, operator = null, isLeftFuzzyNumber = false) {
      getTerm(query, 'left', get(query, 'start', null), operator, isLeftFuzzyNumber)
      const isRightFuzzyNumber = get(query, 'left.similarity', null) !== null
      if (get(query, 'right.left', null) === null) {
        getTerm(query, 'right', null, get(query, 'operator', null), isRightFuzzyNumber)
      } else {
        retTerms(get(query, 'right'), get(query, 'operator', null), isRightFuzzyNumber)
      }
    }
    try {
      retTerms(lucene.parse(state.query.replace('\\@', '@')))
      return terms
    } catch (_) {
      return []
    }
  },
  retrieveContentQueryTerms(state, getters) {
    const fields = ['', 'content']
    return filterCollection(getters.retrieveQueryTerms, (item) => fields.includes(item.field))
  },
  sortBy(state) {
    return find(settings.searchSortFields, { name: state.sort })
  }
}

export const mutations = {
  reset(state, excludedKeys = ['index', 'indices', 'showFilters', 'layout', 'size', 'sort']) {
    const s = initialState()
    Object.keys(s).forEach((key) => {
      if (excludedKeys.indexOf(key) === -1) {
        Vue.set(state, key, s[key])
      }
    })
  },
  resetFilters(state) {
    const { filters } = initialState()
    Vue.set(state, 'filters', filters)
  },
  resetFiltersAndValues(state) {
    const { filters } = initialState()
    Vue.set(state, 'filters', filters)
    Vue.set(state, 'values', {})
    Vue.set(state, 'from', 0)
  },
  resetFilterValues(state) {
    Vue.set(state, 'values', {})
    Vue.set(state, 'from', 0)
  },
  resetQuery(state) {
    Vue.set(state, 'query', '')
    Vue.set(state, 'field', settings.defaultSearchField)
    Vue.set(state, 'from', 0)
  },
  query(state, query) {
    Vue.set(state, 'query', query)
  },
  q(state, query) {
    Vue.set(state, 'query', query)
  },
  from(state, from) {
    Vue.set(state, 'from', Number(from))
  },
  size(state, size) {
    Vue.set(state, 'size', Number(size))
  },
  sort(state, sort) {
    Vue.set(state, 'sort', sort)
  },
  isReady(state, isReady = !state.isReady) {
    Vue.set(state, 'isReady', isReady)
  },
  error(state, error = null) {
    Vue.set(state, 'error', error)
  },
  index(state, index) {
    Vue.set(state, 'index', index)
    Vue.set(state, 'indices', [index])
  },
  indices(state, indices) {
    // Clean indices list to ensure we received an array. This means
    // this mutation can also receive a string with a comma separated
    // list of indices.
    const cleaned = flatten(castArray(indices).map((str) => str.split(',')))
    Vue.set(state, 'indices', cleaned)
    Vue.set(state, 'index', cleaned[0])
  },
  layout(state, layout) {
    Vue.set(state, 'layout', layout)
  },
  field(state, field) {
    const fields = settings.searchFields.map((field) => field.key)
    Vue.set(state, 'field', fields.indexOf(field) > -1 ? field : settings.defaultSearchField)
  },
  buildResponse(state, raw) {
    Vue.set(state, 'response', new EsDocList(raw))
  },
  addFilterValue(state, filter) {
    // We cast the new filter values to allow several new values at the same time
    const values = castArray(filter.value)
    // Look for existing values for this name
    const existingValues = get(state, ['values', filter.name], [])
    const existingValuesAsString = map(existingValues, (value) => toString(value))
    Vue.set(state.values, filter.name, uniq(existingValuesAsString.concat(values)))
  },
  setFilterValue(state, filter) {
    const values = castArray(filter.value)
    Vue.set(state.values, filter.name, values)
  },
  addFilterValues(state, { filter, values }) {
    const existingValues = get(state, ['values', filter.name], [])
    Vue.set(state.values, filter.name, uniq(existingValues.concat(castArray(values))))
  },
  removeFilterValue(state, filter) {
    // Look for existing values for this name
    const existingValues = get(state, ['values', filter.name], [])
    // Filter the values for this name to remove the given value
    Vue.set(
      state.values,
      filter.name,
      filterCollection(existingValues, (value) => value !== filter.value)
    )
  },
  removeFilter(state, name) {
    const i = findIndex(state.filters, ({ options }) => options.name === name)
    Vue.delete(state.filters, i)
    if (name in state.values) {
      Vue.delete(state.values, name)
    }
  },
  addFilter(state, { type = 'FilterText', options = {}, position = null } = {}) {
    if (!find(state.filters, (filter) => filter.options.name === options.name)) {
      if (position === null) {
        state.filters.push({ type, options })
      } else {
        state.filters.splice(position, 0, { type, options })
      }
    }
  },
  sortFilter(state, { name, sortBy = '_count', sortByOrder = 'desc' } = {}) {
    Vue.set(state.sortedFilters, name, { sortBy, sortByOrder })
  },
  unsortFilter(state, name) {
    Vue.delete(state.sortedFilters, name)
  },
  contextualizeFilter(state, name) {
    if (state.contextualizedFilters.indexOf(name) === -1) {
      state.contextualizedFilters.push(name)
    }
  },
  decontextualizeFilter(state, name) {
    Vue.delete(state.contextualizedFilters, state.contextualizedFilters.indexOf(name))
  },
  toggleContextualizedFilter(state, name) {
    const i = state.contextualizedFilters.indexOf(name)
    if (i === -1) {
      state.contextualizedFilters.push(name)
    } else {
      Vue.delete(state.contextualizedFilters, i)
    }
  },
  excludeFilter(state, name) {
    if (state.reversedFilters.indexOf(name) === -1) {
      state.reversedFilters.push(name)
    }
  },
  includeFilter(state, name) {
    Vue.delete(state.reversedFilters, state.reversedFilters.indexOf(name))
  },
  toggleFilter(state, name) {
    const i = state.reversedFilters.indexOf(name)
    if (i === -1) {
      state.reversedFilters.push(name)
    } else if (i > -1) {
      Vue.delete(state.reversedFilters, i)
    }
  },
  toggleFilters(state, toggler = !state.showFilters) {
    Vue.set(state, 'showFilters', toggler)
  },
  updateTab(state, tab) {
    state.tab = tab
  }
}

function actionsBuilder(api) {
  return {
    async refresh({ state, commit, getters }, updateIsReady = true) {
      commit('isReady', !updateIsReady)
      commit('error', null)
      try {
        const indices = state.indices.join(',')
        const raw = await api.elasticsearch.searchDocs(
          indices,
          state.query,
          getters.instantiatedFilters,
          state.from,
          state.size,
          state.sort,
          getters.getFields()
        )
        commit('buildResponse', raw)
        commit('isReady', true)
        return raw
      } catch (error) {
        commit('isReady', true)
        commit('error', error)
        throw error
      }
    },
    updateFromRouteQuery({ commit, getters }, query) {
      const excludedKeys = ['index', 'indices', 'showFilters', 'field', 'layout', 'tab']
      const updatedKeys = ['q', 'index', 'indices', 'from', 'size', 'sort', 'field']
      commit('reset', excludedKeys)
      // Add the query to the state with a mutation to avoid triggering a search
      updatedKeys.forEach((key) => (key in query ? commit(key, query[key]) : null))
      // Iterate over the list of filter
      each(getters.instantiatedFilters, (filter) => {
        // The filter key are formatted in the URL as follow.
        // See `query-string` for more info about query string format.
        each([`f[${filter.name}]`, `f[-${filter.name}]`], (key, isReverse) => {
          // Add the data if the value exist
          if (key in query) {
            // Because the values are grouped for each query parameter and because
            // the `addFilterValue` also accept an array of value, we can directly
            // use the query values.
            commit('addFilterValue', filter.itemParam({ key: query[key] }))
            // Invert the filter if we are using the second key (for reverse filter)
            if (isReverse) {
              commit('excludeFilter', filter.name)
            }
          }
        })
      })
    },
    query(
      { state, commit, dispatch },
      q = {
        index: state.index,
        indices: state.indices,
        query: state.query,
        from: state.from,
        size: state.size,
        sort: state.sort,
        field: state.field
      }
    ) {
      // Only the "query" parameter must be treaten differently
      if (has(q, 'query')) {
        commit('query', q.query)
      } else if (isString(q)) {
        commit('query', q)
      }
      // Then mutates all values if they are in queryOrParams. The mutation
      // for "indices" must be after "index" since the two mutations are
      // updating concurent values.
      ;['index', 'indices', 'from', 'size', 'sort', 'field'].forEach((key) => {
        if (has(q, key)) {
          commit(key, q[key])
        }
      })
      return dispatch('refresh', true)
    },
    queryFilter({ state, getters }, params) {
      return api.elasticsearch
        .searchFilter(
          state.indices.join(','),
          getters.getFilter({ name: params.name }),
          state.query,
          getters.instantiatedFilters,
          !getters.isFilterContextualized(params.name),
          params.options,
          getters.getFields(),
          params.from,
          params.size
        )
        .then((raw) => new EsDocList(raw))
    },
    setFilterValue({ commit, dispatch }, filter) {
      commit('setFilterValue', filter)
      return dispatch('query')
    },
    addFilterValue({ commit, dispatch }, filter) {
      commit('addFilterValue', filter)
      return dispatch('query')
    },
    removeFilterValue({ commit, dispatch }, filter) {
      commit('removeFilterValue', filter)
      return dispatch('query')
    },
    resetFilterValues({ commit, dispatch }, name) {
      commit('resetFilterValues', name)
      return dispatch('query')
    },
    toggleFilter({ commit, dispatch }, name) {
      commit('toggleFilter', name)
      return dispatch('query')
    },
    previousPage({ state, commit, dispatch }, name) {
      commit('from', state.from - state.size)
      return dispatch('query')
    },
    nextPage({ state, commit, dispatch }, name) {
      commit('from', state.from + state.size)
      return dispatch('query')
    },
    deleteQueryTerm({ state, commit, dispatch }, term) {
      function deleteQueryTermFromSimpleQuery(query) {
        if (get(query, 'left.term', '') === term) query = omit(query, 'left')
        if (get(query, 'right.term', '') === term) query = omit(query, 'right')
        if (has(query, 'left.left')) query.left = deleteQueryTermFromSimpleQuery(get(query, 'left', null))
        if (has(query, 'right.left')) query.right = deleteQueryTermFromSimpleQuery(get(query, 'right', null))
        if (has(query, 'right.right') && !has(query, 'right.left') && get(query, 'operator', '').includes('NOT'))
          query.operator = '<implicit>'
        if (has(query, 'start') && !has(query, 'left')) query = omit(query, 'start')
        if (has(query, 'operator') && (!has(query, 'left') || !has(query, 'right'))) query = omit(query, 'operator')
        if (has(query, 'parenthesized') && (!has(query, 'left') || !has(query, 'right')))
          query = omit(query, 'parenthesized')
        return query
      }

      const query = deleteQueryTermFromSimpleQuery(lucene.parse(state.query))
      commit('query', lucene.toString(query))
      return dispatch('query')
    },
    async runBatchDownload({ state, getters }, uri = null) {
      // CD TODO: untested function
      const q = ['', null, undefined].indexOf(state.query) === -1 ? state.query : '*'
      const { indices: projectIds } = state
      const { query } = api.elasticsearch.rootSearch(getters.instantiatedFilters, q).build()
      return api.runBatchDownload({
        projectIds,
        query,
        uri
      })
    },
    setTab({ state, commit }, tab) {
      if (state.tab !== tab) {
        commit('updateTab', tab)
      }
    }
  }
}

export function searchStoreBuilder(api) {
  return {
    namespaced: true,
    state,
    getters,
    mutations,
    actions: actionsBuilder(api)
  }
}