client/stores/selectors/filtered-torrents.ts

import FlexSearch from 'flexsearch';
import { createSelector } from '@reduxjs/toolkit';

import { RootState } from '../state';
import { TorrentClasses } from '@client/models';

/**
 * Construct and return a predicate which given some torrent id
 * will return whether the class of the associated torrent matches
 * the active torrent class in puddles filter.
 */
const selectTorrentsMatchingClassPredicate =
  createSelector(
    [(state: RootState) => state.torrents.byClass,
     (state: RootState) => state.ui.filters.classes],
    (byClasses, activeClass) => {
      // we ignore the single source of truth principle here
      // because implicitly there's no need to have a check
      // for this... unless our store schema is broken.
      if (activeClass === TorrentClasses.ALL) {
        return () => true
      }

      const activeClassIds = new Set(byClasses[activeClass])
      return (id: number) => activeClassIds.has(id)
    }
  )

/**
 * Convert a mapping from some index type to torrent ids, into
 * a set of unique ids filtered by membership in {@code active}.
 *
 * @param collection of some arbitrary identifier to torrent ids
 * @param active, subset of keys in collection
 * @returns the flattened unique set of torrent ids retrieved by
 *          mapping each key in {@code active} through
 *          {@code collection}.
 */
function reduceMapping(collection: { [key: string]: number[] }, active: string[]) {
  return active.reduce((acc, key) => {
    const entries = collection[key]
    if (entries) {
      entries.forEach(v => acc.add(v))
    }

    return acc
  }, new Set())
}

/**
 * Construct and return a predicate which, given some torrent id
 * returns whether the associated torrent has a tracker in the
 * active trackers filter. If the filter is empty then every torrent
 * passes the filter.
 */
const selectTorrentsMatchingTrackersPredicate =
  createSelector(
    [(state: RootState) => state.torrents.byTracker,
     (state: RootState) => state.ui.filters.trackers],
    (byTracker, activeTrackers) => {
      if (activeTrackers.length === 0) {
        return () => true
      }

      const activeTrackerIds = reduceMapping(byTracker, activeTrackers)
      return (id: number) => activeTrackerIds.has(id)
    }
  )

/**
 * Like {@code selectTorrentsMatchingTrackersPredicate} but targets
 * torrent labels instead of torrent trackers.
 */
const selectTorrentsMatchingLabelsPredicate =
  createSelector(
    [(state: RootState) => state.torrents.byLabels,
     (state: RootState) => state.ui.filters.labels],
    (byLabel, activeLabels) => {
      if (activeLabels.length === 0) {
        return () => true
      }

      const activeLabelIds = reduceMapping(byLabel, activeLabels)
      return (id: number) => activeLabelIds.has(id);
    }
  )

/**
 * Construct and return a flexsearch search index for the current
 * list of torrent names. Using {@code createSelector} here means
 * that this index will only be reconstructed when a torrents name
 * changes... which is very unlikely, so for the vast majority of
 * puddles runtime, it'll be cached.
 *
 * tldr; MAJOR MEMORY AND RUNTIME BENEFITS.
 */
const selectTorrentsSearchIndex =
  createSelector(
    [(state: RootState) => state.torrents.toName],
    (toName) => {
      const index = FlexSearch.create({
        async: false,
        encode: "icase",
        tokenize: "reverse",
        threshold: 1,
        resolution: 3,
        depth: 2,
      })

      Object.entries(toName)
        .forEach(([id, name]) => index.add(Number(id), name))

      return index
    }
  )

/**
 * Construct and return a predicate which will, given some torrent id,
 * will return whether that torrents name matches the query.
 */
const selectTorrentsMatchingQueryPredicate =
  createSelector(
    [selectTorrentsSearchIndex,
     (state: RootState) => state.ui.filters.query],
    (index, query) => {
      if (query.trim() === '') {
        return () => true
      }

      // flexsearch doesn't support synchronous rendering types yet, for now
      // we'll bypass type safety but hopefully, soon, we won't need to.
      const res = index.search(query) as unknown as number[]
      const hash = new Set(res)
      return (id: number) => hash.has(id)
    }
  )

/**
 * Returns the ids of the subset of torrents in the torrent list
 * which the filters will accept IN LINEAR TIME.
 *
 * This is a heavily optomised selector for filtering through the
 * active torrents list. It works based on caching the list of
 * torrents matching each individual filter (each of which is only
 * rebuilt when their associated filter properties are modified)
 * and then simply for membership in all of the possible filter
 * predicated.
 */
export const selectFilteredTorrents = createSelector(
  [(state: RootState) => state.torrents.ordered,
   selectTorrentsMatchingClassPredicate,
   selectTorrentsMatchingLabelsPredicate,
   selectTorrentsMatchingTrackersPredicate,
   selectTorrentsMatchingQueryPredicate],
  (ids, classPredicate, labelsPredicate, trackersPredicate, queryPredicate) => {
    return ids.filter(
      id => classPredicate(id) &&
            trackersPredicate(id) &&
            labelsPredicate(id) &&
            queryPredicate(id))
   }
)