Skip to content

Commit 72ee8de

Browse files
authored
Merge branch 'master' into google-docs-document-outline
2 parents 84b0941 + fbd0e96 commit 72ee8de

7 files changed

Lines changed: 146 additions & 46 deletions

File tree

apps/google-docs/src/locations/Page/Page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ const Page = () => {
5454
<>
5555
<Layout withBoxShadow={true} offsetTop={10}>
5656
{previewPayload ? (
57-
<PreviewPageView payload={previewPayload} onLeavePreview={handleReturnToMainPage} />
57+
<PreviewPageView
58+
payload={previewPayload}
59+
oauthToken={oauthToken}
60+
onLeavePreview={handleReturnToMainPage}
61+
/>
5862
) : (
5963
<MainPageView
6064
oauthToken={oauthToken}

apps/google-docs/src/locations/Page/components/mainpage/MainPageView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const MainPageView = ({
6060
<Flex flexDirection="column" alignItems="flex-start">
6161
<Heading marginBottom="spacingS">Select your file</Heading>
6262
<Paragraph>
63-
Create entries using existing content types from a Drive Integration file.
63+
Create entries using existing content types from a Google Drive file.
6464
<br />
6565
Get started by selecting the file you would like to use.
6666
</Paragraph>

apps/google-docs/src/locations/Page/components/mainpage/PreviewPageView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { PageAppSDK } from '@contentful/app-sdk';
1212

1313
interface PreviewPageViewProps {
1414
payload: PreviewPayload;
15+
oauthToken: string;
1516
onLeavePreview: () => void;
1617
}
1718

18-
export const PreviewPageView = ({ payload, onLeavePreview }: PreviewPageViewProps) => {
19+
export const PreviewPageView = ({ payload, oauthToken, onLeavePreview }: PreviewPageViewProps) => {
1920
const sdk = useSDK<PageAppSDK>();
2021
const [isConfirmCancelModalOpen, setIsConfirmCancelModalOpen] = useState(false);
2122
const fixture = loadGoogleDocsReviewFixture();
@@ -47,6 +48,8 @@ export const PreviewPageView = ({ payload, onLeavePreview }: PreviewPageViewProp
4748
<OverviewSection
4849
sdk={sdk}
4950
payload={payload || fixture}
51+
payload={payload}
52+
oauthToken={oauthToken}
5053
onReturnToMainPage={onLeavePreview}
5154
/>
5255
<Heading as="h2" marginBottom="none">

apps/google-docs/src/locations/Page/components/overview/OverviewSection.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ import { SummaryModal } from '../modals/SummaryModal';
1818
interface OverviewSectionProps {
1919
sdk: PageAppSDK;
2020
payload: PreviewPayload;
21+
oauthToken: string;
2122
onReturnToMainPage: () => void;
2223
}
2324

24-
const OverviewSection = ({ sdk, payload, onReturnToMainPage }: OverviewSectionProps) => {
25+
const OverviewSection = ({
26+
sdk,
27+
payload,
28+
oauthToken,
29+
onReturnToMainPage,
30+
}: OverviewSectionProps) => {
2531
const [contentTypeDisplayInfoMap, setContentTypeDisplayInfoMap] = useState<
2632
ContentTypeDisplayInfoMap | undefined
2733
>();
@@ -77,7 +83,12 @@ const OverviewSection = ({ sdk, payload, onReturnToMainPage }: OverviewSectionPr
7783
}
7884
setIsCreating(true);
7985
try {
80-
const result = await createEntriesFromPreviewPayload(sdk, payload, selectedEntryTempIds);
86+
const result = await createEntriesFromPreviewPayload(
87+
sdk,
88+
payload,
89+
selectedEntryTempIds,
90+
oauthToken
91+
);
8192
if (result.errors.length > 0) {
8293
sdk.notifier.error('Failed to create entries');
8394
} else {

apps/google-docs/src/services/entryService.ts

Lines changed: 93 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,89 @@ export interface EntryCreationResult {
2020
}>;
2121
}
2222

23-
async function createAssetFromUrlFast(
23+
interface AssetFileInput {
24+
fileName: string;
25+
contentType: string;
26+
upload?: string;
27+
uploadFrom?: Record<string, unknown>;
28+
}
29+
30+
async function createAssetFromUrl(
2431
cma: PageAppSDK['cma'] | ConfigAppSDK['cma'],
2532
spaceId: string,
2633
environmentId: string,
2734
url: string,
2835
defaultLocale: string,
29-
metadata?: { title?: string; altText?: string; fileName?: string; contentType?: string }
36+
metadata?: { title?: string; altText?: string; fileName?: string; contentType?: string },
37+
oauthToken?: string
3038
) {
3139
const fileName = metadata?.fileName || 'image.jpg';
3240
const contentType = metadata?.contentType || 'image/jpeg';
3341
const title = metadata?.title || metadata?.altText || 'Image';
3442

43+
let fileField: AssetFileInput;
44+
45+
if (oauthToken) {
46+
// Google content URLs are signed (the ?key= param is the credential) so no
47+
// Authorization header is needed or permitted cross-origin. Fetch the binary
48+
// directly here in the browser so Contentful's servers don't have to reach an
49+
// expiring signed URL, then upload the bytes via the CMA upload endpoint.
50+
const imageResponse = await fetch(url);
51+
if (!imageResponse.ok) {
52+
throw new Error(`Failed to fetch image (HTTP ${imageResponse.status})`);
53+
}
54+
const arrayBuffer = await imageResponse.arrayBuffer();
55+
const upload = await cma.upload.create({ spaceId, environmentId }, { file: arrayBuffer });
56+
fileField = {
57+
contentType,
58+
fileName,
59+
uploadFrom: { sys: { type: 'Link', linkType: 'Upload', id: upload.sys.id } },
60+
};
61+
} else {
62+
fileField = { contentType, fileName, upload: url };
63+
}
64+
3565
const asset = await cma.asset.create(
3666
{ spaceId, environmentId },
3767
{
3868
fields: {
3969
title: { [defaultLocale]: title },
40-
file: {
41-
[defaultLocale]: {
42-
contentType,
43-
fileName,
44-
upload: url,
45-
},
46-
},
70+
file: { [defaultLocale]: fileField },
4771
},
4872
}
4973
);
5074

51-
try {
52-
await cma.asset.processForAllLocales({ spaceId, environmentId }, asset);
53-
} catch (error) {
54-
console.error(`Failed to process asset for URL: ${url}`, error);
55-
}
75+
await cma.asset.processForAllLocales({ spaceId, environmentId }, asset);
5676

5777
return asset;
5878
}
5979

60-
async function transformFieldsForContentType(
80+
function resolveAssetPlaceholder(value: unknown, urlToAssetId: Record<string, string>): unknown {
81+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
82+
const v = value as Record<string, unknown>;
83+
const sys = v.sys;
84+
if (sys === null || typeof sys !== 'object' || Array.isArray(sys)) return value;
85+
const s = sys as Record<string, unknown>;
86+
if (s.type !== 'Link' || s.linkType !== 'Asset' || typeof s.id !== 'string') return value;
87+
const resolvedId = urlToAssetId[s.id];
88+
if (!resolvedId) {
89+
// Placeholder not found — asset creation likely failed. Return undefined so
90+
// the caller can omit this field rather than writing a dangling reference.
91+
console.warn('[asset] unresolved asset placeholder:', s.id);
92+
return undefined;
93+
}
94+
return { sys: { type: 'Link', linkType: 'Asset', id: resolvedId } };
95+
}
96+
97+
function transformFieldsForContentType(
6198
fields: Record<string, Record<string, unknown>>,
6299
contentType: ContentTypeProps | undefined,
63100
urlToAssetId?: Record<string, string>
64101
) {
65102
if (!contentType) return fields;
66103

67104
const fieldDefs = new Map(contentType.fields.map((f) => [f.id, f]));
105+
const assetMap = urlToAssetId && Object.keys(urlToAssetId).length > 0 ? urlToAssetId : undefined;
68106
const transformed: Record<string, Record<string, unknown>> = {};
69107

70108
for (const [fieldId, localizedValue] of Object.entries(fields)) {
@@ -77,9 +115,24 @@ async function transformFieldsForContentType(
77115
const perLocale: Record<string, unknown> = {};
78116
for (const [locale, value] of Object.entries(localizedValue)) {
79117
if (def.type === 'RichText') {
80-
const assetMap =
81-
urlToAssetId && Object.keys(urlToAssetId).length > 0 ? urlToAssetId : undefined;
82118
perLocale[locale] = normalizeAgentRichTextJson(value, assetMap);
119+
} else if (assetMap && def.type === 'Link' && def.linkType === 'Asset') {
120+
const resolved = resolveAssetPlaceholder(value, assetMap);
121+
if (resolved !== undefined) {
122+
perLocale[locale] = resolved;
123+
}
124+
} else if (
125+
assetMap &&
126+
def.type === 'Array' &&
127+
def.items?.linkType === 'Asset' &&
128+
Array.isArray(value)
129+
) {
130+
const resolved = value
131+
.map((item) => resolveAssetPlaceholder(item, assetMap))
132+
.filter((item): item is NonNullable<typeof item> => item !== undefined);
133+
if (resolved.length > 0) {
134+
perLocale[locale] = resolved;
135+
}
83136
} else {
84137
perLocale[locale] = value;
85138
}
@@ -96,7 +149,8 @@ async function createAssetsFromAgentOutput(
96149
spaceId: string,
97150
environmentId: string,
98151
defaultLocale: string,
99-
assets: AssetToCreate[]
152+
assets: AssetToCreate[],
153+
oauthToken?: string
100154
): Promise<Record<string, string>> {
101155
const urlToAssetId: Record<string, string> = {};
102156

@@ -106,7 +160,7 @@ async function createAssetsFromAgentOutput(
106160

107161
const assetCreationPromises = assets.map(async (asset) => {
108162
try {
109-
const createdAsset = await createAssetFromUrlFast(
163+
const createdAsset = await createAssetFromUrl(
110164
cma,
111165
spaceId,
112166
environmentId,
@@ -117,7 +171,8 @@ async function createAssetsFromAgentOutput(
117171
altText: asset.altText,
118172
fileName: asset.fileName,
119173
contentType: asset.contentType,
120-
}
174+
},
175+
oauthToken
121176
);
122177

123178
const normalizedUrl = asset.url.replace(/\s+/g, '');
@@ -141,12 +196,19 @@ async function createAssetsFromAgentOutput(
141196
if (!result) continue;
142197

143198
const { normalizedUrl, altText, assetId } = result;
199+
const rawUrl = assets[i]?.url;
144200
const placeholderId = assets[i]?.placeholderId;
145201

146202
if (placeholderId) {
147203
urlToAssetId[placeholderId] = assetId;
148204
}
149205

206+
// Store both the raw URL and the whitespace-normalised form so the lookup
207+
// in resolveAssetPlaceholder succeeds regardless of how the agent encoded
208+
// the URL in the entry field.
209+
if (rawUrl && rawUrl !== normalizedUrl) {
210+
urlToAssetId[rawUrl] = assetId;
211+
}
150212
urlToAssetId[normalizedUrl] = assetId;
151213

152214
const compositeKey = `${normalizedUrl}::${altText || 'image'}`;
@@ -163,7 +225,8 @@ async function createAssetsFromAgentOutput(
163225
export async function createEntriesFromPreviewPayload(
164226
sdk: PageAppSDK | ConfigAppSDK,
165227
payload: PreviewPayload,
166-
selectedEntryTempIds?: Set<string>
228+
selectedEntryTempIds?: Set<string>,
229+
oauthToken?: string
167230
): Promise<EntryCreationResult> {
168231
const effectivePayload =
169232
selectedEntryTempIds !== undefined
@@ -184,20 +247,23 @@ export async function createEntriesFromPreviewPayload(
184247
sdk,
185248
entriesForSpaceLocale,
186249
contentTypeIds,
187-
effectivePayload.assets
250+
effectivePayload.assets,
251+
oauthToken
188252
);
189253
}
190254

191255
/**
192256
* Creates entries in two passes: without reference fields, then patches references
193-
* (including Rich Text entry links). Assets from `assets` are created first; Rich Text
194-
* asset placeholders use the resulting id map in both passes.
257+
* (including Rich Text entry links). Assets are created first; the resulting
258+
* placeholder→id map is used to resolve asset references in RichText fields,
259+
* standalone Media fields, and arrays of Media fields across both passes.
195260
*/
196261
export async function createEntriesFromPreview(
197262
sdk: PageAppSDK | ConfigAppSDK,
198263
entries: EntryToCreate[],
199264
contentTypeIds: string[],
200-
assets: AssetToCreate[] = []
265+
assets: AssetToCreate[] = [],
266+
oauthToken?: string
201267
): Promise<EntryCreationResult> {
202268
const spaceId = sdk.ids.space;
203269
const environmentId = sdk.ids.environment;
@@ -236,7 +302,8 @@ export async function createEntriesFromPreview(
236302
spaceId,
237303
environmentId,
238304
defaultLocale,
239-
assets
305+
assets,
306+
oauthToken
240307
);
241308

242309
// PASS 1: Create all entries WITHOUT reference fields

apps/mux/frontend/src/index.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ vi.mock('contentful-management', () => ({
1919
})),
2020
}));
2121

22+
// Mock MuxPlayer to avoid Web Component errors in jsdom
23+
vi.mock('@mux/mux-player-react', () => ({
24+
default: vi.fn(() => null),
25+
}));
26+
2227
// Mock the API client
2328
vi.mock('./util/apiClient', () => ({
2429
default: vi.fn().mockImplementation(() => ({

0 commit comments

Comments
 (0)