diff --git a/src/App.jsx b/src/App.jsx
index e81aaaf3a..f1c784b5d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -51,6 +51,7 @@ const GalaBoard = React.lazy(() => import("./pages/GalaPage"));
const IngredientPage = React.lazy(() => import("./pages/ingredients"));
const Brandinator = React.lazy(() => import("./pages/Brandinator"));
const BugPage = React.lazy(() => import("./pages/bug"));
+const SpellCheck = React.lazy(() => import("./pages/spell-check"));
const TaxonomyWalk = React.lazy(() => import("./pages/taxonomyWalk"));
@@ -348,6 +349,8 @@ export default function App() {
)
}
/>
+ } />
+
{showFlaggedImage && (
} />
)}
diff --git a/src/pages/spell-check/HighlightIndexes.tsx b/src/pages/spell-check/HighlightIndexes.tsx
new file mode 100644
index 000000000..b31237b6b
--- /dev/null
+++ b/src/pages/spell-check/HighlightIndexes.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+export default function Highlight({ text, highlight }) {
+ let rep = "";
+ // console.log(highlight);
+ let lastIndex = 0;
+ highlight.forEach(({ color, index }) => {
+ rep =
+ rep +
+ text.slice(lastIndex, index) +
+ `${text[index]}`;
+ lastIndex = index + 1;
+ });
+
+ rep = rep + text.slice(lastIndex);
+
+ return
;
+}
diff --git a/src/pages/spell-check/ShowImage.tsx b/src/pages/spell-check/ShowImage.tsx
new file mode 100644
index 000000000..8681e086c
--- /dev/null
+++ b/src/pages/spell-check/ShowImage.tsx
@@ -0,0 +1,105 @@
+import * as React from "react";
+import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
+import off from "../../off";
+import { OFF_IMAGE_URL } from "../../const";
+import { Button, Stack } from "@mui/material";
+import { useCountry } from "../../contexts/CountryProvider";
+interface ShowImageProps {
+ barcode: string;
+ images?: Record;
+}
+
+export default function ShowImage(props: ShowImageProps) {
+ const { barcode, images } = props;
+ const [country] = useCountry();
+
+ const [selectedImageId, setSelectedImageId] = React.useState(null);
+ const availableIngredientImages = React.useMemo<
+ { country: string; imgid: number }[]
+ >(
+ () =>
+ Object.keys(images ?? {})
+ .map((key) => {
+ if (key.startsWith("ingredients_")) {
+ return { country: key.split("_")[1], imgid: images[key].imgid };
+ }
+ return null;
+ })
+ .filter(Boolean),
+ [images],
+ );
+
+ React.useEffect(() => {
+ if (images?.[`ingredients_${country}`] !== undefined) {
+ setSelectedImageId(images?.[`ingredients_${country}`]?.imgid ?? null);
+ } else {
+ setSelectedImageId(availableIngredientImages[0]?.imgid ?? null);
+ }
+ }, [images, availableIngredientImages]);
+
+ const availableImgids = React.useMemo(() => {
+ const ids = Object.values(images ?? {})
+ .map((img) => img.imgid)
+ .filter((id) => id !== undefined);
+
+ // Dumb filter
+ return ids.filter((id, index) => !ids.slice(0, index).includes(id));
+ }, [images]);
+
+ const imageUrl = selectedImageId
+ ? `${OFF_IMAGE_URL}/${off.getFormatedBarcode(
+ barcode,
+ )}/${selectedImageId}.jpg`
+ : "";
+
+ return (
+
+
+ {availableIngredientImages.map(({ country, imgid }) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {availableImgids.map((id) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/spell-check/TextCorrection/DiffPrint.tsx b/src/pages/spell-check/TextCorrection/DiffPrint.tsx
new file mode 100644
index 000000000..bb227e108
--- /dev/null
+++ b/src/pages/spell-check/TextCorrection/DiffPrint.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { Typography } from "@mui/material";
+
+interface DiffPrintProps {
+ text: string;
+ mask: string;
+ /**
+ * Map mask character to style
+ */
+ mapping: Record;
+}
+export function DiffPrint(props: DiffPrintProps) {
+ const { text, mask, mapping } = props;
+
+ return (
+
+ {text.split("").map((char, index) => (
+
+ {char}
+
+ ))}
+
+ );
+}
diff --git a/src/pages/spell-check/TextCorrection/IngeredientDisplay.tsx b/src/pages/spell-check/TextCorrection/IngeredientDisplay.tsx
new file mode 100644
index 000000000..5ef6f8f44
--- /dev/null
+++ b/src/pages/spell-check/TextCorrection/IngeredientDisplay.tsx
@@ -0,0 +1,239 @@
+import * as React from "react";
+import Tooltip from "@mui/material/Tooltip";
+
+import { useTheme } from "@mui/material";
+
+import off from "../../../off";
+
+type BooleanEstimation = "no" | "yes" | "maybe";
+type ParsedIngredientsType = {
+ ciqual_proxy_food_code?: string;
+ id: string;
+ ingredients?: ParsedIngredientsType[];
+ is_in_taxonomy: 0 | 1;
+ origins?: string;
+ percent_estimate: number;
+ percent_max: number;
+ percent_min: number;
+ text: string;
+ vegan: BooleanEstimation;
+ vegetarian: BooleanEstimation;
+};
+
+function getColor(ingredient: ParsedIngredientsType) {
+ if (ingredient.is_in_taxonomy === 1) return "green";
+ if (ingredient.is_in_taxonomy === 0) return "orange";
+ if (ingredient.ciqual_proxy_food_code !== undefined) return "green";
+ if (ingredient.vegetarian !== undefined) return "lightgreen";
+ if (ingredient.ingredients !== undefined) return "blue";
+ return "orange";
+}
+
+function getTitle(ingredient: ParsedIngredientsType) {
+ if (ingredient.ciqual_proxy_food_code !== undefined)
+ return "This ingredient has CIQUAL id";
+ if (ingredient.vegetarian !== undefined) return "recognised as a vegetarian";
+ if (ingredient.ingredients !== undefined) return "contains sub ingredients";
+ return `unknown ingredient: ${ingredient.text}"`;
+}
+function ColorText({
+ text,
+ ingredients,
+}: {
+ text: string;
+ ingredients?: ParsedIngredientsType[];
+}) {
+ const theme = useTheme();
+ const isDark = theme.palette.mode === "dark";
+
+ const lightColors = ["gray", "black"];
+ const darkColors = ["lightgray", "white"];
+
+ if (ingredients === undefined) {
+ // Without parsing, we just split with coma
+ return text.split(",").map((txt, i) => (
+
+
+ {txt}
+
+ {i === text.split(",").length - 1 ? "" : ","}
+
+ ));
+ }
+
+ const flattendIngredients = ingredients.flatMap(
+ ({ ingredients, ...ingredient }) => [ingredient, ...(ingredients || [])],
+ );
+
+ let lastIndex = 0;
+
+ return [
+ ...flattendIngredients.map((ingredient, i) => {
+ // Don't ask me why OFF use this specific character
+ const ingredientText = ingredient.text.replace("‚", ",").toLowerCase();
+
+ const startIndex = text.toLowerCase().indexOf(ingredientText, lastIndex);
+ if (startIndex < 0) {
+ return null;
+ }
+ const endIndex = startIndex + ingredient.text.length;
+
+ const prefix = text.slice(lastIndex, startIndex);
+ const ingredientName = text.slice(startIndex, endIndex);
+ lastIndex = endIndex;
+
+ return (
+
+ {prefix}
+
+
+
+ {ingredientName}
+
+
+
+ );
+ }),
+ text.slice(lastIndex, text.length),
+ ];
+}
+
+export function useIngredientParsing() {
+ const [isLoading, setLoading] = React.useState(false);
+ const [parsings, setParsing] = React.useState({});
+
+ async function fetchIngredients(text: string, lang: string) {
+ if (parsings[text] !== undefined) {
+ return;
+ }
+
+ setLoading(true);
+ const parsing = await off.getIngedrientParsing({
+ text,
+ lang,
+ });
+ const ingredients = parsing.data?.product?.ingredients;
+ setParsing((prev) => ({ ...prev, [text]: ingredients }));
+ setLoading(false);
+ }
+
+ return { isLoading, fetchIngredients, parsings };
+}
+
+export function IngeredientDisplay(props) {
+ const { text, onChange, parsings } = props;
+
+ const theme = useTheme();
+ const isDark = theme.palette.mode === "dark";
+
+ return (
+
+
+
+ -
+ green: ingrédient dans la
+ taxonomy
+
+ -
+ orange: ingrédient inconnu
+
+
+
+ );
+}
diff --git a/src/pages/spell-check/TextCorrection/TextCorrection.tsx b/src/pages/spell-check/TextCorrection/TextCorrection.tsx
new file mode 100644
index 000000000..268e75724
--- /dev/null
+++ b/src/pages/spell-check/TextCorrection/TextCorrection.tsx
@@ -0,0 +1,187 @@
+import * as React from "react";
+import { useTextCorrection } from "./useTextCorrection";
+import Typography from "@mui/material/Typography";
+import Box from "@mui/material/Box";
+import Paper from "@mui/material/Paper";
+import Popper from "@mui/material/Popper";
+import Stack from "@mui/material/Stack";
+import Button from "@mui/material/Button";
+import { DiffPrint } from "./DiffPrint";
+import { IngeredientDisplay, useIngredientParsing } from "./IngeredientDisplay";
+
+interface TextCorrectionProps {
+ original: string;
+ correction: string;
+ barcode?: string;
+ nextItem: () => void;
+}
+
+export function TextCorrection(props: TextCorrectionProps) {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const [editedText, setEditedText] = React.useState(null);
+
+ const {
+ text,
+ suggestion,
+ suggestionChoices,
+ nbSuggestions,
+ suggestionIndex,
+ actions,
+ } = useTextCorrection(props.original, props.correction);
+ const { isLoading, fetchIngredients, parsings } = useIngredientParsing();
+
+ const hasSuggestion = suggestion != null;
+ React.useEffect(() => {
+ if (hasSuggestion) {
+ // Make sure that each time we are using suggestion the only source of truth is the textCorrection hook.
+ setEditedText(null);
+ } else {
+ fetchIngredients(text.before, "fr");
+ }
+ }, [hasSuggestion]);
+
+ return (
+
+ {suggestion ? (
+
+ {text.before}
+ {
+ setAnchorEl(item);
+ }}
+ style={{ fontWeight: "bold" }}
+ >
+ {text.section}
+
+ {text.after}
+
+ ) : (
+ <>
+ {
+ console.log(event.target.value);
+ setEditedText(event.target.value);
+ }}
+ parsings={parsings}
+ />
+
+ >
+ )}
+
+
+ {suggestion && (
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: nbSuggestions }, (_, i) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/spell-check/TextCorrection/getDiff.ts b/src/pages/spell-check/TextCorrection/getDiff.ts
new file mode 100644
index 000000000..386d01327
--- /dev/null
+++ b/src/pages/spell-check/TextCorrection/getDiff.ts
@@ -0,0 +1,157 @@
+const COST_REMOVE = 2;
+const COST_DIFF = 1;
+
+export interface SuggestionType {
+ from: number;
+ to: number;
+ current: string;
+ proposed: string;
+ proposedStart: number;
+ proposedEnd: number;
+}
+
+export type UpdateType =
+ | { type: "REMOVED_1"; index1: number }
+ | { type: "REMOVED_2"; index2: number }
+ | { type: "MODIFY"; index1: number; index2: number };
+
+/**
+ *
+ * @param text1 The original text
+ * @param text2 The corrected text
+ * @returns an array of character modification, and a list of suggestion the group those diffs per words.
+ */
+export function getDiff(
+ text1: string,
+ text2: string,
+): [UpdateType[], SuggestionType[]] {
+ const words1 = text1.split("");
+ const words2 = text2.split("");
+
+ // Comput the cost for each substring alignment.
+ const computedCost = {};
+
+ computedCost["-1_-1"] = 0;
+ for (let i = 0; i < words1.length; i += 1) {
+ computedCost[`${i}_-1`] = (i + 1) * COST_REMOVE;
+ }
+ for (let j = 0; j < words2.length; j += 1) {
+ computedCost[`-1_${j}`] = (j + 1) * COST_REMOVE;
+ }
+
+ for (let i = 0; i < words1.length; i += 1) {
+ const word1 = words1[i];
+ for (let j = 0; j < words2.length; j += 1) {
+ const word2 = words2[j];
+
+ const c1 = COST_REMOVE * word1.length + computedCost[`${i - 1}_${j}`];
+ const c2 = COST_REMOVE * word2.length + computedCost[`${i}_${j - 1}`];
+ const matchCost =
+ (word1 !== word2 ? COST_DIFF : 0) + computedCost[`${i - 1}_${j - 1}`];
+ computedCost[`${i}_${j}`] = Math.min(c1, c2, matchCost);
+ }
+ }
+
+ let i = words1.length - 1;
+ let j = words2.length - 1;
+
+ // Back path used to deduce modification from the cost matrix.
+ const updates = [];
+ while (i >= 0 && j >= 0) {
+ const word1 = words1[i];
+ const word2 = words2[j];
+
+ if (
+ computedCost[`${i}_${j}`] ===
+ COST_REMOVE * word1.length + computedCost[`${i - 1}_${j}`]
+ ) {
+ updates.push({ type: "REMOVED_1", index1: i });
+ i = i - 1;
+ } else if (
+ computedCost[`${i}_${j}`] ===
+ COST_REMOVE * word2.length + computedCost[`${i}_${j - 1}`]
+ ) {
+ updates.push({ type: "REMOVED_2", index2: j });
+ j = j - 1;
+ } else if (word1 !== word2) {
+ updates.push({ type: "MODIFY", index1: i, index2: j });
+ i = i - 1;
+ j = j - 1;
+ } else {
+ i = i - 1;
+ j = j - 1;
+ }
+ }
+
+ while (i > 0) {
+ updates.push({ type: "REMOVED_1", index1: i });
+ i = i - 1;
+ }
+ while (j > 0) {
+ updates.push({ type: "REMOVED_2", index2: j });
+ j = j - 1;
+ }
+
+ updates.reverse();
+
+ // Deduce suggestions to display.
+ // A suggestion start at a space character common to the two strings, and end at the next common space.
+ let i1 = 0;
+ let i2 = 0;
+
+ const lastCommonSpace = {
+ i1: 0,
+ i2: 0,
+ };
+
+ let updateFound = false;
+ let nextUpdateIndex = 0;
+
+ const suggestions: SuggestionType[] = [];
+ while (i1 < text1.length && i2 < text2.length) {
+ const currentUpdate = updates[nextUpdateIndex];
+
+ if (
+ currentUpdate &&
+ (currentUpdate.index1 === i1 || currentUpdate.index2 === i2)
+ ) {
+ // We are matching with an update
+ nextUpdateIndex += 1;
+ updateFound = true;
+
+ switch (currentUpdate.type) {
+ case "REMOVED_2":
+ i2 += 1;
+ break;
+ case "REMOVED_1":
+ i1 += 1;
+ break;
+ default:
+ i1 += 1;
+ i2 += 1;
+ break;
+ }
+ } else {
+ if (text1[i1] === " " && text2[i2] === " ") {
+ if (updateFound) {
+ suggestions.push({
+ from: lastCommonSpace.i1 + 1, // +1 to remove the common space
+ to: i1,
+ current: text1.slice(lastCommonSpace.i1 + 1, i1),
+ proposed: text2.slice(lastCommonSpace.i2 + 1, i2),
+ proposedStart: lastCommonSpace.i2 + 1,
+ proposedEnd: i2,
+ });
+ updateFound = false;
+ }
+ lastCommonSpace.i1 = i1;
+ lastCommonSpace.i2 = i2;
+ }
+
+ i1 += 1;
+ i2 += 1;
+ }
+ }
+
+ return [updates, suggestions];
+}
diff --git a/src/pages/spell-check/TextCorrection/useTextCorrection.ts b/src/pages/spell-check/TextCorrection/useTextCorrection.ts
new file mode 100644
index 000000000..5b870cdb2
--- /dev/null
+++ b/src/pages/spell-check/TextCorrection/useTextCorrection.ts
@@ -0,0 +1,200 @@
+import * as React from "react";
+import { getDiff, SuggestionType, UpdateType } from "./getDiff";
+
+interface UseTextCorrectionReturnValue {
+ suggestionIndex: number;
+ nbSuggestions: number;
+ suggestionChoices: boolean[];
+ text: {
+ before: string;
+ section: string;
+ after: string;
+ };
+ suggestion: null | {
+ current: string;
+ proposed: string;
+ currentMask: string;
+ proposedMask: string;
+ };
+ actions: {
+ resetSuggestions: () => void;
+ acceptSuggestion: () => void;
+ ignoreSuggestion: () => void;
+ revertLastSuggestion: () => void;
+ ignoreAllRemainingSuggestions: () => void;
+ };
+}
+
+function useDiffComputation(
+ original: string,
+ correction: string,
+): [UpdateType[], SuggestionType[]] {
+ // Might need to use web worker if I d'ont manage to speedup this function.
+ // const [diff, setDiff] = React.useState([]);
+ // const [suggestions, setSuggestions] = React.useState([]);
+
+ const [diff, suggestions] = React.useMemo(
+ () => getDiff(original, correction),
+ [original, correction],
+ );
+
+ return [diff, suggestions];
+}
+
+export function useTextCorrection(
+ original: string,
+ correction: string,
+): UseTextCorrectionReturnValue {
+ const [suggestionChoices, setSuggestionChoices] = React.useState([]);
+ const [diff, suggestions] = useDiffComputation(original, correction);
+
+ const suggestionIndex = React.useMemo(
+ () => suggestionChoices.length,
+ [suggestionChoices],
+ );
+
+ // The text with suggestions applied, and the index diff implied by those modifications.
+ const [correctedText, indexDiff] = React.useMemo(() => {
+ let indexDiff = 0;
+ let correctedText = original;
+ suggestionChoices.map((applySuggestion, sugIndex) => {
+ const suggestion = suggestions[sugIndex];
+
+ if (applySuggestion && suggestion) {
+ // We test is suggestion exist because before useEffect reset the `suggestionChoices` we have an invalid intermediate state.
+ correctedText = `${correctedText.slice(
+ 0,
+ suggestion.from + indexDiff,
+ )}${suggestion.proposed}${correctedText.slice(
+ suggestion.to + indexDiff,
+ )}`;
+ indexDiff += suggestion.proposed.length - suggestion.current.length;
+ }
+ });
+ return [correctedText, indexDiff];
+ }, [original, suggestions, suggestionChoices]);
+
+ React.useEffect(() => {
+ setSuggestionChoices([]);
+ }, [original]);
+
+ const resetSuggestions = React.useCallback(() => {
+ setSuggestionChoices([]);
+ }, []);
+
+ const acceptSuggestion = React.useCallback(() => {
+ setSuggestionChoices((p) =>
+ p.length === suggestions.length ? p : [...p, true],
+ );
+ }, []);
+
+ const ignoreSuggestion = React.useCallback(() => {
+ setSuggestionChoices((p) =>
+ p.length === suggestions.length ? p : [...p, false],
+ );
+ }, []);
+
+ const ignoreAllRemainingSuggestions = React.useCallback(() => {
+ setSuggestionChoices((p) =>
+ p.length === suggestions.length
+ ? p
+ : [
+ ...p,
+ ...Array.from(
+ { length: suggestions.length - p.length },
+ () => false,
+ ),
+ ],
+ );
+ }, []);
+
+ const revertLastSuggestion = React.useCallback(() => {
+ setSuggestionChoices((p) => p.slice(0, p.length - 1));
+ }, []);
+
+ const suggestion = suggestions[suggestionIndex];
+
+ const before = suggestion
+ ? correctedText.slice(0, suggestion.from + indexDiff)
+ : correctedText;
+ const section = suggestion
+ ? correctedText.slice(
+ suggestion.from + indexDiff,
+ suggestion.to + indexDiff,
+ )
+ : "";
+ const after = suggestion
+ ? correctedText.slice(suggestion.to + indexDiff)
+ : "";
+
+ const currentMask = suggestion
+ ? suggestion.current.split("").map(() => "*")
+ : []; // Generate an array with one start per character.
+ const proposedMask = suggestion
+ ? suggestion.proposed.split("").map(() => "*")
+ : [];
+
+ diff.forEach((itemDiff: UpdateType) => {
+ if (!suggestion) {
+ return;
+ }
+ switch (itemDiff.type) {
+ case "REMOVED_1": {
+ const i1 = itemDiff.index1 - suggestion.from;
+ if (i1 >= 0 && i1 < currentMask.length) {
+ // The diff impacts the current suggestion
+ currentMask[i1] = "D";
+ }
+ break;
+ }
+ case "REMOVED_2": {
+ const i2 = itemDiff.index2 - suggestion.proposedStart;
+ if (i2 >= 0 && i2 < proposedMask.length) {
+ // The diff impacts the current suggestion
+ proposedMask[i2] = "D";
+ }
+ break;
+ }
+ case "MODIFY": {
+ const i1 = itemDiff.index1 - suggestion.from;
+ const i2 = itemDiff.index2 - suggestion.proposedStart;
+ if (
+ i1 >= 0 &&
+ i1 < currentMask.length &&
+ i2 >= 0 &&
+ i2 < proposedMask.length
+ ) {
+ // The diff impacts the current suggestion
+ currentMask[i1] = "M";
+ proposedMask[i2] = "M";
+ }
+ }
+ }
+ });
+
+ return {
+ suggestionIndex,
+ nbSuggestions: suggestions.length,
+ suggestionChoices,
+ text: {
+ before,
+ section,
+ after,
+ },
+ suggestion: suggestion
+ ? {
+ current: suggestion.current,
+ proposed: suggestion.proposed,
+ currentMask: currentMask.join(""),
+ proposedMask: proposedMask.join(""),
+ }
+ : null,
+ actions: {
+ resetSuggestions,
+ acceptSuggestion,
+ ignoreSuggestion,
+ revertLastSuggestion,
+ ignoreAllRemainingSuggestions,
+ },
+ };
+}
diff --git a/src/pages/spell-check/index.tsx b/src/pages/spell-check/index.tsx
new file mode 100644
index 000000000..3ea44b5f9
--- /dev/null
+++ b/src/pages/spell-check/index.tsx
@@ -0,0 +1,50 @@
+import * as React from "react";
+import { useRobotoffPredictions } from "./useRobotoffPredictions";
+
+import { Box, Typography } from "@mui/material";
+import { ErrorBoundary } from "../taxonomyWalk/Error";
+import ShowImage from "./ShowImage";
+import LinksToProduct from "../nutrition/LinksToProduct";
+import { TextCorrection } from "./TextCorrection/TextCorrection";
+
+export default function Nutrition() {
+ const { isLoading, insight, nextItem, count, product } =
+ useRobotoffPredictions();
+
+ const { original, correction } = insight?.data ?? {};
+
+ if (isLoading) {
+ return Loading;
+ }
+ if (!insight) {
+ return No prediction found.;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {original === undefined || correction === undefined ? (
+ Loading texts...
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/spell-check/useRobotoffPredictions.ts b/src/pages/spell-check/useRobotoffPredictions.ts
new file mode 100644
index 000000000..1126e3d82
--- /dev/null
+++ b/src/pages/spell-check/useRobotoffPredictions.ts
@@ -0,0 +1,93 @@
+import * as React from "react";
+import axios from "axios";
+import robotoff from "../../robotoff";
+import { InsightType } from "./insight.types";
+import { useCountry } from "../../contexts/CountryProvider";
+
+export type ProductType = {
+ images: Record;
+ serving_size?: any;
+ nutriments?: any;
+};
+
+export function useRobotoffPredictions() {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [count, setCount] = React.useState(0);
+
+ const [insights, setInsights] = React.useState([]);
+
+ const [offData, setOffData] = React.useState<{
+ [barecode: string]: "loading" | ProductType;
+ }>({});
+
+ const [country] = useCountry();
+
+ React.useEffect(() => {
+ if (isLoading || insights.length > 0) {
+ return;
+ }
+ let valid = true;
+ setIsLoading(true);
+
+ robotoff
+ .getInsights(
+ "",
+ "ingredient_spellcheck",
+ "",
+ "not_annotated",
+ 1,
+ 25,
+ "",
+ country,
+ )
+ .then(({ data }) => {
+ if (!valid) {
+ return;
+ }
+
+ setCount(data.count);
+ setInsights(data.insights);
+
+ setIsLoading(false);
+ });
+
+ return () => {
+ valid = false;
+ };
+ }, [insights]);
+
+ React.useEffect(() => {
+ const barecodeToImport = insights
+ .slice(0, 5)
+ .filter((insight) => offData[insight.barcode] === undefined)
+ .map((insight) => insight.barcode);
+
+ barecodeToImport.forEach((code) => {
+ setOffData((prev) => ({ ...prev, [code]: "loading" }));
+ axios
+ .get(
+ `https://world.openfoodfacts.org/api/v2/product/${code}.json?fields=serving_size,nutriments,images`,
+ )
+ .then(({ data: { product } }) => {
+ setOffData((prev) => ({ ...prev, [code]: product }));
+ });
+ });
+ }, [insights]);
+
+ const nextItem = React.useCallback(() => {
+ setInsights((p) => p.slice(1));
+ setCount((p) => p - 1);
+ }, []);
+
+ const insight = insights[0];
+
+ const product = insight !== undefined ? offData[insight.barcode] : undefined;
+
+ return {
+ isLoading,
+ insight,
+ nextItem,
+ count,
+ product: product === "loading" ? undefined : product,
+ };
+}