diff --git a/src/renderer/src/burrito/jamesPublishingFixture.ts b/src/renderer/src/burrito/jamesPublishingFixture.ts new file mode 100644 index 00000000..cadaaba7 --- /dev/null +++ b/src/renderer/src/burrito/jamesPublishingFixture.ts @@ -0,0 +1,369 @@ +import { AltBkSeq, BookSeq } from '../model/section'; +import type { BibleD } from '../model'; +import type { GraphicD } from '../model'; +import type { MediaFileD } from '../model'; +import type { PassageD } from '../model'; +import type { SectionArray, SectionD } from '../model'; + +export const JAMES_BOOK = 'JAS'; +export const JAMES_BOOK_PATH = '/burrito/JAS'; + +/** Remote numeric ids for section graphics (resourceId on GraphicD). */ +export const JAMES_SECTION_REMOTE_NUM: Record = { + 'sec-book': 901, + 'sec-alt': 902, + 'sec-m1': 903, + 'sec-s1': 904, + 'sec-m2': 905, + 'sec-s2': 906, +}; + +export const jamesBibleFixture = { + id: 'bib-james', + type: 'bible', + attributes: { + bibleId: 'TST', + bibleName: 'Test', + iso: 'eng', + }, +} as BibleD; + +export function copyDests(ipc: { + copyFile: { mock: { calls: unknown[][] } }; +}): string[] { + return ipc.copyFile.mock.calls.map((c) => c[1] as string); +} + +export function destForMediaSrc( + ipc: { copyFile: { mock: { calls: unknown[][] } } }, + srcSubstring: string +): string | undefined { + const call = ipc.copyFile.mock.calls.find((c) => + (c[0] as string).includes(srcSubstring) + ); + return call ? (call[1] as string) : undefined; +} + +export function chapterInPath(p: string): string | undefined { + return p.match(/\/(\d{3})\//)?.[1]; +} + +export function isBookRootPath(p: string, bookPath: string): boolean { + if (!p.startsWith(`${bookPath}/`)) return false; + const suffix = p.slice(bookPath.length); + return suffix.startsWith('/') && !/\/\d{3}\//.test(suffix); +} + +export interface JamesPublishingFixture { + planId: string; + projectId: string; + sectionMap: SectionArray; + /** Sections passed to burrito hooks (book rows resolved via sectionsAll). */ + sections: SectionD[]; + sectionsAll: SectionD[]; + passages: PassageD[]; + mediafiles: MediaFileD[]; + graphics: GraphicD[]; + sharedResources: unknown[]; +} + +function sectionRow( + id: string, + sequencenum: number, + opts: { + state?: string; + titleMediaId?: string; + planId: string; + } +): SectionD { + const rels: Record = { + plan: { data: { id: opts.planId } }, + }; + if (opts.titleMediaId) { + rels.titleMediafile = { data: { id: opts.titleMediaId } }; + } + return { + id, + type: 'section', + attributes: { + sequencenum, + state: opts.state ?? '', + }, + relationships: rels, + } as unknown as SectionD; +} + +function titleMedia(id: string, filename: string): MediaFileD { + return { + id, + type: 'mediafile', + attributes: { + audioUrl: `/tmp/${filename}`, + originalFile: filename, + contentType: 'audio/mpeg', + }, + } as unknown as MediaFileD; +} + +function noteMedia( + id: string, + passageId: string, + planId: string, + filename: string +): MediaFileD { + return { + id, + type: 'mediafile', + keys: { remoteId: id }, + attributes: { + audioUrl: `/tmp/${filename}`, + originalFile: filename, + contentType: 'audio/mpeg', + versionNumber: 1, + segments: '{}', + }, + relationships: { + plan: { data: { id: planId } }, + passage: { data: { id: passageId } }, + artifactType: { data: null }, + }, + } as unknown as MediaFileD; +} + +function sectionGraphic( + id: string, + sectionId: string, + mediaId: string +): GraphicD { + return { + id, + type: 'graphic', + keys: { remoteId: id }, + attributes: { + resourceType: 'section', + resourceId: JAMES_SECTION_REMOTE_NUM[sectionId], + info: '', + }, + relationships: { + mediafile: { data: { id: mediaId } }, + }, + } as unknown as GraphicD; +} + +function graphicMedia(id: string, filename: string): MediaFileD { + return { + id, + type: 'mediafile', + attributes: { + audioUrl: `/tmp/${filename}`, + originalFile: filename, + contentType: 'image/png', + }, + } as unknown as MediaFileD; +} + +function scripturePassage( + id: string, + sectionId: string, + reference: string, + sequencenum: number, + startChapter: number, + startVerse: number, + endChapter: number, + endVerse: number +): PassageD { + return { + id, + type: 'passage', + attributes: { + book: JAMES_BOOK, + reference, + sequencenum, + startChapter, + startVerse, + endChapter, + endVerse, + }, + relationships: { + section: { data: { id: sectionId } }, + sharedResource: { data: null }, + }, + } as unknown as PassageD; +} + +function notePassage( + id: string, + sectionId: string, + label: string, + sequencenum: number, + startChapter?: number +): PassageD { + return { + id, + type: 'passage', + attributes: { + book: JAMES_BOOK, + reference: `NOTE|${label}`, + sequencenum, + startChapter: startChapter ?? 0, + startVerse: 0, + endChapter: startChapter ?? 0, + endVerse: 0, + }, + relationships: { + section: { data: { id: sectionId } }, + sharedResource: { data: null }, + }, + } as unknown as PassageD; +} + +function chnumPassage( + id: string, + sectionId: string, + chapter: number, + sequencenum: number +): PassageD { + return { + id, + type: 'passage', + attributes: { + book: JAMES_BOOK, + reference: `CHNUM|${chapter}`, + sequencenum, + startChapter: chapter, + startVerse: 0, + endChapter: chapter, + endVerse: 0, + }, + relationships: { + section: { data: { id: sectionId } }, + sharedResource: { data: null }, + }, + } as unknown as PassageD; +} + +export function buildJamesPublishingFixture(): JamesPublishingFixture { + const planId = 'plan-james'; + const projectId = 'proj-james'; + + const sectionMap: SectionArray = [ + [1.5, 'M1'], + [2, 'M1 S1'], + [3.5, 'M2'], + [4, 'M2 S2'], + ]; + + const secBook = sectionRow('sec-book', BookSeq, { + state: `BOOK ${JAMES_BOOK}`, + titleMediaId: 'med-book-title', + planId, + }); + const secAlt = sectionRow('sec-alt', AltBkSeq, { + state: `ALTBK ${JAMES_BOOK}`, + titleMediaId: 'med-alt-title', + planId, + }); + const secM1 = sectionRow('sec-m1', 1.5, { + titleMediaId: 'med-m1-title', + planId, + }); + const secS1 = sectionRow('sec-s1', 2, { + titleMediaId: 'med-s1-title', + planId, + }); + const secM2 = sectionRow('sec-m2', 3.5, { + titleMediaId: 'med-m2-title', + planId, + }); + const secS2 = sectionRow('sec-s2', 4, { + titleMediaId: 'med-s2-title', + planId, + }); + + const sectionsAll = [secBook, secAlt, secM1, secS1, secM2, secS2]; + const sections = [secM1, secS1, secM2, secS2]; + + const passages: PassageD[] = [ + notePassage('p-note-book', 'sec-book', 'Book', 1), + notePassage('p-note-alt', 'sec-alt', 'AltBook', 1), + notePassage('p-note-m1', 'sec-m1', 'M1', 1), + chnumPassage('p-chnum-1', 'sec-s1', 1, 0), + scripturePassage( + 'p-jas-1-1', + 'sec-s1', + 'JAS 1:1', + 1, + 1, + 1, + 1, + 26 + ), + notePassage('p-note-s1', 'sec-s1', 'S1', 2, 1), + notePassage('p-note-m2', 'sec-m2', 'M2', 1), + chnumPassage('p-chnum-14', 'sec-s2', 14, 0), + scripturePassage( + 'p-jas-14-1', + 'sec-s2', + 'JAS 14:1', + 1, + 14, + 1, + 14, + 26 + ), + notePassage('p-note-s2', 'sec-s2', 'S2', 2, 14), + notePassage('p-note-ch14', 'sec-s2', 'Ch14', 3, 14), + ]; + + const mediafiles: MediaFileD[] = [ + titleMedia('med-book-title', 'book-title.mp3'), + titleMedia('med-alt-title', 'alt-title.mp3'), + titleMedia('med-m1-title', 'm1-title.mp3'), + titleMedia('med-s1-title', 's1-title.mp3'), + titleMedia('med-m2-title', 'm2-title.mp3'), + titleMedia('med-s2-title', 's2-title.mp3'), + graphicMedia('med-g-book', 'book-graphic.png'), + graphicMedia('med-g-alt', 'alt-graphic.png'), + graphicMedia('med-g-m1', 'm1-graphic.png'), + graphicMedia('med-g-s1', 's1-graphic.png'), + graphicMedia('med-g-m2', 'm2-graphic.png'), + graphicMedia('med-g-s2', 's2-graphic.png'), + noteMedia('med-book-note', 'p-note-book', planId, 'book-note.mp3'), + noteMedia('med-alt-note', 'p-note-alt', planId, 'alt-note.mp3'), + noteMedia('med-m1-note', 'p-note-m1', planId, 'm1-note.mp3'), + noteMedia('med-s1-note', 'p-note-s1', planId, 's1-note.mp3'), + noteMedia('med-m2-note', 'p-note-m2', planId, 'm2-note.mp3'), + noteMedia('med-s2-note', 'p-note-s2', planId, 's2-note.mp3'), + noteMedia('med-ch14-note', 'p-note-ch14', planId, 'ch14-note.mp3'), + noteMedia('med-chnum-1', 'p-chnum-1', planId, 'chnum-1-title.ogg'), + noteMedia('med-chnum-14', 'p-chnum-14', planId, 'chnum-14-title.ogg'), + ]; + + const graphics: GraphicD[] = [ + sectionGraphic('g-book', 'sec-book', 'med-g-book'), + sectionGraphic('g-alt', 'sec-alt', 'med-g-alt'), + sectionGraphic('g-m1', 'sec-m1', 'med-g-m1'), + sectionGraphic('g-s1', 'sec-s1', 'med-g-s1'), + sectionGraphic('g-m2', 'sec-m2', 'med-g-m2'), + sectionGraphic('g-s2', 'sec-s2', 'med-g-s2'), + ]; + + return { + planId, + projectId, + sectionMap, + sections, + sectionsAll, + passages, + mediafiles, + graphics, + sharedResources: [], + }; +} + +/** All publishing rows for notes export (includes Book / Alt Book). */ +export function jamesNoteSections(fixture: JamesPublishingFixture): SectionD[] { + return [...fixture.sectionsAll].sort( + (a, b) => a.attributes.sequencenum - b.attributes.sequencenum + ); +} diff --git a/src/renderer/src/burrito/resolveBurritoExportFolder.ts b/src/renderer/src/burrito/resolveBurritoExportFolder.ts new file mode 100644 index 00000000..a5edfbb8 --- /dev/null +++ b/src/renderer/src/burrito/resolveBurritoExportFolder.ts @@ -0,0 +1,135 @@ +import path from 'path-browserify'; +import related from '../crud/related'; +import { parseRef } from '../crud/passage'; +import { passageTypeFromRef } from '../control/passageTypeFromRef'; +import { PassageTypeEnum } from '../model/passageTypeEnum'; +import { PassageD, SectionD } from '../model'; +import { AltBkSeq, BookSeq } from '../model/section'; +import { pad3 } from '../utils/pad3'; + +export function isMovementSection(section: SectionD): boolean { + const seq = section.attributes?.sequencenum ?? 0; + return seq !== Math.floor(seq); +} + +export function isBookLevelSection(section: SectionD): boolean { + const seq = section.attributes?.sequencenum ?? 0; + return seq === BookSeq || seq === AltBkSeq; +} + +export interface ResolveBurritoExportFolderInput { + section: SectionD; + bookPath: string; + sections: SectionD[]; + passages: PassageD[]; + computeSectionRef: (sectionId: string) => string; + computeMovementRef: (sectionId: string) => string; +} + +export interface BurritoExportFolder { + folderPath: string; + /** Set when assets live under a chapter subfolder; null for book root. */ + chapter: string | null; + scopeRef: string; +} + +const sortAscend = (a: PassageD, b: PassageD) => + a.attributes.sequencenum - b.attributes.sequencenum; + +const findScripturePassage = (p: PassageD) => + passageTypeFromRef(p.attributes.reference, false) === PassageTypeEnum.PASSAGE; + +function chapterFromSectionPassages( + sectionId: string, + passages: PassageD[] +): number | undefined { + const sectPass = passages + .filter((p) => related(p, 'section') === sectionId) + .sort(sortAscend); + const chnum = sectPass.find( + (p) => + passageTypeFromRef(p.attributes.reference, false) === + PassageTypeEnum.CHAPTERNUMBER + ); + if (chnum) { + const pipe = chnum.attributes.reference.split('|'); + const ch = parseInt(pipe[1] ?? '', 10); + if (ch) return ch; + } + const firstPassage = sectPass.find(findScripturePassage); + if (firstPassage) { + parseRef(firstPassage); + return firstPassage.attributes.startChapter || 1; + } + return undefined; +} + +/** Start chapter for a movement row: first scripture/CHNUM in this movement's span. */ +function movementChapterStart( + movementSection: SectionD, + sections: SectionD[], + passages: PassageD[] +): number { + const planId = related(movementSection, 'plan'); + const sorted = sections + .filter((s) => related(s, 'plan') === planId) + .sort((a, b) => a.attributes.sequencenum - b.attributes.sequencenum); + const startIndex = sorted.findIndex((s) => s.id === movementSection.id); + if (startIndex === -1) return 1; + let endIndex = sorted.findIndex( + (s, i) => + i > startIndex && + s.attributes.sequencenum !== Math.floor(s.attributes.sequencenum) + ); + if (endIndex === -1) endIndex = sorted.length; + for (let i = startIndex; i < endIndex; i++) { + const ch = chapterFromSectionPassages(sorted[i]?.id ?? '', passages); + if (ch) return ch; + } + return 1; +} + +export function resolveBurritoExportFolder({ + section, + bookPath, + sections, + passages, + computeSectionRef, + computeMovementRef, +}: ResolveBurritoExportFolderInput): BurritoExportFolder { + if (isBookLevelSection(section)) { + return { folderPath: bookPath, chapter: null, scopeRef: '' }; + } + if (isMovementSection(section)) { + const chapterNum = movementChapterStart(section, sections, passages); + return { + folderPath: path.join(bookPath, pad3(chapterNum)), + chapter: chapterNum.toString(), + scopeRef: computeMovementRef(section.id), + }; + } + const scopeRef = computeSectionRef(section.id); + let chapterNum = parseInt(scopeRef.split(':')[0] ?? '', 10); + if (isNaN(chapterNum)) { + chapterNum = chapterFromSectionPassages(section.id, passages) ?? 1; + } + return { + folderPath: path.join(bookPath, pad3(chapterNum)), + chapter: chapterNum.toString(), + scopeRef, + }; +} + +/** Folder for a CHNUM publishing row (title media lives on the passage). */ +export function resolveChnumExportFolder( + passage: PassageD, + bookPath: string +): BurritoExportFolder { + const pipe = passage.attributes.reference.split('|'); + const chapterNum = parseInt(pipe[1] ?? '', 10) || 1; + return { + folderPath: path.join(bookPath, pad3(chapterNum)), + chapter: chapterNum.toString(), + scopeRef: `${chapterNum}`, + }; +} diff --git a/src/renderer/src/burrito/useBurritoAudio.test.ts b/src/renderer/src/burrito/useBurritoAudio.test.ts index 85478be9..d38bad1f 100644 --- a/src/renderer/src/burrito/useBurritoAudio.test.ts +++ b/src/renderer/src/burrito/useBurritoAudio.test.ts @@ -1,6 +1,16 @@ import type { Burrito } from './data/types'; import type { BibleD } from '../model'; import type { SectionD } from '../model'; +import { PassageTypeEnum } from '../model/passageTypeEnum'; +import { + JAMES_BOOK, + JAMES_BOOK_PATH, + buildJamesPublishingFixture, + destForMediaSrc, + jamesBibleFixture, + jamesNoteSections, + type JamesPublishingFixture, +} from './jamesPublishingFixture'; jest.mock('../crud/related', () => ({ __esModule: true, @@ -84,6 +94,75 @@ function loadAudioForApi(api: unknown) { return { renderHook, act, useBurritoAudio }; } +function loadAudioForJames( + api: unknown, + fixture: JamesPublishingFixture +) { + /* eslint-disable @typescript-eslint/no-require-imports */ + jest.resetModules(); + (window as unknown as { api?: unknown }).api = api; + + jest.doMock( + '../components/PassageDetail/Internalization/useComputeRef', + () => + jest.requireActual( + '../components/PassageDetail/Internalization/useComputeRef' + ) + ); + + jest.doMock('../utils/dataPath', () => ({ + __esModule: true, + PathType: { MEDIA: 'MEDIA' }, + default: jest.fn( + async (url: string, _pathType: unknown, local: { localname: string }) => { + local.localname = url; + return url; + } + ), + })); + + const { renderHook, act } = require('@testing-library/react/pure'); + const { useOrgDefaults } = require('../crud/useOrgDefaults'); + const { useOrbitData } = require('../hoc/useOrbitData'); + + useOrgDefaults.mockReturnValue(defaultOrgDefaults()); + + useOrbitData.mockImplementation((type: string) => { + if (type === 'mediafile') return fixture.mediafiles; + if (type === 'passage') return fixture.passages; + if (type === 'sectionresource') return []; + if (type === 'sharedresource') return fixture.sharedResources; + if (type === 'section') return fixture.sectionsAll; + return []; + }); + + const { useBurritoAudio } = require('./useBurritoAudio'); + /* eslint-enable @typescript-eslint/no-require-imports */ + return { renderHook, act, useBurritoAudio }; +} + +async function runJamesNotesExport( + ipc: ReturnType, + fixture: JamesPublishingFixture +) { + const { renderHook, act, useBurritoAudio } = loadAudioForJames(ipc, fixture); + const { result } = renderHook(() => useBurritoAudio('team-1')); + const metadata = burritoFixture(); + await act(async () => { + await result.current({ + metadata, + bible: jamesBibleFixture, + book: JAMES_BOOK, + bookPath: JAMES_BOOK_PATH, + preLen: 0, + sections: jamesNoteSections(fixture), + passageTypeFilter: PassageTypeEnum.NOTE, + flavorTypeName: 'x-notes', + }); + }); + return metadata; +} + function burritoFixture(): Burrito { return { format: 'burrito', @@ -782,3 +861,41 @@ describe('useBurritoAudio', () => { })); }); }); + +describe('James publishing hierarchy — note folder placement (TT-7191)', () => { + let ipc: ReturnType; + let fixture: JamesPublishingFixture; + + beforeEach(async () => { + fixture = buildJamesPublishingFixture(); + ipc = makeIpc(); + await runJamesNotesExport(ipc, fixture); + }); + + it('exports note recordings for Book and Movement 1 rows', () => { + expect(destForMediaSrc(ipc, 'book-note.mp3')).toBeDefined(); + expect(destForMediaSrc(ipc, 'm1-note.mp3')).toBeDefined(); + }); + + it('places Movement 2 note in chapter 014 folder', () => { + const dest = destForMediaSrc(ipc, 'm2-note.mp3'); + expect(dest).toBeDefined(); + expect(dest).toContain('/014/'); + }); + + it('places Section 2 and chapter 14 note recordings in chapter 014 folder', () => { + const s2Dest = destForMediaSrc(ipc, 's2-note.mp3'); + const ch14Dest = destForMediaSrc(ipc, 'ch14-note.mp3'); + expect(s2Dest).toBeDefined(); + expect(ch14Dest).toBeDefined(); + expect(s2Dest).toContain('/014/'); + expect(ch14Dest).toContain('/014/'); + }); + + it('does not place Section 2, Movement 2, or chapter 14 notes under chapter 001', () => { + const misplaced = ['s2-note.mp3', 'm2-note.mp3', 'ch14-note.mp3'] + .map((name) => destForMediaSrc(ipc, name)) + .filter((dest): dest is string => dest != null && dest.includes('/001/')); + expect(misplaced).toHaveLength(0); + }); +}); diff --git a/src/renderer/src/burrito/useBurritoAudio.ts b/src/renderer/src/burrito/useBurritoAudio.ts index 2a9e728a..790b74a7 100644 --- a/src/renderer/src/burrito/useBurritoAudio.ts +++ b/src/renderer/src/burrito/useBurritoAudio.ts @@ -31,6 +31,10 @@ import { getSegments, NamedRegions } from '../utils/namedSegments'; import { IRegion } from '../crud/useWavesurferRegions'; import { timeFmt } from '../utils/timeFmt'; import { useComputeRef } from '../components/PassageDetail/Internalization/useComputeRef'; +import { + isBookLevelSection, + resolveBurritoExportFolder, +} from './resolveBurritoExportFolder'; import { MainAPI } from '@model/main-api'; import { Stats } from 'fs'; import getMediaExt from '../utils/getMediaExt'; @@ -80,13 +84,14 @@ interface Props { export const useBurritoAudio = (teamId: string) => { const mediafiles = useOrbitData('mediafile'); const passages = useOrbitData('passage'); + const sectionsAll = useOrbitData('section'); const sectionResources = useOrbitData('sectionresource'); const sharedResources = useOrbitData('sharedresource'); const { slugFromId } = useArtifactType(teamId); const { getOrgDefault } = useOrgDefaults(); const fetchUrl = useFetchUrlNow(); const { showMessage } = useSnackBar(); - const { computeSectionRef } = useComputeRef(); + const { computeSectionRef, computeMovementRef } = useComputeRef(); return async ({ metadata, @@ -365,22 +370,47 @@ export const useBurritoAudio = (teamId: string) => { const passageRecs = passages .filter((p) => related(p, 'section') === section.id) .sort((a, b) => a.attributes.sequencenum - b.attributes.sequencenum); - let sectionRef = ''; - let sectionChapter = 0; - let sectionChapterPath = ''; - if (passageRecs.length > 0) { + const exportFolder = resolveBurritoExportFolder({ + section, + bookPath, + sections: sectionsAll, + passages, + computeSectionRef, + computeMovementRef, + }); + let sectionRef = exportFolder.scopeRef; + let sectionChapter = exportFolder.chapter + ? parseInt(exportFolder.chapter, 10) + : 0; + let sectionChapterPath = exportFolder.folderPath; + await ipc?.createFolder(sectionChapterPath); + if (exportFolder.chapter) { + chapters.add(exportFolder.chapter); + } + chapter = sectionChapter; + chapterPath = sectionChapterPath; + + if ( + passageRecs.length > 0 && + !isBookLevelSection(section) && + !exportFolder.chapter + ) { const firstP = passageRecs[0]; sectionRef = computeSectionRef(section.id); parseRef(firstP); const pt = passageTypeFromRef(firstP.attributes.reference, false); - sectionChapter = - pt === PassageTypeEnum.CHAPTERNUMBER - ? parseInt(firstP.attributes.reference.split(' ')[1], 10) || 1 - : firstP.attributes.startChapter || 1; - if (!isNaN(sectionChapter)) { + if (pt === PassageTypeEnum.CHAPTERNUMBER) { + const pipe = firstP.attributes.reference.split('|'); + sectionChapter = parseInt(pipe[1] ?? '', 10) || 1; + } else { + sectionChapter = firstP.attributes.startChapter || 1; + } + if (!isNaN(sectionChapter) && sectionChapter > 0) { sectionChapterPath = path.join(bookPath, pad3(sectionChapter)); chapters.add(sectionChapter.toString()); await ipc?.createFolder(sectionChapterPath); + chapter = sectionChapter; + chapterPath = sectionChapterPath; } } @@ -432,9 +462,12 @@ export const useBurritoAudio = (teamId: string) => { parseRef(p); let { startChapter } = p.attributes; // content before first passage with a chapter number is in chapter 1 - if (!startChapter && chapter === 0) startChapter = 1; + if (!startChapter && chapter === 0 && !isBookLevelSection(section)) { + startChapter = 1; + } if (passageType === PassageTypeEnum.CHAPTERNUMBER) { - startChapter = parseInt(lastReference.split(' ')[1]); + const pipe = p.attributes.reference.split('|'); + startChapter = parseInt(pipe[1] ?? '', 10) || 1; } // new chapter number create a new chapter folder and usfm chapter header if necessary diff --git a/src/renderer/src/burrito/useBurritoNavigation.test.ts b/src/renderer/src/burrito/useBurritoNavigation.test.ts index acbd1526..d29a72f7 100644 --- a/src/renderer/src/burrito/useBurritoNavigation.test.ts +++ b/src/renderer/src/burrito/useBurritoNavigation.test.ts @@ -3,6 +3,16 @@ import { BookSeq, type BibleD } from '../model'; import type { MediaFileD } from '../model'; import type { SectionD } from '../model'; import type { MainAPI } from '../model/main-api'; +import { + JAMES_BOOK, + JAMES_BOOK_PATH, + JAMES_SECTION_REMOTE_NUM, + buildJamesPublishingFixture, + destForMediaSrc, + isBookRootPath, + jamesBibleFixture, + type JamesPublishingFixture, +} from './jamesPublishingFixture'; jest.mock('../utils/useCompression', () => ({ ApmDim: 40, @@ -177,6 +187,132 @@ function loadNavigationForApi(api: MainAPI | undefined) { return { renderHook, act, useBurritoNavigation }; } +function loadNavigationForJames( + api: MainAPI | undefined, + fixture: JamesPublishingFixture +) { + jest.resetModules(); + (window as unknown as { api?: typeof api }).api = api; + + jest.doMock( + '../components/PassageDetail/Internalization/useComputeRef', + () => + jest.requireActual( + '../components/PassageDetail/Internalization/useComputeRef' + ) + ); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { renderHook, act } = require('@testing-library/react/pure'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useProjectDefaults, projDefSectionMap } = require('../crud/useProjectDefaults'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { findRecord, remoteId, remoteIdGuid, remoteIdNum } = require('../crud'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useGlobal } = require('../context/useGlobal'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useOrbitData } = require('../hoc/useOrbitData'); + + useProjectDefaults.mockReturnValue({ + getProjectDefault: jest.fn((key: string) => + key === projDefSectionMap ? fixture.sectionMap : undefined + ), + setProjectDefault: jest.fn(), + canSetProjectDefault: true, + getLocalDefault: jest.fn(), + setLocalDefault: jest.fn(), + }); + + const planRec = { + id: fixture.planId, + type: 'plan', + relationships: { project: { data: { id: fixture.projectId } } }, + }; + const projectRec = { id: fixture.projectId, type: 'project' }; + + findRecord.mockImplementation( + (_memory: unknown, type: string, id: string) => { + if (type === 'plan') return planRec; + if (type === 'project') return projectRec; + if (type === 'section') { + return fixture.sectionsAll.find((s) => s.id === id); + } + return undefined; + } + ); + + remoteId.mockImplementation((_type: string, localId: string) => localId); + remoteIdNum.mockImplementation((type: string, localId: string) => { + if (type === 'section') { + return JAMES_SECTION_REMOTE_NUM[localId] ?? 0; + } + return 1; + }); + remoteIdGuid.mockImplementation((type: string, numStr: string) => { + if (type === 'section') { + const num = parseInt(numStr, 10); + const entry = Object.entries(JAMES_SECTION_REMOTE_NUM).find( + ([, v]) => v === num + ); + return entry?.[0]; + } + return undefined; + }); + + useGlobal.mockImplementation((key: string) => { + if (key === 'memory') { + return [{ keyMap: {} }, jest.fn()]; + } + return [undefined, jest.fn()]; + }); + + useOrbitData.mockImplementation((model: string) => { + switch (model) { + case 'mediafile': + return fixture.mediafiles; + case 'passage': + return fixture.passages; + case 'sharedresource': + return fixture.sharedResources; + case 'graphic': + return fixture.graphics; + case 'artifactcategory': + return []; + case 'section': + return fixture.sectionsAll; + default: + return []; + } + }); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useBurritoNavigation } = require('./useBurritoNavigation'); + return { renderHook, act, useBurritoNavigation }; +} + +async function runJamesNavigationExport( + ipc: ReturnType, + fixture: JamesPublishingFixture +) { + const { renderHook, act, useBurritoNavigation } = loadNavigationForJames( + ipc as never, + fixture + ); + const { result } = renderHook(() => useBurritoNavigation('team-1')); + const metadata = burritoFixture(); + await act(async () => { + await result.current({ + metadata, + bible: jamesBibleFixture, + book: JAMES_BOOK, + bookPath: JAMES_BOOK_PATH, + preLen: 0, + sections: fixture.sections, + }); + }); + return metadata; +} + describe('useBurritoNavigation', () => { const teamId = 'team-1'; @@ -211,10 +347,9 @@ describe('useBurritoNavigation', () => { }); expect(metadata.type?.flavorType?.name).toBe('x-nav'); - expect(ipc.createFolder).toHaveBeenCalled(); expect( ipc.createFolder.mock.calls.some((c) => c[0].includes('graphics')) - ).toBe(true); + ).toBe(false); expect(ipc.write).toHaveBeenCalled(); const writePaths = (ipc.write as jest.Mock).mock.calls.map((c) => c[0]); expect(writePaths.some((p: string) => p.includes('navigation.json'))).toBe( @@ -332,3 +467,78 @@ describe('useBurritoNavigation', () => { ); }); }); + +describe('James publishing hierarchy — navigation folder placement (TT-7189, TT-7190)', () => { + let ipc: ReturnType; + let fixture: JamesPublishingFixture; + + beforeEach(async () => { + fixture = buildJamesPublishingFixture(); + ipc = makeIpc(); + await runJamesNavigationExport(ipc, fixture); + }); + + it('places Movement 2 title in chapter 014 folder', () => { + const dest = destForMediaSrc(ipc, 'm2-title.mp3'); + expect(dest).toBeDefined(); + expect(dest).toContain('/014/'); + }); + + it('places Movement 1 title in chapter 001 folder', () => { + const dest = destForMediaSrc(ipc, 'm1-title.mp3'); + expect(dest).toBeDefined(); + expect(dest).toContain('/001/'); + }); + + it('places Section 2 title in chapter 014 folder', () => { + const dest = destForMediaSrc(ipc, 's2-title.mp3'); + expect(dest).toBeDefined(); + expect(dest).toContain('/014/'); + }); + + it('places Movement 2 graphic in chapter 014 folder, not chapter 001', () => { + const dest = destForMediaSrc(ipc, 'm2-graphic.png'); + expect(dest).toBeDefined(); + expect(dest).toContain('/014/'); + expect(dest).not.toContain('/001/'); + }); + + it('places Book title at book root, not inside a chapter folder', () => { + const dest = destForMediaSrc(ipc, 'book-title.mp3'); + expect(dest).toBeDefined(); + expect(isBookRootPath(dest!, JAMES_BOOK_PATH)).toBe(true); + }); + + it('places Alt Book title at book root, not inside a chapter folder', () => { + const dest = destForMediaSrc(ipc, 'alt-title.mp3'); + expect(dest).toBeDefined(); + expect(isBookRootPath(dest!, JAMES_BOOK_PATH)).toBe(true); + }); + + it('exports Book and Alt Book section graphics to book root', () => { + const bookGraphic = destForMediaSrc(ipc, 'book-graphic.png'); + const altGraphic = destForMediaSrc(ipc, 'alt-graphic.png'); + expect(bookGraphic).toBeDefined(); + expect(altGraphic).toBeDefined(); + expect(isBookRootPath(bookGraphic!, JAMES_BOOK_PATH)).toBe(true); + expect(isBookRootPath(altGraphic!, JAMES_BOOK_PATH)).toBe(true); + }); + + it('exports CHNUM row title recordings into the matching chapter folder', () => { + const ch1 = destForMediaSrc(ipc, 'chnum-1-title.ogg'); + const ch14 = destForMediaSrc(ipc, 'chnum-14-title.ogg'); + expect(ch1).toBeDefined(); + expect(ch14).toBeDefined(); + expect(ch1).toContain('/001/'); + expect(ch14).toContain('/014/'); + }); + + it('does not create an empty book-level graphics folder without category assets', () => { + const folderPaths = (ipc.createFolder as jest.Mock).mock.calls.map( + (c) => c[0] as string + ); + expect( + folderPaths.some((p) => p.replace(/\\/g, '/').endsWith('/graphics')) + ).toBe(false); + }); +}); diff --git a/src/renderer/src/burrito/useBurritoNavigation.ts b/src/renderer/src/burrito/useBurritoNavigation.ts index eb6b6188..9c016245 100644 --- a/src/renderer/src/burrito/useBurritoNavigation.ts +++ b/src/renderer/src/burrito/useBurritoNavigation.ts @@ -43,6 +43,15 @@ import { import { useRef } from 'react'; import cleanFileName from '../utils/cleanFileName'; import { AltBkSeq, BookSeq } from '../model/section'; +import { + resolveBurritoExportFolder, + resolveChnumExportFolder, +} from './resolveBurritoExportFolder'; +import { + isPublishingTitle, + passageTypeFromRef, +} from '../control/passageTypeFromRef'; +import { PassageTypeEnum } from '../model/passageTypeEnum'; const ipc = window?.api as MainAPI; const FullSize = 1024; @@ -391,11 +400,20 @@ export const useBurritoNavigation = (teamId: string) => { for (const section of sectionsForNav) { loadSegionArr(section); - const sectionRef = computeSectionRef(section.id); - const sectionChapter = sectionRef.split(':')[0] || '1'; - navChapterPath = path.join(bookPath, pad3(parseInt(sectionChapter, 10))); + const exportFolder = resolveBurritoExportFolder({ + section, + bookPath, + sections: sectionsAll, + passages, + computeSectionRef, + computeMovementRef, + }); + navChapterPath = exportFolder.folderPath; await ipc?.createFolder(navChapterPath); - chapters.add(sectionChapter); + if (exportFolder.chapter) { + chapters.add(exportFolder.chapter); + } + const sectionRef = exportFolder.scopeRef; const sectionRemId = remoteId('section', section.id, keyMap); const titleMediaId = related(section, 'titleMediafile'); @@ -471,9 +489,23 @@ export const useBurritoNavigation = (teamId: string) => { } const categoryGraphicsPath = path.join(bookPath, 'graphics'); - await ipc?.createFolder(categoryGraphicsPath); + const categoriesForExport = artifactCategories.filter((c) => + categoryIds.has(c.id) + ); + const hasCategoryAssets = categoriesForExport.some((cat) => { + if (related(cat, 'titleMediafile')) return true; + return graphics.some( + (g) => + g.attributes.resourceType === 'category' && + g.attributes.resourceId === + remoteIdNum('artifactcategory', cat.id, keyMap) + ); + }); + + if (hasCategoryAssets) { + await ipc?.createFolder(categoryGraphicsPath); - for (const cat of artifactCategories.filter((c) => categoryIds.has(c.id))) { + for (const cat of categoriesForExport) { const catRemId = remoteId('artifactcategory', cat.id, keyMap); if (!catRemId) continue; @@ -510,20 +542,71 @@ export const useBurritoNavigation = (teamId: string) => { catRemId ); } + } } + const latestPassageTitleMedia = (passageId: string) => + mediafiles + .filter( + (mf) => + related(mf, 'passage') === passageId && !related(mf, 'artifactType') + ) + .sort( + (a, b) => b.attributes.versionNumber - a.attributes.versionNumber + )[0]; + for (const p of passages.filter((p) => sectionIds.has(related(p, 'section') as string) )) { const passRemId = remoteId('passage', p.id, keyMap); if (!passRemId) continue; const sectionId = related(p, 'section') as string; - const sectionRef = computeSectionRef(sectionId); - const { chapter, chapterPath } = await chapterPathForSectionRef( - bookPath, - sectionRef - ); - chapters.add(chapter); + const section = sectionsAll.find((s) => s.id === sectionId); + const passageType = passageTypeFromRef(p.attributes.reference, false); + const exportFolder = + passageType === PassageTypeEnum.CHAPTERNUMBER + ? resolveChnumExportFolder(p, bookPath) + : section + ? resolveBurritoExportFolder({ + section, + bookPath, + sections: sectionsAll, + passages, + computeSectionRef, + computeMovementRef, + }) + : { + folderPath: path.join(bookPath, pad3(1)), + chapter: '1', + scopeRef: computeSectionRef(sectionId), + }; + const chapterPath = exportFolder.folderPath; + const sectionRef = exportFolder.scopeRef; + await ipc?.createFolder(chapterPath); + if (exportFolder.chapter) { + chapters.add(exportFolder.chapter); + } + + if (isPublishingTitle(p.attributes.reference, false)) { + const m = latestPassageTitleMedia(p.id); + if (m) { + const ext = getMediaExt(m); + const chSuffix = + passageType === PassageTypeEnum.CHAPTERNUMBER + ? exportFolder.chapter ?? '1' + : cleanFileName(sectionRef); + const destName = `${bibleId}-${book}-chapter-${chSuffix}-title-${passRemId}.${ext}`; + const destPath = path.join(chapterPath, destName); + const ok = await processMediaFile(m, destPath, sectionRef); + if (ok) { + titleMediaManifest.push({ + resourceType: 'passage', + remoteId: passRemId, + path: destPath.substring(preLen), + }); + } + } + } const passageGraphic = graphics.find( (g) =>