From 41c8459a285bc3466459371a7e19b2a729e4c75a Mon Sep 17 00:00:00 2001 From: mattrace-gloo Date: Wed, 21 Oct 2026 02:25:34 -0700 Subject: [PATCH 1/2] fix(resources): pass image caption, attribution, and licenseInfo (#273) Images & Maps items omitted optional Aquifer metadata that mobile #594 already maps. Pass through content caption/attribution when present and grouping.licenseInfo like TN/TQ; never fabricate missing fields. --- .../translation-resources.license.test.ts | 121 ++++++++++++++++++ .../translation-resources.service.ts | 33 +++-- .../translation-resources.types.ts | 16 ++- 3 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/domains/translation-resources/translation-resources.license.test.ts b/src/domains/translation-resources/translation-resources.license.test.ts index dff6da95..2f32fe86 100644 --- a/src/domains/translation-resources/translation-resources.license.test.ts +++ b/src/domains/translation-resources/translation-resources.license.test.ts @@ -6,11 +6,13 @@ import { ok } from '@/lib/types'; import { getPrepareOfflineManifest, + getTranslationImages, getTranslationNotes, getTranslationQuestions, } from './translation-resources.service'; import { prepareOfflineManifestResponseSchema, + translationImagesResponseSchema, translationNotesResponseSchema, translationQuestionsResponseSchema, } from './translation-resources.types'; @@ -75,6 +77,125 @@ describe('resource license attribution', () => { } ); + it('carries licenseInfo through images — CC BY-SA attribution obligation; do not drop', async () => { + const imageHit = { + ...hit, + mediaType: 'Image' as const, + grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, + }; + // Mock Aquifer image details (not a live payload). + const imageDetail = aquiferResourceDetailsSchema.parse({ + ...detail, + mediaType: 'Image', + content: { url: 'https://cdn.example/mock-image.jpg', size: 42 }, + grouping: { + type: 'Images', + name: 'Images', + mediaType: 'Image', + licenseInfo, + collectionCode: 'Images', + }, + }); + vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit])); + vi.mocked(getResource).mockResolvedValue(ok(imageDetail)); + + const result = await getTranslationImages({ + bookCode: 'GEN', + chapter: 3, + verse: 1, + languageCode: 'eng', + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('Image hydration failed'); + expect(translationImagesResponseSchema.parse(result.data).items[0]?.licenseInfo).toEqual( + licenseInfo + ); + }); + + it('passes through mock caption and attribution on images when Aquifer content has them (#273 / mobile #594)', async () => { + const imageHit = { + ...hit, + mediaType: 'Image' as const, + grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, + }; + // Mock Aquifer image details only — not live payload proof. + const imageDetail = aquiferResourceDetailsSchema.parse({ + ...detail, + content: { + url: 'https://cdn.example/mock-image.jpg', + thumbnailUrl: 'https://cdn.example/mock-thumb.jpg', + caption: 'Mock map caption', + attribution: 'Mock explicit credit', + size: 11, + }, + grouping: { + type: 'Images', + name: 'Images', + mediaType: 'Image', + licenseInfo, + collectionCode: 'Images', + }, + }); + vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit])); + vi.mocked(getResource).mockResolvedValue(ok(imageDetail)); + + const result = await getTranslationImages({ + bookCode: 'GEN', + chapter: 3, + verse: 1, + languageCode: 'eng', + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('Image hydration failed'); + const item = translationImagesResponseSchema.parse(result.data).items[0]; + expect(item).toMatchObject({ + url: 'https://cdn.example/mock-image.jpg', + thumbnailUrl: 'https://cdn.example/mock-thumb.jpg', + caption: 'Mock map caption', + attribution: 'Mock explicit credit', + licenseInfo, + size: 11, + }); + }); + + it('omits caption, attribution, and licenseInfo on images when Aquifer mock omits them (#273 / mobile #594)', async () => { + const imageHit = { + ...hit, + mediaType: 'Image' as const, + grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, + }; + const imageDetail = aquiferResourceDetailsSchema.parse({ + ...detail, + content: { url: 'https://cdn.example/mock-image.jpg', size: 3 }, + grouping: { + type: 'Images', + name: 'Images', + mediaType: 'Image', + collectionCode: 'Images', + }, + }); + vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit])); + vi.mocked(getResource).mockResolvedValue(ok(imageDetail)); + + const result = await getTranslationImages({ + bookCode: 'GEN', + chapter: 3, + verse: 1, + languageCode: 'eng', + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('Image hydration failed'); + const item = translationImagesResponseSchema.parse(result.data).items[0]; + expect(item).toMatchObject({ + url: 'https://cdn.example/mock-image.jpg', + size: 3, + }); + expect(item).not.toHaveProperty('caption'); + expect(item).not.toHaveProperty('attribution'); + expect(item).not.toHaveProperty('licenseInfo'); + expect(item).not.toHaveProperty('thumbnailUrl'); + }); + it.each([true, false])( 'carries licenseInfo through the manifest (includeContent=%s) — CC BY-SA attribution obligation; do not drop', async (includeContent) => { diff --git a/src/domains/translation-resources/translation-resources.service.ts b/src/domains/translation-resources/translation-resources.service.ts index 593f6a4d..c1d3d221 100644 --- a/src/domains/translation-resources/translation-resources.service.ts +++ b/src/domains/translation-resources/translation-resources.service.ts @@ -23,14 +23,9 @@ export const AQUIFER_RESOURCE_COLLECTIONS = { translationQuestions: 'UWTranslationQuestions', } as const; -/** Parallel getResource calls when hydrating search hits. */ -const HYDRATE_CONCURRENCY = 5; -/** Max hydrated items kept per MANIFEST_SEARCH_CONFIGS entry. */ -const MAX_MANIFEST_ITEMS_PER_CONFIG = 100; -/** - * Aquifer end-verse window for chapter-range searches. Chapters never exceed - * this verse count; mirrors fluent-mobile Prepare Offline (1–200). - */ +const HYDRATE_CONCURRENCY = 5; // parallel getResource hydrate +const MAX_MANIFEST_ITEMS_PER_CONFIG = 100; // per MANIFEST_SEARCH_CONFIGS entry +/** Aquifer chapter end-verse window (mobile Prepare Offline 1–200). */ const CHAPTER_RANGE_END_VERSE = 200; interface ResourceSearchConfig { @@ -41,11 +36,7 @@ interface ResourceSearchConfig { resourceType?: AquiferResourceType; } -/** - * Same catalog mobile Prepare Offline already searches via Aquifer directly - * (see fluent-mobile prepareOfflineResourceManifest.ts) — excluding Bible text - * which remains a Fluent/DBL concern. - */ +/** Prepare Offline Aquifer catalog (excludes Bible text / DBL). */ const MANIFEST_SEARCH_CONFIGS: ResourceSearchConfig[] = [ { tier: 2, @@ -120,6 +111,8 @@ interface ExtractedAquiferAsset { url: string; size?: number; thumbnailUrl?: string; + caption?: string; + attribution?: string; } function ownString(record: Record, fieldName: string): string | undefined { @@ -127,19 +120,20 @@ function ownString(record: Record, fieldName: string): string | return typeof value === 'string' && value.length > 0 ? value : undefined; } -/** - * Read url/href, size, and thumbnailUrl from a single Aquifer asset record. - * Nested sibling records must not contribute mixed fields. - */ +/** Own-record url/href + optional media metadata; do not mix nested siblings. */ function readAssetFromRecord(record: Record): ExtractedAquiferAsset | undefined { const url = ownString(record, 'url') ?? ownString(record, 'href'); if (!url) return undefined; const size = typeof record.size === 'number' ? record.size : undefined; const thumbnailUrl = ownString(record, 'thumbnailUrl'); + const caption = ownString(record, 'caption'); + const attribution = ownString(record, 'attribution'); return { url, ...(size !== undefined ? { size } : {}), ...(thumbnailUrl ? { thumbnailUrl } : {}), + ...(caption ? { caption } : {}), + ...(attribution ? { attribution } : {}), }; } @@ -354,7 +348,12 @@ export async function getTranslationImagesForRange( localizedName: hit.localizedName || hit.name, url: asset.url, ...(asset.thumbnailUrl ? { thumbnailUrl: asset.thumbnailUrl } : {}), + ...(asset.caption ? { caption: asset.caption } : {}), + ...(asset.attribution ? { attribution: asset.attribution } : {}), size: asset.size, + ...(details.grouping.licenseInfo !== undefined + ? { licenseInfo: details.grouping.licenseInfo } + : {}), }); } return ok({ items }); diff --git a/src/domains/translation-resources/translation-resources.types.ts b/src/domains/translation-resources/translation-resources.types.ts index 86b3c626..4473f724 100644 --- a/src/domains/translation-resources/translation-resources.types.ts +++ b/src/domains/translation-resources/translation-resources.types.ts @@ -262,8 +262,22 @@ export const translationImageItemSchema = z title: z.string(), localizedName: z.string(), url: z.string(), - thumbnailUrl: z.string().optional(), + thumbnailUrl: z + .string() + .optional() + .openapi({ description: 'Optional Aquifer content.thumbnailUrl when distinct from url' }), + caption: z.string().optional().openapi({ + description: + 'Optional Aquifer content.caption string when present; omitted when absent (no fabricated caption)', + }), + attribution: z.string().optional().openapi({ + description: + 'Optional Aquifer content.attribution string when present; clients may also derive credit from licenseInfo', + }), size: z.number().int().nonnegative().optional(), + licenseInfo: aquiferLicenseInfoWireSchema.optional().openapi({ + description: 'Aquifer grouping.licenseInfo when present (same wire as TN/TQ)', + }), }) .openapi('TranslationImageItem'); From 73de09eb80a1c59b580d3b50757af57ea0b2ee39 Mon Sep 17 00:00:00 2001 From: mattrace-gloo Date: Wed, 21 Oct 2026 13:50:59 -0700 Subject: [PATCH 2/2] fix(resources): address #435 review nits on image metadata Align thumbnailUrl OpenAPI with emit behavior, restore the mobile prepareOfflineResourceManifest pointer, and reuse imageSearchHit in license tests. Co-authored-by: Cursor --- .../translation-resources.license.test.ts | 19 ++++--------------- .../translation-resources.service.ts | 6 +++++- .../translation-resources.types.ts | 2 +- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/domains/translation-resources/translation-resources.license.test.ts b/src/domains/translation-resources/translation-resources.license.test.ts index 2f32fe86..4ad8dd6a 100644 --- a/src/domains/translation-resources/translation-resources.license.test.ts +++ b/src/domains/translation-resources/translation-resources.license.test.ts @@ -10,6 +10,7 @@ import { getTranslationNotes, getTranslationQuestions, } from './translation-resources.service'; +import { imageSearchHit } from './translation-resources.test-fixtures'; import { prepareOfflineManifestResponseSchema, translationImagesResponseSchema, @@ -78,11 +79,7 @@ describe('resource license attribution', () => { ); it('carries licenseInfo through images — CC BY-SA attribution obligation; do not drop', async () => { - const imageHit = { - ...hit, - mediaType: 'Image' as const, - grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, - }; + const imageHit = imageSearchHit(hit.id); // Mock Aquifer image details (not a live payload). const imageDetail = aquiferResourceDetailsSchema.parse({ ...detail, @@ -113,11 +110,7 @@ describe('resource license attribution', () => { }); it('passes through mock caption and attribution on images when Aquifer content has them (#273 / mobile #594)', async () => { - const imageHit = { - ...hit, - mediaType: 'Image' as const, - grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, - }; + const imageHit = imageSearchHit(hit.id); // Mock Aquifer image details only — not live payload proof. const imageDetail = aquiferResourceDetailsSchema.parse({ ...detail, @@ -159,11 +152,7 @@ describe('resource license attribution', () => { }); it('omits caption, attribution, and licenseInfo on images when Aquifer mock omits them (#273 / mobile #594)', async () => { - const imageHit = { - ...hit, - mediaType: 'Image' as const, - grouping: { type: 'Images' as const, name: 'Images', collectionCode: 'Images' }, - }; + const imageHit = imageSearchHit(hit.id); const imageDetail = aquiferResourceDetailsSchema.parse({ ...detail, content: { url: 'https://cdn.example/mock-image.jpg', size: 3 }, diff --git a/src/domains/translation-resources/translation-resources.service.ts b/src/domains/translation-resources/translation-resources.service.ts index c1d3d221..a11805fd 100644 --- a/src/domains/translation-resources/translation-resources.service.ts +++ b/src/domains/translation-resources/translation-resources.service.ts @@ -36,7 +36,11 @@ interface ResourceSearchConfig { resourceType?: AquiferResourceType; } -/** Prepare Offline Aquifer catalog (excludes Bible text / DBL). */ +/** + * Same catalog mobile Prepare Offline already searches via Aquifer directly + * (see fluent-mobile prepareOfflineResourceManifest.ts) — excluding Bible text + * which remains a Fluent/DBL concern. + */ const MANIFEST_SEARCH_CONFIGS: ResourceSearchConfig[] = [ { tier: 2, diff --git a/src/domains/translation-resources/translation-resources.types.ts b/src/domains/translation-resources/translation-resources.types.ts index 4473f724..1b52e9ae 100644 --- a/src/domains/translation-resources/translation-resources.types.ts +++ b/src/domains/translation-resources/translation-resources.types.ts @@ -265,7 +265,7 @@ export const translationImageItemSchema = z thumbnailUrl: z .string() .optional() - .openapi({ description: 'Optional Aquifer content.thumbnailUrl when distinct from url' }), + .openapi({ description: 'Optional Aquifer content.thumbnailUrl when present (non-empty)' }), caption: z.string().optional().openapi({ description: 'Optional Aquifer content.caption string when present; omitted when absent (no fabricated caption)',