diff --git a/src/extensions/file_based_loadorder/UpdateSet.ts b/src/extensions/file_based_loadorder/UpdateSet.ts index 4eae4107a..7cf9ade3e 100644 --- a/src/extensions/file_based_loadorder/UpdateSet.ts +++ b/src/extensions/file_based_loadorder/UpdateSet.ts @@ -22,7 +22,6 @@ export default class UpdateSet extends Set { super([]); this.mApi = api; this.mIsFBLO = isFBLO; - this.registerListeners(); } public addNumericModId = (lo: ILoadOrderEntryExt) => { @@ -79,19 +78,6 @@ export default class UpdateSet extends Set { return filtered; }; - private registerListeners = () => { - this.mApi.events.on('gamemode-activated', this.init); - } - - private removeListeners = () => { - this.mApi.events.removeListener('gamemode-activated', this.init); - } - - public destroy = () => { - this.removeListeners(); - this.reset(); - } - private reset = () => { super.clear(); this.mModEntries = []; @@ -131,24 +117,22 @@ export default class UpdateSet extends Set { return loadOrder; } const restoredLO: ILoadOrderEntry[] = [...loadOrder]; - loadOrder.forEach((iter, idx) => { - // Check if the updateSet has this modId. - const stored: { numId: number, entries: ILoadOrderEntryExt[] } = this.findEntry(iter); - if (stored) { - // We're only interested in 1 specific entry, keep in mind that there might be multiple lo entries - // that are associated with the same numeric mod id. - const entryExt: ILoadOrderEntryExt = stored.entries.find(l => l.name === iter.name); - if (entryExt && entryExt.index !== idx) { - // The entry is in the wrong position - re-arrange the array. - restoredLO.splice(idx, 1); - restoredLO.splice(entryExt.index, 0, iter); - - // We only remove the numeric mod id if we confirm that we modified the - // list, otherwise we keep it around as the restoration functionality - // can be called multiple times without modification. - this.tryRemoveNumId(stored.numId, stored.entries, iter.name); - } + const getEntryExt = (entry: ILoadOrderEntry): ILoadOrderEntryExt | null => { + const stored = this.findEntry(entry); + if (!stored) { + // This is probably an entry for a manually added mod/native game entry + // use the existing index. + return { ...entry, index: loadOrder.findIndex(l => l.name === entry.name) }; + } + return stored.entries.find(l => l.name === entry.name) || null; + } + restoredLO.sort((lhs, rhs) => { + const lhsEntry = getEntryExt(lhs); + const rhsEntry = getEntryExt(rhs); + if (!lhsEntry || !rhsEntry) { + return 0; } + return lhsEntry.index - rhsEntry.index; }); return restoredLO; } diff --git a/src/extensions/file_based_loadorder/index.ts b/src/extensions/file_based_loadorder/index.ts index 84c3d8540..56b7a2e9f 100644 --- a/src/extensions/file_based_loadorder/index.ts +++ b/src/extensions/file_based_loadorder/index.ts @@ -2,7 +2,9 @@ import * as _ from 'lodash'; -import { setValidationResult } from './actions/session'; +import * as path from 'path'; + +import { setFBForceUpdate, setValidationResult } from './actions/session'; import { IExtensionContext } from '../../types/IExtensionContext'; import { @@ -31,6 +33,8 @@ import { setFBLoadOrderRedundancy } from './actions/session'; import { addGameEntry, findGameEntry } from './gameSupport'; import { assertValidationResult, errorHandler } from './util'; +import * as fs from '../../util/fs'; + import UpdateSet, { ILoadOrderEntryExt } from './UpdateSet'; interface IDeployment { @@ -219,6 +223,11 @@ async function applyNewLoadOrder(api: types.IExtensionApi, await gameEntry.serializeLoadOrder(newLO, prev); } catch (err) { return errorHandler(api, gameEntry.gameId, err); + } finally { + // After serialization (even when failed), depending on the game extension, + // we may need to force an update as the serialization function may have + // changed the load order in some way. + api.store.dispatch(setFBForceUpdate(profile.id)); } return; @@ -264,6 +273,15 @@ export default function init(context: IExtensionContext) { context.registerReducer(['persistent', 'loadOrder'], modLoadOrderReducer); context.registerReducer(['session', 'fblo'], sessionReducer); + const setOrder = async (profileId: string, loadOrder: types.LoadOrder, refresh?: boolean) => { + const profile = selectors.profileById(context.api.getState(), profileId); + if (!refresh) { + // Anything that isn't a refresh is a user action. + // The Update set has to be re-initialized with the new load order. + updateSet.init(profile.gameId, loadOrder.map((lo, idx) => ({ ...lo, index: idx }))); + } + context.api.store.dispatch(setFBLoadOrder(profileId, loadOrder)); + } context.registerMainPage('sort-none', 'Load Order', FileBasedLoadOrderPage, { id: 'file-based-loadorder', hotkey: 'E', @@ -277,8 +295,46 @@ export default function init(context: IExtensionContext) { props: () => { return { getGameEntry: findGameEntry, + onImportList: async () => { + const api = context.api; + const file = await api.selectFile({ filters: [{ name: 'JSON', extensions: ['json'] }], title: 'Import Load Order' }); + if (!file) { + return; + } + try { + const fileData = await fs.readFileAsync(file, { encoding: 'utf8' }); + const loData: LoadOrder = JSON.parse(fileData); + if (!Array.isArray(loData)) { + throw new Error('invalid load order data'); + } + updateSet.init(selectors.activeGameId(api.getState()), loData.map((lo, idx) => ({ ...lo, index: idx }))); + const profileId = selectors.activeProfile(api.getState()).id; + context.api.store.dispatch(setFBLoadOrder(profileId, loData)); + api.sendNotification({ type: 'success', message: 'Load order imported', id: 'import-load-order' }); + } catch (err) { + api.showErrorNotification('Failed to import load order', err, { allowReport: false }); + } + }, + onExportList: async () => { + const api = context.api; + const state = api.getState(); + const profileId = selectors.activeProfile(state).id; + const loadOrder = util.getSafe(state, ['persistent', 'loadOrder', profileId], []); + const data = JSON.stringify(loadOrder, null, 2); + const loPath = await api.saveFile({ defaultPath: 'loadorder.json', filters: [{ name: 'JSON', extensions: ['json'] }], title: 'Export Load Order' }); + if (loPath) { + try { + await fs.ensureDirWritableAsync(path.basename(loPath)); + await fs.writeFileAsync(loPath, data); + api.sendNotification({ type: 'success', message: 'Load order exported', id: 'export-load-order' }); + } catch (err) { + api.showErrorNotification('Failed to export load order', err, { allowReport: false }); + } + } + }, validateLoadOrder: (profile: types.IProfile, loadOrder: LoadOrder) => validateLoadOrder(context.api, profile, loadOrder), + onSetOrder: setOrder, onStartUp: (gameId: string) => onStartUp(context.api, gameId), onShowError: (gameId: string, error: Error) => errorHandler(context.api, gameId, error), }; @@ -325,6 +381,8 @@ export default function init(context: IExtensionContext) { context.api.onStateChange(['persistent', 'profiles'], (prev, current) => genProfilesChange(context.api, prev, current)); + context.api.events.on('gamemode-activated', (gameId: string) => onGameModeActivated(context.api, gameId)); + context.api.onAsync('did-deploy', genDidDeploy(context.api)); context.api.onAsync('will-purge', genWillPurge(context.api)); context.api.onAsync('did-purge', genDidPurge(context.api)); @@ -339,6 +397,15 @@ export default function init(context: IExtensionContext) { return true; } +async function onGameModeActivated(api: types.IExtensionApi, gameId: string) { + const gameEntry: ILoadOrderGameInfo = findGameEntry(gameId); + if (gameEntry === undefined) { + // Game does not require LO. + return; + } + updateSet.init(gameId); +} + async function onWillRemoveMods(api: types.IExtensionApi, gameId: string, modIds: string[], diff --git a/src/extensions/file_based_loadorder/views/FileBasedLoadOrderPage.tsx b/src/extensions/file_based_loadorder/views/FileBasedLoadOrderPage.tsx index 78a5f4e3c..f0df889ed 100644 --- a/src/extensions/file_based_loadorder/views/FileBasedLoadOrderPage.tsx +++ b/src/extensions/file_based_loadorder/views/FileBasedLoadOrderPage.tsx @@ -2,7 +2,7 @@ import * as _ from 'lodash'; import * as React from 'react'; import { Panel } from 'react-bootstrap'; -import { withTranslation } from 'react-i18next'; +import { WithTranslation, withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import * as actions from '../../../actions/index'; @@ -20,6 +20,7 @@ import { IItemRendererProps, ILoadOrderGameInfo, LoadOrder, import InfoPanel from './InfoPanel'; import ItemRenderer from './ItemRenderer'; import { setFBForceUpdate } from '../actions/session'; +import ToolbarDropdown from '../../../controls/ToolbarDropdown'; const PanelX: any = Panel; @@ -33,6 +34,9 @@ interface IBaseState { export interface IBaseProps { getGameEntry: (gameId: string) => ILoadOrderGameInfo; + onImportList: () => void; + onExportList: () => void; + onSetOrder: (profileId: string, loadOrder: LoadOrder, refresh?: boolean) => void; onStartUp: (gameMode: string) => Promise; onShowError: (gameId: string, error: Error) => void; validateLoadOrder: (profile: types.IProfile, newLO: LoadOrder) => Promise; @@ -60,7 +64,6 @@ interface IConnectedProps { interface IActionProps { onSetDeploymentNecessary: (gameId: string, necessary: boolean) => void; - onSetOrder: (profileId: string, loadOrder: LoadOrder) => void; onForceRefresh: (profileId: string) => void; } @@ -125,13 +128,34 @@ class FileBasedLoadOrderPage extends ComponentEx { onClick: this.onRefreshList, }; }, - }, + }, { + component: ToolbarDropdown, + props: () => { + return { + t: this.props.t, + key: 'btn-import-export-list', + id: 'btn-import-export-list', + instanceId: [], + icons: [ + { + icon: (this.state.updating || this.props.disabled) ? 'spinner' : 'import', + title: 'Load Order Import', + action: this.props.onImportList, + default: true, + }, { + icon: (this.state.updating || this.props.disabled) ? 'spinner' : 'import', + title: 'Load Order Export', + action: this.props.onExportList, + }] + } + } + } ]; } public UNSAFE_componentWillReceiveProps(newProps: IProps) { // Zuckerberg isn't going to like this... - if (this.state.currentRefreshId !== newProps.refreshId) { + if (!!newProps.refreshId && this.state.currentRefreshId !== newProps.refreshId) { this.nextState.currentRefreshId = newProps.refreshId; this.onRefreshList(); return; @@ -298,18 +322,22 @@ class FileBasedLoadOrderPage extends ComponentEx { .finally(() => onSetOrder(profile.id, newLO)); } + private onImportList = () => { + + } + private onRefreshList = () => { const { onStartUp, onSetOrder, profile } = this.props; this.nextState.updating = true; onStartUp(profile?.gameId) .then(lo => { this.nextState.validationError = undefined; - onSetOrder(profile.id, lo); + onSetOrder(profile.id, lo, true); }) .catch(err => { if (err instanceof LoadOrderValidationError) { this.nextState.validationError = err as LoadOrderValidationError; - onSetOrder(profile.id, err.loadOrder); + onSetOrder(profile.id, err.loadOrder, true); } }) .finally(() => this.nextState.updating = false); @@ -336,9 +364,6 @@ function mapDispatchToProps(dispatch: any): IActionProps { return { onSetDeploymentNecessary: (gameId: string, necessary: boolean) => dispatch(actions.setDeploymentNecessary(gameId, necessary)), - onSetOrder: (profileId: string, loadOrder: types.LoadOrder) => { - dispatch(setFBLoadOrder(profileId, loadOrder)); - }, onForceRefresh: (profileId: string) => { dispatch(setFBForceUpdate(profileId)) },