diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx index 887f73ae..c129cd19 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx @@ -92,6 +92,10 @@ import { PassageTypeEnum } from '../../../model/passageType'; import { VertListDnd } from '../../../hoc/VertListDnd'; import usePassageDetailContext from '../../../context/usePassageDetailContext'; import { LaunchLink } from '../../../control/LaunchLink'; +import { + getProjectResourceAssignments, + removeUnselectedProjectResourceAssignments, +} from './projectResourceAssignments'; import FindTabs from './FindTabs'; import { storedCompareKey } from '../../../utils/storedCompareKey'; import { mediaContentType } from '../../../utils/contentType'; @@ -384,12 +388,22 @@ export function PassageDetailArtifacts() { const mf = mediafiles.find((m) => m.id === related(secRes, 'mediafile')) as | MediaFileD | undefined; + const sourceMedia = mediafiles.find( + (m) => m.id === related(mf, 'sourceMedia') + ); + const projectMedia = + mf && related(mf, 'artifactType') === projResourceType + ? mf + : sourceMedia && + related(sourceMedia, 'artifactType') === projResourceType + ? sourceMedia + : undefined; // General (project) resources are reconfigured through the wizard, not the // simple edit dialog (mockup: "use Edit to also configure the General Resource"). - if (mf && related(mf, 'artifactType') === projResourceType) { + if (projectMedia) { resourceTypeRef.current = ResourceTypeEnum.projectResource; isAddingAudioResourceRef.current = false; - handleSelectProjectResource(mf); + handleSelectProjectResource(projectMedia); return; } setEditResource(secRes); @@ -768,6 +782,13 @@ export function PassageDetailArtifacts() { cnt += 1; setComplete(Math.min((cnt * 100) / total, 100)); } + await removeUnselectedProjectResourceAssignments({ + memory, + sourceMedia: projMediaRef.current, + selectedItems: items, + mediafiles, + sectionResources, + }); // Ensure setComplete(0) is always called after processing setComplete(0); }; @@ -1045,12 +1066,17 @@ export function PassageDetailArtifacts() { isOpen={projResPassageVisible} onOpen={handleProjResPassageVisible} disableBackdropClose + showTopCloseButton={false} > {projResPassageVisible ? ( handleProjResPassageVisible(false)} /> ) : ( <> diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx index 4731f749..dcd7a1aa 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx @@ -92,6 +92,10 @@ import { VertListDnd } from '../../../hoc/VertListDnd'; import usePassageDetailContext from '../../../context/usePassageDetailContext'; import { LaunchLink } from '../../../control/LaunchLink'; import FindTabs from './FindTabs'; +import { + getProjectResourceAssignments, + removeUnselectedProjectResourceAssignments, +} from './projectResourceAssignments'; import { storedCompareKey } from '../../../utils/storedCompareKey'; import { mediaContentType } from '../../../utils/contentType'; import { useStepPermissions } from '../../../utils/useStepPermission'; @@ -381,12 +385,22 @@ export function PassageDetailArtifactsMobile() { const mf = mediafiles.find((m) => m.id === related(secRes, 'mediafile')) as | MediaFileD | undefined; + const sourceMedia = mediafiles.find( + (m) => m.id === related(mf, 'sourceMedia') + ); + const projectMedia = + mf && related(mf, 'artifactType') === projResourceType + ? mf + : sourceMedia && + related(sourceMedia, 'artifactType') === projResourceType + ? sourceMedia + : undefined; // General (project) resources are reconfigured through the wizard, not the // simple edit dialog (mockup: "use Edit to also configure the General Resource"). - if (mf && related(mf, 'artifactType') === projResourceType) { + if (projectMedia) { resourceTypeRef.current = ResourceTypeEnum.projectResource; isAddingAudioResourceRef.current = false; - handleSelectProjectResource(mf); + handleSelectProjectResource(projectMedia); return; } setEditResource(secRes); @@ -755,6 +769,13 @@ export function PassageDetailArtifactsMobile() { cnt += 1; setComplete(Math.min((cnt * 100) / total, 100)); } + await removeUnselectedProjectResourceAssignments({ + memory, + sourceMedia: projMediaRef.current, + selectedItems: items, + mediafiles, + sectionResources, + }); // Ensure setComplete(0) is always called after processing setComplete(0); }; @@ -1053,12 +1074,17 @@ export function PassageDetailArtifactsMobile() { isOpen={projResPassageVisible} onOpen={handleProjResPassageVisible} disableBackdropClose + showTopCloseButton={false} > {projResPassageVisible ? ( handleProjResPassageVisible(false)} /> ) : ( <> diff --git a/src/renderer/src/components/PassageDetail/Internalization/ProjectResourceConfigure.tsx b/src/renderer/src/components/PassageDetail/Internalization/ProjectResourceConfigure.tsx index aeee6b5d..c2d2409e 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/ProjectResourceConfigure.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/ProjectResourceConfigure.tsx @@ -48,6 +48,7 @@ import { import { RecordIdentity, RecordTransformBuilder } from '@orbit/records'; import { useOrbitData } from '../../../hoc/useOrbitData'; import Confirm from '../../AlertDialog'; +import { removeUnselectedProjectResourceAssignments } from './projectResourceAssignments'; const NotTable = 420; @@ -267,6 +268,13 @@ export const ProjectResourceConfigure = (props: IProps) => { } setComplete(Math.min((ix * 100) / total, 100)); } + await removeUnselectedProjectResourceAssignments({ + memory, + sourceMedia: media, + selectedItems: items, + mediafiles, + sectionResources, + }); projectSegmentSave({ media, segments: updateSegments( diff --git a/src/renderer/src/components/PassageDetail/Internalization/SelectSections.tsx b/src/renderer/src/components/PassageDetail/Internalization/SelectSections.tsx index 1bcb0e61..1e6d2977 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/SelectSections.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/SelectSections.tsx @@ -9,36 +9,30 @@ import { Plan, IPassageDetailArtifactsStrings, ISharedStrings, - SectionArray, } from '../../../model'; import { Box, - Button, + Checkbox, debounce, + IconButton, Paper, PaperProps, styled, - Typography, } from '@mui/material'; -import { useOrganizedBy, findRecord, usePlanType } from '../../../crud'; +import { findRecord, useOrganizedBy, usePlanType } from '../../../crud'; import { sharedSelector } from '../../../selector'; -import { eqSet } from '../../../utils'; import { RecordIdentity } from '@orbit/records'; import { useOrbitData } from '../../../hoc/useOrbitData'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import DoneAllIcon from '@mui/icons-material/DoneAll'; import { - projDefSectionMap, - useProjectDefaults, -} from '../../../crud/useProjectDefaults'; -import { - GridColDef, - GridColumnVisibilityModel, - GridRowSelectionModel, - GridSortModel, -} from '@mui/x-data-grid'; -import { TreeDataGrid } from '../../../components/TreeDataGrid'; + ActionRow, + AltButton, + GrowingSpacer, + PriButton, +} from '../../../control'; import { buildSelectSectionRows, - selectSectionRowType, SelectSectionRow, } from './buildSelectSectionRows'; @@ -55,19 +49,21 @@ const StyledPaper = styled(Paper)(({ theme }) => ({ type IRow = SelectSectionRow; interface IProps { - title: string; - visual?: boolean; + initialItems?: RecordIdentity[]; onSelect?: (items: RecordIdentity[]) => void; + onCancel?: () => void; } export function SelectSections(props: IProps) { - const { visual, title, onSelect } = props; + const { initialItems, onSelect, onCancel } = props; + const initialSelectionKey = (initialItems ?? []) + .map((item) => `${item.type}:${item.id}`) + .join('|'); const passages = useOrbitData('passage'); const sections = useOrbitData('section'); const [memory] = useGlobal('memory'); const [plan] = useGlobal('plan'); //will be constant here const [data, setData] = useState(Array()); - const [openSections, setOpenSections] = useState([]); const [heightStyle, setHeightStyle] = useState({ maxHeight: `${window.innerHeight - 200}px`, }); @@ -79,33 +75,17 @@ export function SelectSections(props: IProps) { passageDetailArtifactsSelector, shallowEqual ); - const [buttonText, setButtonText] = useState(ta.projectResourceConfigure); const allBookData = useSelector((state: IState) => state.books.bookData); - const [columns, setColumns] = useState([]); - const [checks, setChecks] = useState>([]); - const [selectedRows, setSelectedRows] = useState({ - type: 'include', - ids: new Set(), - }); - const { getProjectDefault } = useProjectDefaults(); - const sectionMap = new Map( - (getProjectDefault(projDefSectionMap) ?? []) as SectionArray - ); + const [selected, setSelected] = useState>(new Set()); const setDimensions = () => { setHeightStyle({ maxHeight: `${window.innerHeight - 200}px`, }); }; const planType = usePlanType(); - const columnVisibilityModel: GridColumnVisibilityModel = { expand: false }; const boxRef = useRef(null); const [tableHeight, setTableHeight] = useState(300); - useEffect(() => { - setButtonText(visual ? ta.createResources : ta.projectResourceConfigure); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visual]); - useEffect(() => { setDimensions(); const handleResize = debounce(() => { @@ -129,93 +109,129 @@ export function SelectSections(props: IProps) { }, [plan]); useEffect(() => { - const newColumns: GridColDef[] = [ - { - field: 'name', - headerName: getOrganizedBy(true), - width: 300, - cellClassName: 'word-wrap', - }, - ]; - if (!isFlat) { - newColumns.push({ - field: 'passages', - headerName: ts.passages, - width: 120, - align: 'right', - }); - } - setColumns([...newColumns]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFlat]); - - const getSections = ( - passages: PassageD[], - sections: SectionD[], - bookData: typeof allBookData - ) => { - return buildSelectSectionRows({ - passages, - sections, - bookData, - planId: planRec?.id, - isFlat: Boolean(isFlat), - sectionMap, - }); - }; - - useEffect(() => { - setData(getSections(passages, sections, allBookData)); + setData( + buildSelectSectionRows({ + passages, + sections, + bookData: allBookData, + planId: planRec?.id, + isFlat: Boolean(isFlat), + organizedBy: getOrganizedBy(true), + }) + ); if (boxRef.current) { const height = boxRef.current.parentNode?.parentNode?.parentElement?.clientHeight; setTableHeight((height ?? 300) - 250); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plan, passages, sections, allBookData, openSections, isFlat]); + }, [plan, passages, sections, allBookData, isFlat]); - const handleRowSelectionChange = (newSelection: GridRowSelectionModel) => { - let chks = Array.from(newSelection.ids).map( - (c) => parseInt(c as string) - 1 + useEffect(() => { + setSelected( + new Set(initialSelectionKey ? initialSelectionKey.split('|') : []) ); - if (newSelection.type === 'exclude') { - chks = []; - data.forEach((_r, i) => { - if (!newSelection.ids.has(i)) chks.push(i); - }); - } - if (!eqSet(new Set(chks), new Set(checks))) { - for (const c of chks) { - let n = c; - if (data[n]?.parentId === '' && !checks.includes(n)) { - while (++n < data.length && data[n].parentId !== '') { - if (!chks.includes(n)) chks.push(n); - } - } + }, [initialSelectionKey]); + + const toggleSection = (sectionId: string) => { + setSelected((current) => { + const next = new Set(current); + const sectionKey = `section:${sectionId}`; + const passageKeys = data + .filter((row) => row.parentId === sectionId) + .map((row) => `passage:${row.recId}`); + if (next.has(sectionKey)) { + next.delete(sectionKey); + passageKeys.forEach((key) => next.delete(key)); + } else { + next.add(sectionKey); + passageKeys.forEach((key) => next.add(key)); } - setChecks(chks); - } - setSelectedRows({ - ...newSelection, - type: 'include', - ids: new Set(chks.map((c) => c + 1)), + return next; + }); + }; + + const togglePassage = (passageId: string, sectionId: string) => { + setSelected((current) => { + const next = new Set(current); + const passageKey = `passage:${passageId}`; + if (next.has(passageKey)) next.delete(passageKey); + else next.add(passageKey); + + const passageKeys = data + .filter((row) => row.parentId === sectionId) + .map((row) => `passage:${row.recId}`); + const sectionKey = `section:${sectionId}`; + if ( + passageKeys.length > 0 && + passageKeys.every((key) => next.has(key)) + ) { + next.add(sectionKey); + } else { + next.delete(sectionKey); + } + return next; }); }; const handleSelected = () => { - const results = checks - .sort((i, j) => parseInt(i as string) - parseInt(j as string)) - .map((c) => { - const n = parseInt(c as string); - return { - type: selectSectionRowType(data[n], Boolean(isFlat)), - id: data[n].recId, - }; - }) as RecordIdentity[]; - onSelect && onSelect(results); + const results = data + .filter((row) => selected.has(`${row.kind}:${row.recId}`)) + .map((row) => ({ type: row.kind, id: row.recId })) as RecordIdentity[]; + onSelect?.(results); }; - const sortModel: GridSortModel = [{ field: 'name', sort: 'asc' }]; + const columns: GridColDef[] = [ + { + field: 'selected', + headerName: '', + width: 52, + sortable: false, + filterable: false, + disableColumnMenu: true, + display: 'flex', + align: 'center', + cellClassName: 'select-cell', + renderCell: ({ row }) => { + if (row.kind === 'section') { + const isSelected = selected.has(`section:${row.recId}`); + return ( + toggleSection(row.recId)} + size="small" + sx={{ p: 0.5, color: 'text.primary' }} + > + + + ); + } + return ( + togglePassage(row.recId, row.parentId)} + size="small" + sx={{ + p: 0.5, + color: 'text.primary', + '&.Mui-checked': { color: 'text.primary' }, + }} + /> + ); + }, + }, + { + field: 'name', + headerName: '', + flex: 1, + minWidth: 240, + sortable: false, + cellClassName: ({ row }) => + row.kind === 'passage' ? 'passage-row' : '', + }, + ]; return ( - {title} - + indexRelativeToCurrentPage % 2 === 0 ? 'even-row' : 'odd-row' + } sx={{ - '& .word-wrap': { wordWrap: 'break-spaces' }, + border: 0, maxHeight: tableHeight, + '& .MuiDataGrid-cell': { borderBottom: 0 }, + '& .select-cell': { + px: 0.5, + borderRight: 1, + borderColor: 'divider', + }, + '& .even-row': { backgroundColor: 'background.paper' }, + '& .odd-row': { backgroundColor: 'action.hover' }, + '& .passage-row': { pl: 1 }, }} /> -
- -
+ {ta.next} + +
); } diff --git a/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.test.ts b/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.test.ts index ac58f7c0..6500750c 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.test.ts +++ b/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.test.ts @@ -1,7 +1,4 @@ -import { - buildSelectSectionRows, - selectSectionRowType, -} from './buildSelectSectionRows'; +import { buildSelectSectionRows } from './buildSelectSectionRows'; import { PassageD, SectionD } from '../../../model'; const section = (id: string, sequencenum: number, planId = 'plan-1'): SectionD => @@ -26,7 +23,7 @@ const passage = ( }) as PassageD; describe('buildSelectSectionRows', () => { - const sectionMap = new Map(); + const organizedBy = 'Section'; it('omits passage rows when isFlat (TT-6936)', () => { const rows = buildSelectSectionRows({ @@ -35,12 +32,13 @@ describe('buildSelectSectionRows', () => { bookData: [], planId: 'plan-1', isFlat: true, - sectionMap, + organizedBy, }); expect(rows).toHaveLength(1); expect(rows[0].recId).toBe('s1'); expect(rows[0].parentId).toBe(''); expect(rows[0].passages).toBe('2'); + expect(rows[0].kind).toBe('section'); }); it('includes passage children when not flat', () => { @@ -50,31 +48,32 @@ describe('buildSelectSectionRows', () => { bookData: [], planId: 'plan-1', isFlat: false, - sectionMap, + organizedBy, }); expect(rows).toHaveLength(3); expect(rows.map((r) => r.recId)).toEqual(['s1', 'p1', 'p2']); + expect(rows.map((r) => r.kind)).toEqual([ + 'section', + 'passage', + 'passage', + ]); expect(rows[1].parentId).toBe('s1'); expect(rows[2].parentId).toBe('s1'); }); -}); - -describe('selectSectionRowType', () => { - it('returns section for flat section rows', () => { - expect( - selectSectionRowType({ parentId: '', passages: '1' }, true) - ).toBe('section'); - }); - it('returns section for hierarchical multi-passage section rows', () => { - expect( - selectSectionRowType({ parentId: '', passages: '3' }, false) - ).toBe('section'); - }); - - it('returns passage for child rows', () => { - expect( - selectSectionRowType({ parentId: 's1', passages: '' }, false) - ).toBe('passage'); + it('ignores BOOK passages without creating title rows', () => { + const rows = buildSelectSectionRows({ + sections: [section('s1', 1)], + passages: [ + passage('book', 's1', 0, 'BOOK'), + passage('p1', 's1', 1), + ], + bookData: [], + planId: 'plan-1', + isFlat: false, + organizedBy, + }); + expect(rows.map((row) => row.recId)).toEqual(['s1', 'p1']); + expect(rows.map((row) => row.kind)).toEqual(['section', 'passage']); }); }); diff --git a/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.ts b/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.ts index a29d1561..36b645a1 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.ts +++ b/src/renderer/src/components/PassageDetail/Internalization/buildSelectSectionRows.ts @@ -2,40 +2,27 @@ import { BookName, Passage, PassageD, - Section, SectionD, } from '../../../model'; import { related } from '../../../crud/related'; -import { - sectionNumber, - sectionCompare, - sectionRef, -} from '../../../crud/section'; -import { passageCompare, passageDescText } from '../../../crud/passage'; +import { sectionCompare } from '../../../crud/section'; +import { passageCompare, passageRefText } from '../../../crud/passage'; import { passageTypeFromRef } from '../../../control/passageTypeFromRef'; import { PassageTypeEnum } from '../../../model/passageType'; +export type SelectSectionRowKind = 'section' | 'passage'; + export interface SelectSectionRow { id: number; recId: string; name: string; passages: string; parentId: string; + kind: SelectSectionRowKind; } -const getSection = ( - section: Section, - passages: Passage[], - sectionMap: Map, - bookData: BookName[] -) => { - const name = - sectionRef(section, passages, bookData) ?? section?.attributes?.name ?? ''; - return sectionNumber(section, sectionMap) + '.\u00A0\u00A0' + name; -}; - const getReference = (passage: Passage, bookData: BookName[] = []) => { - return passageDescText(passage, bookData); + return passageRefText(passage, bookData); }; export function buildSelectSectionRows(opts: { @@ -44,56 +31,46 @@ export function buildSelectSectionRows(opts: { bookData: BookName[]; planId: string | undefined; isFlat: boolean; - sectionMap: Map; + organizedBy: string; }): SelectSectionRow[] { - const { passages, sections, bookData, planId, isFlat, sectionMap } = opts; + const { passages, sections, bookData, planId, isFlat, organizedBy } = opts; const rowData: SelectSectionRow[] = []; let id = 1; sections .filter((s) => related(s, 'plan') === planId && s.attributes) .sort(sectionCompare) .forEach((section) => { - const sectionpassages = passages + const sectionPassages = passages + .filter((ps) => related(ps, 'section') === section.id) .filter( (ps) => - related(ps, 'section') === section.id && passageTypeFromRef(ps.attributes?.reference, isFlat) === - PassageTypeEnum.PASSAGE + PassageTypeEnum.PASSAGE ) .sort(passageCompare); - const passageCount = sectionpassages.length; + const passageCount = sectionPassages.length; rowData.push({ id: id++, - recId: section.id, - name: getSection(section, sectionpassages, sectionMap, bookData), + recId: section.id ?? '', + name: + section.attributes.name || + `${organizedBy} ${section.attributes.sequencenum}`, passages: passageCount.toString(), parentId: '', + kind: 'section', }); // Flat plans are section-only; do not list passage children (TT-6936). if (isFlat) return; - sectionpassages.forEach((passage: Passage) => { + sectionPassages.forEach((passage: Passage) => { rowData.push({ id: id++, - recId: passage.id as string, - name: `\u00A0\u00A0\u00A0${sectionNumber(section, sectionMap)}.${getReference( - passage, - bookData - )}`, + recId: passage.id ?? '', + name: getReference(passage, bookData), passages: '', - parentId: passageCount === 1 ? '' : section.id, + parentId: section.id ?? '', + kind: 'passage', }); }); }); return rowData; } - -/** Selection identity type for a row in the Select Sections dialog. */ -export function selectSectionRowType( - row: Pick, - isFlat: boolean -): 'section' | 'passage' { - if (row.parentId === '' && (isFlat || parseInt(row.passages, 10) > 1)) { - return 'section'; - } - return 'passage'; -} diff --git a/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.test.ts b/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.test.ts new file mode 100644 index 00000000..8079d3c5 --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.test.ts @@ -0,0 +1,92 @@ +import type Memory from '@orbit/memory'; +import type { MediaFileD, SectionResourceD } from '../../../model'; +import { + getProjectResourceAssignments, + removeUnselectedProjectResourceAssignments, +} from './projectResourceAssignments'; + +const relationship = (type: string, id: string) => ({ + data: { type, id }, +}); + +const source = { + type: 'mediafile', + id: 'source', + attributes: {}, + relationships: {}, +} as MediaFileD; + +const passageMedia = (id: string, passageId: string) => + ({ + type: 'mediafile', + id, + attributes: {}, + relationships: { + sourceMedia: relationship('mediafile', source.id), + passage: relationship('passage', passageId), + }, + }) as MediaFileD; + +const sectionMedia = { + type: 'mediafile', + id: 'section-media', + attributes: {}, + relationships: { + sourceMedia: relationship('mediafile', source.id), + }, +} as MediaFileD; + +const sectionResource = { + type: 'sectionresource', + id: 'section-resource', + attributes: {}, + relationships: { + mediafile: relationship('mediafile', sectionMedia.id), + section: relationship('section', 'section-1'), + }, +} as SectionResourceD; + +describe('project resource assignments', () => { + it('maps derived media to passage and section identities', () => { + expect( + getProjectResourceAssignments( + source, + [source, passageMedia('passage-media', 'passage-1'), sectionMedia], + [sectionResource] + ) + ).toEqual([ + { type: 'passage', id: 'passage-1' }, + { type: 'section', id: 'section-1' }, + ]); + }); + + it('removes media and section resources for unchecked assignments', async () => { + const kept = passageMedia('kept-media', 'passage-1'); + const removed = passageMedia('removed-media', 'passage-2'); + const removedResource = { + ...sectionResource, + id: 'removed-resource', + relationships: { + ...sectionResource.relationships, + mediafile: relationship('mediafile', removed.id), + passage: relationship('passage', 'passage-2'), + }, + } as SectionResourceD; + const removeRecord = jest.fn((record) => ({ op: 'removeRecord', record })); + const memory = { + update: jest.fn(async (callback) => callback({ removeRecord })), + } as unknown as Memory; + + await removeUnselectedProjectResourceAssignments({ + memory, + sourceMedia: source, + selectedItems: [{ type: 'passage', id: 'passage-1' }], + mediafiles: [source, kept, removed], + sectionResources: [removedResource], + }); + + expect(removeRecord).toHaveBeenCalledTimes(2); + expect(removeRecord).toHaveBeenCalledWith(removedResource); + expect(removeRecord).toHaveBeenCalledWith(removed); + }); +}); diff --git a/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.ts b/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.ts new file mode 100644 index 00000000..3b3da565 --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/projectResourceAssignments.ts @@ -0,0 +1,99 @@ +import type { RecordIdentity } from '@orbit/records'; +import type Memory from '@orbit/memory'; +import { related } from '../../../crud/related'; +import type { + MediaFile, + MediaFileD, + SectionResource, + SectionResourceD, +} from '../../../model'; + +/** Stable `type:id` key for comparing {@link RecordIdentity} values in a Set. */ +const identityKey = (identity: RecordIdentity) => + `${identity.type}:${identity.id}`; + +/** + * Resolves which passages/sections a project-resource media file is already + * assigned to. + * + * Walks mediafiles derived from `sourceMedia` (`sourceMedia` relationship). + * Passage-scoped copies map to `{ type: 'passage', id }`; section-scoped copies + * map via their SectionResource to `{ type: 'section', id }`. + * + * Used as `SelectSections` `initialItems` when editing an audio project resource + * so existing assignments stay checked. + */ +export const getProjectResourceAssignments = ( + sourceMedia: MediaFile | undefined, + mediafiles: MediaFile[], + sectionResources: SectionResource[] +) => { + if (!sourceMedia) return []; + + return mediafiles + .filter((media) => related(media, 'sourceMedia') === sourceMedia.id) + .flatMap((media) => { + const passageId = related(media, 'passage'); + if (passageId) return [{ type: 'passage', id: passageId }]; + + const sectionResource = sectionResources.find( + (resource) => related(resource, 'mediafile') === media.id + ); + const sectionId = related(sectionResource, 'section'); + return sectionId ? [{ type: 'section', id: sectionId }] : []; + }) as RecordIdentity[]; +}; + +interface RemoveAssignmentsProps { + memory: Memory; + sourceMedia: MediaFile | undefined; + selectedItems: RecordIdentity[]; + mediafiles: MediaFile[]; + sectionResources: SectionResource[]; +} + +/** + * Deletes derived media (and linked SectionResources) for assignments the user + * unchecked. + * + * After new selections are saved, any prior derived mediafile for `sourceMedia` + * whose passage/section is not in `selectedItems` is removed from Orbit memory + * (SectionResource first when present, then the mediafile). + */ +export const removeUnselectedProjectResourceAssignments = async ({ + memory, + sourceMedia, + selectedItems, + mediafiles, + sectionResources, +}: RemoveAssignmentsProps) => { + if (!sourceMedia) return; + + const selected = new Set(selectedItems.map(identityKey)); + const derivedMedia = mediafiles.filter( + (media) => related(media, 'sourceMedia') === sourceMedia.id + ); + const records: Array = []; + derivedMedia.forEach((media) => { + const sectionResource = sectionResources.find( + (resource) => related(resource, 'mediafile') === media.id + ) as SectionResourceD | undefined; + const passageId = related(media, 'passage'); + const sectionId = related(sectionResource, 'section'); + const assignment = passageId + ? { type: 'passage', id: passageId } + : sectionId + ? { type: 'section', id: sectionId } + : undefined; + + if (!assignment || selected.has(identityKey(assignment))) return; + if (sectionResource) records.push(sectionResource); + records.push(media as MediaFileD); + }); + + if (records.length > 0) { + await memory.update((transform) => + records.map((record) => transform.removeRecord(record)) + ); + } +};