dragoman 0.5.18

Server for scholarly metadata in commonmeta format stored in a SQLite database.
/**
 * The search state the URL carries, and the URL a state reads as.
 *
 * A result list lives in the address bar: the query, the kind, the facet values
 * picked and the page. That is what makes a filtered list linkable, reloadable
 * and steppable with the back button — and it is the one piece of the search UI
 * with enough edge cases to be worth testing on its own, so it lives here
 * rather than inside the component.
 */

/** The entity kinds a search can be restricted to, in the ⌘K cycle order. */
export const SEARCH_KINDS = ['works', 'people', 'organizations', 'containers']

/** The default kind: the bulk of any store, and the one kind every store has. */
export const SEARCH_KIND_DEFAULT = 'works'

/**
 * Every facet: the state key it is held under, the URL parameter it travels
 * as, the field of the facets response that counts it, the kinds that have it,
 * and the heading each of those kinds gives it.
 *
 * A facet belongs to the kind that carries it — a person has no publication
 * year, a work no country — and a kind names it as its own card does, so a
 * country reads "Location" beside an organization and "Country" beside a
 * person, and `content` is headed the way a work's own card heads the files it
 * points at. `type` serves two kinds because the index counts a work's commonmeta
 * type and an organization's ROR type under one root, which is safe: a query
 * is always restricted to one kind. Containers appear in no `kinds` list: a
 * container's works are its own page's business, and its title and publisher
 * are what the query already matched.
 */
export const SEARCH_FACETS = [
  { key: 'year',    param: 'year',    counts: 'years',     label: { works: 'search.facet_year' } },
  { key: 'lang',    param: 'lang',    counts: 'languages', label: { works: 'search.facet_language' } },
  { key: 'type',    param: 'type',    counts: 'types',     label: { works: 'search.facet_type', organizations: 'org.types' } },
  { key: 'fos',     param: 'fos',     counts: 'subjects',  label: { works: 'search.facet_subject' } },
  { key: 'license', param: 'license', counts: 'licenses',  label: { works: 'search.facet_license' } },
  { key: 'content', param: 'content', counts: 'contents',  label: { works: 'bibliography.content' } },
  { key: 'country', param: 'country', counts: 'countries', label: { people: 'entity.country', organizations: 'org.location' } },
]

/**
 * The facets a kind has, each with the heading that kind gives it. Empty for a
 * kind with none, which is what hides the sidebar.
 */
export function searchFacetsFor(kind) {
  return SEARCH_FACETS.filter(facet => facet.label[kind]).map(facet => ({
    ...facet,
    heading: facet.label[kind],
  }))
}

/**
 * Whether a query is worth running.
 *
 * Two characters, so a search does not fire on the first keystroke — except
 * for `*`, which is the query that asks for everything of the chosen kind and
 * is only one character long. The index answers it as a match-all, so it is
 * the whole list, paged and faceted like any other result.
 */
export const SEARCH_ALL = '*'

export function isSearchableQuery(q) {
  const query = (q ?? '').trim()
  return query === SEARCH_ALL || query.length >= 2
}

/**
 * No facet value picked.
 *
 * Each facet holds the list of values picked for it. Within a facet they are
 * ORed and across facets ANDed, so asking for two countries widens that one
 * facet without giving up the type also picked — and the sidebar's counts stay
 * put either way, since they are taken from the unnarrowed match.
 */
export const noSearchFilters = () =>
  Object.fromEntries(SEARCH_FACETS.map(({ key }) => [key, []]))

/** The values picked for one facet, as a list whatever the state holds. */
export const searchFilterValues = (filters, key) => {
  const picked = filters?.[key]
  if (Array.isArray(picked)) return picked.filter(value => value || value === 0)
  // A single value, which is what a link written before facets held lists says.
  return picked ? [picked] : []
}

/** Whether any facet value is picked. */
export const anySearchFilter = filters =>
  SEARCH_FACETS.some(({ key }) => searchFilterValues(filters, key).length > 0)

/** Whether this value of this facet is one of the ones picked. */
export const isSearchFilterPicked = (filters, key, value) =>
  searchFilterValues(filters, key).includes(value)

/**
 * The filters with one value of one facet added, or removed if it was already
 * picked — which is what a click on a facet means.
 *
 * A value is appended rather than inserted, so the order of a link is the
 * order the sidebar was clicked in.
 */
export function toggleSearchFilter(filters, key, value) {
  const picked = searchFilterValues(filters, key)
  return {
    ...filters,
    [key]: picked.includes(value)
      ? picked.filter(v => v !== value)
      : [...picked, value],
  }
}

/**
 * The state a query string describes. An absent or blank value is no value, so
 * a stray `?year=` selects nothing rather than the works published in no year,
 * a facet repeated is all of its values, and an unknown kind falls back to
 * works the way the server does.
 */
export function searchStateFromSearch(search) {
  const params = new URLSearchParams(search ?? '')
  // Every occurrence of the parameter, since a facet may be picked more than
  // once; a blank one is no value, and the same value twice selects the same
  // records, so it is kept once.
  const values = key => {
    const picked = []
    for (const value of params.getAll(key)) {
      const trimmed = value.trim()
      if (trimmed && !picked.includes(trimmed)) picked.push(trimmed)
    }
    return picked
  }
  const kind = params.get('kind')
  const page = Number(params.get('page'))
  return {
    q: params.get('q') ?? '',
    kind: SEARCH_KINDS.includes(kind) ? kind : SEARCH_KIND_DEFAULT,
    filters: Object.fromEntries(
      SEARCH_FACETS.map(({ key, param }) => [key, values(param)]),
    ),
    // A page that is not a counting number is the first one: `?page=0`,
    // `?page=-3` and `?page=nonsense` would all otherwise ask the index for a
    // negative offset.
    page: Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1,
  }
}

/**
 * The URL a state reads as, as a root-relative href.
 *
 * Only what differs from the default is written: no `kind=works`, no `page=1`,
 * and nothing for a facet with nothing picked — so a cleared filter leaves no
 * trace in a shared link, and the plain homepage stays `/`. A facet with
 * several values written once per value, in the order they were picked.
 */
export function searchHrefFor({ q = '', kind = SEARCH_KIND_DEFAULT, filters = {}, page = 1 } = {}) {
  const params = new URLSearchParams()
  if (q.trim()) params.set('q', q.trim())
  if (kind && kind !== SEARCH_KIND_DEFAULT) params.set('kind', kind)
  for (const { key, param } of SEARCH_FACETS) {
    // One parameter per value picked: a facet asking for two countries reads
    // as `?country=DE&country=AT`. A comma-separated list would not do — an
    // OECD field has commas in it.
    for (const value of searchFilterValues(filters, key)) params.append(param, value)
  }
  if (page > 1) params.set('page', String(page))
  const query = params.toString()
  return query ? `/?${query}` : '/'
}