dragoman 0.5.16

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. `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: '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. */
export const noSearchFilters = () =>
  Object.fromEntries(SEARCH_FACETS.map(({ key }) => [key, null]))

/** Whether any facet value is picked. */
export const anySearchFilter = filters =>
  SEARCH_FACETS.some(({ key }) => Boolean(filters?.[key]))

/**
 * 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,
 * and an unknown kind falls back to works the way the server does.
 */
export function searchStateFromSearch(search) {
  const params = new URLSearchParams(search ?? '')
  const value = key => {
    const v = params.get(key)
    return v && v.trim() ? v.trim() : null
  }
  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, value(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 that is not picked — so a cleared filter leaves no
 * trace in a shared link, and the plain homepage stays `/`.
 */
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) {
    const value = filters?.[key]
    if (value) params.set(param, value)
  }
  if (page > 1) params.set('page', String(page))
  const query = params.toString()
  return query ? `/?${query}` : '/'
}