From 77c57453661e0a24a4f37d6a139f27fd4f19462d Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 6 Oct 2026 11:48:59 -0600 Subject: [PATCH 1/4] Switch the displayed analysis language from the toolbar (#291) --- contributions/localizedStrings.json | 1 + .../components/AnalysisStore.test.tsx | 32 +++++ .../components/InterlinearizerLoader.test.tsx | 133 ++++++++++++++++++ .../controls/AnalysisLanguageSelect.test.tsx | 75 ++++++++++ src/__tests__/store/analysisSlice.test.ts | 27 ++++ src/components/AnalysisStore.tsx | 21 ++- src/components/InterlinearizerLoader.tsx | 49 ++++++- .../controls/AnalysisLanguageSelect.tsx | 61 ++++++++ src/store/analysisSlice.ts | 6 + 9 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/components/controls/AnalysisLanguageSelect.test.tsx create mode 100644 src/components/controls/AnalysisLanguageSelect.tsx diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index cc5ddca24..b82333dfa 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -222,6 +222,7 @@ "%interlinearizer_continuousView_nextToken%": "Next word", "%interlinearizer_continuousView_returnToFocus%": "Scroll to focused phrase", "%interlinearizer_viewOptions_label%": "View options", + "%interlinearizer_analysisLanguage_label%": "Analysis language", "%interlinearizer_loading%": "Loading…", "%interlinearizer_modal_create_title%": "Create Interlinear Project", diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index d6533d07d..9fddf2768 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -282,6 +282,22 @@ describe('useGloss', () => { expect(screen.getByTestId('gloss')).toHaveTextContent('bonjour'); }); + it('keeps an analysis written after mount across an analysisLanguage change', async () => { + const at = (analysisLanguage: string) => ( + + + + + ); + const { rerender } = render(at('en')); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + + rerender(at('fr')); + rerender(at('en')); + + expect(screen.getByTestId('gloss')).toHaveTextContent('hello'); + }); + it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => render()).toThrow( @@ -1197,6 +1213,22 @@ describe('useAnalysisLanguage', () => { expect(screen.getByTestId('lang')).toHaveTextContent('fr'); }); + it('follows a change to the analysisLanguage prop', () => { + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(screen.getByTestId('lang')).toHaveTextContent('en'); + }); + it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => render()).toThrow( diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 78357d732..c5051553a 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -153,6 +153,30 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({ ), })); +jest.mock('../../components/controls/AnalysisLanguageSelect', () => ({ + __esModule: true, + default: ({ + languages, + onValueChange, + }: { + languages: readonly string[]; + onValueChange: (tag: string) => void; + }) => ( +
+ {languages.map((tag) => ( + + ))} +
+ ), +})); + jest.mock('../../components/controls/ScriptureNavControls', () => ({ __esModule: true, default: ({ activeBookIds }: { activeBookIds?: string[] }) => ( @@ -5158,3 +5182,112 @@ describe('undo and redo', () => { expect(screen.getByRole('button', { name: '%undoButton_tooltip%' })).toBeEnabled(); }); }); + +describe('analysis language switch', () => { + beforeEach(() => { + capturedStoreProps = undefined; + probeStore = undefined; + mockBookData(); + mockLexiconRegistry(); + mockOptimisticSetting(); + mockLostBoundaries([]); + mockProjectBookIds(undefined); + jest + .mocked(useData) + .mockReturnValue( + new Proxy({}, { get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]) }), + ); + mockKeyAsValueLocalizedStrings(); + mockSettings(); + mockSourceShortName(''); + }); + + afterEach(() => { + mountStoreProbe = false; + }); + + const IMPORT_LISTING_TWO = { ...STUB_IMPORT_PROJECT, analysisLanguages: ['swh', 'en'] }; + + /** Serves a draft listing `languages`, and {@link IMPORT_LISTING_TWO} for any import loaded. */ + function mockDraftLanguages(languages: string[]): void { + mockSendCommand.mockImplementation(async (...args) => + args[0] === 'interlinearizer.getProject' + ? JSON.stringify({ ...IMPORT_LISTING_TWO, analysis: emptyAnalysis() }) + : JSON.stringify({ ...emptyDraft(testProjectId), analysisLanguages: languages }), + ); + } + + it('reads glosses in the picked language', async () => { + mockDraftLanguages(['en', 'fr']); + await act(async () => renderLoader()); + + await act(async () => { + fireEvent.click(screen.getByTestId('analysis-language-fr')); + }); + + expect(capturedStoreProps?.analysisLanguage).toBe('fr'); + }); + + it('switches language without replacing the store', async () => { + // A remount would discard uncommitted gloss edits and the catalog's scroll position. + mountStoreProbe = true; + mockDraftLanguages(['en', 'fr']); + await act(async () => renderLoader()); + const storeBefore = probeStore; + + await act(async () => { + fireEvent.click(screen.getByTestId('analysis-language-fr')); + }); + + expect(probeStore).toBe(storeBefore); + }); + + it('restores the language chosen earlier in the tab', async () => { + mockDraftLanguages(['en', 'fr']); + await act(async () => + renderLoader({ + useWebViewState: makeWebViewState({ analysisLanguageChoices: { draft: 'fr' } }), + }), + ); + + expect(capturedStoreProps?.analysisLanguage).toBe('fr'); + }); + + it('falls back to the first language when the chosen one is no longer listed', async () => { + mockDraftLanguages(['en', 'fr']); + await act(async () => + renderLoader({ + useWebViewState: makeWebViewState({ analysisLanguageChoices: { draft: 'de' } }), + }), + ); + + expect(capturedStoreProps?.analysisLanguage).toBe('en'); + }); + + it("keeps an import's language apart from the draft's", async () => { + mockDraftLanguages(['en', 'fr']); + await act(async () => + renderLoader({ + useWebViewState: makeWebViewState({ + activeProject: IMPORT_LISTING_TWO, + analysisLanguageChoices: { draft: 'en' }, + }), + }), + ); + + await waitFor(() => expect(capturedStoreProps?.analysisLanguage).toBe('swh')); + }); + + it('switches the language of a read-only import', async () => { + mockDraftLanguages(['en']); + await act(async () => + renderLoader({ useWebViewState: makeWebViewState({ activeProject: IMPORT_LISTING_TWO }) }), + ); + + await act(async () => { + fireEvent.click(await screen.findByTestId('analysis-language-en')); + }); + + expect(capturedStoreProps?.analysisLanguage).toBe('en'); + }); +}); diff --git a/src/__tests__/components/controls/AnalysisLanguageSelect.test.tsx b/src/__tests__/components/controls/AnalysisLanguageSelect.test.tsx new file mode 100644 index 000000000..3e9a11f78 --- /dev/null +++ b/src/__tests__/components/controls/AnalysisLanguageSelect.test.tsx @@ -0,0 +1,75 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AnalysisLanguageSelect from '../../../components/controls/AnalysisLanguageSelect'; +import { mockKeyAsValueLocalizedStrings } from '../test-helpers'; + +beforeEach(() => { + mockKeyAsValueLocalizedStrings(); +}); + +describe('AnalysisLanguageSelect', () => { + it('renders nothing for a single language', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('labels the trigger as the analysis language', () => { + render( + , + ); + + expect(screen.getByTestId('analysis-language-select')).toHaveAttribute( + 'aria-label', + '%interlinearizer_analysisLanguage_label%', + ); + }); + + it('names each language beside its tag, in the given locales', () => { + render( + , + ); + + expect(screen.getByTestId('analysis-language-swh')).toHaveTextContent('swahili (swh)'); + expect(screen.getByTestId('analysis-language-fr')).toHaveTextContent('français (fr)'); + }); + + it('shows the bare tag when its name is only the tag again', () => { + // A tag no language answers to is named as itself, which would otherwise read "english (English)". + render( + , + ); + + expect(screen.getByTestId('analysis-language-English')).toHaveTextContent(/^English$/); + }); + + it('marks the current language selected', () => { + render( + , + ); + + expect(screen.getByTestId('analysis-language-fr')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('analysis-language-en')).toHaveAttribute('aria-selected', 'false'); + }); + + it('reports the picked language', async () => { + const onValueChange = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('analysis-language-fr')); + + expect(onValueChange).toHaveBeenCalledWith('fr'); + }); +}); diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts index 29bc9b968..a66a681b7 100644 --- a/src/__tests__/store/analysisSlice.test.ts +++ b/src/__tests__/store/analysisSlice.test.ts @@ -57,6 +57,7 @@ import { writePhraseGloss, writeSegmentFreeTranslation, replaceAnalysis, + setAnalysisLanguage, type AnalysisState, } from '../../store/analysisSlice'; import { emptyAnalysis } from '../../types/empty-factories'; @@ -6445,3 +6446,29 @@ describe('a re-anchored analysis', () => { expect(store.getState().analysis.analysis).toBe(before); }); }); + +describe('setAnalysisLanguage', () => { + it('reads the approved gloss in the new language', () => { + const store = createAnalysisStore({ + analysis: { analysis: emptyAnalysis(), analysisLanguage: 'en' }, + }); + store.dispatch(writeGloss('tok-1', 'word', 'hello')); + + store.dispatch(setAnalysisLanguage('fr')); + + expect(selectApprovedGloss(store.getState().analysis, 'tok-1')).toBe(''); + }); + + it('writes later glosses in the new language, keeping the earlier one', () => { + const store = createAnalysisStore({ + analysis: { analysis: emptyAnalysis(), analysisLanguage: 'en' }, + }); + store.dispatch(writeGloss('tok-1', 'word', 'hello')); + + store.dispatch(setAnalysisLanguage('fr')); + store.dispatch(writeGloss('tok-1', 'word', 'bonjour')); + store.dispatch(setAnalysisLanguage('en')); + + expect(selectApprovedGloss(store.getState().analysis, 'tok-1')).toBe('hello'); + }); +}); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index 8b0799e65..bb34dbdbd 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -5,7 +5,15 @@ import type { TextAnalysis, TokenSnapshot, } from 'interlinearizer'; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; import type { ReactNode } from 'react'; import { Provider as ReduxProvider, useDispatch, useSelector, useStore } from 'react-redux'; import { createAnalysisStore, type AnalysisDispatch, type AnalysisRootState } from '../store'; @@ -48,6 +56,7 @@ import analysisReducer, { writeSegmentFreeTranslation, reapplyStaleAnalysis, replaceAnalysis, + setAnalysisLanguage, type AnalysisDeletionOutcome, type MergedContent, } from '../store/analysisSlice'; @@ -112,7 +121,10 @@ const AnalysisCallbackCtx = createContext(undefined); type AnalysisStoreProviderProps = Readonly<{ /** Subtree given access to the analysis store. */ children: ReactNode; - /** BCP 47 analysis-language tag used when reading and writing `TokenAnalysis.gloss` values. */ + /** + * BCP 47 tag glosses and free translations are read and written in; a new tag never reseeds the + * store. + */ analysisLanguage: string; /** * The initial `TextAnalysis` to seed the store. Not reactive after mount — the caller is @@ -186,6 +198,11 @@ export function AnalysisStoreProvider({ [subscribeToReplacements, store], ); + // Layout phase, so no frame paints the previous language's glosses beside the new selection. + useLayoutEffect(() => { + store.dispatch(setAnalysisLanguage(analysisLanguage)); + }, [store, analysisLanguage]); + // Use refs so the dispatch callback never needs to re-create when parent re-renders const onSaveRef = useRef(onSave); onSaveRef.current = onSave; diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index d3e5e51c1..830aa2814 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -55,6 +55,7 @@ import { ConcordanceIndexProvider } from './ConcordanceIndexContext'; import ConcordancePanel from './ConcordancePanel'; import type { SidePanelView } from './SidePanelTabs'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; +import AnalysisLanguageSelect from './controls/AnalysisLanguageSelect'; import type { PhraseMode } from '../types/phrase-mode'; import ProjectModals, { type ModalState } from './modals/ProjectModals'; import { CopyToEditableModal } from './modals/CopyToEditableModal'; @@ -277,6 +278,9 @@ const BANNER_STRIP_CLASS = */ const PT9_CHECKING_DELAY_MS = 400; +/** Key the draft's display-language choice is stored under; project ids never take this form. */ +const DRAFT_LANGUAGE_KEY = 'draft'; + /** The phrase mode a read-only view is always in, shared so its identity stays stable. */ const VIEW_PHRASE_MODE: PhraseMode = { kind: 'view' }; @@ -371,6 +375,8 @@ function InterlinearizerLoaderInner({ const platformLanguage = isPlatformError(interfaceLanguages) ? 'und' : interfaceLanguages[0] || 'und'; + /* v8 ignore next -- useSetting never returns PlatformError for this key in practice */ + const interfaceLocales = isPlatformError(interfaceLanguages) ? undefined : interfaceLanguages; /** * Persisted snapshot of the active interlinear project — kept in WebView state so it survives tab @@ -437,18 +443,41 @@ function InterlinearizerLoaderInner({ subscribeToHistoryRevisions, } = useDraftProject(projectId, platformLanguage); - /** - * BCP 47 tag used for reading and writing gloss values. Prefers the draft's first configured - * analysis language; falls back to the platform UI language for a brand-new source. - */ - const analysisLanguage = draft?.analysisLanguages[0] ?? platformLanguage; - /** * Whether the active project is a Paratext 9 import, which renders read-only: the view is fed * from the stored analysis rather than the draft, and every editing affordance stays away. */ const isImportView = activeProject?.pt9Import !== undefined; + /** Analysis languages of whichever analysis is the view: the import's or the draft's. */ + const viewLanguages = + (isImportView ? activeProject?.analysisLanguages : draft?.analysisLanguages) ?? []; + + /** + * The analysis language picked per viewed analysis, keyed by import id or + * {@link DRAFT_LANGUAGE_KEY}. + */ + const [chosenLanguages, setChosenLanguages] = useWebViewState>( + 'analysisLanguageChoices', + {}, + ); + const languageKey = isImportView && activeProject ? activeProject.id : DRAFT_LANGUAGE_KEY; + const chosenLanguage = chosenLanguages[languageKey]; + + /** + * BCP 47 tag glosses are read and written in: the chosen language while the view still lists it, + * else the first listed, else the platform UI language for a brand-new source. + */ + const analysisLanguage = + chosenLanguage !== undefined && viewLanguages.includes(chosenLanguage) + ? chosenLanguage + : (viewLanguages[0] ?? platformLanguage); + + const handleAnalysisLanguageChange = useCallback( + (tag: string) => setChosenLanguages({ ...chosenLanguages, [languageKey]: tag }), + [chosenLanguages, languageKey, setChosenLanguages], + ); + /** * Which version of the import the view is on: its id and the modification time a sync bumps. * `undefined` while the draft is the view. @@ -1642,7 +1671,7 @@ function InterlinearizerLoaderInner({ {panelGroup} @@ -1703,6 +1732,12 @@ function InterlinearizerLoaderInner({ localizedStrings={localizedStrings} /> )} + void; + /** Interface languages to name each tag in, most preferred first. */ + locales?: readonly string[]; +}>; + +/** + * Toolbar picker for the analysis language glosses and free translations are shown and edited in. + * Renders nothing when there is no choice to make. + */ +export default function AnalysisLanguageSelect({ + languages, + value, + onValueChange, + locales, +}: AnalysisLanguageSelectProps) { + const [localizedStrings] = useLocalizedStrings(STRING_KEYS); + if (languages.length < 2) return undefined; + + return ( + + ); +} + +/** Names `tag` with the tag beside it, since distinct tags can share a name. */ +function optionLabel(tag: string, locales: readonly string[] | undefined): string { + const name = languageNameForTag(tag, locales); + return name.toLowerCase() === tag.toLowerCase() ? tag : `${name} (${tag})`; +} diff --git a/src/store/analysisSlice.ts b/src/store/analysisSlice.ts index c9996a2b7..ef39bdf8b 100644 --- a/src/store/analysisSlice.ts +++ b/src/store/analysisSlice.ts @@ -1812,6 +1812,11 @@ const analysisSlice = createSlice({ replaceAnalysis(state, action: PayloadAction) { state.analysis = action.payload; }, + + /** Switches the language every gloss and free translation is read and written in. */ + setAnalysisLanguage(state, action: PayloadAction) { + state.analysisLanguage = action.payload; + }, }, }); @@ -1838,6 +1843,7 @@ export const { keepStaleFreeTranslation, discardStaleFreeTranslation, replaceAnalysis, + setAnalysisLanguage, } = analysisSlice.actions; export default analysisSlice.reducer; From 89523648cfaf0cc0169e96898dbcd57a4d748503 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 5 Oct 2026 11:53:19 -0600 Subject: [PATCH 2/4] Keep the catalog's place when the analysis language changes --- .../components/AnalysisCatalogPanel.test.tsx | 54 +++++++++++++++++++ src/components/AnalysisCatalogPanel.tsx | 12 ++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 248845dcf..2dc33c8b5 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -1399,6 +1399,60 @@ describe('AnalysisCatalogPanel', () => { expect(list.scrollTop).toBe(0); }); + /** The panel over {@link MANY}, read in `analysisLanguage`. */ + function panelIn(analysisLanguage: string) { + return ( + + {}} + onShowConcordance={() => {}} + showMorphology + sourceLanguageTag="el" + /> + + ); + } + + it('leaves the scroll where it is when the analysis language changes under a usage sort', () => { + const { rerender } = render(panelIn('en')); + const list = rowList(); + list.scrollTop = 500; + + rerender(panelIn('fr')); + + expect(list.scrollTop).toBe(500); + }); + + it('returns the list to its top when the analysis language changes under a gloss sort', async () => { + const { rerender } = render(panelIn('en')); + await userEvent.click(screen.getByTestId('catalog-sort-gloss')); + const list = rowList(); + list.scrollTop = 500; + + rerender(panelIn('fr')); + + expect(list.scrollTop).toBe(0); + }); + + it('returns the list to its top when the analysis language changes under the missing-gloss filter', async () => { + const { rerender } = render(panelIn('en')); + await openFilters(); + await userEvent.click( + screen.getByRole('checkbox', { + name: '%interlinearizer_analysisCatalog_filter_missingGloss%', + }), + ); + const list = rowList(); + list.scrollTop = 500; + + rerender(panelIn('fr')); + + expect(list.scrollTop).toBe(0); + }); + it('keeps the window where it is when the analysis changes under an unchanged query', () => { // A gloss approved in the view beside an open catalog rebuilds every row without narrowing // anything, and collapsing a deeply scrolled list back to its first chunk on that would throw diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx index 7d6753ca4..458b41cfe 100644 --- a/src/components/AnalysisCatalogPanel.tsx +++ b/src/components/AnalysisCatalogPanel.tsx @@ -296,12 +296,22 @@ export default function AnalysisCatalogPanel({ return languageNameForTag(analysisLanguage, locales); }, [analysisLanguage, interfaceLanguages]); + /** + * The analysis language's collation, held only where that language decides the listing's rows or + * their order: elsewhere it reorders no more than ties, which leaves the reader's place + * standing. + */ + const listingGlossCollator = sort === 'gloss' || filters.missingGloss ? glossCollator : undefined; + /** * Everything that decides which listing is on screen. The book counts alongside the query because * each row's per-book usage is taken against it: moving to another book reorders a listing sorted * by that count, and relabels that column in every other. */ - const listing = useMemo(() => ({ query, currentBook }), [query, currentBook]); + const listing = useMemo( + () => ({ search, sort, filters, surfaceCollator, listingGlossCollator, currentBook }), + [search, sort, filters, surfaceCollator, listingGlossCollator, currentBook], + ); /** * What the last edit's collapse left standing, or `undefined` when no edit has collapsed one. From dca5e135ec31822145c4e0305fabe2e2d42febb2 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 5 Oct 2026 12:01:44 -0600 Subject: [PATCH 3/4] Note that tied homographs may trade places on a language switch --- src/components/AnalysisCatalogPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx index 458b41cfe..4e2d04be5 100644 --- a/src/components/AnalysisCatalogPanel.tsx +++ b/src/components/AnalysisCatalogPanel.tsx @@ -298,8 +298,8 @@ export default function AnalysisCatalogPanel({ /** * The analysis language's collation, held only where that language decides the listing's rows or - * their order: elsewhere it reorders no more than ties, which leaves the reader's place - * standing. + * their order: elsewhere it reorders only homographs the sort key ties, which stay adjacent, so + * the scroll stands though they may trade places beneath it. */ const listingGlossCollator = sort === 'gloss' || filters.missingGloss ? glossCollator : undefined; From 48d6e8e957f7bd5192d4c889e726540cf1a1c212 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 6 Oct 2026 12:10:27 -0600 Subject: [PATCH 4/4] Keep each analysis language once when parsing the language field --- src/__tests__/utils/language-tags.test.ts | 8 +++++++- src/components/AnalysisStore.tsx | 5 +++-- src/utils/language-tags.ts | 6 ++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/__tests__/utils/language-tags.test.ts b/src/__tests__/utils/language-tags.test.ts index c51b63e20..cc274517a 100644 --- a/src/__tests__/utils/language-tags.test.ts +++ b/src/__tests__/utils/language-tags.test.ts @@ -1,6 +1,12 @@ /// -import { collatorForTag, languageNameForTag } from '../../utils/language-tags'; +import { collatorForTag, languageNameForTag, parseLanguageTags } from '../../utils/language-tags'; + +describe('parseLanguageTags', () => { + it('keeps a repeated tag only where it first appears', () => { + expect(parseLanguageTags('en, fr, en')).toEqual(['en', 'fr']); + }); +}); describe('collatorForTag', () => { it('collates under the tag it is given', () => { diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index bb34dbdbd..02bbda083 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -489,8 +489,9 @@ export function useMorphemePayloadIsSolelyOwned(tokenRef: string): boolean { * * The result keeps its reference while the analyses and their links keep theirs, so an unrelated * write — a free translation, a phrase link — leaves the list unrendered. It changes with - * `currentBook`, which the per-book usage count is taken against, and with `headingPlacements`, - * which places each heading's usages among its verse's text. + * `currentBook`, which the per-book usage count is taken against, with `headingPlacements`, which + * places each heading's usages among its verse's text, and with the analysis language its glosses + * are read in. * * @throws When called outside an {@link AnalysisStoreProvider}. */ diff --git a/src/utils/language-tags.ts b/src/utils/language-tags.ts index 239277f3a..4d79e1d67 100644 --- a/src/utils/language-tags.ts +++ b/src/utils/language-tags.ts @@ -7,13 +7,15 @@ import { Collator } from 'platform-bible-utils'; * Applies no fallback when the result is empty — treating an empty list (defaulting it to * `['und']`, or blocking submission) is the caller's decision. * - * @returns The trimmed, non-empty tags in input order; an empty array when the field is blank. + * @returns The trimmed, non-empty tags in input order, each kept at its first appearance; an empty + * array when the field is blank. */ export function parseLanguageTags(input: string): string[] { - return input + const tags = input .split(',') .map((tag) => tag.trim()) .filter((tag) => tag.length > 0); + return [...new Set(tags)]; } /**