<commit_before>import {
  defaultGlobalSettings,
  DestinyVersion,
  GlobalSettings,
  ItemAnnotation,
  ItemHashTag,
  Loadout,
  ProfileUpdateResult,
  Search,
  TagValue,
} from '@destinyitemmanager/dim-api-types';
import { DestinyAccount } from 'app/accounts/destiny-account';
import { convertDimLoadoutToApiLoadout } from 'app/loadout-drawer/loadout-type-converters';
import { recentSearchComparator } from 'app/search/autocomplete';
import { searchConfigSelector } from 'app/search/search-config';
import { parseAndValidateQuery } from 'app/search/search-utils';
import { RootState } from 'app/store/types';
import { emptyArray } from 'app/utils/empty';
import { errorLog, infoLog, timer } from 'app/utils/log';
import { count } from 'app/utils/util';
import { clearWishLists } from 'app/wishlists/actions';
import { deepEqual } from 'fast-equals';
import produce, { Draft } from 'immer';
import _ from 'lodash';
import { ActionType, getType } from 'typesafe-actions';
import * as inventoryActions from '../inventory/actions';
import * as loadoutActions from '../loadout-drawer/actions';
import { Loadout as DimLoadout } from '../loadout-drawer/loadout-types';
import * as settingsActions from '../settings/actions';
import { initialSettingsState, Settings } from '../settings/initial-settings';
import { DeleteLoadoutUpdateWithRollback, ProfileUpdateWithRollback } from './api-types';
import * as actions from './basic-actions';
import { makeProfileKey, makeProfileKeyFromAccount } from './selectors';

// After you've got a search history of more than this many items, we start deleting the older ones
const MAX_SEARCH_HISTORY = 300;

export interface DimApiState {
  globalSettings: GlobalSettings;
  globalSettingsLoaded: boolean;

  /** Has the user granted us permission to store their info? */
  apiPermissionGranted: boolean | null;

  profileLoadedFromIndexedDb: boolean;
  profileLoaded: boolean;
  profileLoadedError?: Error;
  // unix timestamp for when the profile was last loaded
  profileLastLoaded: number;

  /**
   * App settings. Settings are global, not per-platform-membership
   */
  settings: Settings;

  /**
   * Tags-by-item-hash are only available for D2 and are not profile-specific. Mostly for tagging shaders.
   */
  itemHashTags: {
    [itemHash: string]: ItemHashTag;
  };

  /*
   * DIM API profile data, per account. The key is `${platformMembershipId}-d${destinyVersion}`.
   */
  profiles: {
    [accountKey: string]: {
      /** Loadouts stored by loadout ID */
      loadouts: {
        [id: string]: Loadout;
      };
      /** Tags/notes stored by inventory item ID */
      tags: {
        [itemId: string]: ItemAnnotation;
      };
      /** Tracked triumphs */
      triumphs: number[];
    };
  };

  /**
   * Saved searches are per-Destiny-version
   */
  searches: {
    [version in DestinyVersion]: Search[];
  };

  /**
   * Updates that haven't yet been flushed to the API. Each one is optimistic - we apply its
   * effects to local state immediately, but if they fail later we undo their effects. This
   * is stored locally to be redriven.
   */
  updateQueue: ProfileUpdateWithRollback[];

  /**
   * This watermark indicates how many items in the update queue (starting with the head of the
   * queue) are currently in the process of being flushed to the server. Items at indexes
   * less than the watermark should not be modified. Once the flush is done, those items can
   * be removed from the queue and this watermark set back to 0.
   */
  updateInProgressWatermark: number;
}

function getInitialApiPermissionSetting() {
  const setting = localStorage.getItem('dim-api-enabled');
  if (setting === null) {
    return null;
  } else if (setting === 'true') {
    return true;
  } else {
    return false;
  }
}

/**
 * Global DIM platform settings from the DIM API.
 */
export const initialState: DimApiState = {
  globalSettingsLoaded: false,
  globalSettings: {
    ...defaultGlobalSettings,
    // 2019-12-17 we've been asked to disable auto-refresh
    autoRefresh: false,
    showIssueBanner: false,
  },

  apiPermissionGranted: getInitialApiPermissionSetting(),

  profileLoaded: false,
  profileLoadedFromIndexedDb: false,
  profileLastLoaded: 0,

  settings: initialSettingsState,

  itemHashTags: {},
  profiles: {},
  searches: {
    1: [],
    2: [],
  },

  updateQueue: [],
  updateInProgressWatermark: 0,
};

type DimApiAction =
  | ActionType<typeof actions>
  | ActionType<typeof settingsActions>
  | ActionType<typeof clearWishLists>
  | ActionType<typeof loadoutActions>
  | ActionType<typeof inventoryActions>;

export const dimApi = (
  state: DimApiState = initialState,
  action: DimApiAction,
  // This is a specially-handled reducer (see reducers.ts) which gets the current account (based on incoming state) passed along
  account?: DestinyAccount
): DimApiState => {
  switch (action.type) {
    case getType(actions.globalSettingsLoaded):
      return {
        ...state,
        globalSettingsLoaded: true,
        globalSettings: {
          ...state.globalSettings,
          ...action.payload,
        },
      };

    case getType(actions.profileLoadedFromIDB): {
      // When loading from IDB, merge with current state
      const newUpdateQueue = action.payload
        ? [...action.payload.updateQueue, ...state.updateQueue]
        : [];
      return action.payload
        ? {
            ...state,
            profileLoadedFromIndexedDb: true,
            settings: fixBadSettingsTypes({
              ...state.settings,
              ...action.payload.settings,
            }),
            profiles: {
              ...state.profiles,
              ...action.payload.profiles,
            },
            updateQueue: newUpdateQueue,
            itemHashTags: action.payload.itemHashTags || initialState.itemHashTags,
            searches: {
              ...state.searches,
              ...action.payload.searches,
            },
          }
        : {
            ...state,
            profileLoadedFromIndexedDb: true,
          };
    }

    case getType(actions.profileLoaded): {
      const { profileResponse, account } = action.payload;

      // If the user was using "element" sort, replace it with "elementWeapon" and "elementArmor" sorts.
      const sortOrder = profileResponse.settings?.itemSortOrderCustom || [];
      const reversals = profileResponse.settings?.itemSortReversals || [];

      if (sortOrder.includes('element')) {
        sortOrder.splice(sortOrder.indexOf('element'), 1, 'elementWeapon', 'elementArmor');
      }

      if (reversals.includes('element')) {
        reversals.splice(sortOrder.indexOf('element'), 1, 'elementWeapon', 'elementArmor');
      }

      // TODO: clean out invalid/simple searches on first load?
      const newState = {
        ...state,
        profileLoaded: true,
        profileLoadedError: undefined,
        profileLastLoaded: Date.now(),
        settings: fixBadSettingsTypes({
          ...state.settings,
          ...profileResponse.settings,
        }),
        itemHashTags: profileResponse.itemHashTags
          ? _.keyBy(profileResponse.itemHashTags, (t) => t.hash)
          : state.itemHashTags,
        profiles: account
          ? {
              ...state.profiles,
              // Overwrite just this account's profile
              [makeProfileKeyFromAccount(account)]: {
                loadouts: _.keyBy(profileResponse.loadouts || [], (l) => l.id),
                tags: _.keyBy(profileResponse.tags || [], (t) => t.id),
                triumphs: (profileResponse.triumphs || []).map((t) => parseInt(t.toString(), 10)),
              },
            }
          : state.profiles,
        searches: account
          ? {
              ...state.searches,
              [account.destinyVersion]: profileResponse.searches || [],
            }
          : state.searches,
      };

      // If this is the first load, cleanup searches
      if (account && !state.profileLoaded) {
        return produce(newState, (state) => cleanupInvalidSearches(state, account));
      }

      return newState;
    }

    case getType(actions.profileLoadError): {
      return {
        ...state,
        profileLoadedError: action.payload,
      };
    }

    case getType(actions.setApiPermissionGranted): {
      const apiPermissionGranted = action.payload;
      return apiPermissionGranted
        ? {
            ...state,
            apiPermissionGranted,
          }
        : // If we're disabling DIM Sync, unset profile loaded and clear the update queue
          {
            ...state,
            apiPermissionGranted,
            profileLoaded: false,
            updateQueue: [],
            updateInProgressWatermark: 0,
          };
    }

    case getType(actions.prepareToFlushUpdates): {
      return prepareUpdateQueue(state);
    }

    case getType(actions.allDataDeleted): {
      return {
        ...state,
        profiles: initialState.profiles,
        settings: initialState.settings,
        updateQueue: [],
        updateInProgressWatermark: 0,
      };
    }

    case getType(actions.finishedUpdates): {
      return applyFinishedUpdatesToQueue(state, action.payload);
    }

    // For now, a failed update just resets state so we can flush again. Note that flushing will happen immediately...
    case getType(actions.flushUpdatesFailed):
      return {
        ...state,
        updateInProgressWatermark: 0,
      };

    // *** Settings ***

    case getType(settingsActions.setSettingAction):
      return changeSetting(state, action.payload.property, action.payload.value);

    case getType(settingsActions.toggleCollapsedSection):
      return changeSetting(state, 'collapsedSections', {
        ...state.settings.collapsedSections,
        [action.payload]: !state.settings.collapsedSections[action.payload],
      });

    case getType(settingsActions.setCharacterOrder): {
      const order = action.payload;
      return changeSetting(
        state,
        'customCharacterSort',
        // The order includes characters from multiple profiles, so we can't just replace it
        state.settings.customCharacterSort.filter((id) => !order.includes(id)).concat(order)
      );
    }

    // Clearing wish lists also clears the wishListSource setting
    case getType(clearWishLists):
      return changeSetting(state, 'wishListSource', '');

    // *** Loadouts ***

    case getType(loadoutActions.deleteLoadout):
      return deleteLoadout(state, action.payload);

    case getType(loadoutActions.updateLoadout):
      return updateLoadout(state, action.payload, account!);

    // *** Tags/Notes ***

    case getType(inventoryActions.setItemTag):
      return produce(state, (draft) => {
        setTag(draft, action.payload.itemId, action.payload.tag as TagValue, account!);
      });

    case getType(inventoryActions.setItemTagsBulk):
      return produce(state, (draft) => {
        for (const info of action.payload) {
          setTag(draft, info.itemId, info.tag as TagValue, account!);
        }
      });

    case getType(inventoryActions.setItemNote):
      return produce(state, (draft) => {
        setNote(draft, action.payload.itemId, action.payload.note, account!);
      });

    case getType(inventoryActions.tagCleanup):
      return tagCleanup(state, action.payload, account!);

    case getType(inventoryActions.setItemHashTag):
      return produce(state, (draft) => {
        setItemHashTag(draft, action.payload.itemHash, action.payload.tag as TagValue, account!);
      });

    case getType(inventoryActions.setItemHashNote):
      return produce(state, (draft) => {
        setItemHashNote(draft, action.payload.itemHash, action.payload.note, account!);
      });

    // *** Searches ***

    case getType(actions.searchUsed):
      return produce(state, (draft) => {
        searchUsed(draft, account!, action.payload);
      });

    case getType(actions.saveSearch):
      return produce(state, (draft) => {
        saveSearch(account!, draft, action.payload.query, action.payload.saved);
      });

    case getType(actions.searchDeleted):
      return produce(state, (draft) => {
        deleteSearch(draft, account!.destinyVersion, action.payload);
      });

    // *** Triumphs ***

    case getType(actions.trackTriumph):
      return produce(state, (draft) => {
        trackTriumph(draft, account!, action.payload.recordHash, action.payload.tracked);
      });

    default:
      return state;
  }
};

/**
 * DIM accidentally stored some integer settings as strings,
 * so fix them up here.
 */
function fixBadSettingsTypes(settings: Settings) {
  if (typeof settings.charCol === 'string') {
    settings = { ...settings, charCol: parseInt(settings.charCol, 10) };
  }
  if (typeof settings.charColMobile === 'string') {
    settings = { ...settings, charColMobile: parseInt(settings.charColMobile, 10) };
  }
  if (typeof settings.inventoryClearSpaces === 'string') {
    settings = { ...settings, inventoryClearSpaces: parseInt(settings.inventoryClearSpaces, 10) };
  }
  if (typeof settings.itemSize === 'string') {
    settings = { ...settings, itemSize: parseInt(settings.itemSize, 10) };
  }
  return settings;
}

function changeSetting<V extends keyof Settings>(state: DimApiState, prop: V, value: Settings[V]) {
  // Don't worry about changing settings to their current value
  if (deepEqual(state.settings[prop], value)) {
    return state;
  }

  return produce(state, (draft) => {
    const beforeValue = draft.settings[prop];
    draft.settings[prop] = value;
    draft.updateQueue.push({
      action: 'setting',
      payload: {
        [prop]: value,
      },
      before: {
        [prop]: beforeValue,
      },
    });
  });
}

/**
 * This prepares the update queue to be flushed to the DIM API. It first
 * compacts the updates so that there aren't redundant actions, and then sets
 * the update watermark.<commit_msg>Move fixBadSettingsTypes function and sort replacement logic into a single migrateSettings function<commit_after>import {
  defaultGlobalSettings,
  DestinyVersion,
  GlobalSettings,
  ItemAnnotation,
  ItemHashTag,
  Loadout,
  ProfileUpdateResult,
  Search,
  TagValue,
} from '@destinyitemmanager/dim-api-types';
import { DestinyAccount } from 'app/accounts/destiny-account';
import { convertDimLoadoutToApiLoadout } from 'app/loadout-drawer/loadout-type-converters';
import { recentSearchComparator } from 'app/search/autocomplete';
import { searchConfigSelector } from 'app/search/search-config';
import { parseAndValidateQuery } from 'app/search/search-utils';
import { RootState } from 'app/store/types';
import { emptyArray } from 'app/utils/empty';
import { errorLog, infoLog, timer } from 'app/utils/log';
import { count } from 'app/utils/util';
import { clearWishLists } from 'app/wishlists/actions';
import { deepEqual } from 'fast-equals';
import produce, { Draft } from 'immer';
import _ from 'lodash';
import { ActionType, getType } from 'typesafe-actions';
import * as inventoryActions from '../inventory/actions';
import * as loadoutActions from '../loadout-drawer/actions';
import { Loadout as DimLoadout } from '../loadout-drawer/loadout-types';
import * as settingsActions from '../settings/actions';
import { initialSettingsState, Settings } from '../settings/initial-settings';
import { DeleteLoadoutUpdateWithRollback, ProfileUpdateWithRollback } from './api-types';
import * as actions from './basic-actions';
import { makeProfileKey, makeProfileKeyFromAccount } from './selectors';

// After you've got a search history of more than this many items, we start deleting the older ones
const MAX_SEARCH_HISTORY = 300;

export interface DimApiState {
  globalSettings: GlobalSettings;
  globalSettingsLoaded: boolean;

  /** Has the user granted us permission to store their info? */
  apiPermissionGranted: boolean | null;

  profileLoadedFromIndexedDb: boolean;
  profileLoaded: boolean;
  profileLoadedError?: Error;
  // unix timestamp for when the profile was last loaded
  profileLastLoaded: number;

  /**
   * App settings. Settings are global, not per-platform-membership
   */
  settings: Settings;

  /**
   * Tags-by-item-hash are only available for D2 and are not profile-specific. Mostly for tagging shaders.
   */
  itemHashTags: {
    [itemHash: string]: ItemHashTag;
  };

  /*
   * DIM API profile data, per account. The key is `${platformMembershipId}-d${destinyVersion}`.
   */
  profiles: {
    [accountKey: string]: {
      /** Loadouts stored by loadout ID */
      loadouts: {
        [id: string]: Loadout;
      };
      /** Tags/notes stored by inventory item ID */
      tags: {
        [itemId: string]: ItemAnnotation;
      };
      /** Tracked triumphs */
      triumphs: number[];
    };
  };

  /**
   * Saved searches are per-Destiny-version
   */
  searches: {
    [version in DestinyVersion]: Search[];
  };

  /**
   * Updates that haven't yet been flushed to the API. Each one is optimistic - we apply its
   * effects to local state immediately, but if they fail later we undo their effects. This
   * is stored locally to be redriven.
   */
  updateQueue: ProfileUpdateWithRollback[];

  /**
   * This watermark indicates how many items in the update queue (starting with the head of the
   * queue) are currently in the process of being flushed to the server. Items at indexes
   * less than the watermark should not be modified. Once the flush is done, those items can
   * be removed from the queue and this watermark set back to 0.
   */
  updateInProgressWatermark: number;
}

function getInitialApiPermissionSetting() {
  const setting = localStorage.getItem('dim-api-enabled');
  if (setting === null) {
    return null;
  } else if (setting === 'true') {
    return true;
  } else {
    return false;
  }
}

/**
 * Global DIM platform settings from the DIM API.
 */
export const initialState: DimApiState = {
  globalSettingsLoaded: false,
  globalSettings: {
    ...defaultGlobalSettings,
    // 2019-12-17 we've been asked to disable auto-refresh
    autoRefresh: false,
    showIssueBanner: false,
  },

  apiPermissionGranted: getInitialApiPermissionSetting(),

  profileLoaded: false,
  profileLoadedFromIndexedDb: false,
  profileLastLoaded: 0,

  settings: initialSettingsState,

  itemHashTags: {},
  profiles: {},
  searches: {
    1: [],
    2: [],
  },

  updateQueue: [],
  updateInProgressWatermark: 0,
};

type DimApiAction =
  | ActionType<typeof actions>
  | ActionType<typeof settingsActions>
  | ActionType<typeof clearWishLists>
  | ActionType<typeof loadoutActions>
  | ActionType<typeof inventoryActions>;

export const dimApi = (
  state: DimApiState = initialState,
  action: DimApiAction,
  // This is a specially-handled reducer (see reducers.ts) which gets the current account (based on incoming state) passed along
  account?: DestinyAccount
): DimApiState => {
  switch (action.type) {
    case getType(actions.globalSettingsLoaded):
      return {
        ...state,
        globalSettingsLoaded: true,
        globalSettings: {
          ...state.globalSettings,
          ...action.payload,
        },
      };

    case getType(actions.profileLoadedFromIDB): {
      // When loading from IDB, merge with current state
      const newUpdateQueue = action.payload
        ? [...action.payload.updateQueue, ...state.updateQueue]
        : [];
      return action.payload
        ? {
            ...state,
            profileLoadedFromIndexedDb: true,
            settings: migrateSettings({
              ...state.settings,
              ...action.payload.settings,
            }),
            profiles: {
              ...state.profiles,
              ...action.payload.profiles,
            },
            updateQueue: newUpdateQueue,
            itemHashTags: action.payload.itemHashTags || initialState.itemHashTags,
            searches: {
              ...state.searches,
              ...action.payload.searches,
            },
          }
        : {
            ...state,
            profileLoadedFromIndexedDb: true,
          };
    }

    case getType(actions.profileLoaded): {
      const { profileResponse, account } = action.payload;

      // TODO: clean out invalid/simple searches on first load?
      const newState = {
        ...state,
        profileLoaded: true,
        profileLoadedError: undefined,
        profileLastLoaded: Date.now(),
        settings: migrateSettings({
          ...state.settings,
          ...profileResponse.settings,
        }),
        itemHashTags: profileResponse.itemHashTags
          ? _.keyBy(profileResponse.itemHashTags, (t) => t.hash)
          : state.itemHashTags,
        profiles: account
          ? {
              ...state.profiles,
              // Overwrite just this account's profile
              [makeProfileKeyFromAccount(account)]: {
                loadouts: _.keyBy(profileResponse.loadouts || [], (l) => l.id),
                tags: _.keyBy(profileResponse.tags || [], (t) => t.id),
                triumphs: (profileResponse.triumphs || []).map((t) => parseInt(t.toString(), 10)),
              },
            }
          : state.profiles,
        searches: account
          ? {
              ...state.searches,
              [account.destinyVersion]: profileResponse.searches || [],
            }
          : state.searches,
      };

      // If this is the first load, cleanup searches
      if (account && !state.profileLoaded) {
        return produce(newState, (state) => cleanupInvalidSearches(state, account));
      }

      return newState;
    }

    case getType(actions.profileLoadError): {
      return {
        ...state,
        profileLoadedError: action.payload,
      };
    }

    case getType(actions.setApiPermissionGranted): {
      const apiPermissionGranted = action.payload;
      return apiPermissionGranted
        ? {
            ...state,
            apiPermissionGranted,
          }
        : // If we're disabling DIM Sync, unset profile loaded and clear the update queue
          {
            ...state,
            apiPermissionGranted,
            profileLoaded: false,
            updateQueue: [],
            updateInProgressWatermark: 0,
          };
    }

    case getType(actions.prepareToFlushUpdates): {
      return prepareUpdateQueue(state);
    }

    case getType(actions.allDataDeleted): {
      return {
        ...state,
        profiles: initialState.profiles,
        settings: initialState.settings,
        updateQueue: [],
        updateInProgressWatermark: 0,
      };
    }

    case getType(actions.finishedUpdates): {
      return applyFinishedUpdatesToQueue(state, action.payload);
    }

    // For now, a failed update just resets state so we can flush again. Note that flushing will happen immediately...
    case getType(actions.flushUpdatesFailed):
      return {
        ...state,
        updateInProgressWatermark: 0,
      };

    // *** Settings ***

    case getType(settingsActions.setSettingAction):
      return changeSetting(state, action.payload.property, action.payload.value);

    case getType(settingsActions.toggleCollapsedSection):
      return changeSetting(state, 'collapsedSections', {
        ...state.settings.collapsedSections,
        [action.payload]: !state.settings.collapsedSections[action.payload],
      });

    case getType(settingsActions.setCharacterOrder): {
      const order = action.payload;
      return changeSetting(
        state,
        'customCharacterSort',
        // The order includes characters from multiple profiles, so we can't just replace it
        state.settings.customCharacterSort.filter((id) => !order.includes(id)).concat(order)
      );
    }

    // Clearing wish lists also clears the wishListSource setting
    case getType(clearWishLists):
      return changeSetting(state, 'wishListSource', '');

    // *** Loadouts ***

    case getType(loadoutActions.deleteLoadout):
      return deleteLoadout(state, action.payload);

    case getType(loadoutActions.updateLoadout):
      return updateLoadout(state, action.payload, account!);

    // *** Tags/Notes ***

    case getType(inventoryActions.setItemTag):
      return produce(state, (draft) => {
        setTag(draft, action.payload.itemId, action.payload.tag as TagValue, account!);
      });

    case getType(inventoryActions.setItemTagsBulk):
      return produce(state, (draft) => {
        for (const info of action.payload) {
          setTag(draft, info.itemId, info.tag as TagValue, account!);
        }
      });

    case getType(inventoryActions.setItemNote):
      return produce(state, (draft) => {
        setNote(draft, action.payload.itemId, action.payload.note, account!);
      });

    case getType(inventoryActions.tagCleanup):
      return tagCleanup(state, action.payload, account!);

    case getType(inventoryActions.setItemHashTag):
      return produce(state, (draft) => {
        setItemHashTag(draft, action.payload.itemHash, action.payload.tag as TagValue, account!);
      });

    case getType(inventoryActions.setItemHashNote):
      return produce(state, (draft) => {
        setItemHashNote(draft, action.payload.itemHash, action.payload.note, account!);
      });

    // *** Searches ***

    case getType(actions.searchUsed):
      return produce(state, (draft) => {
        searchUsed(draft, account!, action.payload);
      });

    case getType(actions.saveSearch):
      return produce(state, (draft) => {
        saveSearch(account!, draft, action.payload.query, action.payload.saved);
      });

    case getType(actions.searchDeleted):
      return produce(state, (draft) => {
        deleteSearch(draft, account!.destinyVersion, action.payload);
      });

    // *** Triumphs ***

    case getType(actions.trackTriumph):
      return produce(state, (draft) => {
        trackTriumph(draft, account!, action.payload.recordHash, action.payload.tracked);
      });

    default:
      return state;
  }
};

function migrateSettings(settings: Settings) {
  // Fix some integer settings being stored as strings
  if (typeof settings.charCol === 'string') {
    settings = { ...settings, charCol: parseInt(settings.charCol, 10) };
  }
  if (typeof settings.charColMobile === 'string') {
    settings = { ...settings, charColMobile: parseInt(settings.charColMobile, 10) };
  }
  if (typeof settings.inventoryClearSpaces === 'string') {
    settings = { ...settings, inventoryClearSpaces: parseInt(settings.inventoryClearSpaces, 10) };
  }
  if (typeof settings.itemSize === 'string') {
    settings = { ...settings, itemSize: parseInt(settings.itemSize, 10) };
  }

  // Replace 'element' sort with 'elementWeapon' and 'elementArmor'
  const sortOrder = settings.itemSortOrderCustom || [];
  const reversals = settings.itemSortReversals || [];

  if (sortOrder.includes('element')) {
    sortOrder.splice(sortOrder.indexOf('element'), 1, 'elementWeapon', 'elementArmor');
  }

  if (reversals.includes('element')) {
    reversals.splice(sortOrder.indexOf('element'), 1, 'elementWeapon', 'elementArmor');
  }

  settings = { ...settings, itemSortOrderCustom: sortOrder, itemSortReversals: reversals };

  return settings;
}

function changeSetting<V extends keyof Settings>(state: DimApiState, prop: V, value: Settings[V]) {
  // Don't worry about changing settings to their current value
  if (deepEqual(state.settings[prop], value)) {
    return state;
  }

  return produce(state, (draft) => {
    const beforeValue = draft.settings[prop];
    draft.settings[prop] = value;
    draft.updateQueue.push({
      action: 'setting',
      payload: {
        [prop]: value,
      },
      before: {
        [prop]: beforeValue,
      },
    });
  });
}

/**
 * This prepares the update queue to be flushed to the DIM API. It first
 * compacts the updates so that there aren't redundant actions, and then sets
 * the update watermark.
{'fraction_non_alphanumeric': 0.11639185257032007, 'fraction_numerical': 0.002299098322376693, 'mean_word_length': 2.7700433369447452, 'pattern_counts': {'":': 0, '<': 15, '<?xml version=': 0, '>': 49, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5494767', 'n_tokens_mistral': 7853, 'n_tokens_neox': 7669, 'n_words': 1960}