mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-10 15:05:35 -04:00
chore: Nuxt 4 upgrade (#7426)
This commit is contained in:
7
frontend/app/composables/recipes/index.ts
Normal file
7
frontend/app/composables/recipes/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useFraction } from "./use-fraction";
|
||||
export { useRecipe } from "./use-recipe";
|
||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||
export { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||
80
frontend/app/composables/recipes/use-fraction.ts
Normal file
80
frontend/app/composables/recipes/use-fraction.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/* frac.js (C) 2012-present SheetJS -- http://sheetjs.com */
|
||||
/* https://developer.aliyun.com/mirror/npm/package/frac/v/0.3.0 Apache license */
|
||||
|
||||
function frac(x: number, D: number, mixed: boolean) {
|
||||
let n1 = Math.floor(x);
|
||||
let d1 = 1;
|
||||
let n2 = n1 + 1;
|
||||
let d2 = 1;
|
||||
if (x !== n1)
|
||||
while (d1 <= D && d2 <= D) {
|
||||
const m = (n1 + n2) / (d1 + d2);
|
||||
if (x === m) {
|
||||
if (d1 + d2 <= D) {
|
||||
d1 += d2;
|
||||
n1 += n2;
|
||||
d2 = D + 1;
|
||||
}
|
||||
else if (d1 > d2) d2 = D + 1;
|
||||
else d1 = D + 1;
|
||||
break;
|
||||
}
|
||||
else if (x < m) {
|
||||
n2 = n1 + n2;
|
||||
d2 = d1 + d2;
|
||||
}
|
||||
else {
|
||||
n1 = n1 + n2;
|
||||
d1 = d1 + d2;
|
||||
}
|
||||
}
|
||||
if (d1 > D) {
|
||||
d1 = d2;
|
||||
n1 = n2;
|
||||
}
|
||||
if (!mixed) return [0, n1, d1];
|
||||
const q = Math.floor(n1 / d1);
|
||||
return [q, n1 - q * d1, d1];
|
||||
}
|
||||
function cont(x: number, D: number, mixed: boolean) {
|
||||
const sgn = x < 0 ? -1 : 1;
|
||||
let B = x * sgn;
|
||||
let P_2 = 0;
|
||||
let P_1 = 1;
|
||||
let P = 0;
|
||||
let Q_2 = 1;
|
||||
let Q_1 = 0;
|
||||
let Q = 0;
|
||||
let A = Math.floor(B);
|
||||
while (Q_1 < D) {
|
||||
A = Math.floor(B);
|
||||
P = A * P_1 + P_2;
|
||||
Q = A * Q_1 + Q_2;
|
||||
if (B - A < 0.00000005) break;
|
||||
B = 1 / (B - A);
|
||||
P_2 = P_1;
|
||||
P_1 = P;
|
||||
Q_2 = Q_1;
|
||||
Q_1 = Q;
|
||||
}
|
||||
if (Q > D) {
|
||||
if (Q_1 > D) {
|
||||
Q = Q_2;
|
||||
P = P_2;
|
||||
}
|
||||
else {
|
||||
Q = Q_1;
|
||||
P = P_1;
|
||||
}
|
||||
}
|
||||
if (!mixed) return [0, sgn * P, Q];
|
||||
const q = Math.floor((sgn * P) / Q);
|
||||
return [q, sgn * P - q * Q, Q];
|
||||
}
|
||||
|
||||
export const useFraction = function () {
|
||||
return {
|
||||
frac,
|
||||
cont,
|
||||
};
|
||||
};
|
||||
352
frontend/app/composables/recipes/use-recipe-ingredients.test.ts
Normal file
352
frontend/app/composables/recipes/use-recipe-ingredients.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useLocales } from "../use-locales";
|
||||
|
||||
vi.mock("../use-locales");
|
||||
|
||||
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
|
||||
let ingredientToParserString: (ingredient: RecipeIngredient) => string;
|
||||
|
||||
describe("parseIngredientText", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
({ parseIngredientText, ingredientToParserString } = useIngredientTextParser());
|
||||
});
|
||||
|
||||
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||
quantity: 1,
|
||||
food: {
|
||||
id: "1",
|
||||
name: "Item 1",
|
||||
},
|
||||
unit: {
|
||||
id: "1",
|
||||
name: "cup",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("adds note section if note present", () => {
|
||||
const ingredient = createRecipeIngredient({ note: "custom note" });
|
||||
|
||||
expect(parseIngredientText(ingredient)).toContain("custom note");
|
||||
});
|
||||
|
||||
test("ingredient text with fraction", () => {
|
||||
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
|
||||
});
|
||||
|
||||
test("ingredient text with fraction when unit is null", () => {
|
||||
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: undefined });
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
|
||||
});
|
||||
|
||||
test("ingredient text with fraction no formatting", () => {
|
||||
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
|
||||
const result = parseIngredientText(ingredient, 1, false);
|
||||
|
||||
expect(result).not.contain("<");
|
||||
expect(result).not.contain(">");
|
||||
expect(result).contain("1 1/2");
|
||||
});
|
||||
|
||||
test("sanitizes html", () => {
|
||||
const ingredient = createRecipeIngredient({ note: "<script>alert('foo')</script>" });
|
||||
|
||||
expect(parseIngredientText(ingredient)).not.toContain("<script>");
|
||||
});
|
||||
|
||||
test("plural test : plural qty : use abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tbsps diced onions");
|
||||
});
|
||||
|
||||
test("plural test : plural qty : not abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural test : single qty : use abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("1 tbsp diced onion");
|
||||
});
|
||||
|
||||
test("plural test : single qty : not abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("1 tablespoon diced onion");
|
||||
});
|
||||
|
||||
test("plural test : small qty : use abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.5,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("0.5 tbsp diced onion");
|
||||
});
|
||||
|
||||
test("plural test : small qty : not abbreviation", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.5,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("0.5 tablespoon diced onion");
|
||||
});
|
||||
|
||||
test("plural test : zero qty", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("diced onions");
|
||||
});
|
||||
|
||||
test("plural test : single qty, scaled", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'always' strategy uses plural food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'never' strategy never uses plural food", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "never" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
unit: undefined,
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("decimal below minimum precision shows < 0.001", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.0001,
|
||||
unit: { id: "1", name: "cup", useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< 0.001 cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< <sup>1</sup><span>⁄</span><sub>10</sub> cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator without formatting shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, false)).toEqual("< 1/10 cup salt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingredientToParserString", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
({ ingredientToParserString } = useIngredientTextParser());
|
||||
});
|
||||
|
||||
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("unparsed ingredient with qty=1 and note containing fraction uses just the note", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
note: "1/2 cup apples",
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("1/2 cup apples");
|
||||
});
|
||||
|
||||
test("ingredient with originalText uses originalText", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: { id: "1", name: "cup" },
|
||||
food: { id: "1", name: "apples" },
|
||||
note: "some note",
|
||||
originalText: "half a cup of apples",
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("half a cup of apples");
|
||||
});
|
||||
|
||||
test("parsed ingredient with unit and food uses full reconstruction", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "cup" },
|
||||
food: { id: "1", name: "flour" },
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("2 cup flour");
|
||||
});
|
||||
|
||||
test("ingredient with no data returns empty string", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
note: undefined,
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("");
|
||||
});
|
||||
|
||||
test("unparsed ingredient with note starting with an integer uses just the note", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
note: "2 tbsp olive oil",
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("2 tbsp olive oil");
|
||||
});
|
||||
|
||||
test("unparsed ingredient with purely descriptive note uses just the note", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
note: "salt to taste",
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("salt to taste");
|
||||
});
|
||||
|
||||
test("originalText wins even when ingredient is unparsed (no unit, no food)", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 1,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
note: "2 tbsp olive oil",
|
||||
originalText: "two tablespoons olive oil",
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("two tablespoons olive oil");
|
||||
});
|
||||
|
||||
test("ingredient with only food (no unit) uses full reconstruction", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: undefined,
|
||||
food: { id: "1", name: "apples" },
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("2 apples");
|
||||
});
|
||||
|
||||
test("ingredient with only unit (no food) uses full reconstruction", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "cup" },
|
||||
food: undefined,
|
||||
});
|
||||
|
||||
expect(ingredientToParserString(ingredient)).toEqual("2 cup");
|
||||
});
|
||||
});
|
||||
165
frontend/app/composables/recipes/use-recipe-ingredients.ts
Normal file
165
frontend/app/composables/recipes/use-recipe-ingredients.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { useFraction } from "./use-fraction";
|
||||
import { useLocales } from "../use-locales";
|
||||
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
const FRAC_MIN_DENOM = 10;
|
||||
const DECIMAL_PRECISION = 3;
|
||||
|
||||
export function sanitizeIngredientHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["b", "q", "i", "strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
function useFoodName(food: CreateIngredientFood | IngredientFood | undefined, usePlural: boolean) {
|
||||
if (!food) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (usePlural ? food.pluralName || food.name : food.name) || "";
|
||||
}
|
||||
|
||||
function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, usePlural: boolean) {
|
||||
if (!unit) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let returnVal = "";
|
||||
if (unit.useAbbreviation) {
|
||||
returnVal = (usePlural ? unit.pluralAbbreviation || unit.abbreviation : unit.abbreviation) || "";
|
||||
}
|
||||
|
||||
if (!returnVal) {
|
||||
returnVal = (usePlural ? unit.pluralName || unit.name : unit.name) || "";
|
||||
}
|
||||
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
|
||||
if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
|
||||
}
|
||||
|
||||
type ParsedIngredientText = {
|
||||
quantity?: string;
|
||||
unit?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
|
||||
/**
|
||||
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
|
||||
*/
|
||||
recipeLink?: string;
|
||||
};
|
||||
|
||||
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
|
||||
if (quantity && quantity <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (pluralFoodHandling) {
|
||||
case "always":
|
||||
return true;
|
||||
case "without-unit":
|
||||
return !(quantity && hasUnit);
|
||||
case "never":
|
||||
return false;
|
||||
|
||||
default:
|
||||
// same as without-unit
|
||||
return !(quantity && hasUnit);
|
||||
}
|
||||
}
|
||||
|
||||
export function useIngredientTextParser() {
|
||||
const { locales, locale } = useLocales();
|
||||
|
||||
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||
const filteredLocales = locales.filter(lc => lc.value === locale.value);
|
||||
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
|
||||
|
||||
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
const scaledQuantity = Number((quantity * scale));
|
||||
|
||||
if (unit && !unit.fraction) {
|
||||
const minVal = 10 ** -DECIMAL_PRECISION;
|
||||
returnQty = scaledQuantity >= minVal
|
||||
? Number(scaledQuantity.toPrecision(DECIMAL_PRECISION)).toString()
|
||||
: `< ${minVal}`;
|
||||
}
|
||||
else {
|
||||
const minVal = 1 / FRAC_MIN_DENOM;
|
||||
const isUnderMinVal = !(scaledQuantity >= minVal);
|
||||
|
||||
const fraction = !isUnderMinVal ? frac(scaledQuantity, FRAC_MIN_DENOM, true) : [0, 1, FRAC_MIN_DENOM];
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
returnQty += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
if (isUnderMinVal) {
|
||||
returnQty = `< ${returnQty}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
};
|
||||
|
||||
function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
};
|
||||
|
||||
function ingredientToParserString(ingredient: RecipeIngredient): string {
|
||||
if (ingredient.originalText) {
|
||||
return ingredient.originalText;
|
||||
}
|
||||
|
||||
// If the ingredient has no unit and no food, it's unparsed — the note
|
||||
// contains the full ingredient text. Using parseIngredientText would
|
||||
// incorrectly prepend the quantity (e.g. "1 1/2 cup apples").
|
||||
if (!ingredient.unit && !ingredient.food) {
|
||||
return ingredient.note || "";
|
||||
}
|
||||
|
||||
return parseIngredientText(ingredient, 1, false) ?? "";
|
||||
}
|
||||
|
||||
return {
|
||||
useParsedIngredientText,
|
||||
parseIngredientText,
|
||||
ingredientToParserString,
|
||||
};
|
||||
}
|
||||
59
frontend/app/composables/recipes/use-recipe-nutrition.ts
Normal file
59
frontend/app/composables/recipes/use-recipe-nutrition.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface NutritionLabelType {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
suffix: string;
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function useNutritionLabels() {
|
||||
const i18n = useI18n();
|
||||
const labels = <NutritionLabelType>{
|
||||
calories: {
|
||||
label: i18n.t("recipe.calories"),
|
||||
suffix: i18n.t("recipe.calories-suffix"),
|
||||
},
|
||||
carbohydrateContent: {
|
||||
label: i18n.t("recipe.carbohydrate-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
cholesterolContent: {
|
||||
label: i18n.t("recipe.cholesterol-content"),
|
||||
suffix: i18n.t("recipe.milligrams"),
|
||||
},
|
||||
fatContent: {
|
||||
label: i18n.t("recipe.fat-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
fiberContent: {
|
||||
label: i18n.t("recipe.fiber-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
proteinContent: {
|
||||
label: i18n.t("recipe.protein-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
saturatedFatContent: {
|
||||
label: i18n.t("recipe.saturated-fat-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
sodiumContent: {
|
||||
label: i18n.t("recipe.sodium-content"),
|
||||
suffix: i18n.t("recipe.milligrams"),
|
||||
},
|
||||
sugarContent: {
|
||||
label: i18n.t("recipe.sugar-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
transFatContent: {
|
||||
label: i18n.t("recipe.trans-fat-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
unsaturatedFatContent: {
|
||||
label: i18n.t("recipe.unsaturated-fat-content"),
|
||||
suffix: i18n.t("recipe.grams"),
|
||||
},
|
||||
};
|
||||
|
||||
return { labels };
|
||||
}
|
||||
125
frontend/app/composables/recipes/use-recipe-permissions.test.ts
Normal file
125
frontend/app/composables/recipes/use-recipe-permissions.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useRecipePermissions } from "./use-recipe-permissions";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
describe("test use recipe permissions", () => {
|
||||
const commonUserId = "my-user-id";
|
||||
const commonGroupId = "my-group-id";
|
||||
const commonHouseholdId = "my-household-id";
|
||||
|
||||
const createRecipe = (overrides: Partial<Recipe>, isLocked = false): Recipe => ({
|
||||
id: "my-recipe-id",
|
||||
userId: commonUserId,
|
||||
groupId: commonGroupId,
|
||||
householdId: commonHouseholdId,
|
||||
settings: {
|
||||
locked: isLocked,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createUser = (overrides: Partial<UserOut>): UserOut => ({
|
||||
id: commonUserId,
|
||||
groupId: commonGroupId,
|
||||
groupSlug: "my-group",
|
||||
group: "my-group",
|
||||
householdId: commonHouseholdId,
|
||||
householdSlug: "my-household",
|
||||
household: "my-household",
|
||||
email: "bender.rodriguez@example.com",
|
||||
cacheKey: "1234",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createRecipeHousehold = (overrides: Partial<HouseholdSummary>, lockRecipeEdits = false): Ref<HouseholdSummary> => (
|
||||
ref({
|
||||
id: commonHouseholdId,
|
||||
groupId: commonGroupId,
|
||||
name: "My Household",
|
||||
slug: "my-household",
|
||||
preferences: {
|
||||
id: "my-household-preferences-id",
|
||||
lockRecipeEditsFromOtherHouseholds: lockRecipeEdits,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
);
|
||||
|
||||
test("when user is null, cannot edit", () => {
|
||||
const result = useRecipePermissions(createRecipe({}), createRecipeHousehold({}), null);
|
||||
expect(result.canEditRecipe.value).toBe(false);
|
||||
});
|
||||
|
||||
test("when user is recipe owner, can edit", () => {
|
||||
const result = useRecipePermissions(createRecipe({}), ref(), createUser({}));
|
||||
expect(result.canEditRecipe.value).toBe(true);
|
||||
});
|
||||
|
||||
test(
|
||||
"when user is not recipe owner, is correct group and household, recipe is unlocked, and household is unlocked, can edit",
|
||||
() => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}),
|
||||
createRecipeHousehold({}),
|
||||
createUser({ id: "other-user-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"when user is not recipe owner, is correct group and household, recipe is unlocked, but household is locked, can edit",
|
||||
() => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}),
|
||||
createRecipeHousehold({}, true),
|
||||
createUser({ id: "other-user-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("when user is not recipe owner, and user is other group, cannot edit", () => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}),
|
||||
createRecipeHousehold({}),
|
||||
createUser({ id: "other-user-id", groupId: "other-group-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(false);
|
||||
});
|
||||
|
||||
test("when user is not recipe owner, and user is other household, and household is unlocked, can edit", () => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}),
|
||||
createRecipeHousehold({}),
|
||||
createUser({ id: "other-user-id", householdId: "other-household-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(true);
|
||||
});
|
||||
|
||||
test("when user is not recipe owner, and user is other household, and household is locked, cannot edit", () => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}),
|
||||
createRecipeHousehold({}, true),
|
||||
createUser({ id: "other-user-id", householdId: "other-household-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(false);
|
||||
});
|
||||
|
||||
test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
|
||||
const result = useRecipePermissions(
|
||||
createRecipe({}, true),
|
||||
createRecipeHousehold({}),
|
||||
createUser({ id: "other-user-id" }),
|
||||
);
|
||||
expect(result.canEditRecipe.value).toBe(false);
|
||||
});
|
||||
|
||||
test("when user is recipe owner, and recipe is locked, and household is locked, can edit", () => {
|
||||
const result = useRecipePermissions(createRecipe({}, true), createRecipeHousehold({}, true), createUser({}));
|
||||
expect(result.canEditRecipe.value).toBe(true);
|
||||
});
|
||||
});
|
||||
44
frontend/app/composables/recipes/use-recipe-permissions.ts
Normal file
44
frontend/app/composables/recipes/use-recipe-permissions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { computed } from "vue";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
export function useRecipePermissions(
|
||||
recipe: Recipe,
|
||||
recipeHousehold: Ref<HouseholdSummary | undefined>,
|
||||
user: UserOut | null,
|
||||
) {
|
||||
const canEditRecipe = computed(() => {
|
||||
// Check recipe owner
|
||||
if (!user?.id) {
|
||||
return false;
|
||||
}
|
||||
if (user.id === recipe.userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check group and household
|
||||
if (user.groupId !== recipe.groupId) {
|
||||
return false;
|
||||
}
|
||||
if (user.householdId !== recipe.householdId) {
|
||||
if (!recipeHousehold.value?.preferences) {
|
||||
return false;
|
||||
}
|
||||
if (recipeHousehold.value?.preferences.lockRecipeEditsFromOtherHouseholds) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check recipe
|
||||
if (recipe.settings?.locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
canEditRecipe,
|
||||
};
|
||||
}
|
||||
70
frontend/app/composables/recipes/use-recipe-search.ts
Normal file
70
frontend/app/composables/recipes/use-recipe-search.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import type { UserApi } from "~/lib/api";
|
||||
import type { ExploreApi } from "~/lib/api/public/explore";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface UseRecipeSearchReturn {
|
||||
query: Ref<string>;
|
||||
error: Ref<string>;
|
||||
loading: Ref<boolean>;
|
||||
data: Ref<Recipe[]>;
|
||||
trigger(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `useRecipeSearch` constructs a basic reactive search query
|
||||
* that when `query` is changed, will search for recipes based
|
||||
* on the query. Useful for searchable list views. For advanced
|
||||
* search, use the `useRecipeQuery` composable.
|
||||
*/
|
||||
export function useRecipeSearch(api: UserApi | ExploreApi): UseRecipeSearchReturn {
|
||||
const query = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
const recipes = ref<Recipe[]>([]);
|
||||
|
||||
async function searchRecipes(term: string) {
|
||||
loading.value = true;
|
||||
const { data, error } = await api.recipes.search({
|
||||
search: term,
|
||||
page: 1,
|
||||
orderBy: "name",
|
||||
orderDirection: "asc",
|
||||
perPage: 20,
|
||||
_searchSeed: Date.now().toString(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
loading.value = false;
|
||||
recipes.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
recipes.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => query.value,
|
||||
async (term: string) => {
|
||||
await searchRecipes(term);
|
||||
},
|
||||
{ debounce: 500 },
|
||||
);
|
||||
|
||||
async function trigger() {
|
||||
await searchRecipes(query.value);
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
error,
|
||||
loading,
|
||||
data: recipes,
|
||||
trigger,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TimelineEventType } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface TimelineEventTypeData {
|
||||
value: TimelineEventType;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const useTimelineEventTypes = () => {
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const eventTypeOptions = computed<TimelineEventTypeData[]>(() => {
|
||||
return [
|
||||
{
|
||||
value: "comment",
|
||||
label: i18n.t("recipe.comment"),
|
||||
icon: $globals.icons.commentTextMultiple,
|
||||
},
|
||||
{
|
||||
value: "info",
|
||||
label: i18n.t("settings.theme.info"),
|
||||
icon: $globals.icons.informationVariant,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: i18n.t("general.system"),
|
||||
icon: $globals.icons.cog,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
eventTypeOptions,
|
||||
};
|
||||
};
|
||||
101
frontend/app/composables/recipes/use-recipe-tools.ts
Normal file
101
frontend/app/composables/recipes/use-recipe-tools.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import type { VForm } from "~/types/vuetify";
|
||||
import type { RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export const useTools = function (eager = true) {
|
||||
const workingToolData = reactive<RecipeTool>({
|
||||
id: "",
|
||||
name: "",
|
||||
slug: "",
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
const validForm = ref(false);
|
||||
|
||||
const actions = {
|
||||
getAll() {
|
||||
loading.value = true;
|
||||
const units = useAsyncData(useAsyncKey(), async () => {
|
||||
const { data } = await api.tools.getAll();
|
||||
|
||||
if (data) {
|
||||
return data.items;
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
return units;
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
loading.value = true;
|
||||
const { data } = await api.tools.getAll();
|
||||
|
||||
if (data) {
|
||||
tools.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
},
|
||||
|
||||
async createOne(domForm: VForm | null = null) {
|
||||
if (domForm && !domForm.validate()) {
|
||||
validForm.value = false;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { data } = await api.tools.createOne(workingToolData);
|
||||
|
||||
if (data) {
|
||||
tools.value?.push(data);
|
||||
}
|
||||
|
||||
domForm?.reset();
|
||||
this.reset();
|
||||
},
|
||||
|
||||
async updateOne() {
|
||||
loading.value = true;
|
||||
const { data } = await api.tools.updateOne(workingToolData.id, workingToolData);
|
||||
if (data) {
|
||||
tools.value?.push(data);
|
||||
}
|
||||
this.reset();
|
||||
},
|
||||
|
||||
async deleteOne(id: number) {
|
||||
loading.value = true;
|
||||
await api.tools.deleteOne(id);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
reset() {
|
||||
workingToolData.name = "";
|
||||
workingToolData.id = "";
|
||||
loading.value = false;
|
||||
validForm.value = true;
|
||||
},
|
||||
};
|
||||
|
||||
const tools = (() => {
|
||||
if (eager) {
|
||||
return actions.getAll();
|
||||
}
|
||||
else {
|
||||
return ref([]);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
tools,
|
||||
actions,
|
||||
workingToolData,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
46
frontend/app/composables/recipes/use-recipe.ts
Normal file
46
frontend/app/composables/recipes/use-recipe.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export const useRecipe = function (slug: string, eager = true) {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const recipe = ref<Recipe | null>(null);
|
||||
|
||||
async function fetchRecipe() {
|
||||
loading.value = true;
|
||||
const { data } = await api.recipes.getOne(slug);
|
||||
loading.value = false;
|
||||
if (data) {
|
||||
recipe.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
loading.value = true;
|
||||
const { data } = await api.recipes.deleteOne(slug);
|
||||
loading.value = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function updateRecipe(recipe: Recipe) {
|
||||
loading.value = true;
|
||||
const { data } = await api.recipes.updateOne(slug, recipe);
|
||||
loading.value = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (eager) {
|
||||
fetchRecipe();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
recipe,
|
||||
loading,
|
||||
fetchRecipe,
|
||||
deleteRecipe,
|
||||
updateRecipe,
|
||||
};
|
||||
};
|
||||
161
frontend/app/composables/recipes/use-recipes.ts
Normal file
161
frontend/app/composables/recipes/use-recipes.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ref } from "vue";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import type { OrderByNullPosition, Recipe } from "~/lib/api/types/recipe";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
|
||||
export const allRecipes = ref<Recipe[]>([]);
|
||||
export const recentRecipes = ref<Recipe[]>([]);
|
||||
|
||||
function getParams(
|
||||
orderBy: string | null = null,
|
||||
orderDirection = "desc",
|
||||
orderByNullPosition: OrderByNullPosition | null = null,
|
||||
query: RecipeSearchQuery | null = null,
|
||||
queryFilter: string | null = null,
|
||||
) {
|
||||
return {
|
||||
orderBy,
|
||||
orderDirection,
|
||||
orderByNullPosition,
|
||||
paginationSeed: query?._searchSeed, // propagate searchSeed to stabilize random order pagination
|
||||
searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data
|
||||
search: query?.search,
|
||||
cookbook: query?.cookbook,
|
||||
households: query?.households,
|
||||
categories: query?.categories,
|
||||
requireAllCategories: query?.requireAllCategories,
|
||||
tags: query?.tags,
|
||||
requireAllTags: query?.requireAllTags,
|
||||
tools: query?.tools,
|
||||
requireAllTools: query?.requireAllTools,
|
||||
foods: query?.foods,
|
||||
requireAllFoods: query?.requireAllFoods,
|
||||
queryFilter,
|
||||
};
|
||||
};
|
||||
|
||||
export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
|
||||
const router = useRouter();
|
||||
|
||||
// passing the group slug switches to using the public API
|
||||
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
|
||||
|
||||
const recipes = ref<Recipe[]>([]);
|
||||
|
||||
async function fetchMore(
|
||||
page: number,
|
||||
perPage: number,
|
||||
orderBy: string | null = null,
|
||||
orderDirection = "desc",
|
||||
orderByNullPosition: OrderByNullPosition | null = null,
|
||||
query: RecipeSearchQuery | null = null,
|
||||
queryFilter: string | null = null,
|
||||
) {
|
||||
const { data, error } = await api.recipes.getAll(
|
||||
page,
|
||||
perPage,
|
||||
getParams(orderBy, orderDirection, orderByNullPosition, query, queryFilter),
|
||||
);
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return data ? data.items : [];
|
||||
}
|
||||
|
||||
function appendRecipes(val: Array<Recipe>) {
|
||||
val.forEach((recipe) => {
|
||||
recipes.value.push(recipe);
|
||||
});
|
||||
}
|
||||
|
||||
function assignSorted(val: Array<Recipe>) {
|
||||
recipes.value = val;
|
||||
}
|
||||
|
||||
function removeRecipe(slug: string) {
|
||||
for (let i = 0; i < recipes?.value?.length; i++) {
|
||||
if (recipes?.value[i].slug === slug) {
|
||||
recipes?.value.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRecipes(val: Array<Recipe>) {
|
||||
recipes.value = val;
|
||||
}
|
||||
|
||||
async function getRandom(query: RecipeSearchQuery | null = null, queryFilter: string | null = null) {
|
||||
query = query || {};
|
||||
query._searchSeed = query._searchSeed || Date.now().toString();
|
||||
const { data } = await api.recipes.getAll(1, 1, getParams("random", "desc", null, query, queryFilter));
|
||||
if (data?.items.length) {
|
||||
return data.items[0];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recipes,
|
||||
fetchMore,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
getRandom,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRecipes = (
|
||||
all = false,
|
||||
fetchRecipes = true,
|
||||
loadFood = false,
|
||||
queryFilter: string | null = null,
|
||||
publicGroupSlug: string | null = null,
|
||||
) => {
|
||||
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
|
||||
|
||||
// recipes is non-reactive!!
|
||||
const { recipes, page, perPage } = (() => {
|
||||
if (all) {
|
||||
return {
|
||||
recipes: allRecipes,
|
||||
page: 1,
|
||||
perPage: -1,
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
recipes: recentRecipes,
|
||||
page: 1,
|
||||
perPage: 30,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
async function refreshRecipes() {
|
||||
const { data } = await api.recipes.getAll(page, perPage, { loadFood, orderBy: "created_at", queryFilter });
|
||||
if (data) {
|
||||
recipes.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllRecipes() {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
await refreshRecipes();
|
||||
});
|
||||
}
|
||||
|
||||
function assignSorted(val: Array<Recipe>) {
|
||||
recipes.value = val;
|
||||
}
|
||||
|
||||
if (fetchRecipes) {
|
||||
getAllRecipes();
|
||||
}
|
||||
|
||||
return { getAllRecipes, assignSorted, refreshRecipes };
|
||||
};
|
||||
68
frontend/app/composables/recipes/use-scaled-amount.test.ts
Normal file
68
frontend/app/composables/recipes/use-scaled-amount.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { useScaledAmount } from "./use-scaled-amount";
|
||||
|
||||
describe("test use recipe yield", () => {
|
||||
function asFrac(numerator: number, denominator: number): string {
|
||||
return `<sup>${numerator}</sup><span>⁄</span><sub>${denominator}</sub>`;
|
||||
}
|
||||
|
||||
test("base case", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3);
|
||||
expect(scaledAmount).toStrictEqual(3);
|
||||
expect(scaledAmountDisplay).toStrictEqual("3");
|
||||
});
|
||||
|
||||
test("base case scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 2);
|
||||
expect(scaledAmount).toStrictEqual(6);
|
||||
expect(scaledAmountDisplay).toStrictEqual("6");
|
||||
});
|
||||
|
||||
test("zero scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("zero quantity", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("basic fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.5);
|
||||
expect(scaledAmount).toStrictEqual(0.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 2));
|
||||
});
|
||||
|
||||
test("mixed fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5);
|
||||
expect(scaledAmount).toStrictEqual(1.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("mixed fraction scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5, 9);
|
||||
expect(scaledAmount).toStrictEqual(13.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`13${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("small scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1, 0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("small qty", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("rounded decimal", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.3344559997);
|
||||
expect(scaledAmount).toStrictEqual(1.334);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 3)}`);
|
||||
});
|
||||
});
|
||||
32
frontend/app/composables/recipes/use-scaled-amount.ts
Normal file
32
frontend/app/composables/recipes/use-scaled-amount.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useFraction } from "~/composables/recipes";
|
||||
|
||||
function formatQuantity(val: number): string {
|
||||
if (Number.isInteger(val)) {
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
let valString = "";
|
||||
const fraction = frac(val, 10, true);
|
||||
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
valString += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
valString += `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
|
||||
return valString.trim();
|
||||
}
|
||||
|
||||
export function useScaledAmount(amount: number, scale = 1) {
|
||||
const scaledAmount = Number(((amount || 0) * scale).toFixed(3));
|
||||
const scaledAmountDisplay = scaledAmount ? formatQuantity(scaledAmount) : "";
|
||||
|
||||
return {
|
||||
scaledAmount,
|
||||
scaledAmountDisplay,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user