diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx index 8b7e7e7..52856f2 100644 --- a/src/features/settings/panels/PanelQMCv2Key.tsx +++ b/src/features/settings/panels/PanelQMCv2Key.tsx @@ -2,6 +2,7 @@ import { Box, Button, ButtonGroup, + Checkbox, Flex, HStack, Heading, @@ -14,23 +15,29 @@ import { MenuItem, MenuList, Text, + Tooltip, } from '@chakra-ui/react'; import { useDispatch, useSelector } from 'react-redux'; -import { qmc2AddKey, qmc2ClearKeys } from '../settingsSlice'; +import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys } from '../settingsSlice'; import { selectStagingQMCv2Settings } from '../settingsSelector'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md'; import { ImportFileModal } from './QMCv2/ImportFileModal'; import { KeyInput } from './QMCv2/KeyInput'; +import { InfoOutlineIcon } from '@chakra-ui/icons'; export function PanelQMCv2Key() { const dispatch = useDispatch(); - const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys; + const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings); const [showImportModal, setShowImportModal] = useState(false); const addKey = () => dispatch(qmc2AddKey()); const clearAll = () => dispatch(qmc2ClearKeys()); + const handleAllowFuzzyNameSearchCheckbox = (e: React.ChangeEvent) => { + dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked })); + }; + return ( @@ -60,6 +67,34 @@ export function PanelQMCv2Key() { + + + + 匹配相似文件名 + + + 若文件名匹配失败,则使用相似文件名的密钥。 + + 使用「 + + 莱文斯坦距离 + ( + Levenshtein distance + ) + + 」算法计算相似程度。 + + 若密钥数量过多,匹配时可能会造成浏览器卡顿或无响应一段时间。 + + } + > + + + diff --git a/src/features/settings/panels/QMCv2/InstructionsIOS.tsx b/src/features/settings/panels/QMCv2/InstructionsIOS.tsx index 9bb4662..1060f20 100644 --- a/src/features/settings/panels/QMCv2/InstructionsIOS.tsx +++ b/src/features/settings/panels/QMCv2/InstructionsIOS.tsx @@ -54,6 +54,8 @@ export function InstructionsIOS() { 解密离线文件 + 勾选设定界面的「使用近似文件名匹配」可跳过该节内容。 + ⚠ 注意:若密钥过多,匹配过程可能会造成浏览器卡顿或无响应。 diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index 5a1f6a5..c77d543 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -10,9 +10,16 @@ const DEFAULT_STORAGE_KEY = 'um-react-settings'; function mergeSettings(settings: ProductionSettings): ProductionSettings { return produce(settingsSlice.getInitialState().production, (draft) => { - for (const [k, v] of enumObject(settings.qmc2?.keys)) { - if (typeof v === 'string') { - draft.qmc2.keys[k] = v; + if (settings?.qmc2) { + const { allowFuzzyNameSearch, keys } = settings.qmc2; + for (const [k, v] of enumObject(keys)) { + if (typeof v === 'string') { + draft.qmc2.keys[k] = v; + } + } + + if (typeof allowFuzzyNameSearch === 'boolean') { + draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch; } } }); diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index 8ea41d4..f53f796 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -1,5 +1,6 @@ import type { DecryptCommandOptions } from '~/decrypt-worker/types'; import type { RootState } from '~/store'; +import { closestByLevenshtein } from '~/util/levenshtein'; import { hasOwn } from '~/util/objects'; export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2; @@ -7,9 +8,21 @@ export const selectFinalQMCv2Settings = (state: RootState) => state.settings.pro export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => { const normalizedName = name.normalize(); - const qmc2Keys = selectFinalQMCv2Settings(state).keys; + + let qmc2Key: string | undefined; + const { keys: qmc2Keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state); + if (hasOwn(qmc2Keys, normalizedName)) { + qmc2Key = qmc2Keys[normalizedName]; + } else if (allowFuzzyNameSearch) { + const qmc2KeyStoreNames = Object.keys(qmc2Keys); + if (qmc2KeyStoreNames.length > 0) { + const closestName = closestByLevenshtein(normalizedName, qmc2KeyStoreNames); + console.debug('qmc2: key db could not find %o, using closest %o instead.', normalizedName, closestName); + qmc2Key = qmc2Keys[closestName]; + } + } return { - qmc2Key: hasOwn(qmc2Keys, normalizedName) ? qmc2Keys[normalizedName] : undefined, + qmc2Key, }; }; diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts index 45097f3..dacd10b 100644 --- a/src/features/settings/settingsSlice.ts +++ b/src/features/settings/settingsSlice.ts @@ -6,12 +6,14 @@ import { objectify } from 'radash'; export interface StagingSettings { qmc2: { keys: { id: string; name: string; key: string }[]; + allowFuzzyNameSearch: boolean; }; } export interface ProductionSettings { qmc2: { keys: Record; // { [fileName]: ekey } + allowFuzzyNameSearch: boolean; }; } @@ -22,11 +24,13 @@ export interface SettingsState { const initialState: SettingsState = { staging: { qmc2: { + allowFuzzyNameSearch: false, keys: [], }, }, production: { qmc2: { + allowFuzzyNameSearch: false, keys: {}, }, }, @@ -39,12 +43,14 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({ (item) => item.name.normalize(), (item) => item.key.trim() ), + allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch, }, }); const productionToStaging = (production: ProductionSettings): StagingSettings => ({ qmc2: { keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })), + allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch, }, }); @@ -81,6 +87,9 @@ export const settingsSlice = createSlice({ qmc2ClearKeys(state) { state.staging.qmc2.keys = []; }, + qmc2AllowFuzzyNameSearch(state, { payload: { enable } }: PayloadAction<{ enable: boolean }>) { + state.staging.qmc2.allowFuzzyNameSearch = enable; + }, discardStagingChanges: (state) => { state.staging = productionToStaging(state.production); }, @@ -107,6 +116,7 @@ export const { qmc2DeleteKey, qmc2ClearKeys, qmc2ImportKeys, + qmc2AllowFuzzyNameSearch, commitStagingChange, discardStagingChanges, diff --git a/src/util/__tests__/levenshtein.test.ts b/src/util/__tests__/levenshtein.test.ts new file mode 100644 index 0000000..e3c5b1e --- /dev/null +++ b/src/util/__tests__/levenshtein.test.ts @@ -0,0 +1,17 @@ +import { closestByLevenshtein, levenshtein } from '../levenshtein'; + +test('levenshtein quick test', () => { + expect(levenshtein('cat', '')).toStrictEqual(3); + expect(levenshtein('', 'cat')).toStrictEqual(3); + expect(levenshtein('cat', 'cat')).toStrictEqual(0); + expect(levenshtein('cat', 'cut')).toStrictEqual(1); + expect(levenshtein('kitten', 'sitting')).toStrictEqual(3); + expect(levenshtein('tier', 'tor')).toStrictEqual(2); + expect(levenshtein('mississippi', 'swiss miss')).toStrictEqual(8); +}); + +test('closestByLevenshtein quick test', () => { + expect(closestByLevenshtein('cat', ['cut', 'dog', 'bat'])).toStrictEqual('cut'); + expect(closestByLevenshtein('dag', ['dog', 'pumpkin'])).toStrictEqual('dog'); + expect(closestByLevenshtein('hello', ['jello', 'yellow', 'bello'])).toStrictEqual('jello'); +}); diff --git a/src/util/levenshtein.ts b/src/util/levenshtein.ts new file mode 100644 index 0000000..49617c5 --- /dev/null +++ b/src/util/levenshtein.ts @@ -0,0 +1,77 @@ +const textEncoder = new TextEncoder(); + +// translation of pseudocode from Wikipedia: +// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows +export function levenshtein(str1: string, str2: string) { + if (str1 === str2) { + return 0; + } + + if (str1.length === 0) { + return str2.length; + } else if (str2.length === 0) { + return str1.length; + } + + // Convert them to Uint8Array to avoid expensive string APIs. + const s = textEncoder.encode(str1.toLowerCase()); + const t = textEncoder.encode(str2.toLowerCase()); + const m = s.byteLength; + const n = t.byteLength; + + // create two work vectors of integer distances + let v0 = new Uint32Array(n + 1); + let v1 = new Uint32Array(n + 1); + + // initialize v0 (the previous row of distances) + // this row is A[0][i]: edit distance from an empty s to t; + // that distance is the number of characters to append to s to make t. + for (let i = 0; i <= n; i++) { + v0[i] = i; + } + + for (let i = 0; i < m; i++) { + // calculate v1 (current row distances) from the previous row v0 + + // first element of v1 is A[i + 1][0] + // edit distance is delete (i + 1) chars from s to match empty t + v1[0] = i + 1; + + // use formula to fill in the rest of the row + for (let j = 0; j < n; j++) { + // calculating costs for A[i + 1][j + 1] + const deletionCost = v0[j + 1] + 1; + const insertionCost = v1[j] + 1; + const substitutionCost = v0[j] + (s[i] === t[j] ? 0 : 1); + v1[j + 1] = Math.min(deletionCost, insertionCost, substitutionCost); + } + + // copy v1 (current row) to v0 (previous row) for next iteration + // since data in v1 is always invalidated, a swap without copy could be more efficient + [v0, v1] = [v1, v0]; + } + + // after the last swap, the results of v1 are now in v0 + return v0[n]; +} + +export function closestByLevenshtein(str: string, candidates: string[]) { + // Faster than pre-calculate all and pass scores to Math.min. + const n = candidates.length; + if (n === 0) { + throw new Error('empty candidates'); + } + + let lowestIdx = 0; + let lowestScore = levenshtein(str, candidates[0]); + + for (let i = 1; i < n; i++) { + const score = levenshtein(str, candidates[i]); + if (score < lowestScore) { + lowestScore = score; + lowestIdx = i; + } + } + + return candidates[lowestIdx]; +}