Merge pull request #608 from Drewsif/add-auto-bookmark-copy

Feat: Configurable Wormhole Bookmark Formatting and Auto-Copy
This commit is contained in:
Dmitry Popov
2026-05-27 09:46:57 +02:00
committed by GitHub
20 changed files with 1361 additions and 82 deletions
@@ -0,0 +1,18 @@
import {
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
} from '@/hooks/Mapper/components/map/constants.ts';
export const getSystemClassGroup = (systemClassId: number | undefined | null): string | null => {
if (systemClassId == null) return null;
const systemClassKey = Object.keys(SOLAR_SYSTEM_CLASS_IDS).find(
key => SOLAR_SYSTEM_CLASS_IDS[key as keyof typeof SOLAR_SYSTEM_CLASS_IDS] === systemClassId,
);
if (!systemClassKey) return null;
return (
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS[systemClassKey as keyof typeof SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS] || null
);
};
@@ -1,20 +1,21 @@
import { Dialog } from 'primereact/dialog';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
import {
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
} from '@/hooks/Mapper/components/map/constants.ts';
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
import { MULTI_DEST_WHS, ALL_DEST_TYPES_MAP, DEST_TYPES_MAP_MAP } from '@/hooks/Mapper/constants.ts';
import { SETTINGS_KEYS, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures';
import { getSystemClassGroup } from '@/hooks/Mapper/components/map/helpers/getSystemClassGroup.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { CommandLinkSignatureToSystem, SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
import { useSystemSignaturesData } from '../../widgets/SystemSignatures/hooks/useSystemSignaturesData';
import { useLinkSignature } from './hooks/useLinkSignature';
const MULTI_DEST_TYPES = MULTI_DEST_WHS.map((type: string) => WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME[type].shortName);
@@ -23,7 +24,7 @@ interface SystemLinkSignatureDialogProps {
setVisible: (visible: boolean) => void;
}
export const LINK_SIGNTATURE_SETTINGS: SignatureSettingsType = {
export const LINK_SIGNATURE_SETTINGS: SignatureSettingsType = {
[SETTINGS_KEYS.COSMIC_SIGNATURE]: true,
[SETTINGS_KEYS.WORMHOLE]: true,
[SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN]: true,
@@ -38,13 +39,9 @@ interface ExtendedSignatureCustomInfo {
export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignatureDialogProps) => {
const {
outCommand,
data: { wormholes },
} = useMapRootState();
const ref = useRef({ outCommand });
ref.current = { outCommand };
// Get system info for the target system
const { staticInfo: targetSystemInfo, dynamicInfo: targetSystemDynamicInfo } = useSystemInfo({
systemId: `${data.solar_system_target}`,
@@ -53,17 +50,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
// Get the system class group for the target system
const targetSystemClassGroup = useMemo(() => {
if (!targetSystemInfo) return null;
const systemClassId = targetSystemInfo.system_class;
const systemClassKey = Object.keys(SOLAR_SYSTEM_CLASS_IDS).find(
key => SOLAR_SYSTEM_CLASS_IDS[key as keyof typeof SOLAR_SYSTEM_CLASS_IDS] === systemClassId,
);
if (!systemClassKey) return null;
return (
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS[systemClassKey as keyof typeof SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS] || null
);
return getSystemClassGroup(targetSystemInfo.system_class);
}, [targetSystemInfo]);
const handleHide = useCallback(() => {
@@ -115,32 +102,26 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
[targetSystemClassGroup, wormholes],
);
const { signatures } = useSystemSignaturesData({
systemId: `${data.solar_system_source}`,
settings: LINK_SIGNATURE_SETTINGS,
});
const { handleLinkSignature } = useLinkSignature({ data, targetSystemClassGroup });
const handleSelect = useCallback(
(signature: SystemSignature) => {
async (signature: SystemSignature) => {
if (!signature) {
return;
}
const { outCommand } = ref.current;
outCommand({
type: OutCommand.linkSignatureToSystem,
data: {
...data,
signature_eve_id: signature.eve_id,
},
});
await handleLinkSignature(signature);
setVisible(false);
},
[data, setVisible],
[handleLinkSignature, setVisible],
);
const { signatures } = useSystemSignaturesData({
systemId: `${data.solar_system_source}`,
settings: LINK_SIGNTATURE_SETTINGS,
});
useEffect(() => {
if (!targetSystemDynamicInfo) {
handleHide();
@@ -160,7 +141,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
systemId={`${data.solar_system_source}`}
signatures={signatures}
hasUnsupportedLanguage={false}
settings={LINK_SIGNTATURE_SETTINGS}
settings={LINK_SIGNATURE_SETTINGS}
hideLinkedSignatures
selectable
onSelect={handleSelect}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { handleAutoBookmark, numberToLetters } from '@/hooks/Mapper/helpers/bookmarkFormatHelper.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { CommandLinkSignatureToSystem, SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager.ts';
export interface UseLinkSignatureProps {
data: CommandLinkSignatureToSystem;
targetSystemClassGroup: string | null;
}
export const useLinkSignature = ({ data, targetSystemClassGroup }: UseLinkSignatureProps) => {
const {
outCommand,
data: { systemSignatures, systems, wormholesData },
} = useMapRootState();
const ref = useRef({ outCommand });
ref.current = { outCommand };
const [userSettings, setUserSettings] = useState<any>(null);
useEffect(() => {
outCommand({ type: OutCommand.getUserSettings, data: null })
.then((res: any) => setUserSettings(res?.user_settings))
.catch((e: any) => console.warn('Failed to fetch user settings', e));
}, [outCommand]);
const handleLinkSignature = useCallback(
async (signature: SystemSignature) => {
const { outCommand } = ref.current;
const sourceSystem = systems.find((s: any) => s.system_static_info?.solar_system_id === data.solar_system_source);
const systemUuid = sourceSystem?.id || data.solar_system_source.toString();
const targetSystem = systems.find((s: any) => s.system_static_info?.solar_system_id === data.solar_system_target);
const targetSystemUuid = targetSystem?.id;
const targetSolarSystemIdStr = data.solar_system_target?.toString();
const signatureToLink = { ...signature, group: SignatureGroup.Wormhole };
const { updatedSignature, shouldUpdate } = await handleAutoBookmark(
signatureToLink,
userSettings,
systemSignatures,
systemUuid,
data.solar_system_source.toString(),
wormholesData,
targetSystemClassGroup,
targetSystemUuid,
targetSolarSystemIdStr,
);
if (shouldUpdate) {
await outCommand({
type: OutCommand.updateSignatures,
data: {
system_id: `${data.solar_system_source}`,
updated: [updatedSignature],
removed: [],
deleteTimeout: 0,
},
});
}
await outCommand({
type: OutCommand.linkSignatureToSystem,
data: {
...data,
signature_eve_id: signature.eve_id,
},
});
const systemAutoTag = userSettings?.system_auto_tag;
const systemCustomLabelName = userSettings?.system_custom_label_name;
if (systemAutoTag || systemCustomLabelName) {
const info = parseSignatureCustomInfo(updatedSignature.custom_info);
if (info.bookmark_index !== undefined) {
const bIndex = info.bookmark_index;
const startAtZero = userSettings?.bookmark_wormholes_start_at_zero;
const letter = numberToLetters(bIndex, startAtZero);
if (targetSystem) {
if (systemAutoTag) {
let tagValue = '';
switch (systemAutoTag) {
case 'index':
tagValue = bIndex.toString();
break;
case 'chain_index':
tagValue = (info.bookmark_index_chained as string) || bIndex.toString();
break;
case 'index_letter':
tagValue = letter;
break;
case 'chain_index_letters':
tagValue = (info.bookmark_index_chained_letters as string) || letter;
break;
}
if (tagValue) {
await outCommand({
type: OutCommand.updateSystemTag,
data: {
system_id: targetSystem.id,
value: tagValue,
},
});
}
}
if (systemCustomLabelName) {
let labelValue = '';
switch (systemCustomLabelName) {
case 'index':
labelValue = bIndex.toString();
break;
case 'index_letter':
labelValue = letter;
break;
case 'chain_index':
labelValue = (info.bookmark_index_chained as string) || bIndex.toString();
break;
case 'chain_index_letters':
labelValue = (info.bookmark_index_chained_letters as string) || letter;
break;
}
if (labelValue) {
const outLabel = new LabelsManager(targetSystem.labels ?? '');
outLabel.updateCustomLabel(labelValue);
await outCommand({
type: OutCommand.updateSystemLabels,
data: {
system_id: targetSystem.id,
value: outLabel.toString(),
},
});
}
}
}
}
}
},
[data, userSettings, targetSystemClassGroup, systemSignatures, systems, wormholesData],
);
return { handleLinkSignature };
};
@@ -11,6 +11,7 @@ import {
} from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx';
import { WidgetsSettings } from './components/WidgetsSettings';
import { CommonSettings } from './components/CommonSettings';
import { BookmarksSettings } from './components/BookmarksSettings';
import { SettingsListItem } from './types.ts';
import { ImportExport } from './components/ImportExport.tsx';
import { ServerSettings } from './components/ServerSettings.tsx';
@@ -26,7 +27,7 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const { outCommand } = useMapRootState();
const { renderSettingItem, setUserRemoteSettings } = useMapSettings();
const { renderSettingItem, setUserRemoteSettings, settings } = useMapSettings();
const isAdmin = useMapCheckPermissions([UserPermission.ADMIN_MAP]);
const refVars = useRef({ outCommand, onHide, visible });
@@ -62,15 +63,16 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
header="Map user settings"
visible
draggable={false}
className="w-[600px] h-[400px]"
className="w-[600px] h-[460px]"
contentClassName="custom-scrollbar"
onShow={handleShow}
onHide={handleHide}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3 h-full">
<div className="flex flex-col gap-2 h-full">
<TabView
activeIndex={activeIndex}
className="vertical-tabs-container"
className="vertical-tabs-container h-full"
onTabChange={e => setActiveIndex(e.index)}
>
<TabPanel header="Common" headerClassName={styles.verticalTabHeader}>
@@ -89,6 +91,10 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
{renderSettingsList(SIGNATURES_CHECKBOXES_PROPS)}
</TabPanel>
<TabPanel header="Bookmarks" className="h-full" headerClassName={styles.verticalTabHeader}>
<BookmarksSettings />
</TabPanel>
<TabPanel header="Widgets" className="h-full" headerClassName={styles.verticalTabHeader}>
<WidgetsSettings />
</TabPanel>
@@ -21,12 +21,15 @@ import {
import { OutCommand } from '@/hooks/Mapper/types';
import { PrettySwitchbox } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components';
import { Dropdown } from 'primereact/dropdown';
import { InputText } from 'primereact/inputtext';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { WithChildren } from '@/hooks/Mapper/types/common.ts';
type MapSettingsContextType = {
renderSettingItem: (item: SettingsListItem) => ReactNode;
updateSetting: (prop: keyof UserSettings, value: boolean | string | Record<string, string>) => Promise<void>;
setUserRemoteSettings: Dispatch<SetStateAction<UserSettingsRemote>>;
settings: UserSettings;
};
const MapSettingsContext = createContext<MapSettingsContextType | undefined>(undefined);
@@ -51,29 +54,39 @@ export const MapSettingsProvider = ({ children }: WithChildren) => {
const refVars = useRef({ mergedSettings, userRemoteSettings, interfaceSettings, outCommand, setInterfaceSettings });
refVars.current = { mergedSettings, userRemoteSettings, interfaceSettings, outCommand, setInterfaceSettings };
const handleSettingChange = useCallback(async (prop: keyof UserSettings, value: boolean | string) => {
const { userRemoteSettings, interfaceSettings, outCommand, setInterfaceSettings } = refVars.current;
const handleSettingChange = useCallback(
async (prop: keyof UserSettings, value: boolean | string | Record<string, string>) => {
const { userRemoteSettings, interfaceSettings, outCommand, setInterfaceSettings } = refVars.current;
if (UserSettingsRemoteList.includes(prop as any)) {
const newRemoteSettings = {
...userRemoteSettings,
[prop]: value,
};
await outCommand({
type: OutCommand.updateUserSettings,
data: newRemoteSettings,
});
setUserRemoteSettings(newRemoteSettings);
} else {
setInterfaceSettings({
...interfaceSettings,
[prop]: value,
});
}
}, []);
if (UserSettingsRemoteList.includes(prop as any)) {
const newRemoteSettings = {
...userRemoteSettings,
[prop]: value,
};
await outCommand({
type: OutCommand.updateUserSettings,
data: newRemoteSettings,
});
setUserRemoteSettings(newRemoteSettings);
} else {
setInterfaceSettings({
...interfaceSettings,
[prop]: value,
});
}
},
[],
);
const renderSettingItem = useCallback(
(item: SettingsListItem) => {
if (item.dependsOn) {
const dependsOnValue = refVars.current.mergedSettings[item.dependsOn];
if (!dependsOnValue) {
return null;
}
}
const currentValue = refVars.current.mergedSettings[item.prop];
if (item.type === 'checkbox') {
@@ -103,13 +116,30 @@ export const MapSettingsProvider = ({ children }: WithChildren) => {
);
}
if (item.type === 'text') {
return (
<div key={item.prop.toString()} className="flex flex-col gap-1 w-full mt-2 mb-2">
{item.label && <label className="text-[var(--gray-200)] text-[13px] select-none">{item.label}</label>}
<InputText
className="text-sm w-full"
defaultValue={(currentValue as string) || ''}
onBlur={e => handleSettingChange(item.prop, e.target.value)}
placeholder={item.placeholder}
/>
{item.helperText && <small className="text-gray-400 text-xs mt-1">{item.helperText}</small>}
</div>
);
}
return null;
},
[handleSettingChange],
);
return (
<MapSettingsContext.Provider value={{ renderSettingItem, setUserRemoteSettings }}>
<MapSettingsContext.Provider
value={{ renderSettingItem, updateSetting: handleSettingChange, setUserRemoteSettings, settings: mergedSettings }}
>
{children}
</MapSettingsContext.Provider>
);
@@ -0,0 +1,352 @@
import { useMapSettings } from '../MapSettingsProvider';
import { UserSettingsRemoteProps } from '../types';
import { InputText } from 'primereact/inputtext';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { formatBookmarkName } from '@/hooks/Mapper/helpers/bookmarkFormatHelper';
import { SignatureGroup, SignatureKind, SystemSignature } from '@/hooks/Mapper/types';
import { MassState, TimeStatus } from '@/hooks/Mapper/types/connection';
const DUMMY_SIG_BASE: SystemSignature = {
eve_id: 'ABC-123',
name: 'ABC-123',
kind: SignatureKind.CosmicSignature,
type: 'K162',
description: 'To Jita',
temporary_name: 'Temp',
group: SignatureGroup.Wormhole,
custom_info: '',
};
const VARIABLES = [
{ id: '{index}', desc: 'Numeric index (e.g., 1, 2, 3)' },
{ id: '{index_letter}', desc: 'Letter index (e.g., A, B, C)' },
{ id: '{chain_index}', desc: 'Numeric chain path (e.g., 11, 12, 121)' },
{ id: '{chain_index_letters}', desc: 'Letter chain path (e.g., A, A1, A21)' },
{ id: '{sig_letters}', desc: 'First 3 chars of signature (e.g., ABC)' },
{ id: '{sig}', desc: 'Full signature ID (e.g., ABC-123)' },
{ id: '{dest_type}', desc: 'Destination class (e.g., C5, HS, Thera)' },
{
id: '{dest_class_index}',
desc: 'Letter index for multiple holes to same class (empty if only 1, otherwise a, b, c...)',
},
{ id: '{type}', desc: 'Wormhole type (e.g., K162, H900)' },
{ id: '{size}', desc: 'Hole size (e.g., S, M, XL)' },
{ id: '{mass}', desc: 'Total mass in bil (e.g., 3.3)' },
{ id: '{time_status}', desc: 'Time remaining (e.g., 1H, 4H, 16H)' },
{ id: '{mass_status}', desc: 'Mass remaining (e.g., Destab, Crit)' },
{ id: '{temporary_name}', desc: 'Temporary name if set' },
{ id: '{description}', desc: 'Custom description' },
];
interface CustomMappingInputProps {
mappingKey: string;
label: string;
defaultVal: string;
localMapping: Record<string, string>;
setLocalMapping: React.Dispatch<React.SetStateAction<Record<string, string>>>;
updateSetting: (prop: any, value: any) => void;
}
const CustomMappingInput = ({
mappingKey,
label,
defaultVal,
localMapping,
setLocalMapping,
updateSetting,
}: CustomMappingInputProps) => {
const value = localMapping[mappingKey] !== undefined ? localMapping[mappingKey] : defaultVal;
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setLocalMapping(prev => {
const newMapping = { ...prev };
newMapping[mappingKey] = e.target.value;
return newMapping;
});
}, [mappingKey, setLocalMapping]);
const handleBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
const val = e.target.value;
setLocalMapping(prev => {
const currentVal = prev[mappingKey] !== undefined ? prev[mappingKey] : defaultVal;
if (val === currentVal) return prev;
const newMapping = { ...prev };
if (val === defaultVal) {
delete newMapping[mappingKey];
} else {
newMapping[mappingKey] = val;
}
updateSetting(UserSettingsRemoteProps.bookmark_custom_mapping, newMapping);
return newMapping;
});
}, [mappingKey, defaultVal, setLocalMapping, updateSetting]);
return (
<div className="flex flex-col gap-1 w-[120px]">
<label className="text-stone-400 text-[10px]">{label}</label>
<InputText
className="text-xs w-full py-1 px-2"
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder="(empty)"
/>
</div>
);
};
const TIME_OPTIONS = [
{ key: 'time_1h', label: '1 Hour', defaultVal: '1H' },
{ key: 'time_4h', label: '4 Hours', defaultVal: '4H' },
{ key: 'time_4h30m', label: '4.5 Hours', defaultVal: '4.5H' },
{ key: 'time_16h', label: '16 Hours', defaultVal: '16H' },
{ key: 'time_24h', label: '24 Hours', defaultVal: '' },
{ key: 'time_48h', label: '48 Hours', defaultVal: '' },
];
const MASS_OPTIONS = [
{ key: 'mass_normal', label: 'Normal Mass', defaultVal: '' },
{ key: 'mass_half', label: 'Destab', defaultVal: 'Destab' },
{ key: 'mass_verge', label: 'Critical', defaultVal: 'Crit' },
];
const OTHER_OPTIONS = [{ key: 'chain_separator', label: 'Chain Separator', defaultVal: '' }];
const SIZE_OPTIONS = [
{ key: 'size_small', label: 'Small (Frigate)', defaultVal: 'S' },
{ key: 'size_medium', label: 'Medium', defaultVal: 'M' },
{ key: 'size_large', label: 'Large', defaultVal: '' },
{ key: 'size_freight', label: 'Huge / Freight', defaultVal: 'XL' },
{ key: 'size_capital', label: 'Capital', defaultVal: 'C' },
];
const CLASS_OPTIONS = [
{ key: 'class_c1', label: 'Class 1', defaultVal: 'C1' },
{ key: 'class_c2', label: 'Class 2', defaultVal: 'C2' },
{ key: 'class_c3', label: 'Class 3', defaultVal: 'C3' },
{ key: 'class_c4', label: 'Class 4', defaultVal: 'C4' },
{ key: 'class_c5', label: 'Class 5', defaultVal: 'C5' },
{ key: 'class_c6', label: 'Class 6', defaultVal: 'C6' },
{ key: 'class_c13', label: 'Class 13', defaultVal: 'C13' },
{ key: 'class_c1c2c3', label: 'Class 1/2/3', defaultVal: 'C1/C2/C3' },
{ key: 'class_c4c5', label: 'Class 4/5', defaultVal: 'C4/C5' },
{ key: 'class_hs', label: 'High-Sec', defaultVal: 'HS' },
{ key: 'class_ls', label: 'Low-Sec', defaultVal: 'LS' },
{ key: 'class_ns', label: 'Null-Sec', defaultVal: 'NS' },
{ key: 'class_thera', label: 'Thera', defaultVal: 'Thera' },
{ key: 'class_pochven', label: 'Pochven', defaultVal: 'Pochven' },
{ key: 'class_drifter', label: 'Drifter', defaultVal: 'Drifter' },
];
export const BookmarkNameFormatSetting = () => {
const { settings, updateSetting } = useMapSettings();
const formatStr = settings.bookmark_name_format || '';
const customMapping = settings.bookmark_custom_mapping || {};
const inputRef = useRef<HTMLInputElement>(null);
const [localFormat, setLocalFormat] = useState(formatStr);
const [localMapping, setLocalMapping] = useState(customMapping);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
setLocalFormat(formatStr);
}, [formatStr]);
useEffect(() => {
setLocalMapping(customMapping);
}, [customMapping]);
const preview = useMemo(() => {
const isZero = settings.bookmark_wormholes_start_at_zero;
const sep = localMapping?.chain_separator || '';
const chainNum = isZero ? `0${sep}0${sep}1` : `1${sep}1${sep}2`;
const chainLet = isZero ? `A${sep}0${sep}1` : `A${sep}1${sep}2`;
const currentIndex = isZero ? 1 : 2;
const dummySig: SystemSignature = {
...DUMMY_SIG_BASE,
type: 'V283',
custom_info: JSON.stringify({
time_status: TimeStatus._1h,
mass_status: MassState.verge,
bookmark_index_chained: chainNum,
bookmark_index_chained_letters: chainLet,
}),
};
const otherDummySig: SystemSignature = {
...DUMMY_SIG_BASE,
type: 'V283',
eve_id: 'DEF-456',
name: 'DEF-456',
custom_info: JSON.stringify({
time_status: TimeStatus._1h,
mass_status: MassState.verge,
}),
};
const dummyWormholesData = {
V283: {
total_mass: 3300000000,
max_mass_per_jump: 62000000,
dest: ['hs'],
} as any,
};
return formatBookmarkName(
localFormat,
dummySig,
'HS',
currentIndex,
dummyWormholesData,
isZero,
localMapping,
{ preview_sys: [otherDummySig] },
'preview_sys',
);
}, [localFormat, settings.bookmark_wormholes_start_at_zero, localMapping]);
const handleBlur = useCallback(() => {
if (localFormat !== formatStr) {
updateSetting(UserSettingsRemoteProps.bookmark_name_format, localFormat);
}
}, [localFormat, formatStr, updateSetting]);
const insertVariable = useCallback((variable: string) => {
const input = inputRef.current;
if (input) {
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const newFormat = localFormat.substring(0, start) + variable + localFormat.substring(end);
setLocalFormat(newFormat);
updateSetting(UserSettingsRemoteProps.bookmark_name_format, newFormat);
setTimeout(() => {
input.focus();
input.setSelectionRange(start + variable.length, start + variable.length);
}, 0);
} else {
const newFormat = localFormat + variable;
setLocalFormat(newFormat);
updateSetting(UserSettingsRemoteProps.bookmark_name_format, newFormat);
}
}, [localFormat, updateSetting]);
const resetToDefault = useCallback(() => {
const defaultFormat = '{chain_index} {sig_letters} {dest_type} {size} {mass_status} {time_status}';
setLocalFormat(defaultFormat);
updateSetting(UserSettingsRemoteProps.bookmark_name_format, defaultFormat);
}, [updateSetting]);
const renderCustomMappingInputs = (options: { key: string; label: string; defaultVal: string }[]) => {
return options.map(opt => (
<CustomMappingInput
key={opt.key}
mappingKey={opt.key}
label={opt.label}
defaultVal={opt.defaultVal}
localMapping={localMapping}
setLocalMapping={setLocalMapping}
updateSetting={updateSetting}
/>
));
};
return (
<div className="flex flex-col gap-3 mt-2">
<div className="flex justify-between items-end">
<label className="text-[var(--gray-200)] text-[13px] select-none">Bookmark Name Format</label>
<WdButton size="small" outlined onClick={resetToDefault} className="text-xs py-1 px-2 h-auto min-h-[24px]">
Reset to Default
</WdButton>
</div>
<InputText
ref={inputRef}
className="text-sm w-full"
value={localFormat}
onChange={e => setLocalFormat(e.target.value)}
onBlur={handleBlur}
placeholder="e.g. {chain_index} {sig_letters} {dest_type} {size} {mass_status} {time_status}"
/>
<div className="text-sm p-2 bg-stone-800 rounded border border-stone-700 flex flex-col gap-1">
<span className="text-stone-400 text-xs">Live Preview:</span>
<span className="text-stone-200 font-mono">
{preview || <span className="italic text-stone-500">Empty</span>}
</span>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 text-xs text-stone-400 p-2 bg-stone-800/50 rounded border border-stone-800 mt-2 max-h-[160px]">
<h4 className="text-stone-300 font-semibold mb-2">Available Variables (Click to insert)</h4>
<ul className="space-y-1">
{VARIABLES.map(v => (
<li key={v.id}>
<code
className="text-stone-200 cursor-pointer hover:bg-stone-700 px-1 rounded transition-colors inline-block"
onClick={() => insertVariable(v.id)}
>
{v.id}
</code>{' '}
- {v.desc}
</li>
))}
</ul>
</div>
<div className="mt-2">
<WdButton
className="text-xs w-full justify-center bg-stone-800 hover:bg-stone-700 border-stone-700 text-stone-300 py-1"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? 'Hide Advanced String Customization' : 'Show Advanced String Customization'}
</WdButton>
{showAdvanced && (
<div className="p-3 bg-stone-900 rounded border border-stone-800 mt-2 flex flex-col gap-4">
<div className="flex justify-between items-start">
<p className="text-stone-400 text-xs italic">Override the default output of specific format variables.</p>
<WdButton
size="small"
outlined
className="text-xs py-1 px-2 h-auto min-h-[24px]"
onClick={() => {
setLocalMapping({});
updateSetting(UserSettingsRemoteProps.bookmark_custom_mapping, {});
}}
>
Reset Mappings
</WdButton>
</div>
<div className="flex flex-col gap-2">
<h5 className="text-stone-300 text-xs font-semibold uppercase tracking-wider">Time</h5>
<div className="flex flex-wrap gap-2">{renderCustomMappingInputs(TIME_OPTIONS)}</div>
</div>
<div className="flex flex-col gap-2">
<h5 className="text-stone-300 text-xs font-semibold uppercase tracking-wider">Mass</h5>
<div className="flex flex-wrap gap-2">{renderCustomMappingInputs(MASS_OPTIONS)}</div>
</div>
<div className="flex flex-col gap-2">
<h5 className="text-stone-300 text-xs font-semibold uppercase tracking-wider">Other / Formatting</h5>
<div className="flex flex-wrap gap-2">{renderCustomMappingInputs(OTHER_OPTIONS)}</div>
</div>
<div className="flex flex-col gap-2">
<h5 className="text-stone-300 text-xs font-semibold uppercase tracking-wider">Hole Sizes</h5>
<div className="flex flex-wrap gap-2">{renderCustomMappingInputs(SIZE_OPTIONS)}</div>
</div>
<div className="flex flex-col gap-2">
<h5 className="text-stone-300 text-xs font-semibold uppercase tracking-wider">Destination Classes</h5>
<div className="flex flex-wrap gap-2">{renderCustomMappingInputs(CLASS_OPTIONS)}</div>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,33 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useMapSettings } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx';
import { BOOKMARKS_SETTINGS_PROPS } from '../constants.ts';
import { BookmarkNameFormatSetting } from './BookmarkNameFormatSetting';
export const BookmarksSettings = () => {
const {
storedSettings: { interfaceSettings, setInterfaceSettings },
} = useMapRootState();
const { renderSettingItem, settings } = useMapSettings();
return (
<div className="w-full h-full flex flex-col gap-3 overflow-y-auto custom-scrollbar pr-1">
{!settings.link_signature_on_splash && !interfaceSettings.hideBookmarkWarning && (
<div className="relative p-2 pr-6 bg-yellow-900/30 border border-yellow-700/50 rounded text-yellow-500/90 text-sm">
It is highly recommended to enable 'Link signature on splash' (in the Signatures tab) to fully utilize
automatic bookmark naming.
<button
type="button"
className="absolute top-1 right-2 text-yellow-700 hover:text-yellow-500 transition-colors"
onClick={() => setInterfaceSettings(prev => ({ ...prev, hideBookmarkWarning: true }))}
>
<i className="pi pi-times text-xs"></i>
</button>
</div>
)}
<div className="flex flex-col gap-1 mt-2">{BOOKMARKS_SETTINGS_PROPS.map(renderSettingItem)}</div>
<BookmarkNameFormatSetting />
</div>
);
};
@@ -6,12 +6,38 @@ export const DEFAULT_REMOTE_SETTINGS = {
[UserSettingsRemoteProps.link_signature_on_splash]: false,
[UserSettingsRemoteProps.select_on_spash]: false,
[UserSettingsRemoteProps.delete_connection_with_sigs]: false,
[UserSettingsRemoteProps.bookmark_name_format]: '',
[UserSettingsRemoteProps.bookmark_custom_mapping]: {},
[UserSettingsRemoteProps.bookmark_wormholes_start_at_zero]: false,
[UserSettingsRemoteProps.bookmark_auto_copy]: true,
[UserSettingsRemoteProps.bookmark_auto_temp_name]: '',
[UserSettingsRemoteProps.system_auto_tag]: '',
[UserSettingsRemoteProps.system_custom_label_name]: '',
[UserSettingsRemoteProps.bookmark_return_hole_ignore]: false,
[UserSettingsRemoteProps.bookmark_return_hole_symbol]: '',
};
export const AUTO_FORMAT_OPTIONS = [
{ label: 'Disabled', value: '' },
{ label: 'Numeric index (1, 2, 3)', value: 'index' },
{ label: 'Letter index (A, B, C)', value: 'index_letter' },
{ label: 'Numeric chain (11, 12, 121)', value: 'chain_index' },
{ label: 'Letter chain (A, A1, A21)', value: 'chain_index_letters' },
];
export const UserSettingsRemoteList = [
UserSettingsRemoteProps.link_signature_on_splash,
UserSettingsRemoteProps.select_on_spash,
UserSettingsRemoteProps.delete_connection_with_sigs,
UserSettingsRemoteProps.bookmark_name_format,
UserSettingsRemoteProps.bookmark_custom_mapping,
UserSettingsRemoteProps.bookmark_wormholes_start_at_zero,
UserSettingsRemoteProps.bookmark_auto_copy,
UserSettingsRemoteProps.bookmark_auto_temp_name,
UserSettingsRemoteProps.system_auto_tag,
UserSettingsRemoteProps.system_custom_label_name,
UserSettingsRemoteProps.bookmark_return_hole_ignore,
UserSettingsRemoteProps.bookmark_return_hole_symbol,
];
// export const COMMON_CHECKBOXES_PROPS: SettingsListItem[] = [
@@ -48,6 +74,48 @@ export const SIGNATURES_CHECKBOXES_PROPS: SettingsListItem[] = [
},
];
export const BOOKMARKS_SETTINGS_PROPS: SettingsListItem[] = [
{
prop: UserSettingsRemoteProps.bookmark_auto_copy,
label: 'Automatically copy bookmarks to clipboard',
type: 'checkbox',
},
{
prop: UserSettingsRemoteProps.bookmark_wormholes_start_at_zero,
label: 'Start wormhole indices at 0',
type: 'checkbox',
},
{
prop: UserSettingsRemoteProps.bookmark_return_hole_ignore,
label: 'Ignore return hole when creating indexes',
type: 'checkbox',
},
{
prop: UserSettingsRemoteProps.bookmark_return_hole_symbol,
label: 'Return hole symbol (use space for empty)',
type: 'text',
dependsOn: UserSettingsRemoteProps.bookmark_return_hole_ignore,
},
{
prop: UserSettingsRemoteProps.bookmark_auto_temp_name,
label: 'Auto-fill wormhole temporary name',
type: 'dropdown',
options: AUTO_FORMAT_OPTIONS,
},
{
prop: UserSettingsRemoteProps.system_auto_tag,
label: 'Auto-tag jumped system',
type: 'dropdown',
options: AUTO_FORMAT_OPTIONS,
},
{
prop: UserSettingsRemoteProps.system_custom_label_name,
label: 'Auto-label jumped system',
type: 'dropdown',
options: AUTO_FORMAT_OPTIONS,
},
];
export const CONNECTIONS_CHECKBOXES_PROPS: SettingsListItem[] = [
{
prop: UserSettingsRemoteProps.delete_connection_with_sigs,
@@ -4,12 +4,30 @@ export enum UserSettingsRemoteProps {
link_signature_on_splash = 'link_signature_on_splash',
select_on_spash = 'select_on_spash',
delete_connection_with_sigs = 'delete_connection_with_sigs',
bookmark_name_format = 'bookmark_name_format',
bookmark_custom_mapping = 'bookmark_custom_mapping',
bookmark_wormholes_start_at_zero = 'bookmark_wormholes_start_at_zero',
bookmark_auto_copy = 'bookmark_auto_copy',
bookmark_auto_temp_name = 'bookmark_auto_temp_name',
system_auto_tag = 'system_auto_tag',
system_custom_label_name = 'system_custom_label_name',
bookmark_return_hole_ignore = 'bookmark_return_hole_ignore',
bookmark_return_hole_symbol = 'bookmark_return_hole_symbol',
}
export type UserSettingsRemote = {
link_signature_on_splash: boolean;
select_on_spash: boolean;
delete_connection_with_sigs: boolean;
bookmark_name_format: string;
bookmark_custom_mapping?: Record<string, string>;
bookmark_wormholes_start_at_zero: boolean;
bookmark_auto_copy: boolean;
bookmark_auto_temp_name: string;
system_auto_tag: string;
system_custom_label_name: string;
bookmark_return_hole_ignore: boolean;
bookmark_return_hole_symbol: string;
};
export type UserSettings = UserSettingsRemote & InterfaceStoredSettings;
@@ -17,6 +35,9 @@ export type UserSettings = UserSettingsRemote & InterfaceStoredSettings;
export type SettingsListItem = {
prop: keyof UserSettings;
label: string;
type: 'checkbox' | 'dropdown';
type: 'checkbox' | 'dropdown' | 'text';
options?: { label: string; value: string }[];
placeholder?: string;
helperText?: string;
dependsOn?: keyof UserSettings;
};
@@ -2,18 +2,22 @@ import {
SignatureGroupContent,
SignatureGroupSelect,
} from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components';
import { getSystemClassGroup } from '@/hooks/Mapper/components/map/helpers/getSystemClassGroup.ts';
import { SystemsSettingsProvider } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/Provider.tsx';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
import { handleAutoBookmark } from '@/hooks/Mapper/helpers/bookmarkFormatHelper.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { MassState, OutCommand, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
type SystemSignaturePrepared = Omit<SystemSignature, 'linked_system'> & {
linked_system: string;
destType: string;
k162Type?: string;
time_status: TimeStatus;
mass_status: MassState;
};
@@ -26,11 +30,23 @@ export interface MapSettingsProps {
}
export const SignatureSettings = ({ systemId, show, onHide, signatureData }: MapSettingsProps) => {
const { outCommand } = useMapRootState();
const {
outCommand,
data: { systemSignatures, systems, wormholesData },
} = useMapRootState();
const handleShow = async () => {};
const signatureForm = useForm<Partial<SystemSignaturePrepared>>({});
const [userSettings, setUserSettings] = useState<any>(null);
useEffect(() => {
outCommand({ type: OutCommand.getUserSettings, data: null })
.then((res: any) => setUserSettings(res?.user_settings))
.catch((e: any) => console.warn('Failed to fetch user settings', e));
}, [outCommand]);
const handleSave = useCallback(
// TODO: need fix
async (e: any) => {
@@ -44,10 +60,13 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
switch (group) {
case SignatureGroup.Wormhole:
const existingInfo = parseSignatureCustomInfo(signatureData.custom_info);
out = {
...out,
custom_info: JSON.stringify({
...existingInfo,
destType: values.destType,
k162Type: values.k162Type,
time_status: values.time_status,
mass_status: values.mass_status,
}),
@@ -103,6 +122,45 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
// Note: despite groups have optional type - this will always set
out = { ...out, group: group! };
if (group === SignatureGroup.Wormhole) {
let targetSystem = null;
if (values.linked_system) {
targetSystem = systems.find((s: any) => s.system_static_info?.solar_system_id?.toString() === values.linked_system);
}
let targetSystemClassGroup = null;
if (targetSystem?.system_static_info) {
targetSystemClassGroup = getSystemClassGroup(targetSystem.system_static_info.system_class);
}
const targetSystemUuid = targetSystem?.id;
let targetSolarSystemIdStr = values.linked_system;
if (targetSystem?.system_static_info?.solar_system_id) {
targetSolarSystemIdStr = targetSystem.system_static_info.solar_system_id.toString();
}
const currentSystem = systems.find((s: any) => s.id === systemId);
let solarSystemIdStr = systemId;
if (currentSystem?.system_static_info?.solar_system_id) {
solarSystemIdStr = currentSystem.system_static_info.solar_system_id.toString();
}
const { updatedSignature } = await handleAutoBookmark(
out,
userSettings,
systemSignatures,
systemId,
solarSystemIdStr,
wormholesData,
targetSystemClassGroup,
targetSystemUuid,
targetSolarSystemIdStr,
);
out = updatedSignature;
}
await outCommand({
type: OutCommand.updateSignatures,
data: {
@@ -131,7 +189,17 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
signatureForm.reset();
onHide();
},
[signatureData, signatureForm, outCommand, systemId, onHide],
[
signatureData,
signatureForm,
outCommand,
systemId,
onHide,
systemSignatures,
systems,
wormholesData,
userSettings,
],
);
useEffect(() => {
@@ -142,19 +210,22 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
const { linked_system, custom_info, ...rest } = signatureData;
let destType = null;
let destType: string | undefined = undefined;
let k162Type: string | undefined = undefined;
let time_status = TimeStatus._24h;
let mass_status = MassState.normal;
if (custom_info) {
const customInfo = JSON.parse(custom_info);
const customInfo = parseSignatureCustomInfo(custom_info);
destType = customInfo.destType;
time_status = customInfo.time_status;
k162Type = customInfo.k162Type;
time_status = customInfo.time_status ?? TimeStatus._24h;
mass_status = customInfo.mass_status ?? MassState.normal;
}
signatureForm.reset({
linked_system: linked_system?.solar_system_id.toString() ?? undefined,
destType: destType,
k162Type: k162Type,
time_status: time_status,
mass_status: mass_status,
...rest,
@@ -0,0 +1,491 @@
import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { MassState, TimeStatus } from '@/hooks/Mapper/types/connection';
import { getSystemClassGroup } from '@/hooks/Mapper/components/map/helpers/getSystemClassGroup';
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes';
import {
WORMHOLES_ADDITIONAL_INFO,
SHIP_MASSES_SIZE,
SHIP_SIZES_NAMES_SHORT,
} from '@/hooks/Mapper/components/map/constants';
import { ALL_DEST_TYPES_MAP, MULTI_DEST_WHS } from '@/hooks/Mapper/constants';
import { ShipSizeStatus } from '@/hooks/Mapper/types/connection';
const getTimeStatusString = (status?: TimeStatus, mapping?: Record<string, string>): string => {
switch (status) {
case TimeStatus._1h:
return mapping?.time_1h !== undefined ? mapping.time_1h : '1H';
case TimeStatus._4h:
return mapping?.time_4h !== undefined ? mapping.time_4h : '4H';
case TimeStatus._4h30m:
return mapping?.time_4h30m !== undefined ? mapping.time_4h30m : '4.5H';
case TimeStatus._16h:
return mapping?.time_16h !== undefined ? mapping.time_16h : '16H';
case TimeStatus._24h:
return mapping?.time_24h !== undefined ? mapping.time_24h : '';
case TimeStatus._48h:
return mapping?.time_48h !== undefined ? mapping.time_48h : '';
default:
return '';
}
};
const getMassStatusString = (status?: MassState, mapping?: Record<string, string>): string => {
switch (status) {
case MassState.normal:
return mapping?.mass_normal !== undefined ? mapping.mass_normal : '';
case MassState.half:
return mapping?.mass_half !== undefined ? mapping.mass_half : 'Destab';
case MassState.verge:
return mapping?.mass_verge !== undefined ? mapping.mass_verge : 'Crit';
default:
return '';
}
};
const DEST_CLASS_OVERRIDES: Record<string, string> = {
h: 'HS',
hs: 'HS',
'hi-sec': 'HS',
l: 'LS',
ls: 'LS',
'low-sec': 'LS',
n: 'NS',
ns: 'NS',
'null-sec': 'NS',
t: 'Thera',
thera: 'Thera',
d: 'Drifter',
drifter: 'Drifter',
p: 'Pochven',
pochven: 'Pochven',
'c1/c2/c3': 'C1/C2/C3',
'c4/c5': 'C4/C5',
};
const formatDestString = (dest: string | null | undefined, mapping?: Record<string, string>): string => {
if (!dest) return '?';
const lowerDest = dest.toLowerCase();
const normalizedDest = DEST_CLASS_OVERRIDES[lowerDest] ? DEST_CLASS_OVERRIDES[lowerDest].toLowerCase() : lowerDest;
const mappingKey = `class_${normalizedDest.replace(/[^a-z0-9]/g, '')}`;
if (mapping && mapping[mappingKey] !== undefined) {
return mapping[mappingKey];
}
if (DEST_CLASS_OVERRIDES[lowerDest]) {
return DEST_CLASS_OVERRIDES[lowerDest];
}
if (lowerDest.length <= 3) return dest.toUpperCase();
return dest.charAt(0).toUpperCase() + dest.slice(1);
};
export const numberToLetters = (num: number, startAtZero: boolean = false): string => {
if (startAtZero) {
num += 1;
}
let letters = '';
while (num > 0) {
const mod = (num - 1) % 26;
letters = String.fromCharCode(65 + mod) + letters;
num = Math.floor((num - mod) / 26);
}
return letters;
};
export const calculateBookmarkIndex = (
systemSignatures: Record<string, SystemSignature[]>,
currentSystemUuid: string,
currentSolarSystemId: string,
currentEveId: string,
startAtZero: boolean = false,
separator: string = '',
): { index: number; chained: string; chainedLetters: string } => {
let parentBookmarkIndex: string | undefined;
let parentBookmarkIndexLetters: string | undefined;
for (const [sysId, sigs] of Object.entries(systemSignatures)) {
if (sysId === currentSystemUuid || sysId === currentSolarSystemId) continue;
const parentSigs = sigs.filter(sig => sig.linked_system?.solar_system_id?.toString() === currentSolarSystemId);
for (const parentSig of parentSigs) {
const parentInfo = parseSignatureCustomInfo(parentSig.custom_info);
// Return holes have their bookmark_index deleted, so we skip them to avoid hijacking the chain
if (parentInfo.bookmark_index === undefined) continue;
if (parentInfo.bookmark_index_chained != null) {
if (!parentBookmarkIndex || String(parentInfo.bookmark_index_chained).length < parentBookmarkIndex.length) {
parentBookmarkIndex = String(parentInfo.bookmark_index_chained);
}
} else if (parentInfo.bookmark_index != null) {
if (!parentBookmarkIndex || String(parentInfo.bookmark_index).length < parentBookmarkIndex.length) {
parentBookmarkIndex = String(parentInfo.bookmark_index);
}
}
if (parentInfo.bookmark_index_chained_letters != null) {
if (
!parentBookmarkIndexLetters ||
String(parentInfo.bookmark_index_chained_letters).length < parentBookmarkIndexLetters.length
) {
parentBookmarkIndexLetters = String(parentInfo.bookmark_index_chained_letters);
}
}
}
}
const currentSigsRaw = [
...(systemSignatures[currentSystemUuid] || []),
...(systemSignatures[currentSolarSystemId] || []),
];
// Deduplicate in case both keys map to the same or overlapping arrays
const uniqueCurrentSigs = Array.from(new Map(currentSigsRaw.map(sig => [sig.eve_id, sig])).values());
const existingIndices = uniqueCurrentSigs
.filter(sig => sig.eve_id !== currentEveId)
.map(sig => parseSignatureCustomInfo(sig.custom_info).bookmark_index)
.filter((i): i is number => typeof i === 'number' && i >= 0);
let i = startAtZero ? 0 : 1;
while (existingIndices.includes(i)) {
i++;
}
const chained = parentBookmarkIndex !== undefined ? `${parentBookmarkIndex}${separator}${i}` : `${i}`;
const chainedLetters =
parentBookmarkIndexLetters !== undefined
? `${parentBookmarkIndexLetters}${separator}${i}`
: numberToLetters(i, startAtZero);
return { index: i, chained, chainedLetters };
};
export const formatBookmarkName = (
formatStr: string,
signature: SystemSignature,
destSystemClass: string | null,
bookmarkIndex: number | string,
wormholesData: Record<string, WormholeDataRaw> = {},
startAtZero: boolean = false,
mapping?: Record<string, string>,
systemSignatures?: Record<string, SystemSignature[]>,
currentSystemId?: string,
currentSolarSystemId?: string,
): string => {
let result = formatStr;
const info = parseSignatureCustomInfo(signature.custom_info);
// Replace {index}
result = result.replace(/\{index\}/g, () => bookmarkIndex.toString());
// Replace {chain_index}
result = result.replace(/\{chain_index\}/g, () => info.bookmark_index_chained || bookmarkIndex.toString());
// Replace {index_letter}
result = result.replace(/\{index_letter\}/g, () =>
typeof bookmarkIndex === 'number' ? numberToLetters(bookmarkIndex, startAtZero) : bookmarkIndex.toString(),
);
// Replace {chain_index_letters}
result = result.replace(
/\{chain_index_letters\}/g,
() =>
info.bookmark_index_chained_letters ||
info.bookmark_index_chained ||
(typeof bookmarkIndex === 'number' ? numberToLetters(bookmarkIndex, startAtZero) : bookmarkIndex.toString()),
);
// Replace {sig_letters} (first 3 chars of eve_id)
const sigLetters = signature.eve_id.substring(0, 3).toUpperCase();
result = result.replace(/\{sig_letters\}/g, () => sigLetters);
// Replace {sig} (full signature ID)
const fullSig = signature.eve_id.toUpperCase();
result = result.replace(/\{sig\}/g, () => fullSig);
// Replace {dest_type}
let destTypeStr = '';
if (destSystemClass) {
destTypeStr = destSystemClass;
} else if (signature.type && MULTI_DEST_WHS.includes(signature.type) && info.destType) {
const destOption = ALL_DEST_TYPES_MAP[info.destType];
if (destOption) {
destTypeStr = destOption.label;
}
} else if (signature.type === 'K162' && info.k162Type) {
const k162Option = ALL_DEST_TYPES_MAP[info.k162Type];
if (k162Option) {
destTypeStr = k162Option.label;
}
} else if (signature.type && wormholesData[signature.type]) {
const whData = wormholesData[signature.type];
const whClass = whData?.dest?.length === 1 ? WORMHOLES_ADDITIONAL_INFO[whData.dest[0]] : null;
if (whClass) {
destTypeStr = whClass.shortName || whClass.shortTitle;
}
} else if (info.destType) {
const destOption = ALL_DEST_TYPES_MAP[info.destType];
destTypeStr = destOption ? destOption.label : info.destType;
}
const finalDestTypeStr = formatDestString(destTypeStr, mapping);
result = result.replace(/\{dest_type\}/g, () => (finalDestTypeStr !== '?' ? finalDestTypeStr : ''));
// Calculate {dest_class_index}
let destClassIndexStr = '';
if (
result.includes('{dest_class_index}') &&
systemSignatures &&
(currentSystemId || currentSolarSystemId) &&
destTypeStr
) {
const currentSigsRaw = [
...(systemSignatures[currentSystemId || ''] || []),
...(systemSignatures[currentSolarSystemId || ''] || []),
];
// Deduplicate and ensure current signature is included
const sigsMap = new Map(currentSigsRaw.map(sig => [sig.eve_id, sig]));
sigsMap.set(signature.eve_id, signature);
const sigsInSystem = Array.from(sigsMap.values());
// Helper to get a simplified comparable class for a signature
const getSigDestClass = (sig: SystemSignature) => {
if (sig.eve_id === signature.eve_id) return finalDestTypeStr;
const sigInfo = parseSignatureCustomInfo(sig.custom_info);
let sDestTypeStr = '';
if (sig.type && MULTI_DEST_WHS.includes(sig.type) && sigInfo.destType) {
const destOption = ALL_DEST_TYPES_MAP[sigInfo.destType];
if (destOption) sDestTypeStr = destOption.label;
} else if (sig.type === 'K162' && sigInfo.k162Type) {
const k162Option = ALL_DEST_TYPES_MAP[sigInfo.k162Type];
if (k162Option) sDestTypeStr = k162Option.label;
} else if (sig.type && wormholesData[sig.type]) {
const whData = wormholesData[sig.type];
const whClass = whData?.dest?.length === 1 ? WORMHOLES_ADDITIONAL_INFO[whData.dest[0]] : null;
if (whClass) sDestTypeStr = whClass.shortName || whClass.shortTitle;
} else if (sigInfo.destType) {
const destOption = ALL_DEST_TYPES_MAP[sigInfo.destType];
sDestTypeStr = destOption ? destOption.label : sigInfo.destType;
}
return formatDestString(sDestTypeStr, mapping);
};
const sameClassSigs = sigsInSystem.filter(s => {
if (s.group !== SignatureGroup.Wormhole) return false;
return getSigDestClass(s) === finalDestTypeStr;
});
// Sort by eve_id to ensure consistent ordering across clients
sameClassSigs.sort((a, b) => a.eve_id.localeCompare(b.eve_id));
const indexInClass = sameClassSigs.findIndex(s => s.eve_id === signature.eve_id);
if (indexInClass > 0 || (indexInClass === 0 && sameClassSigs.length > 1)) {
destClassIndexStr = String.fromCharCode(97 + indexInClass); // 0->a, 1->b, 2->c...
}
}
result = result.replace(/\{dest_class_index\}/g, () => destClassIndexStr);
// Replace {size} and {mass}
let sizeStr = '';
let massStr = '';
let whDataForSize: WormholeDataRaw | null = null;
if (signature.type && MULTI_DEST_WHS.includes(signature.type) && info.destType) {
const destOption = ALL_DEST_TYPES_MAP[info.destType];
if (destOption && destOption.whClassName) {
const whName = destOption.whClassName.split('_')[0];
whDataForSize = wormholesData[whName];
}
} else if (signature.type === 'K162' && info.k162Type) {
const k162Option = ALL_DEST_TYPES_MAP[info.k162Type];
if (k162Option && k162Option.whClassName) {
const whName = k162Option.whClassName.split('_')[0];
whDataForSize = wormholesData[whName];
}
} else if (signature.type && wormholesData[signature.type]) {
whDataForSize = wormholesData[signature.type];
}
if (whDataForSize) {
if (whDataForSize.max_mass_per_jump) {
const sizeStatus = SHIP_MASSES_SIZE[whDataForSize.max_mass_per_jump] ?? ShipSizeStatus.large;
const defaultSizeNames: Record<ShipSizeStatus, string> = {
[ShipSizeStatus.small]: 'S',
[ShipSizeStatus.medium]: 'M',
[ShipSizeStatus.large]: '',
[ShipSizeStatus.freight]: 'XL',
[ShipSizeStatus.capital]: 'C',
};
const sizeMappingKeys: Record<ShipSizeStatus, string> = {
[ShipSizeStatus.small]: 'size_small',
[ShipSizeStatus.medium]: 'size_medium',
[ShipSizeStatus.large]: 'size_large',
[ShipSizeStatus.freight]: 'size_freight',
[ShipSizeStatus.capital]: 'size_capital',
};
const mappingKey = sizeMappingKeys[sizeStatus];
if (mapping && mapping[mappingKey] !== undefined) {
sizeStr = mapping[mappingKey];
} else {
sizeStr = defaultSizeNames[sizeStatus] ?? SHIP_SIZES_NAMES_SHORT[sizeStatus] ?? '';
}
}
if (whDataForSize.total_mass) {
massStr = Number((whDataForSize.total_mass / 1_000_000_000).toFixed(2)).toString();
}
}
result = result.replace(/\{size\}/g, () => sizeStr);
result = result.replace(/\{mass\}/g, () => massStr);
// Replace {type} -> signature.type
result = result.replace(/\{type\}/g, () => signature.type || '');
// Replace {time_status} -> Parsed from custom_info.time_status
result = result.replace(/\{time_status\}/g, () => getTimeStatusString(info.time_status, mapping));
// Replace {mass_status} -> Parsed from custom_info.mass_status
result = result.replace(/\{mass_status\}/g, () => getMassStatusString(info.mass_status, mapping));
// Replace {temporary_name} -> signature.temporary_name
result = result.replace(/\{temporary_name\}/g, () => signature.temporary_name || '');
// Replace {description} -> signature.description
result = result.replace(/\{description\}/g, () => signature.description || '');
// Cleanup whitespace
return result.trim().replace(/\s+/g, ' ');
};
export const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.warn('Failed to copy to clipboard', err);
}
};
export const handleAutoBookmark = async (
signature: SystemSignature,
currentSettings: any,
systemSignatures: Record<string, SystemSignature[]>,
currentSystemId: string,
currentSolarSystemId: string,
wormholesData: Record<string, WormholeDataRaw>,
targetSystemClassGroup: string | null,
targetSystemUuid?: string,
targetSolarSystemId?: string,
): Promise<{ updatedSignature: SystemSignature; shouldUpdate: boolean }> => {
let updatedSignature = signature;
let shouldUpdate = false;
if (
signature.group !== SignatureGroup.Wormhole ||
(!currentSettings?.bookmark_name_format && !currentSettings?.bookmark_auto_temp_name)
) {
return { updatedSignature, shouldUpdate };
}
const info = parseSignatureCustomInfo(signature.custom_info);
let bookmarkIndex = info.bookmark_index;
let bookmarkIndexToUse: number | string = bookmarkIndex != null ? bookmarkIndex : '';
let isReturnHole = false;
let symbol = '';
if (currentSettings?.bookmark_return_hole_ignore && (targetSystemUuid || targetSolarSystemId)) {
const targetSigsRaw = [
...(targetSystemUuid ? systemSignatures[targetSystemUuid] || [] : []),
...(targetSolarSystemId ? systemSignatures[targetSolarSystemId] || [] : []),
];
const uniqueTargetSigs = Array.from(new Map(targetSigsRaw.map(sig => [sig.eve_id, sig])).values());
isReturnHole = uniqueTargetSigs.some(
sig => sig.linked_system?.solar_system_id?.toString() === currentSolarSystemId.toString(),
);
if (isReturnHole) {
symbol = currentSettings.bookmark_return_hole_symbol || '';
if (symbol === ' ') symbol = '';
}
}
if (isReturnHole) {
if (info.bookmark_index !== undefined) {
delete info.bookmark_index;
}
info.bookmark_index_chained = symbol;
info.bookmark_index_chained_letters = symbol;
bookmarkIndexToUse = symbol;
updatedSignature = { ...signature, custom_info: JSON.stringify(info) };
shouldUpdate = true;
} else if (bookmarkIndex == null) {
const separator = currentSettings?.bookmark_custom_mapping?.chain_separator || '';
const calculated = calculateBookmarkIndex(
systemSignatures,
currentSystemId,
currentSolarSystemId,
signature.eve_id,
currentSettings?.bookmark_wormholes_start_at_zero,
separator,
);
bookmarkIndex = calculated.index;
info.bookmark_index = calculated.index;
info.bookmark_index_chained = calculated.chained;
info.bookmark_index_chained_letters = calculated.chainedLetters;
bookmarkIndexToUse = calculated.index;
updatedSignature = { ...signature, custom_info: JSON.stringify(info) };
shouldUpdate = true;
}
const needsTempNameUpdate =
!updatedSignature.temporary_name ||
(isReturnHole && updatedSignature.temporary_name !== symbol && currentSettings?.bookmark_auto_temp_name);
if (currentSettings?.bookmark_auto_temp_name && needsTempNameUpdate) {
let autoName = '';
switch (currentSettings.bookmark_auto_temp_name) {
case 'index':
autoName = bookmarkIndexToUse.toString();
break;
case 'index_letter':
autoName =
typeof bookmarkIndexToUse === 'number'
? numberToLetters(bookmarkIndexToUse, currentSettings.bookmark_wormholes_start_at_zero)
: bookmarkIndexToUse.toString();
break;
case 'chain_index':
autoName = info.bookmark_index_chained || bookmarkIndexToUse.toString();
break;
case 'chain_index_letters':
autoName = info.bookmark_index_chained_letters || info.bookmark_index_chained || bookmarkIndexToUse.toString();
break;
}
if (autoName !== '' || isReturnHole) {
updatedSignature = { ...updatedSignature, temporary_name: autoName };
shouldUpdate = true;
}
}
if (currentSettings?.bookmark_name_format && currentSettings?.bookmark_auto_copy !== false) {
const formattedStr = formatBookmarkName(
currentSettings.bookmark_name_format,
updatedSignature,
targetSystemClassGroup,
bookmarkIndexToUse,
wormholesData,
currentSettings.bookmark_wormholes_start_at_zero,
currentSettings.bookmark_custom_mapping,
systemSignatures,
currentSystemId,
currentSolarSystemId,
);
// Run this synchronously to avoid clipboard issues if possible
await copyToClipboard(formattedStr);
}
return { updatedSignature, shouldUpdate };
};
@@ -5,5 +5,10 @@ export const parseSignatureCustomInfo = (str: string | undefined): SignatureCust
return {};
}
return JSON.parse(str);
try {
return JSON.parse(str);
} catch (e) {
console.warn('Failed to parse signature custom_info', e);
return {};
}
};
@@ -22,6 +22,7 @@ export const STORED_INTERFACE_DEFAULT_VALUES: InterfaceStoredSettings = {
theme: AvailableThemes.default,
pingsPlacement: PingsPlacement.rightTop,
minimapPlacement: MiniMapPlacement.rightBottom,
hideBookmarkWarning: false,
};
export const DEFAULT_ROUTES_SETTINGS: RoutesType = {
@@ -1,6 +1,7 @@
import { to_1 } from './to_1.ts';
import { to_2 } from './to_2.ts';
import { to_3 } from './to_3.ts';
import { to_4 } from './to_4.ts';
import { MigrationStructure } from '@/hooks/Mapper/mapRootProvider/types.ts';
export default [to_1, to_2, to_3] as MigrationStructure[];
export default [to_1, to_2, to_3, to_4] as MigrationStructure[];
@@ -0,0 +1,28 @@
import { MigrationStructure } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/constants.ts';
export const to_4: MigrationStructure = {
to: 4,
up: (prev: any) => {
let hideBookmarkWarning = false;
// Check if the old unmanaged setting exists
const unmanagedSetting = localStorage.getItem('hide_bookmark_warning');
if (unmanagedSetting) {
hideBookmarkWarning = unmanagedSetting === 'true';
localStorage.removeItem('hide_bookmark_warning');
}
const interfaceSettings = prev?.interface || {};
return {
...prev,
interface: {
...STORED_INTERFACE_DEFAULT_VALUES,
...interfaceSettings,
// Carry over the unmanaged setting if it's true, or just use what interfaceSettings has
hideBookmarkWarning: hideBookmarkWarning || interfaceSettings.hideBookmarkWarning || false,
},
};
},
};
@@ -31,6 +31,7 @@ export type InterfaceStoredSettings = {
theme: AvailableThemes;
minimapPlacement: MiniMapPlacement;
pingsPlacement: PingsPlacement;
hideBookmarkWarning: boolean;
};
export type RoutesType = {
@@ -47,13 +48,7 @@ export type RoutesType = {
avoid: number[];
};
export type RoutesByCategoryType =
| 'blueLoot'
| 'redLoot'
| 'thera'
| 'turnur'
| 'so_cleaning'
| 'trade_hubs';
export type RoutesByCategoryType = 'blueLoot' | 'redLoot' | 'thera' | 'turnur' | 'so_cleaning' | 'trade_hubs';
export type RoutesByScopeType = 'ALL' | 'HIGH';
@@ -1,4 +1,4 @@
export const STORED_SETTINGS_VERSION = 3;
export const STORED_SETTINGS_VERSION = 4;
export const LS_KEY_LEGASY = 'map-user-settings';
export const LS_KEY = 'map-user-settings-v3';
@@ -29,9 +29,13 @@ export type GroupType = {
export type SignatureCustomInfo = {
destType?: string;
k162Type?: string;
time_status?: number;
isCrit?: boolean;
mass_status?: number;
bookmark_index?: number;
bookmark_index_chained?: string;
bookmark_index_chained_letters?: string;
};
export type SystemSignature = {
@@ -5,7 +5,13 @@ defmodule WandererApp.MapUserSettingsRepo do
"select_on_spash" => false,
"link_signature_on_splash" => false,
"delete_connection_with_sigs" => false,
"primary_character_id" => nil
"primary_character_id" => nil,
"bookmark_name_format" => "",
"bookmark_custom_mapping" => %{},
"system_auto_tag" => "",
"system_custom_label_name" => "",
"bookmark_return_hole_ignore" => false,
"bookmark_return_hole_symbol" => ""
}
def get(map_id, user_id) do
@@ -232,7 +232,20 @@ defmodule WandererAppWeb.MapCoreEventHandler do
) do
settings =
user_settings_form
|> Map.take(["select_on_spash", "link_signature_on_splash", "delete_connection_with_sigs"])
|> Map.take([
"select_on_spash",
"link_signature_on_splash",
"delete_connection_with_sigs",
"bookmark_name_format",
"bookmark_custom_mapping",
"bookmark_wormholes_start_at_zero",
"bookmark_auto_copy",
"bookmark_auto_temp_name",
"system_auto_tag",
"system_custom_label_name",
"bookmark_return_hole_ignore",
"bookmark_return_hole_symbol"
])
|> Jason.encode!()
{:ok, user_settings} =