Wire up the bulk outfit image converter

Heck yeah, it's looking great!! Good error handling too :) you can test the partial error case by changing some image URLs to have invalid IDs.
This commit is contained in:
Emi Matchu 2021-05-25 05:28:02 -07:00
parent 08eee30743
commit f9f8cdc553
2 changed files with 253 additions and 31 deletions

View file

@ -13,6 +13,12 @@ import {
Input, Input,
InputGroup, InputGroup,
InputRightElement, InputRightElement,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Spinner,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
@ -25,9 +31,10 @@ import {
VStack, VStack,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Delay, Heading1, Heading2 } from "./util"; import { Delay, ErrorMessage, Heading1, Heading2 } from "./util";
import HangerSpinner from "./components/HangerSpinner"; import HangerSpinner from "./components/HangerSpinner";
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { CheckIcon, WarningIcon } from "@chakra-ui/icons";
function OutfitUrlsPage() { function OutfitUrlsPage() {
return ( return (
@ -253,9 +260,44 @@ function SingleImageConverter() {
function BulkImageConverter() { function BulkImageConverter() {
const [inputHtml, setInputHtml] = React.useState(""); const [inputHtml, setInputHtml] = React.useState("");
const outputHtml = inputHtml const parsedUrls = parseManyS3OutfitUrlsFromHtml(inputHtml);
? "<!-- TODO: Not implemented yet! -->\n" + inputHtml const outfitIds = parsedUrls.map((pu) => pu.outfitId);
: "";
// TODO: Do this query in batches for large pages?
const { loading, error, data } = useQuery(
gql`
query OutfitUrlsBulkImageConverter($outfitIds: [ID!]!) {
outfits(ids: $outfitIds) {
id
# Rather than send requests for different sizes separately, I'm just
# requesting them all and having the client choose, to simplify the
# query. gzip should compress it very efficiently!
imageUrl600: imageUrl(size: SIZE_600)
imageUrl300: imageUrl(size: SIZE_300)
imageUrl150: imageUrl(size: SIZE_150)
}
}
`,
{
variables: { outfitIds },
skip: outfitIds.length === 0,
onError: (e) => console.error(e),
}
);
const { outputHtml, numReplacements, replacementErrors } = React.useMemo(
() =>
inputHtml && data?.outfits
? replaceS3OutfitUrlsInHtml(inputHtml, data.outfits)
: { outputHtml: "", numReplacements: 0, replacementErrors: [] },
[inputHtml, data?.outfits]
);
React.useEffect(() => {
for (const replacementError of replacementErrors) {
console.error("Error replacing outfit URLs in HTML:", replacementError);
}
}, [replacementErrors]);
const { onCopy, hasCopied } = useClipboard(outputHtml); const { onCopy, hasCopied } = useClipboard(outputHtml);
@ -283,15 +325,32 @@ function BulkImageConverter() {
/> />
</FormControl> </FormControl>
<FormControl gridArea="output"> <FormControl gridArea="output">
<Flex alignItems="center" marginBottom="2"> <Grid
templateAreas={{
base: `
"header"
"textarea"
"status"
`,
md: `
"header status"
"textarea textarea"
`,
}}
alignItems="center"
rowGap="2"
>
<Flex gridArea="header" alignItems="center">
<FormLabel fontSize="sm" margin="0"> <FormLabel fontSize="sm" margin="0">
Then, use this new HTML for your page instead: Then, use this new HTML for your page instead:
</FormLabel> </FormLabel>
<Box width="2" /> <Box width="2" flex={{ base: "1 0 auto", sm: "0 0 auto" }} />
{outputHtml && ( {outputHtml && (
<Button size="xs" onClick={onCopy}> <Button size="xs" onClick={onCopy}>
<Grid templateAreas="the-area"> <Grid templateAreas="the-area">
<Box gridArea="the-area">{hasCopied ? "Copied!" : "Copy"}</Box> <Box gridArea="the-area">
{hasCopied ? "Copied!" : "Copy"}
</Box>
{/* This invisible "Copied!" enforces a min size for the button {/* This invisible "Copied!" enforces a min size for the button
* content, so that the button never changes size. */} * content, so that the button never changes size. */}
<Box gridArea="the-area" aria-hidden visibility="hidden"> <Box gridArea="the-area" aria-hidden visibility="hidden">
@ -302,6 +361,7 @@ function BulkImageConverter() {
)} )}
</Flex> </Flex>
<Textarea <Textarea
gridArea="textarea"
isReadOnly isReadOnly
fontFamily="monospace" fontFamily="monospace"
fontSize="xs" fontSize="xs"
@ -313,12 +373,116 @@ function BulkImageConverter() {
...`} ...`}
value={outputHtml} value={outputHtml}
/> />
<Box gridArea="status" textAlign="right" justifySelf="end">
{loading ? (
<Flex alignItems="center" opacity="0.8">
<Spinner size="xs" marginRight="1.5" />
<Box fontSize="sm">
Found {outfitIds.length} outfit images, converting
</Box>
</Flex>
) : error ? (
<ErrorMessage fontSize="sm">
Error loading outfits. Try again?
</ErrorMessage>
) : inputHtml && !outputHtml && outfitIds.length === 0 ? (
<Popover trigger="hover">
<PopoverTrigger>
<Flex
as={ErrorMessage}
alignItems="center"
fontSize="sm"
tabIndex="0"
borderRadius="md"
paddingX="2"
marginRight="-2"
_focus={{ outline: "0", boxShadow: "outline" }}
>
<WarningIcon marginRight="1.5" />
<Box>No outfit image URLs found</Box>
</Flex>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Box fontSize="xs" textAlign="center">
<b>Make sure they're in the right format:</b>
<br />
https://openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png
<br />
<br />
<b>
If they're already in the new format, then you're
already done!
</b>{" "}
The new format is:
<br />
https://impress-outfit-images.openneo.net/outfits/123456789/v/1020304050/600.png
</Box>
</PopoverBody>
</PopoverContent>
</Popover>
) : outputHtml && replacementErrors.length > 0 ? (
<Popover trigger="hover">
<PopoverTrigger>
<Flex
as={
replacementErrors.length > numReplacements
? ErrorMessage
: undefined
}
alignItems="center"
fontSize="sm"
tabIndex="0"
borderRadius="md"
paddingX="2"
marginRight="-2"
_focus={{ outline: "0", boxShadow: "outline" }}
>
<WarningIcon marginRight="1.5" />
<Box>
Converted {numReplacements} outfit images, with{" "}
{replacementErrors.length} errors
</Box>
</Flex>
</PopoverTrigger>
<PopoverContent width="50ch">
<PopoverArrow />
<PopoverBody>
<Box fontSize="xs" textAlign="center">
Errors are unusual at this point in the process. Sorry
about this!
<br />
Email me at{" "}
<a href="mailto:matchu@openneo.net">
matchu@openneo.net
</a>{" "}
and I'll try to help!
<br />
We've left the {replacementErrors.length} erroring images
unchanged for now.
</Box>
</PopoverBody>
</PopoverContent>
</Popover>
) : outputHtml ? (
<Flex alignItems="center" fontSize="sm" opacity="0.8">
<CheckIcon marginRight="1.5" />
<Box>Converted {numReplacements} outfit images!</Box>
</Flex>
) : null}
</Box>
</Grid>
</FormControl> </FormControl>
</Grid> </Grid>
); );
} }
const S3_OUTFIT_URL_PATTERN = /^https?:\/\/openneo-uploads\.s3\.amazonaws\.com\/outfits\/([0-9]{3})\/([0-9]{3})\/([0-9]{3})\/(preview|medium_preview|small_preview)\.png$/; // These patterns have the same content, but different boundary conditions and
// flags. EXACT is for checking a single string for an exact match, GLOBAL is
// for finding multiple matches in large text.
const S3_OUTFIT_URL_EXACT_PATTERN = /^https?:\/\/openneo-uploads\.s3\.amazonaws\.com\/outfits\/([0-9]{3})\/([0-9]{3})\/([0-9]{3})\/(preview|medium_preview|small_preview)\.png$/;
const S3_OUTFIT_URL_GLOBAL_PATTERN = /https?:\/\/openneo-uploads\.s3\.amazonaws\.com\/outfits\/([0-9]{3})\/([0-9]{3})\/([0-9]{3})\/(preview|medium_preview|small_preview)\.png/g;
const S3_FILENAMES_TO_SIZES = { const S3_FILENAMES_TO_SIZES = {
preview: 600, preview: 600,
medium_preview: 300, medium_preview: 300,
@ -330,7 +494,7 @@ function parseS3OutfitUrl(url) {
return null; return null;
} }
const match = S3_OUTFIT_URL_PATTERN.exec(url); const match = S3_OUTFIT_URL_EXACT_PATTERN.exec(url);
if (!match) { if (!match) {
throw new Error( throw new Error(
`This URL didn't match the expected pattern. Make sure it's formatted like this: https://openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png` `This URL didn't match the expected pattern. Make sure it's formatted like this: https://openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png`
@ -345,4 +509,56 @@ function parseS3OutfitUrl(url) {
return { outfitId, size }; return { outfitId, size };
} }
function parseManyS3OutfitUrlsFromHtml(html) {
const matches = html.match(S3_OUTFIT_URL_GLOBAL_PATTERN) || [];
return matches.map(parseS3OutfitUrl);
}
function replaceS3OutfitUrlsInHtml(html, outfits) {
const outfitsById = new Map();
for (const outfit of outfits) {
if (!outfit) {
continue;
}
outfitsById.set(outfit.id, outfit);
}
// Use the `replace` method to scan the HTML for matches, which will run this
// function on each match to decide what to replace it with. We'll count
// successes and log failures along the way!
let numReplacements = 0;
const replacementErrors = [];
const outputHtml = html.replace(S3_OUTFIT_URL_GLOBAL_PATTERN, (match) => {
let newUrl;
try {
const { outfitId, size } = parseS3OutfitUrl(match);
const outfit = outfitsById.get(outfitId);
if (!outfit) {
throw new Error(`could not find outfit ${outfitId}`);
}
const sizeKey = `imageUrl` + size;
if (!(sizeKey in outfit)) {
throw new Error(
`outfit ${outfitId} has no image key ${sizeKey}: ${JSON.stringify(
outfit
)}`
);
}
newUrl = outfit[sizeKey];
} catch (e) {
e.message += ` (${match})`; // help us understand which URL failed!
replacementErrors.push(e);
return match;
}
numReplacements++;
return newUrl;
});
return { outputHtml, numReplacements, replacementErrors };
}
export default OutfitUrlsPage; export default OutfitUrlsPage;

View file

@ -31,6 +31,7 @@ const typeDefs = gql`
extend type Query { extend type Query {
outfit(id: ID!): Outfit outfit(id: ID!): Outfit
outfits(ids: [ID!]!): [Outfit]!
} }
extend type Mutation { extend type Mutation {
@ -137,6 +138,11 @@ const resolvers = {
return { id }; return { id };
}, },
outfits: async (_, { ids }, { outfitLoader }) => {
const outfits = await outfitLoader.loadMany(ids);
return outfits.map((outfit) => (outfit ? { id: outfit.id } : null));
},
}, },
Mutation: { Mutation: {