├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── assistant-avatar.png ├── author-avatar.png ├── case-icon.svg ├── graph-icon.svg ├── icon.ico ├── patreon-icon.svg ├── regex-icon.svg ├── screenshot-1.png ├── screenshot-2.png └── sims-mod-icon.svg ├── common ├── errors.ts ├── event-emitter.ts ├── indexer │ └── types.ts ├── ipc.ts ├── ipc │ └── ipc-creator.ts ├── l10n │ ├── english.ts │ ├── index.ts │ ├── russian.ts │ └── types.ts ├── tools.ts └── types │ ├── basic-types.ts │ ├── graph-types.ts │ ├── index.ts │ └── types.ts ├── copy-assets.js ├── globals.d.ts ├── package-lock.json ├── package.json ├── server ├── dbpf │ ├── constants.ts │ ├── dbpf-index.ts │ ├── errors.ts │ ├── index.ts │ ├── interfaces.ts │ ├── sims4-header.ts │ └── sims4-package.ts ├── fs-util.ts ├── indexer │ ├── classifiers │ │ ├── dbpf-classifier.ts │ │ ├── file-classifier.ts │ │ └── md5-classifier.ts │ └── indexer.ts ├── logging.ts ├── main.ts ├── md5.ts ├── mover.ts ├── searcher │ └── searcher.ts ├── sims-studio │ └── sims-studio.ts ├── tsconfig.json └── types.ts ├── test └── fs-util.ts ├── tslint.json └── ui ├── components ├── App.tsx ├── DirectoryPanel.tsx ├── DirectorySummary.tsx ├── EstimatedTime.tsx ├── GlobalBackdrop.tsx ├── Main.tsx ├── NotificationSnackbar.tsx ├── ProgressBar.tsx ├── SearchPanel.tsx ├── SearchParametersForm.tsx ├── StartButton.tsx ├── about │ ├── AboutButton.tsx │ ├── AboutDialog.tsx │ └── Contact.tsx ├── common.css ├── files-area │ ├── FilesArea.tsx │ ├── duplicates │ │ ├── DoubleTypeChipBar.tsx │ │ ├── DuplicateGroupEntry.tsx │ │ ├── DuplicateGroupToolbar.tsx │ │ ├── DuplicateMainToolbar.tsx │ │ ├── DuplicateRow.tsx │ │ ├── DuplicateToolbar.tsx │ │ ├── DuplicatesList.tsx │ │ ├── SimsStudioButton.tsx │ │ └── detailed │ │ │ ├── D3Graph.tsx │ │ │ └── DetailedDialog.tsx │ ├── skips │ │ ├── SkipRow.tsx │ │ └── SkipsList.tsx │ └── tools.ts ├── icons │ ├── CaseIcon.tsx │ ├── CheckboxIcon.tsx │ ├── GraphIcon.tsx │ ├── PatreonIcon.tsx │ ├── RegexIcon.tsx │ └── SimsIcon.tsx └── settings │ ├── LanguageField.tsx │ ├── SettingsButton.tsx │ └── SimsStudioPathField.tsx ├── fonts ├── files │ ├── roboto-latin-100.woff │ ├── roboto-latin-100.woff2 │ ├── roboto-latin-100italic.woff │ ├── roboto-latin-100italic.woff2 │ ├── roboto-latin-300.woff │ ├── roboto-latin-300.woff2 │ ├── roboto-latin-300italic.woff │ ├── roboto-latin-300italic.woff2 │ ├── roboto-latin-400.woff │ ├── roboto-latin-400.woff2 │ ├── roboto-latin-400italic.woff │ ├── roboto-latin-400italic.woff2 │ ├── roboto-latin-500.woff │ ├── roboto-latin-500.woff2 │ ├── roboto-latin-500italic.woff │ ├── roboto-latin-500italic.woff2 │ ├── roboto-latin-700.woff │ ├── roboto-latin-700.woff2 │ ├── roboto-latin-700italic.woff │ ├── roboto-latin-700italic.woff2 │ ├── roboto-latin-900.woff │ ├── roboto-latin-900.woff2 │ ├── roboto-latin-900italic.woff │ └── roboto-latin-900italic.woff2 └── index.css ├── index.html ├── index.tsx ├── redux ├── actions.ts ├── backdrop │ ├── action-creators.ts │ ├── actions.ts │ └── reducers.ts ├── conflict-resolver │ ├── action-creators.ts │ ├── actions.ts │ └── reducers.ts ├── notification │ ├── action-creators.ts │ ├── actions.ts │ └── reducers.ts ├── reducers.ts ├── settings │ ├── action-creators.ts │ ├── actions.ts │ └── reducers.ts ├── store.ts ├── thunk │ ├── conflict-resolver.ts │ └── settings.ts └── types.ts ├── theme.ts ├── tsconfig.json └── utils ├── backdrop-hooks.ts ├── checkbox.ts ├── constants.ts ├── filter.ts ├── graph-aggregator.ts ├── ipc-hooks.ts ├── l10n-hooks.ts ├── language-mapping.ts ├── notifications.ts ├── settings └── settings.ts ├── thunk-hooks.ts ├── url.ts └── util-hooks.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | release-builds 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: windows 3 | node_js: 4 | - "10" 5 | 6 | script: 7 | - npm run lint 8 | - npm test 9 | - npm run build 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.cache": true, 4 | "**/dist": true 5 | }, 6 | "files.watcherExclude": { 7 | "**/.cache": true, 8 | "**/dist": true 9 | }, 10 | "editor.codeActionsOnSave": { 11 | "source.organizeImports": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright 2020 Egor Blagov 4 | 5 | Permission to use, copy, modify, and/or distribute this software 6 | for any purpose withor without fee is hereby granted, provided 7 | that the above copyright notice and this permission notice 8 | appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 11 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 12 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 13 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR 14 | CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 15 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 16 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 17 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sims 4 Mod Assistant [![Build Status](https://travis-ci.org/EgorBlagov/sims-mod-assistant.svg?branch=master)](https://travis-ci.org/EgorBlagov/sims-mod-assistant) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 2 | 3 | Small electron app intended to help Sims players and content makers to find and move duplicates and possible conflicts among mods. 4 | 5 | # Features 6 | 7 | - Finds exact duplicates (copy-paste files with different names) 8 | - Parses mod contents and looks for probable conflicts for: 9 | - Catalog/Definition 10 | - Skintone 11 | - CAS 12 | - Sliders 13 | - Groups conflicting files into lists 14 | - Shows visual relationship between conflicting files 15 | - Supports quick open of specific mod in Sims4Studio 16 | - Supports filtering, batch selection and moving selected mods to other directory 17 | 18 | ![Main view](/assets/screenshot-1.png?raw=true) 19 | ![Graph viewer](/assets/screenshot-2.png?raw=true) 20 | 21 | # Usage 22 | 23 | Download application from Releases page and start. 24 | 25 | ## Video tutorial 26 | 27 | [![Sims Mod Assistant Tutorial](https://img.youtube.com/vi/m-VSYq3zcdg/0.jpg)](https://youtu.be/m-VSYq3zcdg "Sims Mod Assistant Tutorial") 28 | 29 | # CLI 30 | 31 | ```bash 32 | # build 33 | npm run build 34 | 35 | # start 36 | npm run start 37 | 38 | # dev 39 | npm run dev 40 | 41 | # unit tests 42 | npm run test 43 | 44 | # lint 45 | npm run lint 46 | ``` 47 | 48 | ## Note on Dev 49 | 50 | I was not able to make **Electron** and **Parcel** best friends, so on `npm run dev` there are several reloads of electron app 51 | 52 | # Localization 53 | 54 | If you want to contribute, you should do: 55 | 56 | - check available localizations at `/common/l10n/` 57 | - make a new one using an existing one as sample 58 | - add enum entry to `/common/l10n/index.ts` 59 | 60 | Or you can just translate and submit an issue, I'll add new one. 61 | 62 | # Support 63 | 64 | - [PayPal](https://www.paypal.com/paypalme/emblagov) 65 | - [Qiwi](https://qiwi.com/n/STRAL577) 66 | 67 | # License 68 | 69 | ISC © [Egor Blagov](https://github.com/EgorBlagov) 70 | -------------------------------------------------------------------------------- /assets/assistant-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/assets/assistant-avatar.png -------------------------------------------------------------------------------- /assets/author-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/assets/author-avatar.png -------------------------------------------------------------------------------- /assets/case-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/graph-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/assets/icon.ico -------------------------------------------------------------------------------- /assets/patreon-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /assets/regex-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/assets/screenshot-1.png -------------------------------------------------------------------------------- /assets/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/assets/screenshot-2.png -------------------------------------------------------------------------------- /assets/sims-mod-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /common/errors.ts: -------------------------------------------------------------------------------- 1 | import { Formatter, Translation } from "../common/l10n"; 2 | 3 | export class LocalizedError { 4 | public args: any[]; 5 | public message: T; 6 | 7 | constructor(msg: T, ...args: Translation[T] extends Formatter ? Parameters : never) { 8 | this.message = msg; 9 | this.args = args; 10 | } 11 | } 12 | 13 | export type LocalizedErrors = LocalizedError; 14 | 15 | export function getErrorMessage(error: Error | LocalizedErrors, l10n: Translation): string { 16 | if (error.message in l10n) { 17 | const localizedError = error as LocalizedErrors; 18 | const translated = l10n[localizedError.message]; 19 | if (translated instanceof Function) { 20 | const translateFunction = translated as Formatter; 21 | return translateFunction(...localizedError.args); 22 | } else { 23 | return translated; 24 | } 25 | } else { 26 | return error.message; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /common/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | interface EventSchema { 4 | [eventName: string]: any; 5 | } 6 | 7 | type TEmit = (data: T) => void; 8 | type TOnOff = (handler: (data: T) => void) => void; 9 | 10 | export type TypesafeEventEmitter = { 11 | emit: { 12 | [K in keyof T]: TEmit; 13 | }; 14 | on: { 15 | [K in keyof T]: TOnOff; 16 | }; 17 | off: { 18 | [K in keyof T]: TOnOff; 19 | }; 20 | ee: EventEmitter; 21 | }; 22 | 23 | export function createTypesafeEvent() { 24 | return null as T; 25 | } 26 | 27 | export function createTypesafeEventEmitter(schema: T): TypesafeEventEmitter { 28 | const result: TypesafeEventEmitter = { 29 | ee: new EventEmitter(), 30 | emit: {}, 31 | on: {}, 32 | off: {}, 33 | } as TypesafeEventEmitter; 34 | for (const eventName of Object.keys(schema)) { 35 | const eventKey: keyof T = eventName; 36 | result.emit[eventKey] = (data) => result.ee.emit(eventKey.toString(), data); 37 | result.on[eventKey] = (handler) => result.ee.on(eventKey.toString(), handler); 38 | result.off[eventKey] = (handler) => result.ee.off(eventKey.toString(), handler); 39 | } 40 | 41 | return result; 42 | } 43 | -------------------------------------------------------------------------------- /common/indexer/types.ts: -------------------------------------------------------------------------------- 1 | import { IFileClassifier } from "../../server/indexer/classifiers/file-classifier"; 2 | import { DoubleTypes, TKeyValue } from "../types"; 3 | 4 | export type TFileKeyInfo = [DoubleTypes, TKeyValue]; 5 | export type TFileKeys = TFileKeyInfo[]; 6 | 7 | export type TIndex = { 8 | [path: string]: TFileKeys; 9 | }; 10 | 11 | export type TClassifiers = { 12 | [K in DoubleTypes]?: IFileClassifier; 13 | }; 14 | 15 | export type TValidator = (filepath: string) => Promise; // should raise if invalid 16 | -------------------------------------------------------------------------------- /common/ipc.ts: -------------------------------------------------------------------------------- 1 | import { createTypesafeIpc, createTypesafeIpcChannel, createTypesafeIpcEvent } from "./ipc/ipc-creator"; 2 | import { 3 | IDirectoryInfo, 4 | IDirectoryParams, 5 | IIndexResult, 6 | IIndexUpdate, 7 | IMoveParams, 8 | IOpenInStudioParams, 9 | ISearchError, 10 | ISearchParams, 11 | ISearchProgress, 12 | IStartResult, 13 | } from "./types"; 14 | 15 | export const IpcSchema = { 16 | rpc: { 17 | getDirectoryInfo: createTypesafeIpcChannel(), 18 | startSearch: createTypesafeIpcChannel(), 19 | interruptSearch: createTypesafeIpcChannel(), 20 | moveDuplicates: createTypesafeIpcChannel(), 21 | isSimsStudioDir: createTypesafeIpcChannel(), 22 | openInStudio: createTypesafeIpcChannel(), 23 | }, 24 | mainEvents: { 25 | searchResult: createTypesafeIpcEvent(), 26 | searchError: createTypesafeIpcEvent(), 27 | searchProgress: createTypesafeIpcEvent(), 28 | indexUpdate: createTypesafeIpcEvent(), 29 | }, 30 | }; 31 | 32 | export const ipc = createTypesafeIpc(IpcSchema); 33 | -------------------------------------------------------------------------------- /common/ipc/ipc-creator.ts: -------------------------------------------------------------------------------- 1 | /* Pretending to be a separate tool */ 2 | 3 | import { BrowserWindow, Event, ipcMain, ipcRenderer } from "electron"; 4 | import { isOk } from "../tools"; 5 | 6 | type TIpcCallback = (args: TArg) => Promise; 7 | type TIpcRegisterHandler = (callback: TIpcCallback) => void; 8 | type TIpcRendererApi = TIpcCallback; 9 | type TIpcMainApi = TIpcRegisterHandler; 10 | 11 | export type TIpcEventHandler = (event: Event, args: TArg) => void; 12 | type TIpcWindowEventEmitter = (window: BrowserWindow, args: TArg) => void; 13 | type TIpcEventOn = (handler: TIpcEventHandler) => void; 14 | type TIpcEventOff = TIpcEventOn; 15 | type Errorable = T & { error: Error }; 16 | 17 | export type TIpcSchema = { 18 | rpc: { 19 | [channelName: string]: ReturnType; 20 | }; 21 | mainEvents: { 22 | [channelName: string]: ReturnType; 23 | }; 24 | }; 25 | 26 | export type TIpcOutput = { 27 | renderer: { 28 | rpc: { [K in keyof T["rpc"]]: TIpcRendererApi }; 29 | on: { [K in keyof T["mainEvents"]]: TIpcEventOn }; 30 | off: { [K in keyof T["mainEvents"]]: TIpcEventOff }; 31 | }; 32 | main: { 33 | handleRpc: { [K in keyof T["rpc"]]: TIpcMainApi }; 34 | emit: { [K in keyof T["mainEvents"]]: TIpcWindowEventEmitter }; 35 | }; 36 | }; 37 | 38 | class IpcCreator { 39 | public interface: TIpcOutput = { 40 | main: { 41 | handleRpc: {}, 42 | emit: {}, 43 | }, 44 | renderer: { 45 | rpc: {}, 46 | on: {}, 47 | off: {}, 48 | }, 49 | } as any; 50 | 51 | constructor(ipcSchema: TIpcSchema) { 52 | for (const channelName of Object.keys(ipcSchema.rpc)) { 53 | this.registerApi(channelName); 54 | } 55 | 56 | for (const channelName of Object.keys(ipcSchema.mainEvents)) { 57 | this.registerMainEvent(channelName); 58 | } 59 | } 60 | 61 | private registerApi(name: K): void { 62 | this.interface.main.handleRpc[name] = this.createTypesafeMainApi(name.toString()); 63 | this.interface.renderer.rpc[name] = this.createTypesafeRendererApi(name.toString()); 64 | } 65 | 66 | private createTypesafeMainApi(channelName: string): TIpcMainApi { 67 | return (callback) => { 68 | ipcMain.handle(channelName, async (event, args) => { 69 | try { 70 | return await callback(args); 71 | } catch (error) { 72 | return { error }; 73 | } 74 | }); 75 | }; 76 | } 77 | 78 | private createTypesafeRendererApi( 79 | channelName: string, 80 | ): TIpcRendererApi { 81 | return async (args) => { 82 | const result: Errorable = await ipcRenderer.invoke(channelName, args); 83 | 84 | if (isOk(result) && isOk(result.error)) { 85 | throw result.error; 86 | } 87 | 88 | return result; 89 | }; 90 | } 91 | 92 | private registerMainEvent(name: K): void { 93 | this.interface.main.emit[name] = (window, arg) => window.webContents.send(name.toString(), arg); 94 | this.interface.renderer.on[name] = (callback) => ipcRenderer.on(name.toString(), callback); 95 | this.interface.renderer.off[name] = (callback) => ipcRenderer.off(name.toString(), callback); 96 | } 97 | } 98 | 99 | export function createTypesafeIpc(ipcSchema: T): TIpcOutput { 100 | const ipcCreator = new IpcCreator(ipcSchema); 101 | return ipcCreator.interface; 102 | } 103 | 104 | export function createTypesafeIpcChannel() { 105 | return { 106 | args: (null as any) as TArg, 107 | return: (null as any) as TReturn, 108 | }; 109 | } 110 | 111 | export function createTypesafeIpcEvent() { 112 | return { 113 | args: (null as any) as TArg, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /common/l10n/english.ts: -------------------------------------------------------------------------------- 1 | export const english = { 2 | language: "Language", 3 | selectLangauge: "Select language", 4 | chooseDir: "Choose a directory", 5 | open: "Open", 6 | dirInfo: (filesCount: string | number, sizeMb: string | number) => `Total files: ${filesCount}, size: ${sizeMb} Mb`, 7 | searchMode: "Search mode", 8 | searchExactDoubles: "Search exact doubles", 9 | searchCatalogConflicts: "Search catalog conflicts", 10 | start: "Start", 11 | cancel: "Cancel", 12 | errorPath: (msg: string) => `Unable to get directory info: ${msg}`, 13 | errorOpenPath: (msg: string) => `Unable to open path with dialog: ${msg}`, 14 | searchFinished: "Search finished", 15 | date: (d: Date) => d.toLocaleString("en-US"), 16 | moveDuplicates: "Move selected", 17 | enableForSearch: "Enable at least one options to start search", 18 | conflicts: (count: number) => `Conflicts (${count})`, 19 | skippedFiles: (count: number) => `Skipped files (${count})`, 20 | unsupportedSimsVersion: "Unsupported Package version", 21 | unsupportedSimsVersionTooltip: "Might be not Sims 4 package at all", 22 | notPackage: "Not package", 23 | notPackageDescription: "File is not any Sims package", 24 | unableToParse: "Unable to parse", 25 | unableToParseDescription: "Unexpected error occured during parse", 26 | momentLibLocale: "en", 27 | calculatingTime: "estimation...", 28 | searchInterrupted: "Search interrupted", 29 | moveSuccess: "Selected files has been moved successfully", 30 | errorMove: (msg: string) => `Unable to move files: ${msg}`, 31 | about: "About", 32 | author: "Author", 33 | assistant: "Assistant & Inspiration", 34 | description: "Simple application to help Sims 4 players in searching and removing duplicates and conflicting mods", 35 | 36 | exact: "Exact", 37 | exactDescription: "Copy of file (MD5 hashes are same)", 38 | 39 | catalog: "Catalog", 40 | catalogDescription: "Build objects: tables, fountains, beds...", 41 | 42 | skintone: "Skintone", 43 | skintoneDescription: "Makeups, skin overlays, facemasks...", 44 | 45 | cas: "CAS", 46 | casDescription: "Clothes, hair, boots...", 47 | 48 | slider: "Slider", 49 | sliderDescription: "Sliders (hotspots): mouth shape, neck shape...", 50 | 51 | settings: "Settings", 52 | simsStudioPath: "Sims4Studio directory (optional)", 53 | fileNotFound: (file: string) => `${file} is not found`, 54 | studioValidPath: "Studio directory selected", 55 | studioDisabled: (message: string) => `Unable to find configured Sims4Studio: ${message}`, 56 | 57 | settingsSaved: "Settings were saved successfully", 58 | settingsSaveError: (err: string) => `Settings save error: ${err}`, 59 | 60 | invalidTicketId: "Internal Error: Invalid search ticket id", 61 | detailed: "Detailed", 62 | openDirectory: "Open directory", 63 | copyPath: "Copy path to clipboard", 64 | 65 | openInStudio: "Open package in Sims4Studio", 66 | studioNotConfigured: "Sims4Studio path is not configured (you can do it Settings)", 67 | copyPathSuccess: (path: string) => `Path ${path} successfully copied to clipboard`, 68 | conflictKeysDescription: "Conflicting keys:", 69 | keyGroup: (count: number, types: string[]) => `Keys: ${count} (${types.join(", ")})`, 70 | fileGroup: (count: number) => `Files: ${count}`, 71 | selectAll: "Select all", 72 | clearSelection: "Clear selection", 73 | searchPlaceholder: "Search...", 74 | regexHelp: "Regular expressions support", 75 | caseHelp: "Case sensitivity", 76 | 77 | moveSubdirForbidden: "Move to same directory or subdirectory is forbidden, please, choose separate directory", 78 | homepage: "Homepage", 79 | }; 80 | -------------------------------------------------------------------------------- /common/l10n/index.ts: -------------------------------------------------------------------------------- 1 | import { english } from "./english"; 2 | import { russian } from "./russian"; 3 | import { Translation } from "./types"; 4 | 5 | export enum Language { 6 | English = "English", 7 | Russian = "Русский", 8 | } 9 | 10 | export const l10n: Record = { 11 | [Language.English]: english, 12 | [Language.Russian]: russian, 13 | }; 14 | 15 | export { Formatter, Translation } from "./types"; 16 | -------------------------------------------------------------------------------- /common/l10n/russian.ts: -------------------------------------------------------------------------------- 1 | import { Translation } from "./types"; 2 | 3 | export const russian: Translation = { 4 | language: "Язык", 5 | selectLangauge: "Выберите язык", 6 | chooseDir: "Выберите папку", 7 | open: "Открыть", 8 | dirInfo: (filesCount, sizeMb) => `Всего файлов: ${filesCount}, размер: ${sizeMb} Мб`, 9 | searchMode: "Режим поиска", 10 | searchExactDoubles: "Искать идеальные дубликаты", 11 | searchCatalogConflicts: "Искать конфликты в каталоге", 12 | start: "Начать", 13 | cancel: "Отменить", 14 | errorPath: (msg: string) => `Не удалось получить информацию о директории: ${msg}`, 15 | errorOpenPath: (msg: string) => `Не удалось открыть директории через диалог: ${msg}`, 16 | searchFinished: "Поиск завершен", 17 | date: (d: Date) => d.toLocaleString("ru-RU"), 18 | moveDuplicates: "Переместить выбранные", 19 | enableForSearch: "Включите хотя бы один параметр чтобы запустить поиск", 20 | conflicts: (count: number) => `Конфликты (${count})`, 21 | skippedFiles: (count: number) => `Пропущенные файлы (${count})`, 22 | unsupportedSimsVersion: "Неподдерживаемая версия Пакета", 23 | unsupportedSimsVersionTooltip: "Возможно это не Пакет Sims 4", 24 | notPackage: "Не Пакет", 25 | notPackageDescription: "Файл не является Пакетом Sims", 26 | unableToParse: "Не удалось распознать", 27 | unableToParseDescription: "Неизвестная ошибка во время распознавания файла", 28 | momentLibLocale: "ru", 29 | calculatingTime: "оценка...", 30 | searchInterrupted: "Поиск прерван", 31 | moveSuccess: "Выбранные файлы успешно перемещены", 32 | errorMove: (msg: string) => `Не удалось переместить файлы: ${msg}`, 33 | about: "О программе", 34 | author: "Автор", 35 | assistant: "Ассистент и Вдохновитель", 36 | description: 37 | "Небольшое приложение для игроков Sims 4 для быстрого и удобного поиска дубликатов и конфликтных дополнений", 38 | 39 | exact: "Копия", 40 | exactDescription: "Полная копия (MD5 хеш совпадает)", 41 | 42 | catalog: "Каталог", 43 | catalogDescription: "Предметы для строительства: столы, фонтаны, кровати...", 44 | 45 | skintone: "Скинтон", 46 | skintoneDescription: "Макияж, особенности кожи, маски...", 47 | 48 | cas: "CAS", 49 | casDescription: "Одежда, волосы, обувь...", 50 | 51 | slider: "Слайдер", 52 | sliderDescription: "Слайдеры (Хот споты): форма рта, форма шеи...", 53 | 54 | settings: "Настройки", 55 | simsStudioPath: "Папка с Sims4Studio (необязательно)", 56 | fileNotFound: (file: string) => `${file} не найден`, 57 | studioValidPath: "Папка с Sims4Studio выбрана", 58 | studioDisabled: (message: string) => `Не удалось найти Sims4Studio из настроек: ${message}`, 59 | 60 | settingsSaved: "Настройки успешно сохранены", 61 | settingsSaveError: (err: string) => `Не удалось сохранить настройки: ${err}`, 62 | invalidTicketId: "Внутренняя ошибка: неверный идентификатор поиска", 63 | detailed: "Детально", 64 | openDirectory: "Открыть папку", 65 | copyPath: "Скопировать путь в буфер обмена", 66 | 67 | openInStudio: "Открыть пакет в Sims4Studio", 68 | studioNotConfigured: "Путь к Sims4Studio не настроен (это можно сделать в Настройках)", 69 | copyPathSuccess: (path: string) => `Путь ${path} успешно скопирован в буфер обмена`, 70 | conflictKeysDescription: "Конфликтующие ключи:", 71 | keyGroup: (count: number, types: string[]) => `Ключи: ${count} (${types.join(", ")})`, 72 | fileGroup: (count: number) => `Файлы: ${count}`, 73 | clearSelection: "Снять выделение", 74 | selectAll: "Выделить все", 75 | searchPlaceholder: "Поиск...", 76 | regexHelp: "Поддержка регулярных выражений", 77 | caseHelp: "Чувствительность к регистру", 78 | 79 | moveSubdirForbidden: "Перемещать в ту же папку или подпапку нельзя, пожалуйста, выберите другую папку", 80 | homepage: "Домашняя страница", 81 | }; 82 | -------------------------------------------------------------------------------- /common/l10n/types.ts: -------------------------------------------------------------------------------- 1 | import { english } from "./english"; 2 | 3 | export type Formatter = (...args: (string | number | Date | string[])[]) => string; 4 | type TLanguageSchemaEntry = string | Formatter; 5 | 6 | interface LSchemaMeta { 7 | [key: string]: TLanguageSchemaEntry; 8 | } 9 | 10 | export type Translation = typeof english; 11 | 12 | // tslint:disable-next-line: no-empty 13 | function assertLanguageSchema(_: LSchemaMeta) {} 14 | assertLanguageSchema(english); 15 | -------------------------------------------------------------------------------- /common/tools.ts: -------------------------------------------------------------------------------- 1 | export function isOk(x: T): boolean { 2 | return x !== undefined && x !== null; 3 | } 4 | -------------------------------------------------------------------------------- /common/types/basic-types.ts: -------------------------------------------------------------------------------- 1 | export type TKeyValue = string; 2 | 3 | export enum DoubleTypes { 4 | Exact = "Exact", 5 | Catalog = "Catalog", 6 | Skintone = "Skintone", 7 | Cas = "Cas", 8 | Slider = "Slider", 9 | } 10 | 11 | export type TTicketId = number; 12 | -------------------------------------------------------------------------------- /common/types/graph-types.ts: -------------------------------------------------------------------------------- 1 | import { DoubleTypes, TKeyValue } from "./basic-types"; 2 | 3 | export interface IDuplicateGraphNode { 4 | path: string; 5 | } 6 | 7 | export type TFileValue = string; 8 | export type TFileGroup = TFileValue[]; 9 | 10 | export interface IEdgeGroup { 11 | fileGroups: TFileGroup[]; 12 | keys: TKeyValue[]; 13 | } 14 | 15 | export interface IDuplicateGraph { 16 | edgeGroups: IEdgeGroup[]; 17 | typeByKey: Record; 18 | } 19 | 20 | export interface IDuplicateGroupSummary { 21 | types: DoubleTypes[]; 22 | files: TFileValue[]; 23 | } 24 | 25 | export interface IDuplicateGroup { 26 | summary: IDuplicateGroupSummary; 27 | detailed: IDuplicateGraph; 28 | } 29 | -------------------------------------------------------------------------------- /common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./basic-types"; 2 | export * from "./graph-types"; 3 | export * from "./types"; 4 | -------------------------------------------------------------------------------- /common/types/types.ts: -------------------------------------------------------------------------------- 1 | import { LocalizedErrors } from "../errors"; 2 | import { TIndex } from "../indexer/types"; 3 | import { DoubleTypes, TTicketId } from "./basic-types"; 4 | import { IDuplicateGroup } from "./graph-types"; 5 | 6 | export interface IDirectoryInfo { 7 | filesCount: number; 8 | sizeMb: number; 9 | } 10 | 11 | export interface IStartResult { 12 | searchTicketId: TTicketId; 13 | } 14 | 15 | export interface IDirectoryParams { 16 | targetPath: string; 17 | } 18 | 19 | export interface ISearchParams { 20 | searchMd5: boolean; 21 | searchTgi: boolean; 22 | } 23 | 24 | export interface IFileDescription { 25 | path: string; 26 | } 27 | 28 | export interface IFileAdditionalInfo { 29 | modifiedDate: Date; 30 | } 31 | 32 | export interface IFileDuplicate extends IFileDescription { 33 | duplicateChecks: { 34 | [K in keyof typeof DoubleTypes]: boolean; 35 | }; 36 | } 37 | 38 | export interface ISearchEntry { 39 | original: IFileDescription; 40 | duplicates: IFileDuplicate[]; 41 | } 42 | 43 | export enum SkipReasons { 44 | UnsupportedSimsVersion, 45 | NotPackage, 46 | UnableToParse, 47 | } 48 | 49 | export interface ISkippedFile extends IFileDescription { 50 | reason: SkipReasons; 51 | } 52 | 53 | export interface IIndexResult { 54 | ticketId: TTicketId; 55 | index: TIndex; 56 | skips: ISkippedFile[]; 57 | fileInfos: Record; 58 | } 59 | 60 | export interface ISearchResult extends IIndexResult { 61 | duplicates: IDuplicateGroup[]; 62 | } 63 | 64 | export interface ISearchProgress { 65 | ticketId: TTicketId; 66 | progressRelative: number; 67 | } 68 | 69 | export interface ISearchError { 70 | error: LocalizedErrors | Error; 71 | ticketId: TTicketId; 72 | } 73 | 74 | export interface IMoveParams { 75 | filePaths: string[]; 76 | searchDir: string; 77 | targetDir: string; 78 | } 79 | 80 | export interface IOpenInStudioParams { 81 | filePath: string; 82 | simsStudioPath: string; 83 | } 84 | 85 | export enum IndexChanges { 86 | Remove = "Remove", 87 | } 88 | 89 | export interface IIndexUpdate { 90 | [path: string]: IIndexChange; 91 | } 92 | 93 | export interface IIndexChange { 94 | change: IndexChanges; 95 | } 96 | -------------------------------------------------------------------------------- /copy-assets.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | if (!fs.existsSync("dist")) { 4 | fs.mkdirSync("dist"); 5 | } 6 | 7 | fs.copyFileSync("assets/icon.ico", "dist/icon.ico"); 8 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sims-mod-assistant", 3 | "nameLong": "Sims 4 mod assistant", 4 | "version": "1.0.1", 5 | "description": "Simple Electron based Application to help Sims 4 players handle their thousands of mods", 6 | "main": "dist/server/main.js", 7 | "devDependencies": { 8 | "@types/axios": "^0.14.0", 9 | "@types/chai": "^4.2.12", 10 | "@types/classnames": "^2.2.10", 11 | "@types/d3": "^5.7.2", 12 | "@types/electron": "^1.6.10", 13 | "@types/electron-json-storage": "^4.0.0", 14 | "@types/express": "^4.17.6", 15 | "@types/lodash": "^4.14.152", 16 | "@types/mocha": "^8.0.2", 17 | "@types/node": "^12.12.6", 18 | "@types/react": "^16.9.35", 19 | "@types/react-redux": "^7.1.9", 20 | "@types/react-virtualized-auto-sizer": "^1.0.0", 21 | "@types/react-window": "^1.8.2", 22 | "@types/redux": "^3.6.0", 23 | "@types/winston": "^2.4.4", 24 | "chai": "^4.2.0", 25 | "concurrently": "^5.2.0", 26 | "electron": "^9.0.0", 27 | "electron-packager": "^15.0.0", 28 | "mocha": "^8.1.1", 29 | "nodemon": "^2.0.4", 30 | "parcel-bundler": "^1.12.4", 31 | "pre-commit": "^1.2.2", 32 | "prettier": "^2.0.5", 33 | "sass": "^1.26.5", 34 | "ts-node": "^8.10.2", 35 | "tslint": "^6.1.2", 36 | "tslint-config-prettier": "^1.18.0", 37 | "tslint-no-unused-expression-chai": "^0.1.4", 38 | "tslint-react": "^5.0.0", 39 | "typescript": "^3.9.3" 40 | }, 41 | "dependencies": { 42 | "@material-ui/core": "^4.10.0", 43 | "@material-ui/icons": "^4.9.1", 44 | "@material-ui/lab": "^4.0.0-alpha.54", 45 | "@types/moment": "^2.13.0", 46 | "@types/react-dom": "^16.9.8", 47 | "axios": "^0.19.2", 48 | "classnames": "^2.2.6", 49 | "d3": "^5.16.0", 50 | "electron-json-storage": "^4.1.8", 51 | "electron-typesafe-ipc": "0.0.17", 52 | "express": "^4.17.1", 53 | "lodash": "^4.17.20", 54 | "moment": "^2.26.0", 55 | "react": "^16.13.1", 56 | "react-dom": "^16.13.1", 57 | "react-redux": "^7.2.0", 58 | "react-virtualized-auto-sizer": "^1.0.2", 59 | "react-window": "^1.8.5", 60 | "redux": "^4.0.5", 61 | "redux-thunk": "^2.3.0", 62 | "winston": "^3.2.1" 63 | }, 64 | "scripts": { 65 | "dev": "concurrently npm:watch-*", 66 | "watch-server-bundled": "set NODE_ENV=development&& nodemon -e js,html --watch dist/**/* --exec \"electron dist/server/main.js\"", 67 | "watch-client": "parcel watch ui/index.html --public-url ./ -d dist/client --target electron", 68 | "watch-client-tsc": "tsc --build ui/tsconfig.json --watch", 69 | "watch-server": "npm run copy:assets && tsc --build server/tsconfig.json --watch", 70 | "lint": "prettier --write {server,ui}/**/*.{tsx,ts,css} && tslint --fix {server,ui}/**/*.{ts,tsx}", 71 | "build": "rm -rf dist && npm run build:ui && npm run build:server", 72 | "build:ui": "npm run build:ui:tsc && parcel build ui/index.html --public-url ./ -d dist/client --target electron", 73 | "build:ui:tsc": "tsc --build ui/tsconfig.json", 74 | "build:server": "npm run copy:assets && tsc --build server/tsconfig.json", 75 | "start": "electron dist/server/main.js", 76 | "bundle": "npm run build && electron-packager . --overwrite --asar --icon=assets/icon.ico --prune --out=release-builds", 77 | "copy:assets": "node copy-assets.js", 78 | "test": "mocha -r ts-node/register 'test/**/*.ts'" 79 | }, 80 | "pre-commit": [ 81 | "lint" 82 | ], 83 | "keywords": [ 84 | "sims", 85 | "dbpf", 86 | "mod", 87 | "mods", 88 | "conflict", 89 | "duplicate" 90 | ], 91 | "author": "Egor Blagov", 92 | "license": "ISC", 93 | "prettier": { 94 | "trailingComma": "all", 95 | "tabWidth": 4, 96 | "semi": true, 97 | "printWidth": 120, 98 | "singleQuote": false 99 | }, 100 | "contacts": { 101 | "authorEmail": "e.m.blagov@gmail.com", 102 | "authorGithub": "github.com/EgorBlagov", 103 | "assistant": "opasnostya", 104 | "assistantEmail": "avchrnnk@gmail.com", 105 | "assistantPatreon": "www.patreon.com/opasnostya", 106 | "homepage": "github.com/EgorBlagov/sims-mod-assistant/blob/master/README.md" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /server/dbpf/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Sizes { 2 | Long = 4, 3 | Short = 2, 4 | BitsInByte = 8, 5 | } 6 | 7 | export enum DbpfResourceTypes { 8 | Catalog = 0x319e4f1d, 9 | Definition = 0xc0db5ae7, 10 | Skintone = 0x0354796a, 11 | RleTexture = 0x3453cf95, 12 | DdsTexture = 0xb6c8b6a0, 13 | CasPart = 0x034aeecb, 14 | HotSpotControl = 0x8b18ff6e, 15 | } 16 | -------------------------------------------------------------------------------- /server/dbpf/dbpf-index.ts: -------------------------------------------------------------------------------- 1 | import { Sizes } from "./constants"; 2 | import { IDbpfHeader, IDbpfIndex, IDbpfRecord } from "./interfaces"; 3 | 4 | const DbpfRecordPrototype = { 5 | resourceType: 0, 6 | resourceGroup: 0, 7 | instanceHi: 0, 8 | instanceLo: 0, 9 | offset: 0, 10 | packedSize: 0, 11 | memSize: 0, 12 | compressedReserved: 0, 13 | }; 14 | 15 | type DbpfRecord = typeof DbpfRecordPrototype; 16 | type DbpfRecordProp = keyof DbpfRecord; 17 | const DbpfRecordProps: DbpfRecordProp[] = Object.keys(DbpfRecordPrototype) as DbpfRecordProp[]; 18 | 19 | type bit = 1 | 0; 20 | export class DbpfIndex implements IDbpfIndex { 21 | private header: IDbpfHeader; 22 | private commonDataMask: [bit, bit, bit, bit, bit, bit, bit, bit]; 23 | public records: IDbpfRecord[]; 24 | private readPos: number; 25 | 26 | constructor(buffer: Buffer, header: IDbpfHeader) { 27 | this.header = header; 28 | this.commonDataMask = [0, 0, 0, 0, 0, 0, 0, 0]; 29 | this.records = []; 30 | this.readPos = 0; 31 | 32 | this.parseCommonDataMask(buffer); 33 | this.parseRecords(buffer); 34 | } 35 | 36 | private parseCommonDataMask(buffer: Buffer) { 37 | const type = buffer.readInt32LE(0); 38 | 39 | for (let i = 0; i < 8; i++) { 40 | this.commonDataMask[i] = ((type & (1 << i)) >> i) as bit; 41 | } 42 | this.readPos += Sizes.Long; 43 | } 44 | 45 | private parseRecords(buffer: Buffer) { 46 | const commonData: DbpfRecord = { ...DbpfRecordPrototype }; 47 | 48 | for (let i = 0; i < 8; i++) { 49 | if (this.commonDataMask[i]) { 50 | commonData[DbpfRecordProps[i]] = buffer.readUInt32LE(this.readPos); 51 | this.readPos += Sizes.Long; 52 | } 53 | } 54 | 55 | for (let i = 0; i < this.header.recordCount; i++) { 56 | const record: DbpfRecord = { ...commonData }; 57 | for (let j = 0; j < 8; j++) { 58 | if (!this.commonDataMask[j]) { 59 | record[DbpfRecordProps[j]] = buffer.readUInt32LE(this.readPos); 60 | this.readPos += Sizes.Long; 61 | } 62 | } 63 | 64 | this.records.push(this.toRecord(record)); 65 | } 66 | } 67 | 68 | private toRecord(record: DbpfRecord): IDbpfRecord { 69 | return { 70 | ...record, 71 | instance: (BigInt(record.instanceHi) << BigInt(32)) | BigInt(record.instanceLo), 72 | compressed: (record.compressedReserved >> (Sizes.Short * Sizes.BitsInByte)) & 0xffff, 73 | reserved: record.compressedReserved & 0xffff, 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/dbpf/errors.ts: -------------------------------------------------------------------------------- 1 | import { SkipReasons } from "../../common/types"; 2 | 3 | export enum DbpfErrors { 4 | NotDbpf = "NotDbpf", 5 | UnsupportedDbpfFormat = "UnsupportedDbpfFormat", 6 | UnknownError = "UnknownDbpfError", 7 | } 8 | 9 | export const DbpfToSkipReason: Record = { 10 | NotDbpf: SkipReasons.NotPackage, 11 | UnknownDbpfError: SkipReasons.UnableToParse, 12 | UnsupportedDbpfFormat: SkipReasons.UnsupportedSimsVersion, 13 | }; 14 | 15 | export function throwDbpfError(type: DbpfErrors, msg?: string): never { 16 | const err = new Error(msg); 17 | err.name = type; 18 | throw err; 19 | } 20 | 21 | export function isDbpfError(err: Error): boolean { 22 | return Object.values(DbpfErrors) 23 | .map((x) => x.toString()) 24 | .includes(err.name); 25 | } 26 | -------------------------------------------------------------------------------- /server/dbpf/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { isOk } from "../../common/tools"; 3 | import { DbpfErrors, isDbpfError, throwDbpfError } from "./errors"; 4 | import { IDbpfPackage } from "./interfaces"; 5 | import { Sims4Package } from "./sims4-package"; 6 | 7 | export async function readDbpf(path: string): Promise { 8 | let fh: fs.promises.FileHandle; 9 | try { 10 | fh = await fs.promises.open(path, "r"); 11 | return await Sims4Package.read(fh); 12 | } catch (err) { 13 | if (!isDbpfError(err)) { 14 | throwDbpfError(DbpfErrors.UnknownError, err.message); 15 | } 16 | throw err; 17 | } finally { 18 | if (isOk(fh)) { 19 | await fh.close(); 20 | } 21 | } 22 | } 23 | 24 | export * from "./interfaces"; 25 | -------------------------------------------------------------------------------- /server/dbpf/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IDbpfHeader { 2 | magic: string; 3 | major: number; 4 | minor: number; 5 | recordCount: number; 6 | indexSize: number; 7 | indexMinorVersion: number; 8 | indexOffset: number; 9 | } 10 | 11 | export interface IDbpfRecord { 12 | resourceType: number; 13 | resourceGroup: number; 14 | instance: bigint; 15 | offset: number; 16 | packedSize: number; 17 | memSize: number; 18 | compressed: number; 19 | reserved: number; 20 | } 21 | 22 | export interface IDbpfPackage { 23 | header: IDbpfHeader; 24 | records: IDbpfRecord[]; 25 | } 26 | 27 | export interface IDbpfIndex { 28 | records: IDbpfRecord[]; 29 | } 30 | -------------------------------------------------------------------------------- /server/dbpf/sims4-header.ts: -------------------------------------------------------------------------------- 1 | import { Sizes } from "./constants"; 2 | import { DbpfErrors, throwDbpfError } from "./errors"; 3 | import { IDbpfHeader } from "./interfaces"; 4 | 5 | export const DBPF_HEADER_SIZE = Sizes.Long * 17; 6 | 7 | export class Sims4Header implements IDbpfHeader { 8 | magic: string; 9 | major: number; 10 | minor: number; 11 | recordCount: number; 12 | indexSize: number; 13 | indexMinorVersion: number; 14 | indexOffset: number; 15 | 16 | constructor(buffer: Buffer) { 17 | this.magic = buffer.toString("ascii", 0, 4); 18 | this.major = buffer.readInt32LE(Sizes.Long); 19 | this.minor = buffer.readInt32LE(Sizes.Long * 2); 20 | this.recordCount = buffer.readInt32LE(Sizes.Long * 9); 21 | this.indexSize = buffer.readInt32LE(Sizes.Long * 11); 22 | this.indexMinorVersion = buffer.readInt32LE(Sizes.Long * 15); 23 | this.indexOffset = buffer.readInt32LE(Sizes.Long * 16); 24 | 25 | if (this.magic.toLowerCase() !== "dbpf") { 26 | throwDbpfError(DbpfErrors.NotDbpf); 27 | } 28 | 29 | if (this.major !== 2 || this.minor !== 1) { 30 | throwDbpfError(DbpfErrors.UnsupportedDbpfFormat); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/dbpf/sims4-package.ts: -------------------------------------------------------------------------------- 1 | import { promises } from "fs"; 2 | import { DbpfIndex } from "./dbpf-index"; 3 | import { IDbpfHeader, IDbpfPackage, IDbpfRecord } from "./interfaces"; 4 | import { DBPF_HEADER_SIZE, Sims4Header } from "./sims4-header"; 5 | 6 | export class Sims4Package implements IDbpfPackage { 7 | header: IDbpfHeader; 8 | records: IDbpfRecord[]; 9 | 10 | private constructor(header: IDbpfHeader, records: IDbpfRecord[]) { 11 | this.header = header; 12 | this.records = records; 13 | } 14 | 15 | public static async read(file: promises.FileHandle) { 16 | const header = await Sims4Package.readHeader(file); 17 | const records = await Sims4Package.parseRecords(header, file); 18 | return new Sims4Package(header, records); 19 | } 20 | 21 | private static async readHeader(file: promises.FileHandle): Promise { 22 | const headerBuffer = Buffer.alloc(DBPF_HEADER_SIZE); 23 | await file.read(headerBuffer, 0, headerBuffer.length); 24 | return new Sims4Header(headerBuffer); 25 | } 26 | 27 | private static async parseRecords(header: IDbpfHeader, file: promises.FileHandle): Promise { 28 | const indexBuffer = Buffer.alloc(header.indexSize); 29 | await file.read(indexBuffer, 0, indexBuffer.length, header.indexOffset); 30 | const index = new DbpfIndex(indexBuffer, header); 31 | return index.records; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/fs-util.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as _ from "lodash"; 3 | import * as path from "path"; 4 | import { IDirectoryInfo } from "../common/types"; 5 | import { IFileWithStats } from "./types"; 6 | 7 | export enum FileSizes { 8 | MB = 1024 * 1024, 9 | } 10 | 11 | export async function getAllFilesInDirectory(targetPath: string, recursive: boolean = true): Promise { 12 | const result: fs.PathLike[] = []; 13 | const contents = await fs.promises.readdir(targetPath, { withFileTypes: true }); 14 | for (const entry of contents) { 15 | const entryPath = path.join(targetPath, entry.name); 16 | if (entry.isDirectory() && recursive) { 17 | const innerFiles = await getAllFilesInDirectory(entryPath); 18 | result.push(...innerFiles); 19 | } else { 20 | result.push(entryPath); 21 | } 22 | } 23 | 24 | return result; 25 | } 26 | 27 | export async function getDirectoryInfo(targetPath: string): Promise { 28 | const allFiles = await this.getFilesAllWithStats(targetPath); 29 | return { 30 | filesCount: allFiles.length, 31 | sizeMb: _.reduce(allFiles, (sum, file) => sum + file.stats.size / FileSizes.MB, 0), 32 | }; 33 | } 34 | 35 | export async function getFilesAllWithStats(targetPath: string): Promise { 36 | const allFiles = await getAllFilesInDirectory(targetPath); 37 | return Promise.all( 38 | _.map(allFiles, async (f) => ({ 39 | path: f.toString(), 40 | stats: await fs.promises.stat(f), 41 | })), 42 | ); 43 | } 44 | 45 | export function isWithinSameDir(parentPath: string, childPath: string): boolean { 46 | const relative = path.relative(parentPath, childPath); 47 | return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative)); 48 | } 49 | -------------------------------------------------------------------------------- /server/indexer/classifiers/dbpf-classifier.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { TKeyValue } from "../../../common/types"; 3 | import { IDbpfRecord, readDbpf } from "../../dbpf"; 4 | import { DbpfResourceTypes } from "../../dbpf/constants"; 5 | import { IFileClassifier } from "./file-classifier"; 6 | 7 | export class DbpfClassifier implements IFileClassifier { 8 | private resourceTypes: DbpfResourceTypes[]; 9 | 10 | public constructor(types: DbpfResourceTypes[]) { 11 | this.resourceTypes = types; 12 | } 13 | 14 | async getKeys(path: string): Promise { 15 | const dbpf = await readDbpf(path); 16 | const toTgi = (rec: IDbpfRecord): string => 17 | `${rec.resourceType.toString(16).padStart(8, "0")}-${rec.resourceGroup 18 | .toString(16) 19 | .padStart(8, "0")}-${rec.instance.toString(16).padStart(16, "0")}`; 20 | 21 | return _(dbpf.records) 22 | .filter((r) => this.resourceTypes.includes(r.resourceType)) 23 | .map(toTgi) 24 | .value(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/indexer/classifiers/file-classifier.ts: -------------------------------------------------------------------------------- 1 | import { TKeyValue } from "../../../common/types"; 2 | 3 | export interface IFileClassifier { 4 | getKeys(path: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /server/indexer/classifiers/md5-classifier.ts: -------------------------------------------------------------------------------- 1 | import { TKeyValue } from "../../../common/types"; 2 | import { md5 } from "../../md5"; 3 | import { IFileClassifier } from "./file-classifier"; 4 | 5 | export class Md5Classifier implements IFileClassifier { 6 | async getKeys(path: string): Promise { 7 | const md5Hash = await md5(path); 8 | return [md5Hash]; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/indexer/indexer.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { TClassifiers, TFileKeys, TIndex, TValidator } from "../../common/indexer/types"; 3 | import { DoubleTypes, ISkippedFile, SkipReasons } from "../../common/types"; 4 | import { DbpfErrors, DbpfToSkipReason, isDbpfError } from "../dbpf/errors"; 5 | import { IFileClassifier } from "./classifiers/file-classifier"; 6 | 7 | export class Indexer { 8 | private skips: ISkippedFile[]; 9 | private classifiers: TClassifiers; 10 | private validator: TValidator; 11 | private index: TIndex; 12 | 13 | constructor() { 14 | this.skips = []; 15 | this.index = {}; 16 | this.classifiers = {}; 17 | this.validator = () => null; 18 | } 19 | 20 | public getIndex() { 21 | return this.index; 22 | } 23 | 24 | public getSkips() { 25 | return this.skips; 26 | } 27 | 28 | public setClassifier(doubleType: DoubleTypes, classifier: IFileClassifier): void { 29 | this.classifiers[doubleType] = classifier; 30 | } 31 | 32 | public setValidator(validator: TValidator) { 33 | this.validator = validator; 34 | } 35 | 36 | public async pushFile(filepath: string): Promise { 37 | try { 38 | await this.validator(filepath); 39 | const keys = await this.getFileKeys(filepath); 40 | this.index[filepath] = keys; 41 | } catch (error) { 42 | this.skips.push({ 43 | path: filepath, 44 | reason: this.getReason(error), 45 | }); 46 | } 47 | } 48 | 49 | private async getFileKeys(filepath: string): Promise { 50 | const result: TFileKeys = []; 51 | 52 | for (const classifierKey of Object.keys(this.classifiers)) { 53 | const keyType: DoubleTypes = classifierKey as DoubleTypes; 54 | const fileKeys = await this.classifiers[keyType].getKeys(filepath); 55 | const keysWithTypes: TFileKeys = _.map(fileKeys, (k) => [keyType, k]); 56 | result.push(...keysWithTypes); 57 | } 58 | 59 | return result; 60 | } 61 | 62 | private getReason(err: Error): SkipReasons { 63 | if (isDbpfError(err)) { 64 | return DbpfToSkipReason[err.name as DbpfErrors]; 65 | } 66 | 67 | return SkipReasons.UnableToParse; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/logging.ts: -------------------------------------------------------------------------------- 1 | import * as winston from "winston"; 2 | 3 | const errorStackFormat = winston.format((info) => { 4 | if (info instanceof Error) { 5 | return { 6 | ...info, 7 | message: info.stack, 8 | }; 9 | } 10 | 11 | return info; 12 | }); 13 | 14 | export const logger = winston.createLogger({ 15 | level: "info", 16 | format: winston.format.combine(errorStackFormat(), winston.format.simple()), 17 | transports: [new winston.transports.Console()], 18 | }); 19 | -------------------------------------------------------------------------------- /server/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, Menu } from "electron"; 2 | import * as path from "path"; 3 | import { ipc } from "../common/ipc"; 4 | import { getDirectoryInfo } from "./fs-util"; 5 | import { mover } from "./mover"; 6 | import { simsModIndexer } from "./searcher/searcher"; 7 | import { simsStudio } from "./sims-studio/sims-studio"; 8 | 9 | const clientPath = path.join(__dirname, "..", "client"); 10 | const isDev = process.env.NODE_ENV === "development"; 11 | 12 | const launchElectron = () => { 13 | const createWindow = (): BrowserWindow => { 14 | const mainWindow = new BrowserWindow({ 15 | minWidth: 800, 16 | minHeight: 800, 17 | width: 1024, 18 | height: 768, 19 | webPreferences: { 20 | nodeIntegration: true, 21 | devTools: isDev, 22 | }, 23 | icon: path.join(__dirname, "..", "icon.ico"), 24 | }); 25 | 26 | mainWindow.loadFile(path.join(clientPath, "index.html")); 27 | 28 | if (isDev) { 29 | mainWindow.webContents.openDevTools(); 30 | mainWindow.maximize(); 31 | } 32 | 33 | return mainWindow; 34 | }; 35 | 36 | Menu.setApplicationMenu(new Menu()); 37 | 38 | app.whenReady().then(() => { 39 | const wnd = createWindow(); 40 | 41 | simsModIndexer.ee.on.searchProgress((data) => ipc.main.emit.searchProgress(wnd, data)); 42 | simsModIndexer.ee.on.searchResult((data) => ipc.main.emit.searchResult(wnd, data)); 43 | simsModIndexer.ee.on.searchError((data) => ipc.main.emit.searchError(wnd, data)); 44 | mover.ee.on.updateIndex((data) => ipc.main.emit.indexUpdate(wnd, data)); 45 | 46 | ipc.main.handleRpc.getDirectoryInfo(async (args) => { 47 | return getDirectoryInfo(args.targetPath); 48 | }); 49 | 50 | ipc.main.handleRpc.startSearch(async (args) => { 51 | return simsModIndexer.startSearch(args.targetPath, args); 52 | }); 53 | 54 | ipc.main.handleRpc.interruptSearch(async () => { 55 | simsModIndexer.interruptSearch(); 56 | }); 57 | 58 | ipc.main.handleRpc.moveDuplicates(async (params) => { 59 | await mover.move(params); 60 | }); 61 | 62 | ipc.main.handleRpc.isSimsStudioDir(async (params) => { 63 | return simsStudio.validateDir(params); 64 | }); 65 | 66 | ipc.main.handleRpc.openInStudio(async (params) => { 67 | return simsStudio.openFile(params.filePath, params.simsStudioPath); 68 | }); 69 | }); 70 | 71 | app.on("window-all-closed", () => { 72 | app.quit(); 73 | }); 74 | }; 75 | 76 | launchElectron(); 77 | -------------------------------------------------------------------------------- /server/md5.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import { promises as fs } from "fs"; 3 | 4 | export async function md5(path: string): Promise { 5 | const file = await fs.readFile(path); 6 | return crypto.createHash("md5").update(file).digest("hex"); 7 | } 8 | -------------------------------------------------------------------------------- /server/mover.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { LocalizedError } from "../common/errors"; 4 | import { createTypesafeEvent, createTypesafeEventEmitter, TypesafeEventEmitter } from "../common/event-emitter"; 5 | import { IIndexUpdate, IMoveParams, IndexChanges } from "../common/types"; 6 | import { isWithinSameDir } from "./fs-util"; 7 | 8 | const MoverEventSchema = { 9 | updateIndex: createTypesafeEvent(), 10 | }; 11 | 12 | class Mover { 13 | public readonly ee: TypesafeEventEmitter; 14 | 15 | constructor() { 16 | this.ee = createTypesafeEventEmitter(MoverEventSchema); 17 | } 18 | 19 | async move(params: IMoveParams): Promise { 20 | this.validateNotSubdir(params.searchDir, params.targetDir); 21 | const indexUpdate: IIndexUpdate = {}; // Mover is too aware of index and allowed operation structure 22 | 23 | try { 24 | for (const filePath of params.filePaths) { 25 | const targetName = await this.findFreeName(params.targetDir, path.basename(filePath)); 26 | await fs.promises.rename(filePath, targetName); 27 | indexUpdate[filePath] = { change: IndexChanges.Remove }; 28 | } 29 | } finally { 30 | if (Object.keys(indexUpdate).length !== 0) { 31 | this.ee.emit.updateIndex(indexUpdate); 32 | } 33 | } 34 | } 35 | 36 | private validateNotSubdir(origDir: string, targetDir: string) { 37 | if (isWithinSameDir(origDir, targetDir)) { 38 | throw new LocalizedError("moveSubdirForbidden"); 39 | } 40 | } 41 | 42 | private async fileExists(targetPath: string): Promise { 43 | try { 44 | await fs.promises.access(targetPath); 45 | return true; 46 | } catch { 47 | return false; 48 | } 49 | } 50 | 51 | private addSuffix(filePath: string, suffix: string): string { 52 | const dirname = path.dirname(filePath); 53 | const basename = path.basename(filePath); 54 | const extension = path.extname(basename); 55 | const name = path.basename(basename, extension); 56 | 57 | return path.join(dirname, `${name}${suffix}${extension}`); 58 | } 59 | 60 | private async findFreeName(targetDir: string, basename: string): Promise { 61 | const baseTargetName = path.join(targetDir, basename); 62 | let targetName = baseTargetName; 63 | let i = 1; 64 | while (await this.fileExists(targetName)) { 65 | targetName = this.addSuffix(baseTargetName, `-copy${i}`); 66 | i++; 67 | } 68 | 69 | return targetName; 70 | } 71 | } 72 | 73 | export const mover: Mover = new Mover(); 74 | -------------------------------------------------------------------------------- /server/searcher/searcher.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { LocalizedError, LocalizedErrors } from "../../common/errors"; 3 | import { createTypesafeEvent, createTypesafeEventEmitter, TypesafeEventEmitter } from "../../common/event-emitter"; 4 | import { 5 | DoubleTypes, 6 | IFileAdditionalInfo, 7 | IIndexResult, 8 | ISearchError, 9 | ISearchParams, 10 | ISearchProgress, 11 | IStartResult, 12 | } from "../../common/types"; 13 | import { readDbpf } from "../dbpf"; 14 | import { DbpfResourceTypes } from "../dbpf/constants"; 15 | import { FileSizes, getFilesAllWithStats } from "../fs-util"; 16 | import { DbpfClassifier } from "../indexer/classifiers/dbpf-classifier"; 17 | import { Md5Classifier } from "../indexer/classifiers/md5-classifier"; 18 | import { Indexer } from "../indexer/indexer"; 19 | import { logger } from "../logging"; 20 | import { IFileWithStats } from "../types"; 21 | 22 | const PROGRESS_FRACTION = 50; 23 | 24 | const SimsModIndexerEventSchema = { 25 | searchResult: createTypesafeEvent(), 26 | searchProgress: createTypesafeEvent(), 27 | searchError: createTypesafeEvent(), 28 | }; 29 | 30 | class SimsModIndexer { 31 | private currentSearchTicket: number; 32 | public readonly ee: TypesafeEventEmitter; 33 | 34 | constructor() { 35 | this.currentSearchTicket = 0; 36 | this.ee = createTypesafeEventEmitter(SimsModIndexerEventSchema); 37 | } 38 | 39 | startSearch(targetPath: string, params: ISearchParams): IStartResult { 40 | this.currentSearchTicket++; 41 | const launchSearchId = this.currentSearchTicket; 42 | this.startSearchProgress(launchSearchId, targetPath, params) 43 | .then((result) => this.ee.emit.searchResult(result)) 44 | .catch((error: LocalizedErrors | Error) => { 45 | logger.error(error); 46 | this.ee.emit.searchError({ error, ticketId: launchSearchId }); 47 | }); 48 | 49 | return { 50 | searchTicketId: launchSearchId, 51 | }; 52 | } 53 | 54 | interruptSearch(): void { 55 | this.currentSearchTicket++; 56 | } 57 | 58 | private async startSearchProgress( 59 | ticketId: number, 60 | targetPath: string, 61 | params: ISearchParams, 62 | ): Promise { 63 | const allFiles = await getFilesAllWithStats(targetPath); 64 | const indexer = this.createIndexer(params); 65 | 66 | let mbPassed = 0; 67 | const mbTotal = _.reduce(allFiles, (sum, f) => sum + f.stats.size / FileSizes.MB, 0); 68 | const fractionCount = Math.round(allFiles.length / PROGRESS_FRACTION); 69 | for (let i = 0; i < allFiles.length; i++) { 70 | const file = allFiles[i]; 71 | 72 | if (ticketId !== this.currentSearchTicket) { 73 | throw new LocalizedError("searchInterrupted"); 74 | } 75 | 76 | await indexer.pushFile(file.path); 77 | 78 | mbPassed += file.stats.size / FileSizes.MB; 79 | 80 | if (i % fractionCount === 0) { 81 | const searchProgress = { ticketId, progressRelative: mbPassed / mbTotal }; 82 | this.ee.emit.searchProgress(searchProgress); 83 | } 84 | } 85 | 86 | const fileInfos = this.getFileInfos(allFiles); 87 | 88 | return { 89 | ticketId, 90 | index: indexer.getIndex(), 91 | skips: indexer.getSkips(), 92 | fileInfos, 93 | }; 94 | } 95 | 96 | private createIndexer(params: ISearchParams): Indexer { 97 | const indexer = new Indexer(); 98 | 99 | if (params.searchMd5) { 100 | indexer.setClassifier(DoubleTypes.Exact, new Md5Classifier()); 101 | } 102 | 103 | if (params.searchTgi) { 104 | indexer.setClassifier( 105 | DoubleTypes.Catalog, 106 | new DbpfClassifier([DbpfResourceTypes.Catalog, DbpfResourceTypes.Definition]), 107 | ); 108 | indexer.setClassifier(DoubleTypes.Skintone, new DbpfClassifier([DbpfResourceTypes.Skintone])); 109 | indexer.setClassifier(DoubleTypes.Cas, new DbpfClassifier([DbpfResourceTypes.CasPart])); 110 | indexer.setClassifier(DoubleTypes.Slider, new DbpfClassifier([DbpfResourceTypes.HotSpotControl])); 111 | } 112 | 113 | indexer.setValidator(async (filepath: string) => { 114 | await readDbpf(filepath); // now we read dbpf twice, 1 - to validate, 2 - from DbpfClassifier 115 | }); 116 | 117 | return indexer; 118 | } 119 | 120 | private getFileInfos(allFiles: IFileWithStats[]): Record { 121 | const result: Record = {}; 122 | 123 | for (const file of allFiles) { 124 | result[file.path.toString()] = { 125 | modifiedDate: file.stats.mtime, 126 | }; 127 | } 128 | return result; 129 | } 130 | } 131 | 132 | export const simsModIndexer = new SimsModIndexer(); 133 | -------------------------------------------------------------------------------- /server/sims-studio/sims-studio.ts: -------------------------------------------------------------------------------- 1 | import { execFile } from "child_process"; 2 | import * as _ from "lodash"; 3 | import * as path from "path"; 4 | import { LocalizedError } from "../../common/errors"; 5 | import { getAllFilesInDirectory } from "../fs-util"; 6 | 7 | export interface ISimsStudio { 8 | validateDir(path: string): Promise; 9 | openFile(path: string, studioPath: string): void; 10 | } 11 | 12 | const SIMS_STUDIO_EXE = "S4Studio.exe"; 13 | 14 | class SimsStudio implements ISimsStudio { 15 | async validateDir(dirPath: string): Promise { 16 | const files = await getAllFilesInDirectory(dirPath, false); 17 | if (!_.some(files, (x) => path.basename(x.toString()).toLowerCase() === SIMS_STUDIO_EXE.toLowerCase())) { 18 | throw new LocalizedError("fileNotFound", SIMS_STUDIO_EXE); 19 | } 20 | } 21 | 22 | openFile(filePath: string, studioPath: string): void { 23 | execFile(path.join(studioPath, SIMS_STUDIO_EXE), [filePath]); 24 | } 25 | } 26 | 27 | export const simsStudio: ISimsStudio = new SimsStudio(); 28 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES6", 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "sourceMap": true, 9 | "outDir": "../dist" 10 | }, 11 | "include": ["./**/*", "../common/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /server/types.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | 3 | export interface IFileWithStats { 4 | path: string; 5 | stats: fs.Stats; 6 | } 7 | -------------------------------------------------------------------------------- /test/fs-util.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { isWithinSameDir } from "../server/fs-util"; 3 | 4 | describe("fs util", () => { 5 | it("should recognize subdir", () => { 6 | const truthySet = [ 7 | ["a", "a/b"], 8 | ["a", "a/b/c/d"], 9 | ["C:/a/b/c-d-e", "C:/a/b/c-d-e/f"], 10 | [ 11 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods", 12 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods\\1", 13 | ], 14 | ]; 15 | 16 | for (const [parent, child] of truthySet) { 17 | expect(isWithinSameDir(parent, child), `invalid ${parent} ${child}`).to.be.true; 18 | } 19 | }); 20 | 21 | it("should recongnize samedir", () => { 22 | const truthySet = [ 23 | [ 24 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods", 25 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods", 26 | ], 27 | ["C:\\Users\\username\\Documents\\", "C:\\Users\\username\\Documents\\"], 28 | ["a\\b", "a/b"], 29 | ]; 30 | 31 | for (const [parent, child] of truthySet) { 32 | expect(isWithinSameDir(parent, child), `invalid ${parent} ${child}`).to.be.true; 33 | } 34 | }); 35 | 36 | it("should recognize not samedir", () => { 37 | const falsySet = [ 38 | ["a", "b/b"], 39 | ["a", "c/b/c/d"], 40 | ["C:/a/b/c-d-e", "C:/a/b/c-d-e-1/f"], 41 | [ 42 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods", 43 | "C:\\Users\\username\\Documents\\Some path\\Subidr name\\Mods-sorted\\1", 44 | ], 45 | ]; 46 | 47 | for (const [parent, child] of falsySet) { 48 | expect(isWithinSameDir(parent, child), `invalid ${parent} ${child}`).to.be.false; 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier", "tslint-no-unused-expression-chai"], 4 | "jsRules": {}, 5 | "rules": { 6 | "object-literal-sort-keys": false, 7 | "no-namespace": false, 8 | "no-bitwise": false, 9 | "file-name-casing": [true, { ".tsx": "pascal-case", ".ts": "kebab-case" }], 10 | "no-return-await": true 11 | }, 12 | "rulesDirectory": [] 13 | } 14 | -------------------------------------------------------------------------------- /ui/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@material-ui/core"; 2 | import React, { useEffect } from "react"; 3 | import { getErrorMessage } from "../../common/errors"; 4 | import { SettingsThunk } from "../redux/thunk/settings"; 5 | import { appTheme } from "../theme"; 6 | import { useL10n } from "../utils/l10n-hooks"; 7 | import { useNotification } from "../utils/notifications"; 8 | import { useThunkDispatch } from "../utils/thunk-hooks"; 9 | import "./common.css"; 10 | import { GlobalBackdrop } from "./GlobalBackdrop"; 11 | import { Main } from "./Main"; 12 | import { NotificationSnackbar } from "./NotificationSnackbar"; 13 | export const App = () => { 14 | const [l10n] = useL10n(); 15 | const notification = useNotification(); 16 | const dispatch = useThunkDispatch(); 17 | 18 | useEffect(() => { 19 | dispatch(SettingsThunk.loadRestAndValidate()).catch((err) => 20 | notification.showError(getErrorMessage(err, l10n)), 21 | ); 22 | }, []); 23 | 24 | return ( 25 | <> 26 | 27 |
28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /ui/components/DirectoryPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, makeStyles, Typography } from "@material-ui/core"; 2 | import { remote } from "electron"; 3 | import React from "react"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { ipc } from "../../common/ipc"; 6 | import { isOk } from "../../common/tools"; 7 | import { ConflictResolverActions } from "../redux/conflict-resolver/action-creators"; 8 | import { TState } from "../redux/reducers"; 9 | import { useBackdropBound } from "../utils/backdrop-hooks"; 10 | import { useL10n } from "../utils/l10n-hooks"; 11 | import { useNotification } from "../utils/notifications"; 12 | import { DirectorySummary } from "./DirectorySummary"; 13 | 14 | const useStyles = makeStyles({ 15 | root: { 16 | overflowWrap: "anywhere", 17 | }, 18 | }); 19 | 20 | export const DirectoryPanel = () => { 21 | const [l10n] = useL10n(); 22 | const notification = useNotification(); 23 | 24 | const path = useSelector((state: TState) => state.conflictResolver.searchDirectory); 25 | const dispatch = useDispatch(); 26 | const setPath = (newPath: string) => dispatch(ConflictResolverActions.setSearchDirectory(newPath)); 27 | 28 | const [filesCount, setFilesCount] = React.useState(); 29 | const [sizeMb, setSizeMb] = React.useState(); 30 | const [openDisabled, setOpenDisabled] = React.useState(false); 31 | 32 | const directoryPathStyles = useStyles(); 33 | 34 | React.useEffect(() => { 35 | if (isOk(path)) { 36 | setFilesCount(undefined); 37 | setSizeMb(undefined); 38 | setOpenDisabled(true); 39 | ipc.renderer.rpc 40 | .getDirectoryInfo({ targetPath: path }) 41 | .then((result) => { 42 | setFilesCount(result.filesCount); 43 | setSizeMb(result.sizeMb); 44 | }) 45 | .catch((error: Error) => { 46 | notification.showError(l10n.errorPath(error.message)); 47 | setPath(undefined); 48 | }) 49 | .finally(() => { 50 | setOpenDisabled(false); 51 | }); 52 | } 53 | }, [path]); 54 | 55 | useBackdropBound(openDisabled); 56 | 57 | const handleOpenDialog = () => { 58 | setOpenDisabled(true); 59 | 60 | remote.dialog 61 | .showOpenDialog({ properties: ["openDirectory"] }) 62 | .then((res) => { 63 | if (!res.canceled && res.filePaths[0] !== path) { 64 | setPath(res.filePaths[0]); // TODO: on path change settings also change due to stack-like design of panels, consider redux integration 65 | ipc.renderer.rpc.interruptSearch(); // it's Async 66 | } 67 | }) 68 | .catch((error: Error) => { 69 | notification.showError(l10n.errorOpenPath(error.message)); 70 | }) 71 | .finally(() => setOpenDisabled(false)); 72 | }; 73 | 74 | return ( 75 | <> 76 | 77 | 78 | {isOk(path) ? path : l10n.chooseDir} 79 | 80 | 81 | 84 | 85 | 86 | {isOk(path) && } 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /ui/components/DirectorySummary.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Typography } from "@material-ui/core"; 2 | import React from "react"; 3 | import { isOk } from "../../common/tools"; 4 | import { useL10n } from "../utils/l10n-hooks"; 5 | import { SearchPanel } from "./SearchPanel"; 6 | 7 | interface IProps { 8 | filesCount: number; 9 | sizeMb: number; 10 | } 11 | 12 | export const DirectorySummary = ({ filesCount, sizeMb }: IProps) => { 13 | const [l10n] = useL10n(); 14 | 15 | if (!isOk(filesCount) || !isOk(sizeMb)) { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | <> 25 | {l10n.dirInfo(filesCount, sizeMb.toFixed(2))} 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /ui/components/EstimatedTime.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@material-ui/core"; 2 | import * as moment from "moment"; 3 | import React from "react"; 4 | import { isOk } from "../../common/tools"; 5 | import { useL10n } from "../utils/l10n-hooks"; 6 | 7 | interface IProps { 8 | startTime: Date; 9 | progressRelative: number; 10 | } 11 | 12 | export const EstimatedTime = ({ startTime, progressRelative }: IProps) => { 13 | const [l10n] = useL10n(); 14 | 15 | if (!isOk(startTime)) { 16 | return null; 17 | } 18 | 19 | const now = new Date(); 20 | const timeSpent = now.getTime() - startTime.getTime(); 21 | let timeCaption = l10n.calculatingTime; 22 | 23 | if (progressRelative > 0) { 24 | const timeTotal = timeSpent / progressRelative; 25 | timeCaption = moment 26 | .duration(timeTotal - timeSpent) 27 | .locale(l10n.momentLibLocale) 28 | .humanize(); 29 | } 30 | 31 | return {timeCaption}; 32 | }; 33 | -------------------------------------------------------------------------------- /ui/components/GlobalBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import { Backdrop, CircularProgress, makeStyles } from "@material-ui/core"; 2 | import React from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { TState } from "../redux/reducers"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | backdrop: { 8 | zIndex: theme.zIndex.drawer + 1, 9 | color: "#fff", 10 | }, 11 | })); 12 | 13 | export const GlobalBackdrop = () => { 14 | const visible = useSelector((state: TState) => state.backdrop.visible); 15 | const classes = useStyles(); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /ui/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, Divider, makeStyles } from "@material-ui/core"; 2 | import React from "react"; 3 | import { AboutButton } from "./about/AboutButton"; 4 | import { DirectoryPanel } from "./DirectoryPanel"; 5 | import { SettingsButton } from "./settings/SettingsButton"; 6 | const useStyles = makeStyles({ 7 | root: { 8 | overflow: "hidden", 9 | height: "100vh", 10 | paddingTop: "1em", 11 | paddingBottom: "1em", 12 | }, 13 | }); 14 | 15 | export const Main = () => { 16 | const classes = useStyles(); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /ui/components/NotificationSnackbar.tsx: -------------------------------------------------------------------------------- 1 | import { Slide, Snackbar, SnackbarCloseReason } from "@material-ui/core"; 2 | import { Alert } from "@material-ui/lab"; 3 | import React from "react"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { NotificationActions } from "../redux/notification/action-creators"; 6 | import { TState } from "../redux/reducers"; 7 | 8 | const SNACKBAR_AUTOHIDE_DURATION: number = 5000; 9 | 10 | function SlideTransition(props) { 11 | return ; 12 | } 13 | 14 | export const NotificationSnackbar = () => { 15 | const { message, type, visible } = useSelector((state: TState) => state.notification); 16 | const dispatch = useDispatch(); 17 | const hide = () => dispatch(NotificationActions.setVisible(false)); 18 | 19 | const handleClose = (event: React.SyntheticEvent, reason?: SnackbarCloseReason) => { 20 | if (reason === "clickaway") { 21 | return; 22 | } 23 | 24 | hide(); 25 | }; 26 | 27 | return ( 28 | 36 | 37 | {message} 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /ui/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Collapse, LinearProgress, makeStyles } from "@material-ui/core"; 2 | import React from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { TState } from "../redux/reducers"; 5 | import { useL10n } from "../utils/l10n-hooks"; 6 | import { EstimatedTime } from "./EstimatedTime"; 7 | 8 | interface IProps { 9 | interruptSearch: () => void; 10 | } 11 | 12 | const useStyles = makeStyles(() => ({ 13 | root: { 14 | height: 10, 15 | borderRadius: 5, 16 | }, 17 | bar: { 18 | borderRadius: 5, 19 | }, 20 | })); 21 | 22 | export const ProgressBar = ({ interruptSearch }: IProps) => { 23 | const classes = useStyles(); 24 | const [l10n] = useL10n(); 25 | const { progressRelative, inProgress } = useSelector((state: TState) => state.conflictResolver.searchProcess); 26 | const [searchStartTime, setSearchStartTime] = React.useState(); 27 | 28 | React.useEffect(() => { 29 | if (inProgress) { 30 | setSearchStartTime(new Date()); 31 | } else { 32 | setSearchStartTime(undefined); 33 | } 34 | }, [inProgress]); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/SearchPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grow } from "@material-ui/core"; 2 | import React, { useEffect } from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { getErrorMessage } from "../../common/errors"; 5 | import { ipc } from "../../common/ipc"; 6 | import { ISearchParams } from "../../common/types"; 7 | import { ConflictResolverActions } from "../redux/conflict-resolver/action-creators"; 8 | import { TState } from "../redux/reducers"; 9 | import { ConflictResolverThunk } from "../redux/thunk/conflict-resolver"; 10 | import { useL10n } from "../utils/l10n-hooks"; 11 | import { useNotification } from "../utils/notifications"; 12 | import { useThunkDispatch } from "../utils/thunk-hooks"; 13 | import { FilesArea } from "./files-area/FilesArea"; 14 | import { ProgressBar } from "./ProgressBar"; 15 | import { SearchParametersForm } from "./SearchParametersForm"; 16 | import { StartButton } from "./StartButton"; 17 | 18 | export const SearchPanel = () => { 19 | const [l10n] = useL10n(); 20 | const [params, setParams] = React.useState({ searchMd5: true, searchTgi: false }); 21 | const { 22 | searchProcess: { inProgress }, 23 | searchResult, 24 | } = useSelector((state: TState) => state.conflictResolver); 25 | 26 | const notification = useNotification(); 27 | const dispatch = useThunkDispatch(); 28 | const targetPath = useSelector((state: TState) => state.conflictResolver.searchDirectory); 29 | 30 | const interruptSearch = () => { 31 | ipc.renderer.rpc.interruptSearch().catch((err) => notification.showError(getErrorMessage(err, l10n))); 32 | }; 33 | 34 | useEffect(() => { 35 | dispatch(ConflictResolverActions.cleanupSearch()); 36 | return interruptSearch; 37 | }, []); 38 | 39 | const startSearch = () => { 40 | if (!inProgress) { 41 | dispatch(ConflictResolverThunk.searchStartAndUpdate({ targetPath, ...params })) 42 | .then(() => notification.showSuccess(l10n.searchFinished)) 43 | .catch((err) => notification.showError(getErrorMessage(err, l10n))); 44 | } 45 | }; 46 | 47 | return ( 48 | <> 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /ui/components/SearchParametersForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlLabel, FormGroup, FormLabel, Radio, RadioGroup } from "@material-ui/core"; 2 | import React from "react"; 3 | import { ISearchParams } from "../../common/types"; 4 | import { useL10n } from "../utils/l10n-hooks"; 5 | 6 | enum ParameterNames { 7 | SearchExact = "SearchExact", 8 | SearchCatalog = "SearchCatalog", 9 | } 10 | 11 | interface IProps { 12 | params: ISearchParams; 13 | setParams: (newParams: ISearchParams) => void; 14 | editable: boolean; 15 | } 16 | 17 | export const SearchParametersForm = ({ params, setParams, editable }: IProps) => { 18 | const [l10n] = useL10n(); 19 | 20 | const setterMap = { 21 | [ParameterNames.SearchExact]: () => { 22 | setParams({ ...params, searchTgi: false, searchMd5: true }); 23 | }, 24 | [ParameterNames.SearchCatalog]: () => { 25 | setParams({ ...params, searchMd5: false, searchTgi: true }); 26 | }, 27 | }; 28 | 29 | const handleChange = (event) => { 30 | setterMap[event.target.value](); 31 | }; 32 | 33 | return ( 34 | 35 | {l10n.searchMode} 36 | 40 | } 43 | disabled={!editable} 44 | label={l10n.searchExactDoubles} 45 | /> 46 | } 49 | disabled={!editable} 50 | label={l10n.searchCatalogConflicts} 51 | /> 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /ui/components/StartButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip } from "@material-ui/core"; 2 | import React from "react"; 3 | import { ISearchParams } from "../../common/types"; 4 | import { useL10n } from "../utils/l10n-hooks"; 5 | 6 | interface IProps { 7 | onClick: () => void; 8 | params: ISearchParams; 9 | } 10 | 11 | export const StartButton = ({ onClick, params }: IProps) => { 12 | const [l10n] = useL10n(); 13 | 14 | const canStart = params.searchMd5 || params.searchTgi; 15 | const popper = canStart ? () => null : undefined; // => null is to disable tooltip 16 | return ( 17 | 18 | 19 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /ui/components/about/AboutButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@material-ui/core"; 2 | import React from "react"; 3 | import { useL10n } from "../../utils/l10n-hooks"; 4 | import { AboutDialog } from "./AboutDialog"; 5 | 6 | export const AboutButton = () => { 7 | const [l10n] = useL10n(); 8 | const [dialogOpen, setDialogOpen] = React.useState(false); 9 | const handleClickOpen = () => setDialogOpen(true); 10 | const handleClickClose = () => setDialogOpen(false); 11 | 12 | return ( 13 | <> 14 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /ui/components/about/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Dialog, 5 | DialogContent, 6 | DialogTitle, 7 | List, 8 | ListItem, 9 | Tooltip, 10 | Typography, 11 | } from "@material-ui/core"; 12 | import Github from "@material-ui/icons/GitHub"; 13 | import Mail from "@material-ui/icons/Mail"; 14 | import { shell } from "electron"; 15 | import _ from "lodash"; 16 | import React from "react"; 17 | import assistantAvatar from "../../../assets/assistant-avatar.png"; 18 | import authorAvatar from "../../../assets/author-avatar.png"; 19 | import pjson from "../../../package.json"; 20 | import { useL10n } from "../../utils/l10n-hooks"; 21 | import { preprocessUrl } from "../../utils/url"; 22 | import { PatreonIcon } from "../icons/PatreonIcon"; 23 | import { Contact, IContact } from "./Contact"; 24 | 25 | interface IProps { 26 | visible: boolean; 27 | close: () => void; 28 | } 29 | 30 | export const AboutDialog = ({ visible, close }: IProps) => { 31 | const [l10n] = useL10n(); 32 | const contacts: IContact[] = [ 33 | { 34 | avatar: authorAvatar, 35 | icons: [ 36 | { 37 | icon: , 38 | link: pjson.contacts.authorGithub, 39 | }, 40 | { 41 | icon: , 42 | link: pjson.contacts.authorEmail, 43 | }, 44 | ], 45 | name: pjson.author, 46 | role: l10n.author, 47 | }, 48 | { 49 | avatar: assistantAvatar, 50 | icons: [ 51 | { 52 | icon: , 53 | link: pjson.contacts.assistantPatreon, 54 | }, 55 | { 56 | icon: , 57 | link: pjson.contacts.assistantEmail, 58 | }, 59 | ], 60 | name: pjson.contacts.assistant, 61 | role: l10n.assistant, 62 | }, 63 | ]; 64 | const openHomepage = () => shell.openExternal(preprocessUrl(pjson.contacts.homepage)); 65 | 66 | return ( 67 | 68 | {l10n.about} 69 | 70 | 71 | {pjson.nameLong} {pjson.version} 72 | 73 | 74 | {l10n.description} 75 |
76 | 77 | {_.map(contacts, (c) => ( 78 | 79 | ))} 80 | 81 | 82 | 83 | 86 | 87 | 88 | 89 | 90 |
91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /ui/components/about/Contact.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, IconButton, ListItem, ListItemAvatar, ListItemText, Tooltip } from "@material-ui/core"; 2 | import { shell } from "electron"; 3 | import _ from "lodash"; 4 | import React from "react"; 5 | import { preprocessUrl } from "../../utils/url"; 6 | 7 | export interface IContactIcon { 8 | icon: React.ReactElement; 9 | link: string; 10 | } 11 | 12 | export interface IContact { 13 | name: string; 14 | avatar: string; 15 | role: string; 16 | icons: IContactIcon[]; 17 | } 18 | 19 | export const Contact = ({ name, role, avatar, icons }: IContact) => { 20 | const handleClick = (link: string) => () => { 21 | shell.openExternal(preprocessUrl(link)); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | {_.map(icons, (icon) => ( 32 | 33 | {icon.icon} 34 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /ui/components/common.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | user-select: none; 4 | overflow: hidden; 5 | } 6 | -------------------------------------------------------------------------------- /ui/components/files-area/FilesArea.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Box, Fade, makeStyles, Tab } from "@material-ui/core"; 2 | import { TabContext, TabList, TabPanel } from "@material-ui/lab"; 3 | import classNames from "classnames"; 4 | import _ from "lodash"; 5 | import React from "react"; 6 | import { isOk } from "../../../common/tools"; 7 | import { ISearchResult } from "../../../common/types"; 8 | import { useL10n } from "../../utils/l10n-hooks"; 9 | import { DuplicatesList } from "./duplicates/DuplicatesList"; 10 | import { SkipsList } from "./skips/SkipsList"; 11 | 12 | interface IProps { 13 | searchInfo: ISearchResult; 14 | } 15 | 16 | enum Tabs { 17 | Duplicates = "Duplicates", 18 | Skips = "Skips", 19 | } 20 | 21 | const useStyles = makeStyles({ 22 | tabScrollable: { 23 | overflow: "auto", 24 | height: "100%", 25 | }, 26 | tabNonScrollable: { 27 | overflow: "hidden", 28 | height: "100%", 29 | }, 30 | tabNoSpacing: { 31 | padding: 0, 32 | }, 33 | }); 34 | 35 | export const FilesArea = ({ searchInfo }: IProps) => { 36 | const [l10n] = useL10n(); 37 | const [tab, setTab] = React.useState(Tabs.Duplicates); 38 | const classes = useStyles(); 39 | 40 | const handleChange = (___: React.ChangeEvent<{}>, newTab: Tabs) => { 41 | setTab(newTab); 42 | }; 43 | 44 | const duplicatesCount = isOk(searchInfo) 45 | ? _.reduce(searchInfo.duplicates, (sum, entry) => sum + entry.summary.files.length, 0) 46 | : 0; 47 | const skipsCount = isOk(searchInfo) ? searchInfo.skips.length : 0; 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DoubleTypeChipBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Chip, Tooltip } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import React from "react"; 4 | import { DoubleTypes } from "../../../../common/types"; 5 | import { useL10n } from "../../../utils/l10n-hooks"; 6 | import { doubleTypeMap } from "../../../utils/language-mapping"; 7 | 8 | interface IProps { 9 | types: DoubleTypes[]; 10 | } 11 | 12 | export const DoubleTypeChipBar = ({ types }: IProps) => { 13 | const [l10n] = useL10n(); 14 | 15 | return ( 16 | 17 | {_.map(types, (type: DoubleTypes, i: number) => ( 18 | 19 | 20 | 21 | 22 | 23 | ))} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicateGroupEntry.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, ListItem, ListItemText, Tooltip } from "@material-ui/core"; 2 | import path from "path"; 3 | import React from "react"; 4 | import { useL10n } from "../../../utils/l10n-hooks"; 5 | import { usePathStyles } from "../tools"; 6 | import { DuplicateToolbar } from "./DuplicateToolbar"; 7 | 8 | export interface IDuplicateGroupEntryProps { 9 | filePath: string; 10 | checked: boolean; 11 | modifiedDate: Date; 12 | onChange: (newValue: boolean) => void; 13 | } 14 | 15 | export const DuplicateGroupEntry = ({ filePath, checked, onChange, modifiedDate }: IDuplicateGroupEntryProps) => { 16 | const [l10n] = useL10n(); 17 | const pathClasses = usePathStyles(); 18 | 19 | const onChangeHandler = (event: any, newChecked: boolean) => onChange(newChecked); 20 | 21 | return ( 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicateGroupToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, IconButton, ListItem, Tooltip } from "@material-ui/core"; 2 | import React from "react"; 3 | import { IDuplicateGraph, IDuplicateGroup } from "../../../../common/types"; 4 | import { CheckboxState } from "../../../utils/checkbox"; 5 | import { useL10n } from "../../../utils/l10n-hooks"; 6 | import { GraphIcon } from "../../icons/GraphIcon"; 7 | import { DoubleTypeChipBar } from "./DoubleTypeChipBar"; 8 | 9 | export interface IDuplicateGroupToolbarProps { 10 | group: IDuplicateGroup; 11 | openDetailed: (group: IDuplicateGraph) => void; 12 | groupChecked: CheckboxState; 13 | onChange: (checked: boolean) => void; 14 | } 15 | 16 | export const DuplicateGroupToolbar = ({ group, openDetailed, groupChecked, onChange }: IDuplicateGroupToolbarProps) => { 17 | const [l10n] = useL10n(); 18 | const onClick = () => openDetailed(group.detailed); 19 | const onChangeHandler = (event: any, checked: boolean) => onChange(checked); 20 | return ( 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicateMainToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Box, Button, Checkbox, InputAdornment, TextField, Tooltip } from "@material-ui/core"; 2 | import SearchIcon from "@material-ui/icons/Search"; 3 | import { remote } from "electron"; 4 | import React, { useEffect } from "react"; 5 | import { useSelector } from "react-redux"; 6 | import { getErrorMessage } from "../../../../common/errors"; 7 | import { ipc } from "../../../../common/ipc"; 8 | import { IIndexUpdate } from "../../../../common/types"; 9 | import { ConflictResolverActions } from "../../../redux/conflict-resolver/action-creators"; 10 | import { TState } from "../../../redux/reducers"; 11 | import { ConflictResolverThunk } from "../../../redux/thunk/conflict-resolver"; 12 | import { useBackdropBound } from "../../../utils/backdrop-hooks"; 13 | import { CheckboxState, getCheckboxState } from "../../../utils/checkbox"; 14 | import { isFilterUsed, isFilterValid, pathFilter } from "../../../utils/filter"; 15 | import { useL10n } from "../../../utils/l10n-hooks"; 16 | import { useNotification } from "../../../utils/notifications"; 17 | import { useThunkDispatch } from "../../../utils/thunk-hooks"; 18 | import { CaseIcon } from "../../icons/CaseIcon"; 19 | import { CheckboxIcon } from "../../icons/CheckboxIcon"; 20 | import { RegexIcon } from "../../icons/RegexIcon"; 21 | export const DuplicateMainToolbar = () => { 22 | const dispatch = useThunkDispatch(); 23 | const { filesFilter, selectedConflictFiles } = useSelector((state: TState) => state.conflictResolver); 24 | const searchDirectory = useSelector((state: TState) => state.conflictResolver.searchDirectory); 25 | const notification = useNotification(); 26 | const [l10n] = useL10n(); 27 | const [moveDisabled, setMoveDisabled] = React.useState(false); 28 | useBackdropBound(moveDisabled); 29 | 30 | const setAllChecked = (checked: boolean) => { 31 | dispatch(ConflictResolverThunk.selectAll(checked)); 32 | }; 33 | const filteredPath = Object.keys(selectedConflictFiles).filter(pathFilter(filesFilter)); 34 | const selectedPaths = filteredPath.filter((p) => selectedConflictFiles[p]); 35 | useEffect(() => { 36 | const onIndexUpdate = (__, event: IIndexUpdate) => { 37 | dispatch(ConflictResolverActions.updateIndex(event)); 38 | }; 39 | ipc.renderer.on.indexUpdate(onIndexUpdate); 40 | return () => { 41 | ipc.renderer.off.indexUpdate(onIndexUpdate); 42 | }; 43 | }, []); 44 | 45 | const moveItems = async () => { 46 | setMoveDisabled(true); 47 | 48 | try { 49 | const dialogResult = await remote.dialog.showOpenDialog({ properties: ["openDirectory"] }); 50 | if (!dialogResult.canceled) { 51 | await ipc.renderer.rpc.moveDuplicates({ 52 | targetDir: dialogResult.filePaths[0], 53 | searchDir: searchDirectory, 54 | filePaths: selectedPaths, 55 | }); 56 | notification.showSuccess(l10n.moveSuccess); 57 | } 58 | } catch (err) { 59 | notification.showError(getErrorMessage(err, l10n)); 60 | } finally { 61 | setMoveDisabled(false); 62 | } 63 | }; 64 | 65 | const openDalog = () => { 66 | moveItems().catch((err) => notification.showError(l10n.errorMove(getErrorMessage(err, l10n)))); 67 | }; 68 | 69 | const globalSelectHandler = (event: React.ChangeEvent) => setAllChecked(event.target.checked); 70 | const allChecked = getCheckboxState(filteredPath.length, selectedPaths.length); 71 | 72 | const setFilterHandler = (event: React.ChangeEvent) => { 73 | dispatch( 74 | ConflictResolverActions.setFilter({ 75 | ...filesFilter, 76 | filter: event.target.value, 77 | }), 78 | ); 79 | }; 80 | 81 | const setRegexHandler = (checked: boolean) => { 82 | dispatch( 83 | ConflictResolverActions.setFilter({ 84 | ...filesFilter, 85 | isRegex: checked, 86 | }), 87 | ); 88 | }; 89 | 90 | const setCaseSensitiveHandler = (checked: boolean) => { 91 | dispatch( 92 | ConflictResolverActions.setFilter({ 93 | ...filesFilter, 94 | isCaseSensitive: checked, 95 | }), 96 | ); 97 | }; 98 | 99 | return ( 100 | 101 | 102 | 107 | 108 | 115 | 116 | 117 | ), 118 | }} 119 | value={filesFilter.filter} 120 | onChange={setFilterHandler} 121 | /> 122 | 123 | 124 | 129 | 130 | 131 | 132 | 133 | 138 | 139 | 140 | 141 | 150 | 151 | 152 | ); 153 | }; 154 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicateRow.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@material-ui/core"; 2 | import React, { ReactNode } from "react"; 3 | import { DuplicateGroupEntry, IDuplicateGroupEntryProps } from "./DuplicateGroupEntry"; 4 | import { DuplicateGroupToolbar, IDuplicateGroupToolbarProps } from "./DuplicateGroupToolbar"; 5 | 6 | export enum DuplicateRowType { 7 | Toolbar, 8 | Entry, 9 | } 10 | 11 | interface IDuplicateGroupToolbarRow extends IDuplicateGroupToolbarProps { 12 | type: DuplicateRowType.Toolbar; 13 | } 14 | 15 | interface IDuplicateGroupEntryRow extends IDuplicateGroupEntryProps { 16 | type: DuplicateRowType.Entry; 17 | } 18 | 19 | type TRowInfo = IDuplicateGroupToolbarRow | IDuplicateGroupEntryRow; 20 | export type TItemData = TRowInfo[]; 21 | 22 | interface IProps { 23 | index: number; 24 | style: object; 25 | isScrolling?: boolean; 26 | data: TItemData; 27 | } 28 | 29 | export const DuplicateRow = ({ index, style, isScrolling, data }: IProps) => { 30 | const rowInfo = data[index]; 31 | 32 | let content: ReactNode; 33 | if (rowInfo.type === DuplicateRowType.Toolbar) { 34 | content = ( 35 | <> 36 | 37 | 38 | 39 | ); 40 | } else { 41 | content = ; 42 | } 43 | 44 | return
{content}
; 45 | }; 46 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicateToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, IconButton, makeStyles, Tooltip } from "@material-ui/core"; 2 | import FileCopyIcon from "@material-ui/icons/FileCopy"; 3 | import FolderOpenIcon from "@material-ui/icons/FolderOpen"; 4 | import { clipboard } from "electron"; 5 | import React from "react"; 6 | import { useL10n } from "../../../utils/l10n-hooks"; 7 | import { useNotification } from "../../../utils/notifications"; 8 | import { getShowFileHandler } from "../tools"; 9 | import { SimsStudioButton } from "./SimsStudioButton"; 10 | 11 | const useStyles = makeStyles((theme) => ({ 12 | button: { 13 | marginLeft: theme.spacing(1), 14 | }, 15 | })); 16 | 17 | interface IProps { 18 | path: string; 19 | } 20 | 21 | export const DuplicateToolbar = ({ path }: IProps) => { 22 | const [l10n] = useL10n(); 23 | const notification = useNotification(); 24 | const classes = useStyles(); 25 | 26 | const copyPathToClipboard = () => { 27 | clipboard.writeText(path); 28 | notification.showSuccess(l10n.copyPathSuccess(path)); 29 | }; 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/DuplicatesList.tsx: -------------------------------------------------------------------------------- 1 | import { Box, makeStyles } from "@material-ui/core"; 2 | import React, { useState } from "react"; 3 | import { useSelector } from "react-redux"; 4 | import AutoSizer from "react-virtualized-auto-sizer"; 5 | import { FixedSizeList } from "react-window"; 6 | import { isOk } from "../../../../common/tools"; 7 | import { IDuplicateGraph, ISearchResult } from "../../../../common/types"; 8 | import { ConflictResolverActions } from "../../../redux/conflict-resolver/action-creators"; 9 | import { TState } from "../../../redux/reducers"; 10 | import { getCheckboxState } from "../../../utils/checkbox"; 11 | import { VIRTUALIZE_CONSTANTS } from "../../../utils/constants"; 12 | import { pathFilter } from "../../../utils/filter"; 13 | import { useL10n } from "../../../utils/l10n-hooks"; 14 | import { useThunkDispatch } from "../../../utils/thunk-hooks"; 15 | import { useForceUpdate } from "../../../utils/util-hooks"; 16 | import { DetailedDialog } from "./detailed/DetailedDialog"; 17 | import { DuplicateMainToolbar } from "./DuplicateMainToolbar"; 18 | import { DuplicateRow, DuplicateRowType, TItemData } from "./DuplicateRow"; 19 | 20 | interface IProps { 21 | searchInfo: ISearchResult; 22 | } 23 | 24 | const useStyles = makeStyles({ 25 | scrollY: { 26 | overflowY: "auto", 27 | }, 28 | }); 29 | 30 | export const DuplicatesList = ({ searchInfo }: IProps) => { 31 | const dispatch = useThunkDispatch(); 32 | const [_, language] = useL10n(); 33 | const [detailedVisible, setDetailedVisible] = useState(false); 34 | const [graph, setGraph] = useState(undefined); 35 | const { filesFilter, selectedConflictFiles: checkedItems } = useSelector((state: TState) => state.conflictResolver); 36 | const forceUpdateKey = useForceUpdate([language]); 37 | 38 | const classes = useStyles(); 39 | 40 | const getCheckboxHandler = (files: string[]) => (checked: boolean) => { 41 | dispatch(ConflictResolverActions.selectFiles(files, checked)); 42 | }; 43 | 44 | const closeDetailedDialog = () => { 45 | setDetailedVisible(false); 46 | setGraph(undefined); 47 | }; 48 | 49 | const openDetailedDialog = (newGraph: IDuplicateGraph) => { 50 | setGraph(newGraph); 51 | setDetailedVisible(true); 52 | }; 53 | 54 | if (!isOk(searchInfo)) { 55 | return null; 56 | } 57 | 58 | const data: TItemData = []; 59 | 60 | for (const group of searchInfo.duplicates) { 61 | const currentPaths = group.summary.files.filter(pathFilter(filesFilter)); 62 | if (currentPaths.length === 0) { 63 | continue; 64 | } 65 | 66 | data.push({ 67 | type: DuplicateRowType.Toolbar, 68 | group, 69 | groupChecked: getCheckboxState(currentPaths.length, currentPaths.filter((p) => checkedItems[p]).length), 70 | onChange: getCheckboxHandler(currentPaths), 71 | openDetailed: openDetailedDialog, 72 | }); 73 | 74 | for (const path of currentPaths) { 75 | data.push({ 76 | type: DuplicateRowType.Entry, 77 | checked: checkedItems[path], 78 | filePath: path, 79 | modifiedDate: searchInfo.fileInfos[path].modifiedDate, 80 | onChange: getCheckboxHandler([path]), 81 | }); 82 | } 83 | } 84 | 85 | return ( 86 | 87 | 88 | 89 | 90 | {({ height, width }) => ( 91 | 99 | {DuplicateRow} 100 | 101 | )} 102 | 103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/SimsStudioButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from "@material-ui/core"; 2 | import React from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { ipc } from "../../../../common/ipc"; 5 | import { isOk } from "../../../../common/tools"; 6 | import { TState } from "../../../redux/reducers"; 7 | import { useL10n } from "../../../utils/l10n-hooks"; 8 | import { SimsIcon } from "../../icons/SimsIcon"; 9 | 10 | interface IProps { 11 | path: string; 12 | className: string; 13 | } 14 | 15 | export const SimsStudioButton = ({ path, className }: IProps) => { 16 | const [l10n] = useL10n(); 17 | const simsStudioPath = useSelector((state: TState) => state.settings.studioPath); 18 | 19 | const studioConfigured = isOk(simsStudioPath); 20 | const StudioTooltip = ({ children, ...rest }) => ( 21 | 22 | {children} 23 | 24 | ); 25 | 26 | const onClick = () => ipc.renderer.rpc.openInStudio({ filePath: path, simsStudioPath }); 27 | 28 | return ( 29 | 30 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/detailed/D3Graph.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Theme, withStyles } from "@material-ui/core"; 2 | import { WithStyles } from "@material-ui/core/styles/withStyles"; 3 | import * as d3 from "d3"; 4 | import path from "path"; 5 | import React, { createRef } from "react"; 6 | import { Translation } from "../../../../../common/l10n"; 7 | import { DoubleTypes, IDuplicateGraph } from "../../../../../common/types"; 8 | import { doubleTypeMap } from "../../../../utils/language-mapping"; 9 | 10 | interface ID3Node { 11 | path: string; 12 | isGroup: boolean; 13 | radius: number; 14 | nodeClass: string; 15 | nodeGroupBorderClass: string; 16 | label: string; 17 | tooltipLines: string[]; 18 | } 19 | 20 | interface ID3Link { 21 | source: string; 22 | target: string; 23 | } 24 | 25 | const styles = (theme: Theme) => 26 | createStyles({ 27 | container: { 28 | overflow: "hidden", 29 | position: "relative", 30 | whiteSpace: "nowrap", 31 | }, 32 | nodeCircle: { 33 | fill: theme.palette.primary.main, 34 | cursor: "pointer", 35 | stroke: "white", 36 | strokeWidth: 1.5, 37 | }, 38 | nodeKeyCircle: { 39 | fill: theme.palette.secondary.main, 40 | cursor: "pointer", 41 | stroke: "white", 42 | strokeWidth: 1.5, 43 | }, 44 | linkLabel: { 45 | fontSize: "0.75em", 46 | fontWeight: 700, 47 | fill: theme.palette.text.secondary, 48 | textAnchor: "middle", 49 | }, 50 | labelBorder: { 51 | fill: "none", 52 | stroke: "white", 53 | strokeWidth: 3, 54 | }, 55 | nodeGroupBorder: { 56 | fill: "none", 57 | stroke: theme.palette.primary.main, 58 | strokeWidth: 2, 59 | opacity: 0.5, 60 | }, 61 | nodeGroupKeyBorder: { 62 | fill: "none", 63 | stroke: theme.palette.secondary.main, 64 | strokeWidth: 2, 65 | opacity: 0.5, 66 | }, 67 | tooltip: { 68 | position: "absolute", 69 | padding: theme.spacing(1), 70 | fontFamily: "monospace", 71 | background: theme.palette.text.secondary, 72 | borderRadius: theme.shape.borderRadius, 73 | color: "white", 74 | pointerEvents: "none", 75 | userSelect: "text", 76 | }, 77 | link: { 78 | stroke: theme.palette.text.secondary, 79 | strokeWidth: 1.5, 80 | strokeOpacity: 0.6, 81 | }, 82 | svg: { 83 | fontFamily: "sans-serif", 84 | }, 85 | }); 86 | 87 | interface IProps extends WithStyles { 88 | graph: IDuplicateGraph; 89 | height: number; 90 | width: number; 91 | l10n: Translation; 92 | } 93 | 94 | export class D3GraphImpl extends React.Component { 95 | private d3RootRef = createRef(); 96 | 97 | componentDidMount() { 98 | const { graph, classes } = this.props; 99 | const d3Graph = this.createD3Graph(graph); 100 | const links = d3Graph.links.map((d) => Object.create(d)); 101 | const nodes = d3Graph.nodes.map((d) => Object.create(d)); 102 | 103 | const simulation = this.createSimulation(nodes, links); 104 | const svg = this.createSvg(); 105 | const tooltip = this.createTooltip(); 106 | const link = svg.append("g").attr("class", classes.link).selectAll("line").data(links).join("line"); 107 | const node = this.createNode(svg, nodes, simulation, tooltip); 108 | simulation.on("tick", () => { 109 | link.attr("x1", (d) => d.source.x) 110 | .attr("y1", (d) => d.source.y) 111 | .attr("x2", (d) => d.target.x) 112 | .attr("y2", (d) => d.target.y); 113 | 114 | node.attr("transform", (d) => `translate(${d.x},${d.y})`); 115 | }); 116 | } 117 | 118 | componentDidUpdate() { 119 | this.selectRoot().select("svg").attr("viewBox", this.getViewBox()); 120 | } 121 | 122 | private selectRoot = () => { 123 | return d3.select(this.d3RootRef.current); 124 | }; 125 | 126 | private getViewBox = (): any => { 127 | const { width, height } = this.props; 128 | return [-width, -height, width * 2, height * 2]; 129 | }; 130 | 131 | private getDrag = (simulation: any) => { 132 | const dragstarted = (d) => { 133 | if (!d3.event.active) simulation.alphaTarget(0.3).restart(); 134 | d.fx = d.x; 135 | d.fy = d.y; 136 | }; 137 | 138 | const dragged = (d) => { 139 | d.fx = d3.event.x; 140 | d.fy = d3.event.y; 141 | }; 142 | 143 | const dragended = (d) => { 144 | if (!d3.event.active) simulation.alphaTarget(0); 145 | d.fx = null; 146 | d.fy = null; 147 | }; 148 | 149 | return d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended); 150 | }; 151 | 152 | private createSimulation = (nodes: any[], links: any[]) => { 153 | return d3 154 | .forceSimulation(nodes) 155 | .force( 156 | "link", 157 | d3.forceLink(links).id((d: any) => d.path), 158 | ) 159 | .force("charge", d3.forceManyBody().strength(-5000)) 160 | .force("x", d3.forceX()) 161 | .force("y", d3.forceY()); 162 | }; 163 | 164 | private createSvg = () => { 165 | const { classes } = this.props; 166 | const svg = this.selectRoot().append("svg").attr("viewBox", this.getViewBox()).attr("class", classes.svg); 167 | const zoom = d3 168 | .zoom() 169 | .scaleExtent([1, 2]) 170 | .translateExtent([ 171 | [0, 0], 172 | [1, 1], 173 | ]) 174 | .on("zoom", () => { 175 | svg.attr("transform", d3.event.transform); 176 | }); 177 | 178 | svg.call(zoom) 179 | .on("mousedown.zoom", null) 180 | .on("touchstart.zoom", null) 181 | .on("touchmove.zoom", null) 182 | .on("touchend.zoom", null) 183 | .on("dblclick.zoom", null); 184 | 185 | zoom.scaleTo(svg, 2); 186 | return svg; 187 | }; 188 | 189 | private createTooltip = () => { 190 | const { classes } = this.props; 191 | const tooltip = this.selectRoot() 192 | .append("div") 193 | .attr("class", classes.tooltip) 194 | .style("opacity", 0) 195 | .style("left", 0) 196 | .style("top", 0) 197 | .on("mouseover", () => { 198 | tooltip.transition().duration(200).style("opacity", 1).style("pointer-events", "all"); 199 | }) 200 | .on("mouseout", () => { 201 | tooltip.transition().duration(200).style("opacity", 0).style("pointer-events", "none"); 202 | }); 203 | 204 | return tooltip; 205 | }; 206 | 207 | private createNode = (svg: any, nodes: any[], simulation: any, tooltip: any) => { 208 | const { classes } = this.props; 209 | 210 | const node = svg 211 | .append("g") 212 | .selectAll("g") 213 | .data(nodes) 214 | .join("g") 215 | .call(this.getDrag(simulation) as any); 216 | 217 | const nodeCircle = node 218 | .append("circle") 219 | .attr("class", (d) => d.nodeClass) 220 | .attr("r", (d) => d.radius); 221 | 222 | nodeCircle 223 | .filter((d) => d.isGroup) 224 | .clone(true) 225 | .attr("r", (d) => d.radius + 2) 226 | .attr("class", (d) => d.nodeGroupBorderClass); 227 | 228 | nodeCircle 229 | .on("mouseover", (d) => { 230 | if (d.tooltipLines.length === 0) { 231 | return; 232 | } 233 | 234 | tooltip.transition().duration(200).style("opacity", 1).style("pointer-events", "all"); 235 | 236 | const rootRect = this.d3RootRef.current.getBoundingClientRect(); 237 | const labelRect = d3.event.target.getBoundingClientRect(); 238 | const x = labelRect.x + labelRect.width - rootRect.left; 239 | const y = labelRect.y + labelRect.height - rootRect.top; 240 | 241 | tooltip.html(d.tooltipLines.join("
")).style("left", `${x}px`).style("top", `${y}px`); 242 | }) 243 | .on("mouseout", () => { 244 | tooltip.transition().duration(200).delay(200).style("opacity", 0).style("pointer-events", "none"); 245 | }); 246 | 247 | node.append("text") 248 | .attr("x", "1em") 249 | .text((d) => d.label) 250 | .clone(true) 251 | .lower() 252 | .attr("class", classes.labelBorder); 253 | 254 | return node; 255 | }; 256 | 257 | private createD3Graph(graph: IDuplicateGraph) { 258 | const added = new Set(); 259 | const result: { 260 | nodes: ID3Node[]; 261 | links: ID3Link[]; 262 | } = { 263 | nodes: [], 264 | links: [], 265 | }; 266 | 267 | const encoded = {}; 268 | let lastId = 0; 269 | const encode = (data: string): string => { 270 | if (!(data in encoded)) { 271 | encoded[data] = lastId++; 272 | } 273 | return encoded[data]; 274 | }; 275 | 276 | for (const { fileGroups, keys } of graph.edgeGroups) { 277 | const keyNode = this.createD3KeyNode(keys, graph.typeByKey, encode); 278 | result.nodes.push(keyNode); 279 | 280 | for (const fileGroup of fileGroups) { 281 | const fileNode = this.createD3FileNode(fileGroup, encode); 282 | if (!added.has(fileNode.path)) { 283 | result.nodes.push(fileNode); 284 | added.add(fileNode.path); 285 | } 286 | 287 | result.links.push({ 288 | source: fileNode.path, 289 | target: keyNode.path, 290 | }); 291 | } 292 | } 293 | 294 | return result; 295 | } 296 | 297 | private toKey(data: string[]): string { 298 | return data.sort().join(","); 299 | } 300 | 301 | private createD3KeyNode(keys, typeByKey, encode): ID3Node { 302 | const { l10n, classes } = this.props; 303 | const keyGroupName = this.toKey(keys.map((k) => encode(k))); 304 | const uniqueTypes: DoubleTypes[] = Array.from(new Set(keys.map((k) => typeByKey[k]))); 305 | return { 306 | path: keyGroupName, 307 | isGroup: keys.length > 1, 308 | radius: 7, 309 | nodeClass: classes.nodeKeyCircle, 310 | nodeGroupBorderClass: classes.nodeGroupKeyBorder, 311 | label: l10n.keyGroup( 312 | keys.length, 313 | uniqueTypes.map((t) => doubleTypeMap[t](l10n).title), 314 | ), 315 | tooltipLines: [ 316 | l10n.conflictKeysDescription, 317 | ...keys.map((k) => `${k} ${doubleTypeMap[typeByKey[k]](l10n).title}`), 318 | ], 319 | }; 320 | } 321 | 322 | private createD3FileNode(fileGroup, encode): ID3Node { 323 | const { classes, l10n } = this.props; 324 | const groupName = this.toKey(fileGroup.map((f) => encode(f))); 325 | 326 | return { 327 | path: groupName, 328 | isGroup: fileGroup.length > 1, 329 | radius: 10, 330 | nodeClass: classes.nodeCircle, 331 | nodeGroupBorderClass: classes.nodeGroupBorder, 332 | label: fileGroup.length === 1 ? path.basename(fileGroup[0]) : l10n.fileGroup(fileGroup.length), 333 | tooltipLines: fileGroup.length > 1 ? fileGroup.map((f) => path.basename(f)) : [], 334 | }; 335 | } 336 | 337 | render() { 338 | const { height, width, classes } = this.props; 339 | return
; 340 | } 341 | } 342 | 343 | export const D3Graph = withStyles(styles)(D3GraphImpl); 344 | -------------------------------------------------------------------------------- /ui/components/files-area/duplicates/detailed/DetailedDialog.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Box, Dialog, IconButton, makeStyles, Toolbar, Typography } from "@material-ui/core"; 2 | import CloseIcon from "@material-ui/icons/Close"; 3 | import React from "react"; 4 | import AutoSizer from "react-virtualized-auto-sizer"; 5 | import { isOk } from "../../../../../common/tools"; 6 | import { IDuplicateGraph } from "../../../../../common/types"; 7 | import { useL10n } from "../../../../utils/l10n-hooks"; 8 | import { D3Graph } from "./D3Graph"; 9 | 10 | interface IProps { 11 | visible: boolean; 12 | close: () => void; 13 | graph: IDuplicateGraph; 14 | } 15 | 16 | const useStyles = makeStyles({ 17 | appBar: { 18 | position: "relative", 19 | }, 20 | title: { 21 | flexGrow: 1, 22 | }, 23 | }); 24 | 25 | export const DetailedDialog = ({ visible, close, graph }: IProps) => { 26 | const [l10n] = useL10n(); 27 | const classes = useStyles(); 28 | if (!isOk(graph)) { 29 | return null; 30 | } 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | {l10n.detailed} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {({ height, width }) => } 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/files-area/skips/SkipRow.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Chip, ListItem, ListItemIcon, ListItemText, Tooltip } from "@material-ui/core"; 2 | import Error from "@material-ui/icons/Error"; 3 | import path from "path"; 4 | import React from "react"; 5 | import { Translation } from "../../../../common/l10n"; 6 | import { IFileAdditionalInfo, ISkippedFile, SkipReasons } from "../../../../common/types"; 7 | import { getShowFileHandler } from "../tools"; 8 | 9 | export interface IItemData { 10 | skips: ISkippedFile[]; 11 | fileInfos: Record; 12 | l10n: Translation; 13 | } 14 | 15 | interface IProps { 16 | index: number; 17 | style: object; 18 | isScrolling?: boolean; 19 | data: IItemData; 20 | } 21 | 22 | type TChipMap = { 23 | [K in SkipReasons]: { 24 | label: string; 25 | tooltip: string; 26 | }; 27 | }; 28 | 29 | export const SkipRow = ({ index, style, isScrolling, data }: IProps) => { 30 | const x = data.skips[index]; 31 | const { l10n } = data; 32 | 33 | const chipMap: TChipMap = { 34 | [SkipReasons.UnsupportedSimsVersion]: { 35 | label: l10n.unsupportedSimsVersion, 36 | tooltip: l10n.unsupportedSimsVersionTooltip, 37 | }, 38 | [SkipReasons.NotPackage]: { 39 | label: l10n.notPackage, 40 | tooltip: l10n.notPackageDescription, 41 | }, 42 | 43 | [SkipReasons.UnableToParse]: { 44 | label: l10n.unableToParse, 45 | tooltip: l10n.unableToParseDescription, 46 | }, 47 | }; 48 | 49 | return ( 50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /ui/components/files-area/skips/SkipsList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AutoSizer from "react-virtualized-auto-sizer"; 3 | import { FixedSizeList } from "react-window"; 4 | import { isOk } from "../../../../common/tools"; 5 | import { ISearchResult } from "../../../../common/types"; 6 | import { VIRTUALIZE_CONSTANTS } from "../../../utils/constants"; 7 | import { useL10n } from "../../../utils/l10n-hooks"; 8 | import { useForceUpdate } from "../../../utils/util-hooks"; 9 | import { IItemData, SkipRow } from "./SkipRow"; 10 | 11 | interface IProps { 12 | searchInfo: ISearchResult; 13 | } 14 | 15 | export const SkipsList = ({ searchInfo }: IProps) => { 16 | const [l10n, language] = useL10n(); 17 | 18 | if (!isOk(searchInfo)) { 19 | return null; 20 | } 21 | 22 | const forceUpdateKey = useForceUpdate([language]); 23 | 24 | const itemData: IItemData = { 25 | skips: searchInfo.skips, 26 | fileInfos: searchInfo.fileInfos, 27 | l10n, 28 | }; 29 | 30 | return ( 31 | 32 | {({ height, width }) => ( 33 | 41 | {SkipRow} 42 | 43 | )} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /ui/components/files-area/tools.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@material-ui/core"; 2 | import { shell } from "electron"; 3 | 4 | export const getShowFileHandler = (path: string) => () => shell.showItemInFolder(path); 5 | 6 | export const usePathStyles = makeStyles({ 7 | path: { 8 | flexGrow: 1, 9 | direction: "rtl", 10 | textOverflow: "ellipsis", 11 | overflow: "hidden", 12 | textAlign: "right", 13 | whiteSpace: "nowrap", 14 | paddingLeft: "1em", 15 | fontSize: "0.9em", 16 | }, 17 | base: { 18 | flex: "none", 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /ui/components/icons/CaseIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | export const CaseIcon = (props) => ( 5 | 6 | 7 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /ui/components/icons/CheckboxIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, makeStyles } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | interface IProps { 5 | value: boolean; 6 | onChange: (newValue: boolean) => void; 7 | IconComponent: React.ComponentType; 8 | } 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | checkboxIcon: { 12 | fontSize: "0.75em", 13 | }, 14 | checkbox: { 15 | marginLeft: theme.spacing(0.5), 16 | padding: theme.spacing(0.5), 17 | }, 18 | checkboxChecked: { 19 | backgroundColor: theme.palette.primary.main, 20 | }, 21 | checkboxColorPrimary: { 22 | "&$checkboxChecked": { 23 | color: "white", 24 | }, 25 | "&$checkboxChecked:hover": { 26 | backgroundColor: theme.palette.primary.light, 27 | }, 28 | }, 29 | })); 30 | 31 | export const CheckboxIcon = ({ onChange, value, IconComponent }: IProps) => { 32 | const classes = useStyles(); 33 | 34 | const onChangeHandler = (event: React.ChangeEvent) => { 35 | onChange(event.target.checked); 36 | }; 37 | 38 | return ( 39 | } 42 | checkedIcon={} 43 | checked={value} 44 | onChange={onChangeHandler} 45 | classes={{ 46 | root: classes.checkbox, 47 | checked: classes.checkboxChecked, 48 | colorPrimary: classes.checkboxColorPrimary, 49 | }} 50 | /> 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /ui/components/icons/GraphIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | export const GraphIcon = (props) => ( 5 | 6 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /ui/components/icons/PatreonIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | export const PatreonIcon = (props) => ( 5 | 6 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /ui/components/icons/RegexIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | export const RegexIcon = (props) => ( 5 | 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /ui/components/icons/SimsIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | export const SimsIcon = (props) => ( 5 | 6 | 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /ui/components/settings/LanguageField.tsx: -------------------------------------------------------------------------------- 1 | import { Box, FormControl, ListItem, ListItemText, makeStyles, MenuItem, Select } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import React from "react"; 4 | import { Language } from "../../../common/l10n"; 5 | import { useL10n } from "../../utils/l10n-hooks"; 6 | 7 | const useStyles = makeStyles({ 8 | formControl: { 9 | minWidth: 120, 10 | }, 11 | }); 12 | 13 | interface IProps { 14 | setLanguage: (l: Language) => void; 15 | } 16 | 17 | export const LanguageField = ({ setLanguage }: IProps) => { 18 | const [l10n, language] = useL10n(); 19 | const classes = useStyles(); 20 | 21 | const handleSelectLanguage = (event) => { 22 | setLanguage(event.target.value); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /ui/components/settings/SettingsButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogContent, DialogTitle, List } from "@material-ui/core"; 2 | import React from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { getErrorMessage } from "../../../common/errors"; 5 | import { Language } from "../../../common/l10n"; 6 | import { TState } from "../../redux/reducers"; 7 | import { SettingsActions } from "../../redux/settings/action-creators"; 8 | import { useL10n } from "../../utils/l10n-hooks"; 9 | import { useNotification } from "../../utils/notifications"; 10 | import { saveSettings } from "../../utils/settings/settings"; 11 | import { LanguageField } from "./LanguageField"; 12 | import { SimsStudioPathField } from "./SimsStudioPathField"; 13 | 14 | export const SettingsButton = () => { 15 | const [l10n] = useL10n(); 16 | const [dialogOpen, setDialogOpen] = React.useState(false); 17 | const notification = useNotification(); 18 | const currentSettings = useSelector((state: TState) => state.settings); 19 | 20 | const handleClickOpen = () => setDialogOpen(true); 21 | 22 | const handleClickClose = () => { 23 | setDialogOpen(false); 24 | saveSettings(currentSettings) 25 | .then(() => notification.showSuccess(l10n.settingsSaved)) 26 | .catch((err) => notification.showError(l10n.settingsSaveError(getErrorMessage(err, l10n)))); 27 | }; 28 | 29 | const dispatch = useDispatch(); 30 | 31 | const setLanguage = (l: Language) => { 32 | dispatch(SettingsActions.setLanguage(l)); 33 | }; 34 | 35 | const setStudioPath = (studioPath: string) => { 36 | dispatch(SettingsActions.setSimsStudioPath(studioPath)); 37 | }; 38 | 39 | return ( 40 | <> 41 | 44 | 45 | {l10n.settings} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /ui/components/settings/SimsStudioPathField.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, ListItem, makeStyles, TextField } from "@material-ui/core"; 2 | import { remote } from "electron"; 3 | import React from "react"; 4 | import { getErrorMessage, LocalizedErrors } from "../../../common/errors"; 5 | import { ipc } from "../../../common/ipc"; 6 | import { isOk } from "../../../common/tools"; 7 | import { useL10n } from "../../utils/l10n-hooks"; 8 | import { useNotification } from "../../utils/notifications"; 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | path: { 12 | overflowWrap: "anywhere", 13 | marginRight: theme.spacing(1), 14 | }, 15 | })); 16 | 17 | interface IProps { 18 | studioPath: string; 19 | setStudioPath: (path: string) => void; 20 | } 21 | 22 | export const SimsStudioPathField = ({ studioPath, setStudioPath }: IProps) => { 23 | const [l10n] = useL10n(); 24 | const classes = useStyles(); 25 | const notification = useNotification(); 26 | const [openDisabled, setOpenDisabled] = React.useState(false); 27 | 28 | const selectStudioPath = async () => { 29 | const openResult = await remote.dialog.showOpenDialog({ properties: ["openDirectory"] }); 30 | if (!openResult.canceled) { 31 | const pathCandidate = openResult.filePaths[0]; 32 | 33 | await ipc.renderer.rpc.isSimsStudioDir(pathCandidate); // raises if invalid 34 | notification.showSuccess(l10n.studioValidPath); 35 | 36 | setStudioPath(pathCandidate); 37 | } 38 | }; 39 | 40 | const handleOpenDialog = () => { 41 | setOpenDisabled(true); 42 | 43 | selectStudioPath() 44 | .catch((error: LocalizedErrors | Error) => { 45 | notification.showError(getErrorMessage(error, l10n)); 46 | }) 47 | .finally(() => setOpenDisabled(false)); 48 | }; 49 | 50 | return ( 51 | 52 | 53 | 59 | 60 | 61 | 64 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-100.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-100.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-100.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-100italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-100italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-100italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-100italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-300.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-300.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-300italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-300italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-300italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-400.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-400.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-400italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-400italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-400italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-400italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-500.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-500.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-500italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-500italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-500italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-700.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-700.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-700italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-700italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-900.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-900.woff2 -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-900italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-900italic.woff -------------------------------------------------------------------------------- /ui/fonts/files/roboto-latin-900italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorBlagov/sims-mod-assistant/d509fa25ce907b752fb0fa2392f40a3d5be93b09/ui/fonts/files/roboto-latin-900italic.woff2 -------------------------------------------------------------------------------- /ui/fonts/index.css: -------------------------------------------------------------------------------- 1 | /* roboto-100normal - latin */ 2 | @font-face { 3 | font-family: "Roboto"; 4 | font-style: normal; 5 | font-display: swap; 6 | font-weight: 100; 7 | src: local("Roboto Thin "), local("Roboto-Thin"), url("./files/roboto-latin-100.woff2") format("woff2"), 8 | /* Super Modern Browsers */ url("./files/roboto-latin-100.woff") format("woff"); /* Modern Browsers */ 9 | } 10 | 11 | /* roboto-100italic - latin */ 12 | @font-face { 13 | font-family: "Roboto"; 14 | font-style: italic; 15 | font-display: swap; 16 | font-weight: 100; 17 | src: local("Roboto Thin italic"), local("Roboto-Thinitalic"), 18 | url("./files/roboto-latin-100italic.woff2") format("woff2"), 19 | /* Super Modern Browsers */ url("./files/roboto-latin-100italic.woff") format("woff"); /* Modern Browsers */ 20 | } 21 | 22 | /* roboto-300normal - latin */ 23 | @font-face { 24 | font-family: "Roboto"; 25 | font-style: normal; 26 | font-display: swap; 27 | font-weight: 300; 28 | src: local("Roboto Light "), local("Roboto-Light"), url("./files/roboto-latin-300.woff2") format("woff2"), 29 | /* Super Modern Browsers */ url("./files/roboto-latin-300.woff") format("woff"); /* Modern Browsers */ 30 | } 31 | 32 | /* roboto-300italic - latin */ 33 | @font-face { 34 | font-family: "Roboto"; 35 | font-style: italic; 36 | font-display: swap; 37 | font-weight: 300; 38 | src: local("Roboto Light italic"), local("Roboto-Lightitalic"), 39 | url("./files/roboto-latin-300italic.woff2") format("woff2"), 40 | /* Super Modern Browsers */ url("./files/roboto-latin-300italic.woff") format("woff"); /* Modern Browsers */ 41 | } 42 | 43 | /* roboto-400normal - latin */ 44 | @font-face { 45 | font-family: "Roboto"; 46 | font-style: normal; 47 | font-display: swap; 48 | font-weight: 400; 49 | src: local("Roboto Regular "), local("Roboto-Regular"), url("./files/roboto-latin-400.woff2") format("woff2"), 50 | /* Super Modern Browsers */ url("./files/roboto-latin-400.woff") format("woff"); /* Modern Browsers */ 51 | } 52 | 53 | /* roboto-400italic - latin */ 54 | @font-face { 55 | font-family: "Roboto"; 56 | font-style: italic; 57 | font-display: swap; 58 | font-weight: 400; 59 | src: local("Roboto Regular italic"), local("Roboto-Regularitalic"), 60 | url("./files/roboto-latin-400italic.woff2") format("woff2"), 61 | /* Super Modern Browsers */ url("./files/roboto-latin-400italic.woff") format("woff"); /* Modern Browsers */ 62 | } 63 | 64 | /* roboto-500normal - latin */ 65 | @font-face { 66 | font-family: "Roboto"; 67 | font-style: normal; 68 | font-display: swap; 69 | font-weight: 500; 70 | src: local("Roboto Medium "), local("Roboto-Medium"), url("./files/roboto-latin-500.woff2") format("woff2"), 71 | /* Super Modern Browsers */ url("./files/roboto-latin-500.woff") format("woff"); /* Modern Browsers */ 72 | } 73 | 74 | /* roboto-500italic - latin */ 75 | @font-face { 76 | font-family: "Roboto"; 77 | font-style: italic; 78 | font-display: swap; 79 | font-weight: 500; 80 | src: local("Roboto Medium italic"), local("Roboto-Mediumitalic"), 81 | url("./files/roboto-latin-500italic.woff2") format("woff2"), 82 | /* Super Modern Browsers */ url("./files/roboto-latin-500italic.woff") format("woff"); /* Modern Browsers */ 83 | } 84 | 85 | /* roboto-700normal - latin */ 86 | @font-face { 87 | font-family: "Roboto"; 88 | font-style: normal; 89 | font-display: swap; 90 | font-weight: 700; 91 | src: local("Roboto Bold "), local("Roboto-Bold"), url("./files/roboto-latin-700.woff2") format("woff2"), 92 | /* Super Modern Browsers */ url("./files/roboto-latin-700.woff") format("woff"); /* Modern Browsers */ 93 | } 94 | 95 | /* roboto-700italic - latin */ 96 | @font-face { 97 | font-family: "Roboto"; 98 | font-style: italic; 99 | font-display: swap; 100 | font-weight: 700; 101 | src: local("Roboto Bold italic"), local("Roboto-Bolditalic"), 102 | url("./files/roboto-latin-700italic.woff2") format("woff2"), 103 | /* Super Modern Browsers */ url("./files/roboto-latin-700italic.woff") format("woff"); /* Modern Browsers */ 104 | } 105 | 106 | /* roboto-900normal - latin */ 107 | @font-face { 108 | font-family: "Roboto"; 109 | font-style: normal; 110 | font-display: swap; 111 | font-weight: 900; 112 | src: local("Roboto Black "), local("Roboto-Black"), url("./files/roboto-latin-900.woff2") format("woff2"), 113 | /* Super Modern Browsers */ url("./files/roboto-latin-900.woff") format("woff"); /* Modern Browsers */ 114 | } 115 | 116 | /* roboto-900italic - latin */ 117 | @font-face { 118 | font-family: "Roboto"; 119 | font-style: italic; 120 | font-display: swap; 121 | font-weight: 900; 122 | src: local("Roboto Black italic"), local("Roboto-Blackitalic"), 123 | url("./files/roboto-latin-900italic.woff2") format("woff2"), 124 | /* Super Modern Browsers */ url("./files/roboto-latin-900italic.woff") format("woff"); /* Modern Browsers */ 125 | } 126 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | Sims 4 Mod Assistant 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ui/index.tsx: -------------------------------------------------------------------------------- 1 | // tslint:disable: file-name-casing 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { Provider } from "react-redux"; 6 | import { App } from "./components/App"; 7 | import "./fonts/index.css"; 8 | import { store } from "./redux/store"; 9 | import { SettingsThunk } from "./redux/thunk/settings"; 10 | 11 | const mountPoint = document.getElementById("root"); 12 | 13 | store 14 | .dispatch(SettingsThunk.loadLanguage()) 15 | .then(() => { 16 | ReactDOM.render( 17 | 18 | 19 | , 20 | mountPoint, 21 | ); 22 | }) 23 | .catch((err) => { 24 | ReactDOM.render(
Application loading error: {err.toString()}
, mountPoint); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "redux"; 2 | import { ThunkAction } from "redux-thunk"; 3 | import { TState } from "./reducers"; 4 | 5 | export enum Actions { 6 | SETTINGS_SET_LANGUAGE = "SET_LANGUAGE", 7 | SETTINGS_SET_STUDIO_PATH = "SETTINGS_SET_STUDIO_PATH", 8 | 9 | NOTIFICATION_SET_TYPE = "NOTIFICATION_SET_TYPE", 10 | NOTIFICATION_SET_MESSAGE = "NOTIFICATION_SET_MESSAGE", 11 | NOTIFICATION_SET_VISIBLE = "NOTIFICATION_SET_VISIBLE", 12 | 13 | BACKDROP_SET_VISIBLE = "BACKDROP_SET_VISIBLE", 14 | 15 | CONFLICT_RESOLVER_SEARCH_SET_SEARCH_IN_PROGRESS = "CONFLICT_RESOLVER_SEARCH_SET_SEARCH_IN_PROGRESS", 16 | CONFLICT_RESOLVER_SEARCH_SET_RESULT = "CONFLICT_RESOLVER_SEARCH_SET_RESULT", 17 | CONFLICT_RESOLVER_SEARCH_SET_PROGRESS_RELATIVE = "CONFLICT_RESOLVER_SEARCH_SET_PROGRESS_RELATIVE", 18 | CONFLICT_RESOLVER_SELECT_FILES = "CONFLICT_RESOLVER_SELECT_FILES", 19 | CONFLICT_RESOLVER_SET_FILES_FILTER = "CONFLICT_RESOLVER_SET_FILES_FILTER", 20 | CONFLICT_RESOLVER_SET_SEARCH_DIRECTORY = "CONFLICT_RESOLVER_SET_SEARCH_DIRECTORY", 21 | CONFLICT_RESOLVER_UPDATE_INDEX = "CONFLICT_RESOLVER_UPDATE_INDEX", 22 | } 23 | 24 | export interface ReduxAction { 25 | type: Actions; 26 | } 27 | 28 | export type ReduxThunkAction = ThunkAction>; 29 | -------------------------------------------------------------------------------- /ui/redux/backdrop/action-creators.ts: -------------------------------------------------------------------------------- 1 | import { Actions } from "../actions"; 2 | import { BackdropSetVisibleAction } from "./actions"; 3 | 4 | const setVisible = (visible: boolean): BackdropSetVisibleAction => ({ 5 | type: Actions.BACKDROP_SET_VISIBLE, 6 | visible, 7 | }); 8 | 9 | export const BackdropActions = { 10 | setVisible, 11 | }; 12 | -------------------------------------------------------------------------------- /ui/redux/backdrop/actions.ts: -------------------------------------------------------------------------------- 1 | import { Actions, ReduxAction } from "../actions"; 2 | 3 | export type BackdropActions = BackdropSetVisibleAction; 4 | 5 | export interface BackdropSetVisibleAction extends ReduxAction { 6 | type: Actions.BACKDROP_SET_VISIBLE; 7 | visible: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /ui/redux/backdrop/reducers.ts: -------------------------------------------------------------------------------- 1 | import { Actions } from "../actions"; 2 | import { BackdropActions } from "./actions"; 3 | 4 | interface BackdropState { 5 | visible: boolean; 6 | } 7 | 8 | const defaultBackdropState: BackdropState = { 9 | visible: false, 10 | }; 11 | 12 | export const backdrop = (state = defaultBackdropState, action: BackdropActions): BackdropState => { 13 | switch (action.type) { 14 | case Actions.BACKDROP_SET_VISIBLE: 15 | return { 16 | ...state, 17 | visible: action.visible, 18 | }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /ui/redux/conflict-resolver/action-creators.ts: -------------------------------------------------------------------------------- 1 | import { IIndexResult, IIndexUpdate } from "../../../common/types"; 2 | import { Actions } from "../actions"; 3 | import { 4 | ConflictResolverSearchSetInProgresstAction, 5 | ConflictResolverSearchSetResultAction, 6 | ConflictResolverSelectFiles, 7 | ConflictResolverSetFilesFilter, 8 | ConflictResolverSetProgressRelativeAction, 9 | ConflictResolverSetSearchDirectory, 10 | ConflictResolverUpdateIndex, 11 | } from "./actions"; 12 | import { IFilterParams } from "./reducers"; 13 | 14 | const setInProgress = (inProgress: boolean): ConflictResolverSearchSetInProgresstAction => ({ 15 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_SEARCH_IN_PROGRESS, 16 | inProgress, 17 | }); 18 | 19 | const setIndexResult = (result: IIndexResult): ConflictResolverSearchSetResultAction => ({ 20 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_RESULT, 21 | result, 22 | }); 23 | 24 | const setProgress = (progressRelative: number): ConflictResolverSetProgressRelativeAction => ({ 25 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_PROGRESS_RELATIVE, 26 | progressRelative, 27 | }); 28 | 29 | const setSearchDirectory = (searchDirectory: string): ConflictResolverSetSearchDirectory => ({ 30 | type: Actions.CONFLICT_RESOLVER_SET_SEARCH_DIRECTORY, 31 | searchDirectory, 32 | }); 33 | 34 | const cleanupSearch = (): ConflictResolverSearchSetResultAction => ({ 35 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_RESULT, 36 | result: undefined, 37 | }); 38 | 39 | const selectFiles = (files: string[], selected: boolean): ConflictResolverSelectFiles => ({ 40 | type: Actions.CONFLICT_RESOLVER_SELECT_FILES, 41 | files, 42 | selected, 43 | }); 44 | 45 | const setFilter = (filesFilter: IFilterParams): ConflictResolverSetFilesFilter => ({ 46 | type: Actions.CONFLICT_RESOLVER_SET_FILES_FILTER, 47 | filesFilter, 48 | }); 49 | 50 | const updateIndex = (indexUpdate: IIndexUpdate): ConflictResolverUpdateIndex => ({ 51 | type: Actions.CONFLICT_RESOLVER_UPDATE_INDEX, 52 | indexUpdate, 53 | }); 54 | 55 | export const ConflictResolverActions = { 56 | setInProgress, 57 | setIndexResult, 58 | setProgress, 59 | cleanupSearch, 60 | selectFiles, 61 | setFilter, 62 | setSearchDirectory, 63 | updateIndex, 64 | }; 65 | -------------------------------------------------------------------------------- /ui/redux/conflict-resolver/actions.ts: -------------------------------------------------------------------------------- 1 | import { IIndexResult, IIndexUpdate } from "../../../common/types"; 2 | import { Actions, ReduxAction } from "../actions"; 3 | import { IFilterParams } from "./reducers"; 4 | 5 | export type ConflictResolverActions = 6 | | ConflictResolverSearchSetInProgresstAction 7 | | ConflictResolverSearchSetResultAction 8 | | ConflictResolverSetProgressRelativeAction 9 | | ConflictResolverSelectFiles 10 | | ConflictResolverSetFilesFilter 11 | | ConflictResolverSetSearchDirectory 12 | | ConflictResolverUpdateIndex; 13 | 14 | export interface ConflictResolverSearchSetInProgresstAction extends ReduxAction { 15 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_SEARCH_IN_PROGRESS; 16 | inProgress: boolean; 17 | } 18 | 19 | export interface ConflictResolverSearchSetResultAction extends ReduxAction { 20 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_RESULT; 21 | result: IIndexResult; 22 | } 23 | 24 | export interface ConflictResolverSetProgressRelativeAction extends ReduxAction { 25 | type: Actions.CONFLICT_RESOLVER_SEARCH_SET_PROGRESS_RELATIVE; 26 | progressRelative: number; 27 | } 28 | 29 | export interface ConflictResolverSelectFiles extends ReduxAction { 30 | type: Actions.CONFLICT_RESOLVER_SELECT_FILES; 31 | files: string[]; 32 | selected: boolean; 33 | } 34 | 35 | export interface ConflictResolverSetFilesFilter extends ReduxAction { 36 | type: Actions.CONFLICT_RESOLVER_SET_FILES_FILTER; 37 | filesFilter: IFilterParams; 38 | } 39 | 40 | export interface ConflictResolverSetSearchDirectory extends ReduxAction { 41 | type: Actions.CONFLICT_RESOLVER_SET_SEARCH_DIRECTORY; 42 | searchDirectory: string; 43 | } 44 | 45 | export interface ConflictResolverUpdateIndex extends ReduxAction { 46 | type: Actions.CONFLICT_RESOLVER_UPDATE_INDEX; 47 | indexUpdate: IIndexUpdate; 48 | } 49 | -------------------------------------------------------------------------------- /ui/redux/conflict-resolver/reducers.ts: -------------------------------------------------------------------------------- 1 | import { isOk } from "../../../common/tools"; 2 | import { IIndexUpdate, IndexChanges, ISearchResult } from "../../../common/types"; 3 | import { pathFilter } from "../../utils/filter"; 4 | import { GraphAggregator } from "../../utils/graph-aggregator"; 5 | import { Actions } from "../actions"; 6 | import { ConflictResolverActions } from "./actions"; 7 | 8 | export interface ISelectedFilesInfo { 9 | [path: string]: boolean; 10 | } 11 | 12 | export interface IFilterParams { 13 | filter: string; 14 | isRegex: boolean; 15 | isCaseSensitive: boolean; 16 | } 17 | 18 | export interface ConflictResolverState { 19 | searchProcess: { 20 | inProgress: boolean; 21 | progressRelative: number; 22 | }; 23 | searchDirectory: string; 24 | searchResult: ISearchResult; 25 | selectedConflictFiles: ISelectedFilesInfo; 26 | filesFilter: IFilterParams; 27 | } 28 | 29 | export const defaultConflictResolverState: ConflictResolverState = { 30 | searchProcess: { 31 | inProgress: false, 32 | progressRelative: 0, 33 | }, 34 | searchDirectory: undefined, 35 | searchResult: undefined, 36 | selectedConflictFiles: {}, 37 | filesFilter: { 38 | filter: "", 39 | isRegex: false, 40 | isCaseSensitive: false, 41 | }, 42 | }; 43 | 44 | const conflictFilesUpdate = (state: ISelectedFilesInfo, newSearchResult: ISearchResult): ISelectedFilesInfo => { 45 | if (isOk(newSearchResult)) { 46 | const newSelectedFiles: ISelectedFilesInfo = {}; 47 | 48 | for (const group of newSearchResult.duplicates) { 49 | for (const path of group.summary.files) { 50 | newSelectedFiles[path] = path in state ? state[path] : false; 51 | } 52 | } 53 | 54 | return newSelectedFiles; 55 | } 56 | 57 | return {}; 58 | }; 59 | 60 | const conflictFilesSelect = (state: ConflictResolverState, files: string[], selected: boolean): ISelectedFilesInfo => { 61 | const newState: ISelectedFilesInfo = { ...state.selectedConflictFiles }; 62 | const filter = state.filesFilter; 63 | for (const file of files.filter(pathFilter(filter))) { 64 | if (file in newState) { 65 | newState[file] = selected; 66 | } 67 | } 68 | 69 | return newState; 70 | }; 71 | 72 | const indexUpdate = (state: ISearchResult, indexUpdateInfo: IIndexUpdate): ISearchResult => { 73 | const result: ISearchResult = { 74 | ticketId: state.ticketId, 75 | index: { ...state.index }, 76 | skips: [...state.skips], // we assume that skips are not related to index 77 | duplicates: [], 78 | fileInfos: { ...state.fileInfos }, 79 | }; 80 | 81 | for (const path of Object.keys(indexUpdateInfo)) { 82 | const change = indexUpdateInfo[path]; 83 | if (change.change === IndexChanges.Remove) { 84 | if (path in result.index) { 85 | delete result.index[path]; 86 | } 87 | 88 | if (path in result.fileInfos) { 89 | delete result.fileInfos[path]; 90 | } 91 | } 92 | } 93 | 94 | result.duplicates = new GraphAggregator(result.index).getResult(); 95 | 96 | return result; 97 | }; 98 | 99 | export const conflictResolver = ( 100 | state = defaultConflictResolverState, 101 | action: ConflictResolverActions, 102 | ): ConflictResolverState => { 103 | switch (action.type) { 104 | case Actions.CONFLICT_RESOLVER_SEARCH_SET_SEARCH_IN_PROGRESS: 105 | return { 106 | ...state, 107 | searchProcess: { 108 | ...state.searchProcess, 109 | inProgress: action.inProgress, 110 | }, 111 | }; 112 | 113 | case Actions.CONFLICT_RESOLVER_SEARCH_SET_RESULT: 114 | let searchResult: ISearchResult; 115 | 116 | if (isOk(action.result)) { 117 | const ga = new GraphAggregator(action.result.index); 118 | searchResult = { 119 | ...action.result, 120 | duplicates: ga.getResult(), 121 | }; 122 | } 123 | 124 | return { 125 | ...state, 126 | searchProcess: { 127 | ...state.searchProcess, 128 | inProgress: false, 129 | progressRelative: 0, 130 | }, 131 | searchResult, 132 | selectedConflictFiles: conflictFilesUpdate(state.selectedConflictFiles, searchResult), 133 | }; 134 | 135 | case Actions.CONFLICT_RESOLVER_SEARCH_SET_PROGRESS_RELATIVE: 136 | return { 137 | ...state, 138 | searchProcess: { 139 | ...state.searchProcess, 140 | progressRelative: action.progressRelative, 141 | }, 142 | }; 143 | 144 | case Actions.CONFLICT_RESOLVER_SELECT_FILES: 145 | return { 146 | ...state, 147 | selectedConflictFiles: conflictFilesSelect(state, action.files, action.selected), 148 | }; 149 | 150 | case Actions.CONFLICT_RESOLVER_SET_FILES_FILTER: 151 | return { 152 | ...state, 153 | filesFilter: action.filesFilter, 154 | }; 155 | 156 | case Actions.CONFLICT_RESOLVER_SET_SEARCH_DIRECTORY: 157 | return { 158 | ...state, 159 | searchDirectory: action.searchDirectory, 160 | }; 161 | 162 | case Actions.CONFLICT_RESOLVER_UPDATE_INDEX: 163 | const newSearchResult = indexUpdate(state.searchResult, action.indexUpdate); 164 | return { 165 | ...state, 166 | searchResult: newSearchResult, 167 | selectedConflictFiles: conflictFilesUpdate(state.selectedConflictFiles, newSearchResult), 168 | }; 169 | 170 | default: 171 | return state; 172 | } 173 | }; 174 | -------------------------------------------------------------------------------- /ui/redux/notification/action-creators.ts: -------------------------------------------------------------------------------- 1 | import { Actions } from "../actions"; 2 | import { NotificationTypes } from "../types"; 3 | import { NotificationSetMessageAction, NotificationSetTypeAction, NotificationSetVisibleAction } from "./actions"; 4 | 5 | const setType = (type: NotificationTypes): NotificationSetTypeAction => ({ 6 | type: Actions.NOTIFICATION_SET_TYPE, 7 | notificationType: type, 8 | }); 9 | 10 | const setVisible = (visible: boolean): NotificationSetVisibleAction => ({ 11 | type: Actions.NOTIFICATION_SET_VISIBLE, 12 | visible, 13 | }); 14 | 15 | const setMessage = (message: string): NotificationSetMessageAction => ({ 16 | type: Actions.NOTIFICATION_SET_MESSAGE, 17 | message, 18 | }); 19 | 20 | export const NotificationActions = { 21 | setType, 22 | setVisible, 23 | setMessage, 24 | }; 25 | -------------------------------------------------------------------------------- /ui/redux/notification/actions.ts: -------------------------------------------------------------------------------- 1 | import { Actions, ReduxAction } from "../actions"; 2 | import { NotificationTypes } from "../types"; 3 | 4 | export type NotificationActions = 5 | | NotificationSetTypeAction 6 | | NotificationSetMessageAction 7 | | NotificationSetVisibleAction; 8 | 9 | export interface NotificationSetTypeAction extends ReduxAction { 10 | type: Actions.NOTIFICATION_SET_TYPE; 11 | notificationType: NotificationTypes; 12 | } 13 | 14 | export interface NotificationSetMessageAction extends ReduxAction { 15 | type: Actions.NOTIFICATION_SET_MESSAGE; 16 | message: string; 17 | } 18 | 19 | export interface NotificationSetVisibleAction extends ReduxAction { 20 | type: Actions.NOTIFICATION_SET_VISIBLE; 21 | visible: boolean; 22 | } 23 | -------------------------------------------------------------------------------- /ui/redux/notification/reducers.ts: -------------------------------------------------------------------------------- 1 | import { Actions } from "../actions"; 2 | import { NotificationTypes } from "../types"; 3 | import { NotificationActions } from "./actions"; 4 | 5 | interface NotificationState { 6 | message: string; 7 | type: NotificationTypes; 8 | visible: boolean; 9 | } 10 | 11 | const defaultNotificationState: NotificationState = { 12 | message: "", 13 | type: NotificationTypes.Success, 14 | visible: false, 15 | }; 16 | 17 | export const notification = (state = defaultNotificationState, action: NotificationActions): NotificationState => { 18 | switch (action.type) { 19 | case Actions.NOTIFICATION_SET_MESSAGE: 20 | return { 21 | ...state, 22 | message: action.message, 23 | }; 24 | case Actions.NOTIFICATION_SET_TYPE: 25 | return { 26 | ...state, 27 | type: action.notificationType, 28 | }; 29 | case Actions.NOTIFICATION_SET_VISIBLE: 30 | return { 31 | ...state, 32 | visible: action.visible, 33 | }; 34 | default: 35 | return state; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /ui/redux/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { backdrop } from "./backdrop/reducers"; 3 | import { conflictResolver } from "./conflict-resolver/reducers"; 4 | import { notification } from "./notification/reducers"; 5 | import { settings } from "./settings/reducers"; 6 | 7 | export const rootReducer = combineReducers({ 8 | settings, 9 | notification, 10 | backdrop, 11 | conflictResolver, 12 | }); 13 | 14 | export type TState = ReturnType; 15 | -------------------------------------------------------------------------------- /ui/redux/settings/action-creators.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../common/l10n"; 2 | import { Actions } from "../actions"; 3 | import { SettingsSetLanguageAction, SettingsSetStudioPathAction } from "./actions"; 4 | 5 | const setLanguage = (newLanguage: Language): SettingsSetLanguageAction => ({ 6 | type: Actions.SETTINGS_SET_LANGUAGE, 7 | newLanguage, 8 | }); 9 | 10 | const setSimsStudioPath = (newPath: string): SettingsSetStudioPathAction => ({ 11 | type: Actions.SETTINGS_SET_STUDIO_PATH, 12 | newPath, 13 | }); 14 | 15 | export const SettingsActions = { 16 | setLanguage, 17 | setSimsStudioPath, 18 | }; 19 | -------------------------------------------------------------------------------- /ui/redux/settings/actions.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../common/l10n"; 2 | import { Actions, ReduxAction } from "../actions"; 3 | 4 | export type LanguageActions = SettingsSetLanguageAction | SettingsSetStudioPathAction; 5 | 6 | export interface SettingsSetLanguageAction extends ReduxAction { 7 | type: Actions.SETTINGS_SET_LANGUAGE; 8 | newLanguage: Language; 9 | } 10 | 11 | export interface SettingsSetStudioPathAction extends ReduxAction { 12 | type: Actions.SETTINGS_SET_STUDIO_PATH; 13 | newPath: string; 14 | } 15 | -------------------------------------------------------------------------------- /ui/redux/settings/reducers.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../common/l10n"; 2 | import { Actions } from "../actions"; 3 | import { LanguageActions as SettingsActions } from "./actions"; 4 | 5 | export interface SettingsState { 6 | language: Language; 7 | studioPath: string; 8 | } 9 | 10 | export const defaultSettingsState: SettingsState = { 11 | language: Language.English, 12 | studioPath: undefined, 13 | }; 14 | 15 | export const settings = (state = defaultSettingsState, action: SettingsActions): SettingsState => { 16 | switch (action.type) { 17 | case Actions.SETTINGS_SET_LANGUAGE: 18 | return { 19 | ...state, 20 | language: action.newLanguage, 21 | }; 22 | case Actions.SETTINGS_SET_STUDIO_PATH: 23 | return { 24 | ...state, 25 | studioPath: action.newPath, 26 | }; 27 | default: 28 | return state; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /ui/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { Action, applyMiddleware, createStore } from "redux"; 2 | import thunk, { ThunkMiddleware } from "redux-thunk"; 3 | import { Actions } from "./actions"; 4 | import { rootReducer, TState } from "./reducers"; 5 | 6 | export const store = createStore(rootReducer, applyMiddleware(thunk as ThunkMiddleware>)); 7 | -------------------------------------------------------------------------------- /ui/redux/thunk/conflict-resolver.ts: -------------------------------------------------------------------------------- 1 | import { ipc } from "../../../common/ipc"; 2 | import { isOk } from "../../../common/tools"; 3 | import { IDirectoryParams, IIndexResult, ISearchParams, ISearchProgress, TTicketId } from "../../../common/types"; 4 | import { ReduxThunkAction } from "../actions"; 5 | import { ConflictResolverActions } from "../conflict-resolver/action-creators"; 6 | 7 | const searchStartAndUpdate = ( 8 | searchParameters: IDirectoryParams & ISearchParams, 9 | ): ReduxThunkAction> => async (dispatch) => { 10 | let searchTicketId: TTicketId; 11 | 12 | const onProgress = (_: any, progress: ISearchProgress) => { 13 | if (progress.ticketId === searchTicketId) { 14 | dispatch(ConflictResolverActions.setProgress(progress.progressRelative)); 15 | } 16 | }; 17 | 18 | try { 19 | ipc.renderer.on.searchProgress(onProgress); 20 | const startResult = await ipc.renderer.rpc.startSearch(searchParameters); 21 | searchTicketId = startResult.searchTicketId; 22 | dispatch(ConflictResolverActions.cleanupSearch()); 23 | dispatch(ConflictResolverActions.setInProgress(true)); 24 | 25 | const indexResult = await new Promise((resolve, reject) => { 26 | ipc.renderer.on.searchResult((_, searchIndexResult) => { 27 | if (searchIndexResult.ticketId === searchTicketId) { 28 | resolve(searchIndexResult); 29 | } 30 | }); 31 | 32 | ipc.renderer.on.searchError((_, error) => { 33 | if (error.ticketId === searchTicketId) { 34 | reject(error.error); 35 | } 36 | }); 37 | }); 38 | 39 | dispatch(ConflictResolverActions.setIndexResult(indexResult)); 40 | } catch (error) { 41 | dispatch(ConflictResolverActions.cleanupSearch()); 42 | throw error; 43 | } finally { 44 | ipc.renderer.off.searchProgress(onProgress); 45 | dispatch(ConflictResolverActions.setInProgress(false)); 46 | } 47 | }; 48 | 49 | const selectAll = (selected: boolean): ReduxThunkAction => (dispatch, getState) => { 50 | const searchResult = getState().conflictResolver.searchResult; 51 | if (isOk(searchResult)) { 52 | dispatch( 53 | ConflictResolverActions.selectFiles( 54 | searchResult.duplicates.reduce((prev, g) => prev.concat(g.summary.files), [] as string[]), 55 | selected, 56 | ), 57 | ); 58 | } 59 | }; 60 | 61 | export const ConflictResolverThunk = { 62 | searchStartAndUpdate, 63 | selectAll, 64 | }; 65 | -------------------------------------------------------------------------------- /ui/redux/thunk/settings.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from "../../../common/errors"; 2 | import { ipc } from "../../../common/ipc"; 3 | import { l10n } from "../../../common/l10n"; 4 | import { isOk } from "../../../common/tools"; 5 | import { createNotificationApiFromDispatch } from "../../utils/notifications"; 6 | import { loadSettings, saveSettings } from "../../utils/settings/settings"; 7 | import { ReduxThunkAction } from "../actions"; 8 | import { SettingsActions } from "../settings/action-creators"; 9 | 10 | const loadLanguage = (): ReduxThunkAction> => async (dispatch) => { 11 | const loaded = await loadSettings(); 12 | dispatch(SettingsActions.setLanguage(loaded.language)); 13 | }; 14 | 15 | const loadRestAndValidate = (): ReduxThunkAction> => async (dispatch, getStore) => { 16 | const translation = l10n[getStore().settings.language]; 17 | const notification = createNotificationApiFromDispatch(dispatch); 18 | const loaded = await loadSettings(); 19 | 20 | if (isOk(loaded.studioPath)) { 21 | try { 22 | await ipc.renderer.rpc.isSimsStudioDir(loaded.studioPath); 23 | dispatch(SettingsActions.setSimsStudioPath(loaded.studioPath)); 24 | } catch (error) { 25 | dispatch(SettingsActions.setSimsStudioPath(undefined)); 26 | notification.showWarning(translation.studioDisabled(getErrorMessage(error, translation))); 27 | } 28 | } 29 | 30 | await saveSettings(getStore().settings); 31 | }; 32 | 33 | export const SettingsThunk = { 34 | loadLanguage, 35 | loadRestAndValidate, 36 | }; 37 | -------------------------------------------------------------------------------- /ui/redux/types.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationTypes { 2 | Error = "error", 3 | Success = "success", 4 | Warning = "warning", 5 | } 6 | -------------------------------------------------------------------------------- /ui/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from "@material-ui/core"; 2 | import { blue, pink } from "@material-ui/core/colors"; 3 | 4 | export const appTheme = createMuiTheme({ 5 | palette: { 6 | primary: blue, 7 | secondary: pink, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "noEmit": true, 5 | "target": "ES6", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "moduleResolution": "node" 9 | }, 10 | "include": ["../globals.d.ts", "./**/*", "./common/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /ui/utils/backdrop-hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { BackdropActions } from "../redux/backdrop/action-creators"; 4 | 5 | export const useBackdrop = () => { 6 | const dispatch = useDispatch(); 7 | 8 | return (visible: boolean) => { 9 | dispatch(BackdropActions.setVisible(visible)); 10 | }; 11 | }; 12 | 13 | export const useBackdropBound = (dependency) => { 14 | const setVisible = useBackdrop(); 15 | 16 | useEffect(() => { 17 | setVisible(dependency); 18 | }, [dependency]); 19 | }; 20 | -------------------------------------------------------------------------------- /ui/utils/checkbox.ts: -------------------------------------------------------------------------------- 1 | export enum CheckboxState { 2 | Checked, 3 | Indeterminate, 4 | Unchecked, 5 | } 6 | 7 | export const getCheckboxState = (totalCount: number, selectedCount: number): CheckboxState => { 8 | if (totalCount === 0 || selectedCount === 0) { 9 | return CheckboxState.Unchecked; 10 | } 11 | 12 | return selectedCount === totalCount ? CheckboxState.Checked : CheckboxState.Indeterminate; 13 | }; 14 | -------------------------------------------------------------------------------- /ui/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const VIRTUALIZE_CONSTANTS = { 2 | SKIP_ITEM_HEIGHT: 72, 3 | SKIP_PLACEHOLDER_PADDING_LEFT: 72, 4 | SKIP_PLACEHOLDER_PADDING_TOP: 14, 5 | DUPLICATE_PLACEHOLDER_PADDING_LEFT: 58, 6 | DUPLICATE_PLACEHOLDER_PADDING_TOP: 14, 7 | }; 8 | -------------------------------------------------------------------------------- /ui/utils/filter.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { isOk } from "../../common/tools"; 3 | import { IFilterParams } from "../redux/conflict-resolver/reducers"; 4 | 5 | const isValidRegex = (reg: string) => { 6 | try { 7 | // tslint:disable-next-line: no-unused-expression-chai 8 | new RegExp(reg); 9 | return true; 10 | } catch { 11 | return false; 12 | } 13 | }; 14 | 15 | export const isFilterUsed = ({ filter }: IFilterParams) => isOk(filter) && filter.length !== 0; 16 | 17 | export const isFilterValid = (filterParams: IFilterParams) => 18 | isFilterUsed(filterParams) && (!filterParams.isRegex || isValidRegex(filterParams.filter)); 19 | 20 | export const pathFilter = (filesFilter: IFilterParams) => (p: string): boolean => { 21 | if (isFilterValid(filesFilter)) { 22 | let basename = path.basename(p); 23 | let filter = filesFilter.filter; 24 | 25 | if (!filesFilter.isCaseSensitive) { 26 | basename = basename.toLowerCase(); 27 | filter = filter.toLowerCase(); 28 | } 29 | 30 | const index = filesFilter.isRegex ? basename.search(filter) : basename.indexOf(filter); 31 | return index !== -1; 32 | } 33 | 34 | return true; 35 | }; 36 | -------------------------------------------------------------------------------- /ui/utils/graph-aggregator.ts: -------------------------------------------------------------------------------- 1 | import { TIndex } from "../../common/indexer/types"; 2 | import { DoubleTypes, IDuplicateGroup, IEdgeGroup, TFileValue, TKeyValue } from "../../common/types"; 3 | 4 | type TEncodeKey = string; 5 | type TEncoded = Record; 6 | type TDecoded = Record; 7 | type TKeyTypes = Record; 8 | 9 | type TEncodeFileGroup = TEncodeKey[]; 10 | type TSimilarFilesEntry = { 11 | keys: TEncodeKey[]; 12 | files: TEncodeFileGroup; 13 | }; 14 | type TByKeys = Record; 15 | type TBySimilarFileGroupsEntry = { 16 | keys: TEncodeKey[]; 17 | fileGroups: TEncodeFileGroup[]; 18 | }; 19 | 20 | type TNodeName = string; 21 | type TGraphInfo = Record; 22 | type TGraph = Record; 23 | 24 | export class GraphAggregator { 25 | /* 26 | the class created to analyze index, split files to independent groups, 27 | group files with similar keys, and create summary of each group to simplify usage outside 28 | 29 | */ 30 | private result: IDuplicateGroup[]; 31 | 32 | constructor(fileIndex: TIndex) { 33 | this.result = this.build(fileIndex); 34 | } 35 | 36 | public getResult() { 37 | return this.result; 38 | } 39 | 40 | private isSingleFile(fileGroups: TEncodeFileGroup[]) { 41 | let total = 0; 42 | for (const group of fileGroups) { 43 | total += group.length; 44 | } 45 | return total === 1; 46 | } 47 | 48 | private build(index) { 49 | /* 50 | Let's say your index looks this way: 51 | file1: [key1, key2, key3] 52 | file2: [key1, key2, key3] 53 | file3: [key3] 54 | file4: [key4] 55 | file5: [key4] 56 | 57 | file1+-------+key1+-------+file2 file4 58 | | | + 59 | +-------+key2+-------+ | 60 | | | + 61 | +-------+key3+-------+ key4 62 | + + 63 | | | 64 | + + 65 | file3 file5 66 | */ 67 | 68 | /* 69 | Here we replace filenames and keys with simple names 70 | (js doesn't support tuple usage as key, so we collapse complex names, to use names.join(', ') 71 | as key) 72 | id1: [id6, id7, id8] 73 | id2: [id6, id7, id8] 74 | id3: [id8] 75 | id4: [id9] 76 | id5: [id9] 77 | 78 | id1+-------+id6+--------+id2 id4 79 | | | + 80 | +-------+id7+--------+ | 81 | | | + 82 | +-------+id8+--------+ id9 83 | + + 84 | | | 85 | + + 86 | id3 id5 87 | */ 88 | const [encoded, decoded, keyTypes] = this.encodeFilesAndKeys(index); 89 | 90 | /* 91 | Here we group files with similar keys 92 | [id1, id2] - [id6, id7, id8] 93 | id3 - [id8] 94 | [id4, id5] - [id9] 95 | 96 | [id1, id2]+-------+id6 [id4, id5] 97 | | + 98 | +-------+id7 | 99 | | + 100 | +-------+id8 id9 101 | + 102 | | 103 | + 104 | id3 105 | */ 106 | const bySimilarFiles = this.aggregateBySimilarFiles(index, encoded); 107 | 108 | /* 109 | Creating reversed data, key <-> files and fileGroups 110 | id6: [id1, id2] 111 | id7: [id1, id2] 112 | id8: [id1, id2], id3 113 | id9: [id4, id5] 114 | 115 | also remove keys that have only single file (those files do not conflict with anything) 116 | */ 117 | const byKeys = this.removeUnconflicted(this.aggregateByKeys(bySimilarFiles)); 118 | 119 | /* 120 | group by keys that share similar file groups 121 | [id6, id7]: [id1, id2] 122 | id8: [id1, id2], id3 123 | id9: [id4, id5] 124 | 125 | [id1, id2]+-------+[id6, id7] [id4, id5] 126 | | + 127 | | | 128 | +-------+id8 + 129 | + id9 130 | | 131 | + 132 | id3 133 | */ 134 | const byFileGroupsAndKeys = this.aggregateBySimilarFileGroups(byKeys); 135 | 136 | /* 137 | build the graph itself, and save infos ([id1, id2] was list, now it's key 'id1,id2', 138 | but the data must be saved) 139 | graph: 140 | 'id6,id7: 'id1,id2', 141 | 'id8': 'id1,id2', 'id3', 142 | 'id9': 'id4,id5', 143 | 'id3': 'id8', 144 | 'id1,id2': 'id6,id7', 'id8', 145 | 'id4,id5': 'id9', 146 | */ 147 | const [graph, infos] = this.buildGraph(byFileGroupsAndKeys); 148 | 149 | /* 150 | dfs through graphs, and distinguish each component 151 | one component is a group of files that conflict with each other 152 | */ 153 | const graphGroups = this.splitIntoGroups(graph); 154 | const result: IDuplicateGroup[] = []; 155 | 156 | // decode all the filenames, key values, types and write as list of groups 157 | for (const group of graphGroups) { 158 | result.push(this.buildResultGroup(group, infos, decoded, keyTypes)); 159 | } 160 | 161 | return result; 162 | } 163 | 164 | private toKey(arr: any[]): string { 165 | return arr.sort().join(","); 166 | } 167 | 168 | private aggregateBySimilarFiles(index: TIndex, encoded: TEncoded) { 169 | const result: Record = {}; 170 | for (const path of Object.keys(index)) { 171 | const keys = index[path].map((x) => encoded[x[1]]); 172 | const keysKey = this.toKey(keys); 173 | if (!(keysKey in result)) { 174 | result[keysKey] = { 175 | keys, 176 | files: [], 177 | }; 178 | } 179 | 180 | result[keysKey].files.push(encoded[path]); 181 | } 182 | 183 | return Object.values(result); 184 | } 185 | 186 | private encodeFilesAndKeys(index): [TEncoded, TDecoded, TKeyTypes] { 187 | const encoded: TEncoded = {}; 188 | const decoded: TDecoded = {}; 189 | const keyTypes: TKeyTypes = {}; 190 | 191 | let lastId = 0; 192 | const getCurrentId = (): TEncodeKey => `id${lastId}`; 193 | const nextId = (): TEncodeKey => { 194 | lastId++; 195 | return getCurrentId(); 196 | }; 197 | 198 | for (const path of Object.keys(index)) { 199 | encoded[path] = nextId(); 200 | decoded[getCurrentId()] = path; 201 | for (const [keyType, key] of index[path]) { 202 | if (!(key in keyTypes)) { 203 | keyTypes[key] = keyType; 204 | } 205 | encoded[key] = nextId(); 206 | decoded[getCurrentId()] = key; 207 | } 208 | } 209 | 210 | return [encoded, decoded, keyTypes]; 211 | } 212 | 213 | private aggregateByKeys(bySimilarFiles: TSimilarFilesEntry[]): TByKeys { 214 | const result: TByKeys = {}; 215 | for (const { keys, files } of bySimilarFiles) { 216 | for (const key of keys) { 217 | if (!(key in result)) { 218 | result[key] = []; 219 | } 220 | 221 | result[key].push(files); 222 | } 223 | } 224 | 225 | return result; 226 | } 227 | 228 | private removeUnconflicted(byKeys: TByKeys): TByKeys { 229 | const result = { ...byKeys }; 230 | for (const key of Object.keys(result)) { 231 | if (this.isSingleFile(result[key])) { 232 | delete result[key]; 233 | } 234 | } 235 | 236 | return result; 237 | } 238 | 239 | private aggregateBySimilarFileGroups(byKeys: TByKeys): TBySimilarFileGroupsEntry[] { 240 | const result: Record = {}; 241 | for (const key of Object.keys(byKeys)) { 242 | const fileKey = this.toKey(byKeys[key].map((x) => `[${this.toKey(x)}]`)); 243 | if (!(fileKey in result)) { 244 | result[fileKey] = { 245 | fileGroups: byKeys[key], 246 | keys: [], 247 | }; 248 | } 249 | 250 | result[fileKey].keys.push(key); 251 | } 252 | 253 | return Object.values(result); 254 | } 255 | 256 | private buildGraph(byFiles: TBySimilarFileGroupsEntry[]): [TGraph, TGraphInfo] { 257 | const infos: TGraphInfo = {}; 258 | const graph: TGraph = {}; 259 | 260 | for (const { fileGroups, keys } of byFiles) { 261 | const keyGroupName = this.toKey(keys); 262 | 263 | if (!(keyGroupName in graph)) { 264 | graph[keyGroupName] = []; 265 | } 266 | 267 | infos[keyGroupName] = { 268 | fileGroups, 269 | keys, 270 | }; 271 | 272 | for (const group of fileGroups) { 273 | const fileGroupName = this.toKey(group); 274 | 275 | if (!(fileGroupName in graph)) { 276 | graph[fileGroupName] = []; 277 | } 278 | 279 | graph[fileGroupName].push(keyGroupName); 280 | graph[keyGroupName].push(fileGroupName); 281 | } 282 | } 283 | 284 | return [graph, infos]; 285 | } 286 | 287 | private splitIntoGroups(graph: TGraph): TNodeName[][] { 288 | const visited: Set = new Set(); 289 | const graphGroups: TNodeName[][] = []; 290 | 291 | for (const nodeName of Object.keys(graph)) { 292 | if (!visited.has(nodeName)) { 293 | const newGroup: TNodeName[] = []; 294 | this.dfs(graph, nodeName, newGroup, visited); 295 | graphGroups.push(newGroup); 296 | } 297 | } 298 | 299 | return graphGroups; 300 | } 301 | 302 | private dfs(graph: TGraph, nodeName: TNodeName, currentGroup: TNodeName[], visited: Set) { 303 | if (visited.has(nodeName)) { 304 | return; 305 | } 306 | 307 | currentGroup.push(nodeName); 308 | visited.add(nodeName); 309 | for (const neighbor of graph[nodeName]) { 310 | this.dfs(graph, neighbor, currentGroup, visited); 311 | } 312 | } 313 | 314 | private buildResultGroup( 315 | group: TNodeName[], 316 | infos: TGraphInfo, 317 | decoded: TDecoded, 318 | keyTypes: TKeyTypes, 319 | ): IDuplicateGroup { 320 | const result: IDuplicateGroup = { 321 | detailed: { 322 | edgeGroups: [], 323 | typeByKey: keyTypes, 324 | }, 325 | summary: { 326 | files: [], 327 | types: [], 328 | }, 329 | }; 330 | 331 | const uniqueFiles: Set = new Set(); 332 | const uniqueTypes: Set = new Set(); 333 | 334 | for (const node of group) { 335 | if (node in infos) { 336 | const edgeGroup: IEdgeGroup = { 337 | fileGroups: infos[node].fileGroups.map((gr) => gr.map((f) => decoded[f])), 338 | keys: infos[node].keys.map((k) => decoded[k]), 339 | }; 340 | edgeGroup.keys.forEach((k) => uniqueTypes.add(keyTypes[k])); 341 | edgeGroup.fileGroups.forEach((gr) => gr.forEach((f) => uniqueFiles.add(f))); 342 | result.detailed.edgeGroups.push(edgeGroup); 343 | } 344 | } 345 | 346 | result.summary.files = Array.from(uniqueFiles); 347 | result.summary.types = Array.from(uniqueTypes); 348 | return result; 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /ui/utils/ipc-hooks.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ipc, IpcSchema } from "../../common/ipc"; 3 | import { TIpcEventHandler, TIpcOutput, TIpcSchema } from "../../common/ipc/ipc-creator"; 4 | 5 | type TIpcHooksOutput = { 6 | use: { 7 | [K in keyof T["mainEvents"]]: (handler: TIpcEventHandler) => void; 8 | }; 9 | }; 10 | 11 | type TIpcRenderer = TIpcOutput["renderer"]; 12 | 13 | class IpcHooksCreator { 14 | public interface: TIpcHooksOutput = { use: {} } as TIpcHooksOutput; 15 | 16 | constructor(schema: T, ipcRendererTypesafe: TIpcRenderer) { 17 | for (const channelName of Object.keys(schema.mainEvents)) { 18 | this.createHook(ipcRendererTypesafe, channelName); 19 | } 20 | } 21 | 22 | private createHook(ipcRendererTypesafe: TIpcRenderer, name: K): void { 23 | this.interface.use[name] = (handler) => { 24 | React.useEffect(() => { 25 | ipcRendererTypesafe.on[name](handler); 26 | return () => ipcRendererTypesafe.off[name](handler); 27 | }); 28 | }; 29 | } 30 | } 31 | 32 | function createTypsafeHooks( 33 | ipcSchema: T, 34 | ipcRendererTypesafe: TIpcRenderer, 35 | ): TIpcHooksOutput { 36 | const hooksCreator = new IpcHooksCreator(ipcSchema, ipcRendererTypesafe); 37 | return hooksCreator.interface; 38 | } 39 | 40 | export const ipcHooks = createTypsafeHooks(IpcSchema, ipc.renderer); 41 | -------------------------------------------------------------------------------- /ui/utils/l10n-hooks.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { l10n, Language, Translation } from "../../common/l10n"; 3 | import { TState } from "../redux/reducers"; 4 | 5 | export function useL10n(): [Translation, Language] { 6 | const language = useSelector((state: TState) => state.settings.language); 7 | return [l10n[language], language]; 8 | } 9 | -------------------------------------------------------------------------------- /ui/utils/language-mapping.ts: -------------------------------------------------------------------------------- 1 | import { Translation } from "../../common/l10n"; 2 | import { DoubleTypes } from "../../common/types"; 3 | 4 | export const doubleTypeMap = { 5 | [DoubleTypes.Exact]: (l10n: Translation) => ({ 6 | tooltip: l10n.exactDescription, 7 | title: l10n.exact, 8 | }), 9 | [DoubleTypes.Catalog]: (l10n: Translation) => ({ 10 | tooltip: l10n.catalogDescription, 11 | title: l10n.catalog, 12 | }), 13 | [DoubleTypes.Skintone]: (l10n: Translation) => ({ 14 | tooltip: l10n.skintoneDescription, 15 | title: l10n.skintone, 16 | }), 17 | [DoubleTypes.Cas]: (l10n: Translation) => ({ 18 | tooltip: l10n.casDescription, 19 | title: l10n.cas, 20 | }), 21 | [DoubleTypes.Slider]: (l10n: Translation) => ({ 22 | tooltip: l10n.sliderDescription, 23 | title: l10n.slider, 24 | }), 25 | }; 26 | -------------------------------------------------------------------------------- /ui/utils/notifications.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import { NotificationActions } from "../redux/notification/action-creators"; 3 | import { NotificationTypes } from "../redux/types"; 4 | 5 | interface INotificationApi { 6 | showError: (msg: string) => void; 7 | showSuccess: (msg: string) => void; 8 | showWarning: (msg: string) => void; 9 | } 10 | 11 | export function createNotificationApiFromDispatch(dispatch): INotificationApi { 12 | const show = (type: NotificationTypes) => (msg) => { 13 | dispatch(NotificationActions.setType(type)); 14 | dispatch(NotificationActions.setMessage(msg)); 15 | dispatch(NotificationActions.setVisible(true)); 16 | }; 17 | 18 | return { 19 | showSuccess: show(NotificationTypes.Success), 20 | showError: show(NotificationTypes.Error), 21 | showWarning: show(NotificationTypes.Warning), 22 | }; 23 | } 24 | 25 | export function useNotification(): INotificationApi { 26 | const dispatch = useDispatch(); 27 | return createNotificationApiFromDispatch(dispatch); 28 | } 29 | -------------------------------------------------------------------------------- /ui/utils/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import storage from "electron-json-storage"; 2 | import _ from "lodash"; 3 | import { isOk } from "../../../common/tools"; 4 | import { defaultSettingsState, SettingsState } from "../../redux/settings/reducers"; 5 | 6 | const SETTINGS_FILENAME = "settings"; 7 | 8 | const get = () => 9 | new Promise((resolve, reject) => { 10 | storage.get(SETTINGS_FILENAME, (err, data) => { 11 | if (isOk(err)) { 12 | reject(err); 13 | } 14 | 15 | resolve(data); 16 | }); 17 | }); 18 | 19 | const set = (newSettings) => 20 | new Promise((resolve, reject) => { 21 | storage.set(SETTINGS_FILENAME, newSettings, (err) => { 22 | if (isOk(err)) { 23 | reject(err); 24 | } 25 | resolve(); 26 | }); 27 | }); 28 | 29 | export const loadSettings = async (): Promise => { 30 | const settings: SettingsState = defaultSettingsState; 31 | const loaded = await get(); 32 | const result = { ...settings, ...loaded }; 33 | if (!_.isEqual(loaded, result)) { 34 | await set(result); 35 | } 36 | 37 | return result; 38 | }; 39 | 40 | export const saveSettings = async (settings: SettingsState) => { 41 | await set(settings); 42 | }; 43 | -------------------------------------------------------------------------------- /ui/utils/thunk-hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import { store } from "../redux/store"; 3 | 4 | export const useThunkDispatch = () => { 5 | return useDispatch(); 6 | }; 7 | -------------------------------------------------------------------------------- /ui/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const preprocessUrl = (url: string): string => { 2 | if (url.includes("@")) { 3 | return `mailto: ${url}`; 4 | } else if (!url.startsWith("http")) { 5 | return `https://${url}`; 6 | } 7 | 8 | return url; 9 | }; 10 | -------------------------------------------------------------------------------- /ui/utils/util-hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from "react"; 2 | 3 | export const useForceUpdate = (deps: any[]) => { 4 | const [key, increaseKey] = useReducer((state) => state + 1, 0); 5 | useEffect(() => { 6 | increaseKey(); 7 | }, deps); 8 | 9 | return key; 10 | }; 11 | --------------------------------------------------------------------------------