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:
parent
08eee30743
commit
f9f8cdc553
2 changed files with 253 additions and 31 deletions
|
@ -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,42 +325,164 @@ function BulkImageConverter() {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl gridArea="output">
|
<FormControl gridArea="output">
|
||||||
<Flex alignItems="center" marginBottom="2">
|
<Grid
|
||||||
<FormLabel fontSize="sm" margin="0">
|
templateAreas={{
|
||||||
Then, use this new HTML for your page instead:
|
base: `
|
||||||
</FormLabel>
|
"header"
|
||||||
<Box width="2" />
|
"textarea"
|
||||||
{outputHtml && (
|
"status"
|
||||||
<Button size="xs" onClick={onCopy}>
|
`,
|
||||||
<Grid templateAreas="the-area">
|
md: `
|
||||||
<Box gridArea="the-area">{hasCopied ? "Copied!" : "Copy"}</Box>
|
"header status"
|
||||||
{/* This invisible "Copied!" enforces a min size for the button
|
"textarea textarea"
|
||||||
* content, so that the button never changes size. */}
|
`,
|
||||||
<Box gridArea="the-area" aria-hidden visibility="hidden">
|
}}
|
||||||
Copied!
|
alignItems="center"
|
||||||
</Box>
|
rowGap="2"
|
||||||
</Grid>
|
>
|
||||||
</Button>
|
<Flex gridArea="header" alignItems="center">
|
||||||
)}
|
<FormLabel fontSize="sm" margin="0">
|
||||||
</Flex>
|
Then, use this new HTML for your page instead:
|
||||||
<Textarea
|
</FormLabel>
|
||||||
isReadOnly
|
<Box width="2" flex={{ base: "1 0 auto", sm: "0 0 auto" }} />
|
||||||
fontFamily="monospace"
|
{outputHtml && (
|
||||||
fontSize="xs"
|
<Button size="xs" onClick={onCopy}>
|
||||||
placeholder={`<table> <!-- Example output, your new HTML will appear here! -->
|
<Grid templateAreas="the-area">
|
||||||
|
<Box gridArea="the-area">
|
||||||
|
{hasCopied ? "Copied!" : "Copy"}
|
||||||
|
</Box>
|
||||||
|
{/* This invisible "Copied!" enforces a min size for the button
|
||||||
|
* content, so that the button never changes size. */}
|
||||||
|
<Box gridArea="the-area" aria-hidden visibility="hidden">
|
||||||
|
Copied!
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<Textarea
|
||||||
|
gridArea="textarea"
|
||||||
|
isReadOnly
|
||||||
|
fontFamily="monospace"
|
||||||
|
fontSize="xs"
|
||||||
|
placeholder={`<table> <!-- Example output, your new HTML will appear here! -->
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456700/v/1234/600.png"></td>
|
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456700/v/1234/600.png"></td>
|
||||||
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456701/v/5678/600.png"></td>
|
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456701/v/5678/600.png"></td>
|
||||||
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456702/v/9012/600.png"></td>
|
<td><img src="https://impress-outfit-images.openneo.net/outfits/123456702/v/9012/600.png"></td>
|
||||||
...`}
|
...`}
|
||||||
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;
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in a new issue