scripts/recipes/insert-recipe-cards.mjs (372 lines of code) (raw):
import crypto from "crypto";
const usage = `Example usage is
node ./insert-recipe-cards.mjs
--curation-path "northern/all-recipes"
--fronts-issue-id "b45d7c3a-497f-4230-8aad-923ce5a8cd2f"
--front-name "Meat-Free"
--stage CODE
--cookie "<get this from a Fronts client request header for the appropriate stage>"
Note that you must specify --dry-run=false in order to populate the collections with content
`;
const getArg = (flag, optional = false) => {
const argIdx = process.argv.indexOf(flag);
const arg = argIdx !== -1 ? process.argv[argIdx + 1] : "";
if (!arg && !optional) {
console.error(`No argument for ${flag} given. ${usage}`);
process.exit(2);
}
return arg;
};
const getFrontsUri = () => {
switch(stage.toLocaleUpperCase()) {
case "PROD":
return "https://fronts.gutools.co.uk";
case "CODE":
return "https://fronts.code.dev-gutools.co.uk";
case "LOCAL":
return "https://fronts.local.dev-gutools.co.uk";
default:
throw new Error("--stage must be one of PROD, CODE or LOCAL")
}
}
const curationPath = getArg("--curation-path");
const frontsIssueId = getArg("--fronts-issue-id");
const frontName = getArg("--front-name");
const stage = getArg("--stage");
const cookie = getArg("--cookie");
const dryRun = getArg("--dry-run", true) !== "false";
const curationBaseUrl = "https://recipes.guardianapis.com";
const curationUrl = `${curationBaseUrl}/${curationPath}/curation.json`;
const frontsBaseUrl = getFrontsUri();
const frontsHeaders = {
"Content-Type": "application/json",
Cookie: cookie,
};
console.log(
`Migrating curation data from ${curationPath} to ${stage} Fronts tool, issue: ${frontsIssueId}, front name: ${frontName}, dry run: ${dryRun}.`
);
if (stage === "PROD") {
console.warn(
`This will run in the PROD environment in 5 seconds - Ctrl-C to cancel.`
);
await new Promise((r) => setTimeout(r, 5000));
}
console.log(`Fetching curation data from ${curationUrl} ...`);
const curationResponse = await fetch(curationUrl);
if (curationResponse.status !== 200) {
console.error(
`Error getting issue data from Fronts tool: ${frontsResponse.status} ${
frontsResponse.statusText
} ${await frontsResponse.text()}`
);
process.exit(1);
}
/**
* @type {Array<{
* title: string,
* body: string,
* id: string,
* items: Array<
* { recipe: { id: string } } |
* { chef: { id: string }} |
* { collection: { recipes: string[] }}
* >}
* >}
*/
const curation = await curationResponse.json();
const frontsIssueUrl = `${frontsBaseUrl}/editions-api/issues/${frontsIssueId}`;
console.log(
`Got curation data.\nFetching fronts issue data from ${frontsIssueUrl}...`
);
const fetchFrontFromIssue = async () => {
const frontsResponse = await fetch(frontsIssueUrl, {
method: "GET",
headers: frontsHeaders,
});
if (frontsResponse.status !== 200) {
console.error(
`Error getting issue data from Fronts tool: ${
frontsResponse.status
} ${frontsResponse.statusText} ${await frontsResponse.text()}`
);
process.exit(1);
}
/**
* @type {{
* id: string;
* edition: string;
* issueDate: string; // YYYY-MM-dd
* createdOn: number;
* createdBy: string;
* createdEmail: string;
* launchedOn?: number;
* launchedBy: string;
* launchedEmail: string;
* fronts: Array<{
* id: string;
* displayName: string;
* isHidden: boolean;
* updatedOn?: number;
* updatedBy?: string;
* updatedEmail?: string;
* collections: Array<{
* id: string;
* displayName: string;
* prefill?: EditionsPrefill;
* isHidden: boolean;
* lastUpdated?: number;
* updatedBy?: string;
* updatedEmail?: string;
* items: any[];
* }>;
* }>;
* supportsProofing: boolean;
* lastProofedVersion?: string;
* platform: string;
* }}
*/
const issueJson = await frontsResponse.json();
console.log("Got Fronts data.");
const front = issueJson.fronts.find(
(front) => front.displayName === frontName
);
if (!front) {
console.error(
`No front found with name ${frontName} in issue ${frontsIssueId}`
);
process.exit(1);
}
return front;
};
let front = await fetchFrontFromIssue();
const collectionNamesInFrontsTool = front.collections.map(
(col) => col.displayName
);
const collectionTitlesMissingInFronts = curation
.filter((col) => !collectionNamesInFrontsTool.includes(col.title.trim()))
.map((col) => col.title);
if (collectionTitlesMissingInFronts.length) {
// Collections are added from the top, so we add the last collection first
for (const title of collectionTitlesMissingInFronts.reverse()) {
const newCollectionResponse = await fetch(
`${frontsBaseUrl}/editions-api/fronts/${front.id}/collection?name=${title}`,
{
method: "PUT",
headers: frontsHeaders,
}
);
if (newCollectionResponse.status !== 200) {
console.error(
`Error creating new collection: ${
newCollectionResponse.status
} ${
newCollectionResponse.statusText
} ${await newCollectionResponse.text()}`
);
process.exit(1);
} else {
console.log(
`Collection with title ${title} added to front ${front.id}`
);
}
}
front = await fetchFrontFromIssue();
}
const titleMap = Object.values(front.collections).reduce(
(acc, collection) => ({
...acc,
[collection.displayName]: collection,
}),
{}
);
console.log(
`Mapped titles to collections: \n\n${JSON.stringify(
Object.entries(titleMap).map(([title, col]) => `${title}: ${col.id}`),
undefined,
" "
)}`
);
const skippedCollections = [];
const updatedCollections = curation.flatMap((collection) => {
const frontCollection = titleMap[collection.title.trim()];
const frontCollectionId = frontCollection?.id;
if (!frontCollectionId) {
console.log(
`No id found mapping the title ${collection.title} to the existing front – skipping this collection`
);
skippedCollections.push(collection.title);
return [];
}
const items = collection.items.flatMap((item, index) => {
const cardMeta = {
uuid: crypto.randomUUID(),
frontPublicationDate: Date.now(),
};
const cardType = item.chef
? "chef"
: item.recipe
? "recipe"
: item.collection
? "collection"
: undefined;
if (!cardType) {
console.log(`No card type for ${item}`);
return [];
}
switch (cardType) {
case "recipe": {
return [
{
...cardMeta,
id: item.recipe.id,
cardType: "recipe",
},
];
}
case "chef": {
const { id, bio, image, foregroundHex, backgroundHex } =
item.chef;
return [
{
...cardMeta,
id: id,
frontPublicationDate: Date.now(),
cardType: "chef",
meta: {
bio: bio,
...(foregroundHex
? {
chefTheme: {
id: "custom",
palette: {
foregroundHex: foregroundHex,
backgroundHex: backgroundHex,
},
},
}
: {}),
...(image
? {
chefImageOverride: {
src: image,
origin: image,
},
}
: {}),
},
},
];
}
case "collection": {
const { title, image, lightPalette, darkPalette, recipes } =
item.collection;
return [
{
...cardMeta,
id: crypto.randomUUID(),
cardType: "feast-collection",
meta: {
title,
supporting: recipes.map((id) => ({
cardType: "recipe",
id,
uuid: crypto.randomUUID(),
frontPublicationDate: Date.now(),
})),
...(lightPalette && darkPalette
? {
feastCollectionTheme: {
id: "custom",
lightPalette,
darkPalette,
...(image ? { imageURL: image } : {}),
},
}
: {}),
},
},
];
}
default: {
console.warn("++?????++ Out of Cheese Error. Redo From Start.");
}
}
});
return {
...frontCollection,
items,
};
});
if (dryRun) {
console.log(
`Dry run, stopping. Would have updated ${
updatedCollections.length
} collections: \n\n ${JSON.stringify(
updatedCollections,
undefined,
" "
)}`
);
process.exit(0);
}
let hasError = false;
for (const updatedCollection of updatedCollections) {
if (hasError) {
console.warn(
`Skipping ${updatedCollection.id} (${updatedCollection.name}), as there were errors.`
);
break;
}
const body = JSON.stringify({
id: updatedCollection.id,
collection: updatedCollection,
});
const bodySize = new Blob([body]).size;
const response = await fetch(
`${frontsBaseUrl}/editions-api/collections/${updatedCollection.id}`,
{
method: "PUT",
headers: frontsHeaders,
body,
}
);
if (response.status !== 200) {
hasError = true;
console.error(
`Error putting new collection data for collection ${
updatedCollection.id
} from Fronts tool. Attempted to post \n\n ${JSON.stringify(
updatedCollection,
null,
" "
)}. \n\n Response was: ${response.status} ${
response.statusText
} ${await response.text()}`
);
} else {
console.log(
`Written updated collection with name: ${
updatedCollection.displayName
}, id: ${
updatedCollection.id
} payload ${new Intl.NumberFormat().format(bodySize)} bytes`
);
}
}
console.log("Script complete.");
if (hasError) {
console.warn(
`There were errors writing the updated collections to the issue.`
);
}
console.log(`Updated ${updatedCollections.length} collections`);
if (updatedCollections.length < curation.length) {
console.warn(
`${
curation.length - updatedCollections.length
} collections were not mapped onto the front. Do the collection names match exactly? \n\n ${skippedCollections.join(
"\n"
)}`
);
}
process.exit(hasError ? 1 : 0);