diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index 4f9e09991d..e84faac50e 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -300,7 +300,6 @@ public enum Status Active, Deleted, Duplicate, - Protected, - Separate + Protected } } diff --git a/src/api/models/status.ts b/src/api/models/status.ts index a60dbb7fc8..88fd8874f1 100644 --- a/src/api/models/status.ts +++ b/src/api/models/status.ts @@ -22,5 +22,4 @@ export enum Status { Deleted = "Deleted", Duplicate = "Duplicate", Protected = "Protected", - Separate = "Separate", } diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index ba67568825..1dfe454a74 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -7,24 +7,25 @@ import { Select, Typography, } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { Droppable } from "react-beautiful-dnd"; import { useTranslation } from "react-i18next"; -import { Flag, ProtectReason, ReasonType } from "api/models"; +import { type Flag, type ProtectReason, ReasonType } from "api/models"; import { FlagButton, IconButtonWithTooltip, NoteButton, } from "components/Buttons"; import MultilineTooltipTitle from "components/MultilineTooltipTitle"; +import { AudioSummary } from "components/WordCard"; import DragSense from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense"; -import { MergeTreeWord } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { type MergeTreeWord } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { flagWord, setVern, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; -import { StoreState } from "types"; +import { type StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import theme from "types/theme"; import { TypographyWithFont } from "utilities/fontComponents"; @@ -110,6 +111,9 @@ export function DropWordCardHeader( const { senses, words } = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.data ); + const { counts, moves } = useAppSelector( + (state: StoreState) => state.mergeDuplicateGoal.audio + ); const { t } = useTranslation(); @@ -126,6 +130,11 @@ export function DropWordCardHeader( ...new Set(guids.map((g) => words[senses[g].srcWordId].vernacular)), ]; + // Compute how many audio pronunciations the word will have post-merge. + const otherIds = moves[props.wordId] ?? []; + const otherCount = otherIds.reduce((sum, id) => sum + counts[id], 0); + const audioCount = (treeWord?.audioCount ?? 0) + otherCount; + // Reset vern if not in vern list. if (treeWord && !verns.includes(treeWord.vern)) { dispatchSetVern(verns.length ? verns[0] : ""); @@ -231,6 +240,7 @@ export function DropWordCardHeader( text={} /> )} + {treeWord.note.text ? : null} ({})); jest.mock("goals/MergeDuplicates/Redux/MergeDupsActions", () => ({ setSidebar: (...args: any[]) => mockSetSidebar(...args), })); +// Mock "i18n", else `Error: connect ECONNREFUSED ::1:80` +jest.mock("i18n", () => ({})); jest.mock("types/hooks", () => { return { ...jest.requireActual("types/hooks"), diff --git a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts index 23958bf6f9..c27d7a0fcd 100644 --- a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts @@ -37,6 +37,7 @@ export interface MergeTreeWord { flag: Flag; note: Note; protected: boolean; + audioCount: number; } export function newMergeTreeSense( @@ -63,6 +64,7 @@ export function newMergeTreeWord( flag: newFlag(), note: newNote(), protected: false, + audioCount: 0, }; } @@ -87,6 +89,7 @@ export function convertWordToMergeTreeWord(word: Word): MergeTreeWord { mergeTreeWord.flag = { ...word.flag }; mergeTreeWord.note = { ...word.note }; mergeTreeWord.protected = word.accessibility === Status.Protected; + mergeTreeWord.audioCount = word.audio.length; return mergeTreeWord; } diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index e7a5332228..e498611df1 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -14,11 +14,17 @@ import { defaultTree, newMergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; -import { newMergeWords } from "goals/MergeDuplicates/MergeDupsTypes"; -import { defaultState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { - buildSenses, - createMergeWords, + defaultAudio, + defaultState, +} from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; +import { + combineIntoFirstSense, + createMergeChildren, + createMergeParent, + gatherWordSenses, + getDeletedMergeWords, + isEmptyMerge, } from "goals/MergeDuplicates/Redux/reducerUtilities"; import { StoreActionTypes } from "rootActions"; import { type Hash } from "types/hash"; @@ -80,6 +86,7 @@ const mergeDuplicatesSlice = createSlice({ deletedSenseGuids.push(...srcGuids); delete sensesGuids[srcRef.mergeSenseId]; if (!Object.keys(sensesGuids).length) { + delete state.audio.moves[srcWordId]; delete words[srcWordId]; } @@ -104,32 +111,51 @@ const mergeDuplicatesSlice = createSlice({ }, getMergeWordsAction: (state) => { - // Handle words with all senses deleted. - const possibleWords = Object.values(state.data.words); + const dataWords = Object.values(state.data.words); const deletedSenseGuids = state.tree.deletedSenseGuids; - const deletedWords = possibleWords.filter((w) => - w.senses.every((s) => deletedSenseGuids.includes(s.guid)) - ); - state.mergeWords = deletedWords.map((w) => - newMergeWords(w, [{ srcWordId: w.id, getAudio: false }], true) + + // First handle words with all senses deleted. + state.mergeWords = getDeletedMergeWords(dataWords, deletedSenseGuids); + + // Then build the rest of the mergeWords. + + // Gather all senses (accessibility will be updated as mergeWords are built). + const wordTreeSenses = gatherWordSenses(dataWords, deletedSenseGuids); + const allSenses = Object.values(wordTreeSenses).flatMap((mergeSenses) => + mergeSenses.map((ms) => ms.sense) ); + // Build one merge word per column. for (const wordId in state.tree.words) { + // Get from tree the basic info for this column. const mergeWord = state.tree.words[wordId]; - const mergeSenses = buildSenses( - mergeWord.sensesGuids, - state.data, - deletedSenseGuids + + // Get from data all senses in this column. + const mergeSenses = Object.values(mergeWord.sensesGuids).map((guids) => + guids.map((g) => state.data.senses[g]) ); - const mergeWords = createMergeWords( - wordId, - mergeWord, + + // Update those senses in the set of all senses. + mergeSenses.forEach((senses) => { + const sensesToUpdate = senses.map( + (s) => wordTreeSenses[s.srcWordId][s.order] + ); + combineIntoFirstSense(sensesToUpdate); + }); + + // Check if nothing to merge. + const wordToUpdate = state.data.words[wordId]; + if (isEmptyMerge(wordToUpdate, mergeWord)) { + continue; + } + + // Create merge words. + const children = createMergeChildren( mergeSenses, - state.data.words[wordId] + state.audio.moves[wordId] ); - if (mergeWords) { - state.mergeWords.push(mergeWords); - } + const parent = createMergeParent(wordToUpdate, mergeWord, allSenses); + state.mergeWords.push({ parent, children, deleteOnly: false }); } }, @@ -161,6 +187,17 @@ const mergeDuplicatesSlice = createSlice({ // Cleanup the srcWord. delete words[srcWordId].sensesGuids[mergeSenseId]; if (!Object.keys(words[srcWordId].sensesGuids).length) { + // If this was the word's last sense, move the audio... + const moves = state.audio.moves; + if (!Object.keys(moves).includes(destWordId)) { + moves[destWordId] = []; + } + moves[destWordId].push(srcWordId); + if (Object.keys(moves).includes(srcWordId)) { + moves[destWordId].push(...moves[srcWordId]); + delete moves[srcWordId]; + } + // ...and delete the word from the tree delete words[srcWordId]; } } @@ -254,15 +291,18 @@ const mergeDuplicatesSlice = createSlice({ const words: Hash = {}; const senses: Hash = {}; const wordsTree: Hash = {}; + const counts: Hash = {}; action.payload.forEach((word: Word) => { words[word.id] = JSON.parse(JSON.stringify(word)); word.senses.forEach((s, order) => { senses[s.guid] = convertSenseToMergeTreeSense(s, word.id, order); }); wordsTree[word.id] = convertWordToMergeTreeWord(word); + counts[word.id] = word.audio.length; }); state.data = { ...defaultData, senses, words }; state.tree = { ...defaultTree, words: wordsTree }; + state.audio = { ...defaultAudio, counts }; state.mergeWords = []; } }, diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts index 3633741662..95663ebb2e 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts @@ -6,18 +6,38 @@ import { defaultData, defaultTree, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { type Hash } from "types/hash"; // Redux state +/** `.counts` is a dictionary of all audio counts of the words being merged: + * - key: id of a word in the set of potential duplicates + * - value: number of audio pronunciations on the word + * + * `.moves` is a dictionary of words receiving the audio of other words: + * - key: id of a word receiving audio + * - value: array of ids of words whose audio is being received */ +export interface MergeAudio { + counts: Hash; + moves: Hash; +} + +export const defaultAudio: MergeAudio = { + counts: {}, + moves: {}, +}; + export interface MergeTreeState { data: MergeData; tree: MergeTree; + audio: MergeAudio; mergeWords: MergeWords[]; } export const defaultState: MergeTreeState = { data: defaultData, tree: defaultTree, + audio: defaultAudio, mergeWords: [], }; diff --git a/src/goals/MergeDuplicates/Redux/reducerUtilities.ts b/src/goals/MergeDuplicates/Redux/reducerUtilities.ts index 1faa00b74c..0fc98d95d4 100644 --- a/src/goals/MergeDuplicates/Redux/reducerUtilities.ts +++ b/src/goals/MergeDuplicates/Redux/reducerUtilities.ts @@ -2,11 +2,11 @@ import { GramCatGroup, type MergeSourceWord, type MergeWords, + type Sense, Status, type Word, } from "api/models"; import { - type MergeData, type MergeTreeSense, type MergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; @@ -16,121 +16,102 @@ import { compareFlags } from "utilities/wordUtilities"; // A collection of helper/utility functions only for use in the MergeDupsReducer. -/** Create hash of senses keyed by id of src word. */ -export function buildSenses( - sensesGuids: Hash, - data: MergeData, +/** Generate dictionary of MergeTreeSense arrays: + * - key: word id + * - value: all merge senses of the word */ +export function gatherWordSenses( + words: Word[], deletedSenseGuids: string[] ): Hash { - const senses: Hash = {}; - for (const senseGuids of Object.values(sensesGuids)) { - for (const guid of senseGuids) { - const senseData = data.senses[guid]; - const wordId = senseData.srcWordId; - - if (!senses[wordId]) { - const dbWord = data.words[wordId]; + return Object.fromEntries( + words.map((w) => [w.id, gatherSenses(w, deletedSenseGuids)]) + ); +} - // Add each sense into senses as separate or deleted. - senses[wordId] = []; - for (const sense of dbWord.senses) { - senses[wordId].push({ - order: senses[wordId].length, - protected: sense.accessibility === Status.Protected, - srcWordId: wordId, - sense: { - ...sense, - accessibility: deletedSenseGuids.includes(sense.guid) - ? Status.Deleted - : Status.Separate, - }, - }); - } - } - } - } +/** Generate MergeTreeSense array with deleted senses set to Status.Deleted. */ +function gatherSenses( + word: Word, + deletedSenseGuids: string[] +): MergeTreeSense[] { + return word.senses.map((sense, index) => ({ + order: index, + protected: sense.accessibility === Status.Protected, + srcWordId: word.id, + sense: { + ...sense, + accessibility: deletedSenseGuids.includes(sense.guid) + ? Status.Deleted + : sense.accessibility, + }, + })); +} - // Set sense and duplicate senses. - Object.values(sensesGuids).forEach((guids) => { - const sensesToCombine = guids - .map((g) => data.senses[g]) - .map((s) => senses[s.srcWordId][s.order]); - combineIntoFirstSense(sensesToCombine); - }); +/** Create a MergeWords array for the words which had all senses deleted. */ +export function getDeletedMergeWords( + words: Word[], + deletedSenseGuids: string[] +): MergeWords[] { + return words + .filter((w) => w.senses.every((s) => deletedSenseGuids.includes(s.guid))) + .map((w) => newMergeWords(w, [{ srcWordId: w.id, getAudio: false }], true)); +} - // Clean order of senses in each src word to reflect backend order. - Object.values(senses).forEach((wordSenses) => { - wordSenses = wordSenses.sort((a, b) => a.order - b.order); - senses[wordSenses[0].srcWordId] = wordSenses; - }); +/** Determine if a merge is empty: + * - no senses have been merged in as duplicates + * - the sense guids all match + * - the flag is unchanged */ +export function isEmptyMerge(word: Word, mergeWord: MergeTreeWord): boolean { + const mergeSensesGuids = Object.values(mergeWord.sensesGuids); + const mergeGuids = mergeSensesGuids.map((guids) => guids[0]); + const wordSenseGuids = word.senses.map((s) => s.guid); + return ( + mergeSensesGuids.every((guids) => guids.length === 1) && + mergeGuids.length === wordSenseGuids.length && + wordSenseGuids.every((guid) => mergeGuids.includes(guid)) && + compareFlags(mergeWord.flag, word.flag) === 0 + ); +} - return senses; +/** Construct children of a MergeWord. */ +export function createMergeChildren( + mergeSenses: MergeTreeSense[][], + audioMoves: string[] = [] +): MergeSourceWord[] { + const redundantIds = mergeSenses.flatMap((senses) => + senses.map((s) => s.srcWordId) + ); + const childrenIds = [...new Set(redundantIds)]; + return childrenIds.map((srcWordId) => ({ + srcWordId, + getAudio: audioMoves.includes(srcWordId), + })); } -export function createMergeWords( - wordId: string, +/** Construct parent of a MergeWord. */ +export function createMergeParent( + word: Word, mergeWord: MergeTreeWord, - mergeSenses: Hash, - word: Word -): MergeWords | undefined { - // Don't return empty merges: when the only child is the parent word - // and has the same number of senses as parent (all Active/Protected) - // and has the same flag. - if (Object.values(mergeSenses).length === 1) { - const onlyChild = Object.values(mergeSenses)[0]; - if ( - onlyChild[0].srcWordId === wordId && - onlyChild.length === word.senses.length && - onlyChild.every((ms) => - [Status.Active, Status.Protected].includes(ms.sense.accessibility) - ) && - compareFlags(mergeWord.flag, word.flag) === 0 - ) { - return; - } - } - - // Construct parent and children. - const parent: Word = { + allSenses: Sense[] +): Word { + // Construct parent. + const senses = Object.values(mergeWord.sensesGuids) + .map((guids) => guids[0]) + .map((g) => allSenses.find((s) => s.guid === g)!); + return { ...word, - senses: [], flag: mergeWord.flag, + senses, + vernacular: mergeWord.vern.trim() || word.vernacular.trim(), }; - if (!parent.vernacular) { - parent.vernacular = mergeWord.vern; - } - const children: MergeSourceWord[] = Object.values(mergeSenses).map( - (msList) => { - msList.forEach((mergeSense) => { - if ( - [Status.Active, Status.Protected].includes( - mergeSense.sense.accessibility - ) - ) { - parent.senses.push(mergeSense.sense); - } - }); - const getAudio = msList.every( - (ms) => ms.sense.accessibility !== Status.Separate - ); - return { srcWordId: msList[0].srcWordId, getAudio }; - } - ); - - return newMergeWords(parent, children); } /** Given an array of senses to combine: - * - change the accessibility of the first one from Separate to Active/Protected, - * - change the accessibility of the rest to Duplicate, - * - merge select content from duplicates into main sense */ -function combineIntoFirstSense(mergeSenses: MergeTreeSense[]): void { - // Set the first sense to be merged as Active/Protected. - // This was the top sense when the sidebar was opened. + * - identify the first/top sense as the main one to keep; + * - change the accessibility of the rest to Status.Duplicate; + * - merge select content from the rest into main sense */ +export function combineIntoFirstSense(mergeSenses: MergeTreeSense[]): void { + // Set the main sense to the first sense (the top one when the sidebar was opened). const mainSense = mergeSenses[0].sense; - mainSense.accessibility = mergeSenses[0].protected - ? Status.Protected - : Status.Active; // Merge the rest as duplicates. // These were senses dropped into another sense. diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx index 7aed209a05..0905887da3 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx @@ -1,4 +1,10 @@ -import { type MergeWords, type Sense, Status, type Word } from "api/models"; +import { + type MergeSourceWord, + type MergeWords, + type Sense, + Status, + type Word, +} from "api/models"; import { defaultState } from "components/App/DefaultState"; import { type MergeData, @@ -14,7 +20,10 @@ import { mergeAll, setData, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; -import { defaultState as defaultMergeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; +import { + defaultAudio, + defaultState as defaultMergeState, +} from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { goalDataMock } from "goals/MergeDuplicates/Redux/tests/MergeDupsDataMock"; import { setupStore } from "store"; import { GoalType } from "types/goals"; @@ -42,6 +51,13 @@ jest.mock("backend", () => ({ mockMergeWords(mergeWordsArray), })); +function newMergeSourceWord( + srcWordId: string, + getAudio = false +): MergeSourceWord { + return { srcWordId, getAudio }; +} + const mockGoal = new MergeDups(); mockGoal.data = goalDataMock; mockGoal.steps = [{ words: [] }, { words: [] }]; @@ -133,8 +149,8 @@ describe("MergeDupActions", () => { expect(mockMergeWords).toHaveBeenCalledTimes(1); const parentA = wordAnyGuids(vernA, [senses["S1"], senses["S2"]], idA); const parentB = wordAnyGuids(vernB, [senses["S4"]], idB); - const childA = { srcWordId: idA, getAudio: true }; - const childB = { srcWordId: idB, getAudio: false }; + const childA = newMergeSourceWord(idA); + const childB = newMergeSourceWord(idB); const mockMerges = [ newMergeWords(parentA, [childA, childB]), newMergeWords(parentB, [childB]), @@ -150,9 +166,9 @@ describe("MergeDupActions", () => { expect(blacklist).not.toContain(idB); }); - // Move sense 3 from B to A + // Move sense 3 from B to middle sense in A it("moves sense between words", async () => { - const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S2], ID3: [S3] }); + const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S3], ID3: [S2] }); const WB = newMergeTreeWord(vernB, { ID1: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; const store = setupStore({ @@ -164,12 +180,12 @@ describe("MergeDupActions", () => { expect(mockMergeWords).toHaveBeenCalledTimes(1); const parentA = wordAnyGuids( vernA, - [senses["S1"], senses["S2"], senses["S3"]], + [senses["S1"], senses["S3"], senses["S2"]], idA ); const parentB = wordAnyGuids(vernB, [senses["S4"]], idB); - const childA = { srcWordId: idA, getAudio: true }; - const childB = { srcWordId: idB, getAudio: false }; + const childA = newMergeSourceWord(idA); + const childB = newMergeSourceWord(idB); const mockMerges = [ newMergeWords(parentA, [childA, childB]), newMergeWords(parentB, [childB]), @@ -199,7 +215,7 @@ describe("MergeDupActions", () => { expect(mockMergeWords).toHaveBeenCalledTimes(1); const parent = wordAnyGuids(vernA, [senses["S1"]], idA); - const child = { srcWordId: idA, getAudio: true }; + const child = newMergeSourceWord(idA); const mockMerge = newMergeWords(parent, [child]); expect(mockMergeWords).toHaveBeenCalledWith([mockMerge]); @@ -227,7 +243,7 @@ describe("MergeDupActions", () => { expect(mockMergeWords).toHaveBeenCalledTimes(1); const parent = wordAnyGuids(vernA, [senses["S1"]], idA); - const child = { srcWordId: idA, getAudio: true }; + const child = newMergeSourceWord(idA); const mockMerge = newMergeWords(parent, [child]); expect(mockMergeWords).toHaveBeenCalledWith([mockMerge]); @@ -253,13 +269,14 @@ describe("MergeDupActions", () => { await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); - const child = { srcWordId: idB, getAudio: false }; + const child = newMergeSourceWord(idB); const mockMerge = newMergeWords(wordB, [child], true); expect(mockMergeWords).toHaveBeenCalledWith([mockMerge]); // No blacklist entry added for only 1 resulting word. expect(mockBlacklistAdd).not.toHaveBeenCalled(); }); + // Move all senses from B to A it("moves all senses to other word", async () => { const WA = newMergeTreeWord(vernA, { @@ -270,7 +287,12 @@ describe("MergeDupActions", () => { const tree: MergeTree = { ...defaultTree, words: { WA } }; const store = setupStore({ ...preloadedState, - mergeDuplicateGoal: { ...defaultMergeState, data, tree }, + mergeDuplicateGoal: { + ...defaultMergeState, + data, + tree, + audio: { ...defaultAudio, moves: { [idA]: [idB] } }, + }, }); await store.dispatch(mergeAll()); @@ -280,8 +302,8 @@ describe("MergeDupActions", () => { [senses["S1"], senses["S2"], senses["S4"]], idA ); - const childA = { srcWordId: idA, getAudio: true }; - const childB = { srcWordId: idB, getAudio: true }; + const childA = newMergeSourceWord(idA); + const childB = newMergeSourceWord(idB, true); const mockMerge = newMergeWords(parentA, [childA, childB]); expect(mockMergeWords).toHaveBeenCalledWith([mockMerge]); @@ -305,7 +327,7 @@ describe("MergeDupActions", () => { const parent = wordAnyGuids(vernA, [senses["S1"], senses["S2"]], idA); parent.flag = WA.flag; - const child = { srcWordId: idA, getAudio: true }; + const child = newMergeSourceWord(idA); const mockMerge = newMergeWords(parent, [child]); expect(mockMergeWords).toHaveBeenCalledWith([mockMerge]);