1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 15:49:42 +02:00

feat: server side search (#2112) (#2117)

* feat: server side search API (#2112)

* refactor repository_recipes filter building

* add food filter to recipe repository page_all

* fix query type annotations

* working search

* add tests and make sure title matches are ordered correctly

* remove instruction matching again

* fix formatting and small issues

* fix another linting error

* make search test no rely on actual words

* fix failing postgres compiled query

* revise incorrectly ordered migration

* automatically extract latest migration version

* test migration orderes

* run type generators

* new search function

* wip: new search page

* sortable field options

* fix virtual scroll issue

* fix search casing bug

* finalize search filters/sorts

* remove old composable

* fix type errors

---------

Co-authored-by: Sören <fleshgolem@gmx.net>
This commit is contained in:
Hayden 2023-02-11 21:26:10 -09:00 committed by GitHub
parent fc105dcebc
commit 71f8c1066a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1057 additions and 822 deletions

View file

@ -0,0 +1,37 @@
"""add more indices necessary for search
Revision ID: 16160bf731a0
Revises: ff5f73b01a7a
Create Date: 2023-02-10 21:18:32.405130
"""
import sqlalchemy as sa
import mealie.db.migration_types
from alembic import op
# revision identifiers, used by Alembic.
revision = "16160bf731a0"
down_revision = "ff5f73b01a7a"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f("ix_recipe_instructions_text"), "recipe_instructions", ["text"], unique=False)
op.create_index(op.f("ix_recipes_description"), "recipes", ["description"], unique=False)
op.create_index(op.f("ix_recipes_ingredients_note"), "recipes_ingredients", ["note"], unique=False)
op.create_index(
op.f("ix_recipes_ingredients_original_text"), "recipes_ingredients", ["original_text"], unique=False
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_recipes_ingredients_original_text"), table_name="recipes_ingredients")
op.drop_index(op.f("ix_recipes_ingredients_note"), table_name="recipes_ingredients")
op.drop_index(op.f("ix_recipes_description"), table_name="recipes")
op.drop_index(op.f("ix_recipe_instructions_text"), table_name="recipe_instructions")
# ### end Alembic commands ###

View file

@ -35,7 +35,7 @@
</v-card-actions> </v-card-actions>
<RecipeCardMobile <RecipeCardMobile
v-for="(recipe, index) in results.slice(0, 10)" v-for="(recipe, index) in searchResults"
:key="index" :key="index"
:tabindex="index" :tabindex="index"
class="ma-1 arrow-nav" class="ma-1 arrow-nav"
@ -55,9 +55,10 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, toRefs, reactive, ref, watch, useRoute } from "@nuxtjs/composition-api"; import { defineComponent, toRefs, reactive, ref, watch, useRoute } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useRecipes, allRecipes, useRecipeSearch } from "~/composables/recipes";
import { RecipeSummary } from "~/lib/api/types/recipe"; import { RecipeSummary } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
const SELECTED_EVENT = "selected"; const SELECTED_EVENT = "selected";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -65,12 +66,10 @@ export default defineComponent({
}, },
setup(_, context) { setup(_, context) {
const { refreshRecipes } = useRecipes(true, false, true);
const state = reactive({ const state = reactive({
loading: false, loading: false,
selectedIndex: -1, selectedIndex: -1,
searchResults: [], searchResults: [] as RecipeSummary[],
}); });
// =========================================================================== // ===========================================================================
@ -78,14 +77,11 @@ export default defineComponent({
const dialog = ref(false); const dialog = ref(false);
// Reset or Grab Recipes on Change // Reset or Grab Recipes on Change
watch(dialog, async (val) => { watch(dialog, (val) => {
if (!val) { if (!val) {
search.value = ""; search.value = "";
state.selectedIndex = -1; state.selectedIndex = -1;
} else if (allRecipes.value && allRecipes.value.length <= 0) { state.searchResults = [];
state.loading = true;
await refreshRecipes();
state.loading = false;
} }
}); });
@ -140,13 +136,33 @@ export default defineComponent({
dialog.value = true; dialog.value = true;
} }
function close() { function close() {
dialog.value = false; dialog.value = false;
} }
// =========================================================================== // ===========================================================================
// Basic Search // Basic Search
const api = useUserApi();
const search = ref("")
watchDebounced(search, async (val) => {
console.log(val)
if (val) {
state.loading = true;
// @ts-expect-error - inferred type is wrong
const { data, error } = await api.recipes.search({ search: val as string, page: 1, perPage: 10 });
if (error || !data) {
console.error(error);
state.searchResults = [];
} else {
state.searchResults = data.items;
}
state.loading = false;
}
}, { debounce: 500, maxWait: 1000 });
const { search, results } = useRecipeSearch(allRecipes);
// =========================================================================== // ===========================================================================
// Select Handler // Select Handler
@ -155,7 +171,7 @@ export default defineComponent({
context.emit(SELECTED_EVENT, recipe); context.emit(SELECTED_EVENT, recipe);
} }
return { allRecipes, refreshRecipes, ...toRefs(state), dialog, open, close, handleSelect, search, results }; return { ...toRefs(state), dialog, open, close, handleSelect, search, };
}, },
}); });
</script> </script>

View file

@ -0,0 +1,105 @@
<template>
<div>
<v-menu v-model="state.menu" offset-y bottom nudge-bottom="3" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-badge :value="selected.length > 0" small overlap color="primary" :content="selected.length">
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
<slot></slot>
</v-btn>
</v-badge>
</template>
<v-card width="400">
<v-card-text>
<v-text-field v-model="state.search" class="mb-2" hide-details dense label="Search" clearable />
<v-switch
v-if="requireAll != undefined"
v-model="requireAllValue"
dense
small
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
>
</v-switch>
<v-card v-if="filtered.length > 0" flat outlined>
<v-virtual-scroll :items="filtered" height="300" item-height="51">
<template #default="{ item }">
<v-list-item :key="item.id" dense :value="item">
<v-list-item-action>
<v-checkbox v-model="selected" :value="item"></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title> {{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
</v-card>
<div v-else>
<v-alert type="info" text> No results found </v-alert>
</div>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, computed } from "@nuxtjs/composition-api";
export interface SelectableItem {
id: string;
name: string;
}
export default defineComponent({
props: {
items: {
type: Array as () => SelectableItem[],
required: true,
},
value: {
type: Array as () => any[],
required: true,
},
requireAll: {
type: Boolean,
default: undefined,
},
},
setup(props, context) {
const state = reactive({
search: "",
menu: false,
});
const requireAllValue = computed({
get: () => props.requireAll,
set: (value) => {
context.emit("update:requireAll", value);
},
});
const selected = computed({
get: () => props.value as SelectableItem[],
set: (value) => {
context.emit("input", value);
},
});
const filtered = computed(() => {
if (!state.search) {
return props.items;
}
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase()));
});
return {
requireAllValue,
state,
selected,
filtered,
};
},
});
</script>

View file

@ -56,9 +56,8 @@ import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue"; import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import { ShoppingListItemOut } from "~/lib/api/types/group"; import { ShoppingListItemOut } from "~/lib/api/types/group";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { MultiPurposeLabelSummary } from "~/lib/api/types/user";
interface actions { interface actions {
text: string; text: string;

View file

@ -176,20 +176,19 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
// V-Model Support // V-Model Support
const drawer = computed({ const drawer = computed({
get: () => { get: () => {
return props.value; return props.value;
}, },
set: (val) => { set: (val) => {
if(window.innerWidth < 760 && state.hasOpenedBefore === false){ if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
state.hasOpenedBefore = true; state.hasOpenedBefore = true;
val = false val = false;
context.emit("input", val); context.emit("input", val);
} } else {
else{ context.emit("input", val);
context.emit("input", val); }
}
}, },
}); });

View file

@ -2,6 +2,5 @@ export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe"; export { useRecipe } from "./use-recipe";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
export { parseIngredientText } from "./use-recipe-ingredients"; export { parseIngredientText } from "./use-recipe-ingredients";
export { useRecipeSearch } from "./use-recipe-search";
export { useTools } from "./use-recipe-tools"; export { useTools } from "./use-recipe-tools";
export { useRecipeMeta } from "./use-recipe-meta"; export { useRecipeMeta } from "./use-recipe-meta";

View file

@ -1,48 +0,0 @@
import { computed, reactive, ref, Ref } from "@nuxtjs/composition-api";
import Fuse from "fuse.js";
import { Recipe } from "~/lib/api/types/recipe";
export const useRecipeSearch = (recipes: Ref<Recipe[] | null>) => {
const localState = reactive({
options: {
ignoreLocation: true,
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
ignoreFieldNorm: true,
keys: [{ name: "name", weight: 1.3 }, { name: "description", weight: 1.2 }, "recipeIngredient.note", "recipeIngredient.food.name"],
},
});
const search = ref("");
const fuse = computed(() => {
return new Fuse(recipes.value || [], localState.options);
});
const fuzzyRecipes = computed(() => {
if (search.value.trim() === "") {
return recipes.value;
}
const result = fuse.value.search(search.value.trim());
return result.map((x) => x.item);
});
const results = computed(() => {
if (!fuzzyRecipes.value) {
return [];
}
if (fuzzyRecipes.value.length > 0 && search.value.length != null && search.value.length >= 1) {
return fuzzyRecipes.value;
} else {
return recipes.value;
}
});
return { results, search };
};

View file

@ -3,12 +3,12 @@ export const LOCALES = [
{ {
name: "繁體中文 (Chinese traditional)", name: "繁體中文 (Chinese traditional)",
value: "zh-TW", value: "zh-TW",
progress: 68, progress: 50,
}, },
{ {
name: "简体中文 (Chinese simplified)", name: "简体中文 (Chinese simplified)",
value: "zh-CN", value: "zh-CN",
progress: 56, progress: 41,
}, },
{ {
name: "Tiếng Việt (Vietnamese)", name: "Tiếng Việt (Vietnamese)",
@ -18,72 +18,72 @@ export const LOCALES = [
{ {
name: "Українська (Ukrainian)", name: "Українська (Ukrainian)",
value: "uk-UA", value: "uk-UA",
progress: 99, progress: 88,
}, },
{ {
name: "Türkçe (Turkish)", name: "Türkçe (Turkish)",
value: "tr-TR", value: "tr-TR",
progress: 47, progress: 41,
}, },
{ {
name: "Svenska (Swedish)", name: "Svenska (Swedish)",
value: "sv-SE", value: "sv-SE",
progress: 91, progress: 66,
}, },
{ {
name: "српски (Serbian)", name: "српски (Serbian)",
value: "sr-SP", value: "sr-SP",
progress: 11, progress: 8,
}, },
{ {
name: "Slovenian", name: "Slovenian",
value: "sl-SI", value: "sl-SI",
progress: 94, progress: 73,
}, },
{ {
name: "Slovak", name: "Slovak",
value: "sk-SK", value: "sk-SK",
progress: 85, progress: 78,
}, },
{ {
name: "Pусский (Russian)", name: "Pусский (Russian)",
value: "ru-RU", value: "ru-RU",
progress: 57, progress: 49,
}, },
{ {
name: "Română (Romanian)", name: "Română (Romanian)",
value: "ro-RO", value: "ro-RO",
progress: 3, progress: 7,
}, },
{ {
name: "Português (Portuguese)", name: "Português (Portuguese)",
value: "pt-PT", value: "pt-PT",
progress: 9, progress: 27,
}, },
{ {
name: "Português do Brasil (Brazilian Portuguese)", name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR", value: "pt-BR",
progress: 40, progress: 31,
}, },
{ {
name: "Polski (Polish)", name: "Polski (Polish)",
value: "pl-PL", value: "pl-PL",
progress: 89, progress: 69,
}, },
{ {
name: "Norsk (Norwegian)", name: "Norsk (Norwegian)",
value: "no-NO", value: "no-NO",
progress: 87, progress: 73,
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 97, progress: 81,
}, },
{ {
name: "Lithuanian", name: "Lithuanian",
value: "lt-LT", value: "lt-LT",
progress: 64, progress: 65,
}, },
{ {
name: "한국어 (Korean)", name: "한국어 (Korean)",
@ -98,37 +98,37 @@ export const LOCALES = [
{ {
name: "Italiano (Italian)", name: "Italiano (Italian)",
value: "it-IT", value: "it-IT",
progress: 82, progress: 81,
}, },
{ {
name: "Magyar (Hungarian)", name: "Magyar (Hungarian)",
value: "hu-HU", value: "hu-HU",
progress: 77, progress: 60,
}, },
{ {
name: "עברית (Hebrew)", name: "עברית (Hebrew)",
value: "he-IL", value: "he-IL",
progress: 33, progress: 24,
}, },
{ {
name: "Français (French)", name: "Français (French)",
value: "fr-FR", value: "fr-FR",
progress: 99, progress: 100,
}, },
{ {
name: "French, Canada", name: "French, Canada",
value: "fr-CA", value: "fr-CA",
progress: 84, progress: 61,
}, },
{ {
name: "Suomi (Finnish)", name: "Suomi (Finnish)",
value: "fi-FI", value: "fi-FI",
progress: 22, progress: 45,
}, },
{ {
name: "Español (Spanish)", name: "Español (Spanish)",
value: "es-ES", value: "es-ES",
progress: 94, progress: 70,
}, },
{ {
name: "American English", name: "American English",
@ -138,12 +138,12 @@ export const LOCALES = [
{ {
name: "British English", name: "British English",
value: "en-GB", value: "en-GB",
progress: 31, progress: 23,
}, },
{ {
name: "Ελληνικά (Greek)", name: "Ελληνικά (Greek)",
value: "el-GR", value: "el-GR",
progress: 70, progress: 51,
}, },
{ {
name: "Deutsch (German)", name: "Deutsch (German)",
@ -153,31 +153,31 @@ export const LOCALES = [
{ {
name: "Dansk (Danish)", name: "Dansk (Danish)",
value: "da-DK", value: "da-DK",
progress: 99, progress: 76,
}, },
{ {
name: "Čeština (Czech)", name: "Čeština (Czech)",
value: "cs-CZ", value: "cs-CZ",
progress: 89, progress: 75,
}, },
{ {
name: "Català (Catalan)", name: "Català (Catalan)",
value: "ca-ES", value: "ca-ES",
progress: 95, progress: 69,
}, },
{ {
name: "Bulgarian", name: "Bulgarian",
value: "bg-BG", value: "bg-BG",
progress: 0, progress: 25,
}, },
{ {
name: "العربية (Arabic)", name: "العربية (Arabic)",
value: "ar-SA", value: "ar-SA",
progress: 24, progress: 18,
}, },
{ {
name: "Afrikaans (Afrikaans)", name: "Afrikaans (Afrikaans)",
value: "af-ZA", value: "af-ZA",
progress: 9, progress: 6,
}, },
] ]

View file

@ -474,6 +474,8 @@
"include": "Include", "include": "Include",
"max-results": "Max Results", "max-results": "Max Results",
"or": "Or", "or": "Or",
"has-any": "Has Any",
"has-all": "Has All",
"results": "Results", "results": "Results",
"search": "Search", "search": "Search",
"search-mealie": "Search Mealie (press /)", "search-mealie": "Search Mealie (press /)",

View file

@ -1,6 +1,6 @@
const parts = { const parts = {
host: "http://localhost.com", host: "http://localhost.com",
prefix: "/api", prefix: "",
}; };
export function overrideParts(host: string, prefix: string) { export function overrideParts(host: string, prefix: string) {

View file

@ -4,21 +4,21 @@ import { route } from ".";
describe("UrlBuilder", () => { describe("UrlBuilder", () => {
it("basic query parameter", () => { it("basic query parameter", () => {
const result = route("/test", { a: "b" }); const result = route("/test", { a: "b" });
expect(result).toBe("/api/test?a=b"); expect(result).toBe("/test?a=b");
}); });
it("multiple query parameters", () => { it("multiple query parameters", () => {
const result = route("/test", { a: "b", c: "d" }); const result = route("/test", { a: "b", c: "d" });
expect(result).toBe("/api/test?a=b&c=d"); expect(result).toBe("/test?a=b&c=d");
}); });
it("no query parameters", () => { it("no query parameters", () => {
const result = route("/test"); const result = route("/test");
expect(result).toBe("/api/test"); expect(result).toBe("/test");
}); });
it("list-like query parameters", () => { it("list-like query parameters", () => {
const result = route("/test", { a: ["b", "c"] }); const result = route("/test", { a: ["b", "c"] });
expect(result).toBe("/api/test?a=b&a=c"); expect(result).toBe("/test?a=b&a=c");
}); });
}); });

View file

@ -98,7 +98,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -121,65 +120,6 @@ export interface RecipeTool {
slug: string; slug: string;
onHand?: boolean; onHand?: boolean;
} }
export interface RecipeIngredient {
title?: string;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean;
quantity?: number;
originalText?: string;
referenceId?: string;
}
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
export interface CreateIngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
}
export interface CustomPageImport { export interface CustomPageImport {
name: string; name: string;
status: boolean; status: boolean;

View file

@ -83,7 +83,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -100,65 +99,6 @@ export interface RecipeTag {
name: string; name: string;
slug: string; slug: string;
} }
export interface RecipeIngredient {
title?: string;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean;
quantity?: number;
originalText?: string;
referenceId?: string;
}
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
export interface CreateIngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
}
export interface SaveCookBook { export interface SaveCookBook {
name: string; name: string;
description?: string; description?: string;

View file

@ -436,7 +436,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -459,34 +458,6 @@ export interface RecipeTool {
slug: string; slug: string;
onHand?: boolean; onHand?: boolean;
} }
export interface RecipeIngredient {
title?: string;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean;
quantity?: number;
originalText?: string;
referenceId?: string;
}
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface CreateIngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
}
export interface ShoppingListRemoveRecipeParams { export interface ShoppingListRemoveRecipeParams {
recipeDecrementQuantity?: number; recipeDecrementQuantity?: number;
} }

View file

@ -93,6 +93,7 @@ export interface ReadPlanEntry {
recipeId?: string; recipeId?: string;
id: number; id: number;
groupId: string; groupId: string;
userId?: string;
recipe?: RecipeSummary; recipe?: RecipeSummary;
} }
export interface RecipeSummary { export interface RecipeSummary {
@ -113,7 +114,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -136,65 +136,6 @@ export interface RecipeTool {
slug: string; slug: string;
onHand?: boolean; onHand?: boolean;
} }
export interface RecipeIngredient {
title?: string;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean;
quantity?: number;
originalText?: string;
referenceId?: string;
}
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
export interface CreateIngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
}
export interface SavePlanEntry { export interface SavePlanEntry {
date: string; date: string;
entryType?: PlanEntryType & string; entryType?: PlanEntryType & string;
@ -202,6 +143,7 @@ export interface SavePlanEntry {
text?: string; text?: string;
recipeId?: string; recipeId?: string;
groupId: string; groupId: string;
userId?: string;
} }
export interface ShoppingListIn { export interface ShoppingListIn {
name: string; name: string;
@ -222,4 +164,5 @@ export interface UpdatePlanEntry {
recipeId?: string; recipeId?: string;
id: number; id: number;
groupId: string; groupId: string;
userId?: string;
} }

View file

@ -7,7 +7,6 @@
export type ExportTypes = "json"; export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute"; export type RegisteredParser = "nlp" | "brute";
export type OrderDirection = "asc" | "desc";
export type TimelineEventType = "system" | "info" | "comment"; export type TimelineEventType = "system" | "info" | "comment";
export interface AssignCategories { export interface AssignCategories {
@ -206,12 +205,12 @@ export interface Recipe {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
updateAt?: string; updateAt?: string;
lastMade?: string; lastMade?: string;
recipeIngredient?: RecipeIngredient[];
recipeInstructions?: RecipeStep[]; recipeInstructions?: RecipeStep[];
nutrition?: Nutrition; nutrition?: Nutrition;
settings?: RecipeSettings; settings?: RecipeSettings;
@ -282,7 +281,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -305,14 +303,6 @@ export interface RecipeCommentUpdate {
export interface RecipeDuplicate { export interface RecipeDuplicate {
name?: string; name?: string;
} }
export interface RecipePaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
loadFood?: boolean;
}
export interface RecipeShareToken { export interface RecipeShareToken {
recipeId: string; recipeId: string;
expiresAt?: string; expiresAt?: string;
@ -456,10 +446,3 @@ export interface UnitFoodBase {
export interface UpdateImageResponse { export interface UpdateImageResponse {
image: string; image: string;
} }
export interface PaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
}

View file

@ -188,7 +188,6 @@ export interface RecipeSummary {
tools?: RecipeTool[]; tools?: RecipeTool[];
rating?: number; rating?: number;
orgURL?: string; orgURL?: string;
recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string; createdAt?: string;
@ -211,65 +210,6 @@ export interface RecipeTool {
slug: string; slug: string;
onHand?: boolean; onHand?: boolean;
} }
export interface RecipeIngredient {
title?: string;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean;
quantity?: number;
originalText?: string;
referenceId?: string;
}
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
export interface CreateIngredientFood {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
}
export interface UserIn { export interface UserIn {
username?: string; username?: string;
fullName?: string; fullName?: string;

View file

@ -1,7 +1,7 @@
import { BaseCRUDAPI } from "../../base/base-clients"; import { BaseCRUDAPI } from "../../base/base-clients";
import { route } from "../../base";
import { CommentsApi } from "./recipe-comments"; import { CommentsApi } from "./recipe-comments";
import { RecipeShareApi } from "./recipe-share"; import { RecipeShareApi } from "./recipe-share";
import { import {
Recipe, Recipe,
CreateRecipe, CreateRecipe,
@ -52,6 +52,33 @@ const routes = {
recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`, recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
}; };
export type RecipeSearchQuery ={
search: string;
orderDirection? : "asc" | "desc";
groupId?: string;
queryFilter?: string;
cookbook?: string;
categories?: string[];
requireAllCategories?: boolean;
tags?: string[];
requireAllTags?: boolean;
tools?: string[];
requireAllTools?: boolean;
foods?: string[];
requireAllFoods?: boolean;
page: number;
perPage: number;
orderBy?: string;
}
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> { export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
baseRoute: string = routes.recipesBase; baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug; itemRoute = routes.recipesRecipeSlug;
@ -66,6 +93,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
this.share = new RecipeShareApi(requests); this.share = new RecipeShareApi(requests);
} }
async search(rsq : RecipeSearchQuery) {
return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesBase, rsq));
}
async getAllByCategory(categories: string[]) { async getAllByCategory(categories: string[]) {
return await this.requests.get<Recipe[]>(routes.recipesCategory, { return await this.requests.get<Recipe[]>(routes.recipesCategory, {
categories, categories,

View file

@ -94,11 +94,11 @@ import {
IngredientFood, IngredientFood,
IngredientUnit, IngredientUnit,
ParsedIngredient, ParsedIngredient,
RecipeIngredient,
} from "~/lib/api/types/recipe"; } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useRecipe } from "~/composables/recipes"; import { useRecipe } from "~/composables/recipes";
import { RecipeIngredient } from "~/lib/api/types/admin";
import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store"; import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store";
import { Parser } from "~/lib/api/user/recipes/recipe"; import { Parser } from "~/lib/api/user/recipes/recipe";

View file

@ -1,309 +1,449 @@
<template> <template>
<v-container fluid> <v-container fluid class="pa-0">
<v-container fluid class="pa-0"> <div class="search-container py-8">
<v-row dense> <form class="search-box" @submit.prevent="search">
<v-col> <div class="d-flex justify-center my-2">
<v-text-field <v-text-field
v-model="searchString" v-model="state.search"
outlined outlined
autofocus autofocus
color="primary accent-3" hide-details
:placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
clearable clearable
> color="primary"
</v-text-field> :placeholder="$tc('search.search-placeholder')"
</v-col> :prepend-inner-icon="$globals.icons.search"
<v-col cols="12" md="2" sm="12">
<v-text-field
v-model="maxResults"
class="mt-0 pt-0"
:label="$t('search.max-results')"
type="number"
outlined
/> />
</v-col> </div>
</v-row> <div class="search-row">
<!-- Sort Options -->
<v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }">
<v-btn small color="accent" :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" v-on="on">
<v-icon :left="!$vuetify.breakpoint.xsOnly">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
</v-btn>
</template>
<v-card>
<v-list>
<v-list-item @click="toggleOrderDirection()">
<v-icon left>
{{ $globals.icons.sort }}
</v-icon>
<v-list-item-title>
{{ state.orderDirection === "asc" ? "Sort Descending" : "Sort Ascending" }}
</v-list-item-title>
</v-list-item>
<v-list-item
v-for="v in sortable"
:key="v.name"
:input-value="state.orderBy === v.value"
@click="state.orderBy = v.value"
>
<v-icon left>
{{ v.icon }}
</v-icon>
<v-list-item-title>{{ v.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<div> <!-- Category Filter -->
<v-switch <SearchFilter
v-model="advanced" v-if="categories"
color="info" v-model="selectedCategories"
class="ma-0 pa-0" :require-all.sync="state.requireAllCategories"
:label="$t('search.advanced')" :items="categories"
@input="advanced = !advanced" >
@click="advanced = !advanced" <v-icon left>
/> {{ $globals.icons.tags }}
<v-expand-transition> </v-icon>
<v-row v-show="advanced" dense class="my-0 dense flex-row align-center justify-space-around"> {{ $t("category.categories") }}
<v-col cols="12" class="d-flex flex-wrap flex-md-nowrap justify-center" style="gap: 0.8rem"> </SearchFilter>
<RecipeOrganizerSelector
v-model="includeCategories" <!-- Tag Filter -->
:input-attrs="{ <SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
solo: true, <v-icon left>
hideDetails: true, {{ $globals.icons.tags }}
dense: false, </v-icon>
}" {{ $t("tag.tags") }}
:show-add="false" </SearchFilter>
:return-object="false"
selector-type="categories" <!-- Tool Filter -->
/> <SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
<RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" /> <v-icon left>
</v-col> {{ $globals.icons.tools }}
<v-col cols="12" class="d-flex flex-wrap flex-md-nowrap justify-center" style="gap: 0.8rem"> </v-icon>
<RecipeOrganizerSelector {{ $t("tool.tools") }}
v-model="includeTags" </SearchFilter>
:input-attrs="{
solo: true, <!-- Food Filter -->
hideDetails: true, <SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
dense: false, <v-icon left>
}" {{ $globals.icons.foods }}
:show-add="false" </v-icon>
:return-object="false" {{ $t("general.foods") }}
selector-type="tags" </SearchFilter>
/>
<RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" /> <!-- Settings -->
</v-col> <v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
<v-col cols="12" class="d-flex flex-wrap flex-md-nowrap justify-center" style="gap: 0.8rem"> <template #activator="{ on, attrs }">
<v-autocomplete <v-btn class="ml-auto" small color="accent" dark v-bind="attrs" v-on="on">
v-model="includeFoods" <v-icon small>
chips {{ $globals.icons.cog }}
hide-details </v-icon>
deletable-chips </v-btn>
solo </template>
multiple <v-card>
:items="foods || []" <v-card-text>
item-text="name" <v-text-field
:prepend-inner-icon="$globals.icons.foods" v-model="state.maxResults"
:label="$t('general.foods')" class="mt-0 pt-0"
> :label="$tc('search.max-results')"
<template #selection="data"> type="number"
<v-chip outlined
:key="data.index" dense
class="ma-1" />
:input-value="data.selected" <v-btn block color="primary" @click="reset">
close {{ $tc("general.reset") }}
label </v-btn>
color="accent" </v-card-text>
dark </v-card>
@click:close="includeFoods.splice(data.index, 1)" </v-menu>
> </div>
{{ data.item.name || data.item }} <div class="search-button-container">
</v-chip> <v-btn :loading="state.loading" x-large color="primary" type="submit" block>
</template> <v-icon left>
</v-autocomplete> {{ $globals.icons.search }}
<RecipeSearchFilterSelector class="mb-1" @update="updateFoodParams" /> </v-icon>
</v-col> {{ $tc("search.search") }}
</v-row> </v-btn>
</v-expand-transition> </div>
</div> </form>
</v-container> </div>
<v-container class="px-0 mt-6"> <v-divider></v-divider>
<v-container class="mt-6 px-md-6">
<RecipeCardSection <RecipeCardSection
class="mt-n5" class="mt-n5"
:icon="$globals.icons.search" :icon="$globals.icons.search"
:title="$tc('search.results')" :title="$tc('search.results')"
:recipes="showRecipes.slice(0, maxResults)" :recipes="state.results"
@sort="assignFuzzy"
/> />
</v-container> </v-container>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import Fuse from "fuse.js"; import { ref, defineComponent, useRouter, onMounted, useContext } from "@nuxtjs/composition-api";
import { defineComponent, toRefs, computed, reactive } from "@nuxtjs/composition-api"; import SearchFilter from "~/components/Domain/SearchFilter.vue";
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue"; import { useUserApi } from "~/composables/api";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useRecipes, allRecipes } from "~/composables/recipes"; import { IngredientFood, RecipeCategory, RecipeSummary, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { RecipeSummary } from "~/lib/api/types/recipe"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useRouteQuery } from "~/composables/use-router";
import { RecipeTag } from "~/lib/api/types/user";
import { useFoodStore } from "~/composables/store";
interface GenericFilter {
exclude: boolean;
matchAny: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: { SearchFilter, RecipeCardSection },
RecipeOrganizerSelector,
RecipeSearchFilterSelector,
RecipeCardSection,
},
setup() { setup() {
const { assignSorted } = useRecipes(true, true, true); const router = useRouter();
const api = useUserApi();
const { $globals, i18n } = useContext();
// ================================================================ const state = ref({
// Advanced Toggle loading: false,
search: "",
const advancedQp = useRouteQuery("advanced"); orderBy: "created_at",
const advanced = computed({ orderDirection: "desc" as "asc" | "desc",
get: () => advancedQp.value === "true",
set: (val) => {
advancedQp.value = val ? "true" : "false";
},
});
// ================================================================
// Global State
const state = reactive({
maxResults: 21, maxResults: 21,
results: [] as RecipeSummary[],
// Filters // and/or
includeCategories: [] as string[], requireAllCategories: false,
catFilter: { requireAllTags: false,
exclude: false, requireAllTools: false,
matchAny: false, requireAllFoods: false,
} as GenericFilter,
includeTags: [] as string[],
tagFilter: {
exclude: false,
matchAny: false,
} as GenericFilter,
includeFoods: [] as string[],
foodFilter: {
exclude: false,
matchAny: false,
} as GenericFilter,
// Recipes Holders
searchResults: [] as RecipeSummary[],
sortedResults: [] as RecipeSummary[],
// Search Options
options: {
ignoreLocation: true,
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
ignoreFieldNorm: true,
keys: [{ name: "name", weight: 1.3 }, { name: "description", weight: 1.2 }, "recipeIngredient.note", "recipeIngredient.food.name"],
},
}); });
// ================================================================ const categories = useCategoryStore();
// Search Functions const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
const searchString = useRouteQuery("q", ""); const foods = useFoodStore();
const selectedFoods = ref<IngredientFood[]>([]);
const filteredRecipes = computed(() => { const tags = useTagStore();
if (!allRecipes.value) { const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
return [];
const tools = useToolStore();
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
function reset() {
state.value.search = "";
state.value.maxResults = 21;
state.value.orderBy = "created_at";
state.value.orderDirection = "desc";
state.value.requireAllCategories = false;
state.value.requireAllTags = false;
state.value.requireAllTools = false;
state.value.requireAllFoods = false;
selectedCategories.value = [];
selectedFoods.value = [];
selectedTags.value = [];
selectedTools.value = [];
router.push({
query: {},
});
search();
}
function toggleOrderDirection() {
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
}
function toIDArray(array: { id: string }[]) {
return array.map((item) => item.id);
}
async function search() {
state.value.loading = true;
await router.push({
query: {
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
// Only add the query param if it's or not default
...{
search: state.value.search === "" ? undefined : state.value.search,
maxResults: state.value.maxResults === 21 ? undefined : state.value.maxResults.toString(),
orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy,
orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection,
requireAllCategories: state.value.requireAllCategories ? "true" : undefined,
requireAllTags: state.value.requireAllTags ? "true" : undefined,
requireAllTools: state.value.requireAllTools ? "true" : undefined,
requireAllFoods: state.value.requireAllFoods ? "true" : undefined,
},
},
});
const { data, error } = await api.recipes.search({
search: state.value.search,
page: 1,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
perPage: state.value.maxResults,
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
requireAllTags: state.value.requireAllTags,
requireAllTools: state.value.requireAllTools,
requireAllFoods: state.value.requireAllFoods,
});
if (error) {
console.error(error);
state.value.loading = false;
state.value.results = [];
return;
} }
// TODO: Fix Type Declarations for RecipeSummary
return allRecipes.value.filter((recipe: RecipeSummary) => {
const includesTags = check(
state.includeTags,
// @ts-ignore See above if (data) {
recipe.tags.map((x: RecipeTag) => x.name), state.value.results = data.items;
state.tagFilter.matchAny, }
state.tagFilter.exclude
);
const includesCats = check(
state.includeCategories,
// @ts-ignore See above state.value.loading = false;
recipe.recipeCategory.map((x) => x.name), }
state.catFilter.matchAny,
state.catFilter.exclude
);
const includesFoods = check(
state.includeFoods,
// @ts-ignore See above function waitUntilAndExecute(
recipe.recipeIngredient.map((x) => x?.food?.name || ""), condition: () => boolean,
state.foodFilter.matchAny, callback: () => void,
state.foodFilter.exclude opts = { timeout: 2000, interval: 500 }
): Promise<void> {
return new Promise((resolve, reject) => {
const state = {
timeout: undefined as number | undefined,
interval: undefined as number | undefined,
};
const check = () => {
if (condition()) {
clearInterval(state.interval);
clearTimeout(state.timeout);
callback();
resolve();
}
};
// For some reason these were returning NodeJS.Timeout
state.interval = setInterval(check, opts.interval) as unknown as number;
state.timeout = setTimeout(() => {
clearInterval(state.interval);
reject(new Error("Timeout"));
}, opts.timeout) as unknown as number;
});
}
const sortable = [
{
icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.tc("general.sort-alphabetically"),
value: "name",
},
{
icon: $globals.icons.newBox,
name: i18n.tc("general.created"),
value: "created_at",
},
{
icon: $globals.icons.chefHat,
name: i18n.tc("general.last-made"),
value: "time",
},
{
icon: $globals.icons.star,
name: i18n.tc("general.rating"),
value: "rating",
},
{
icon: $globals.icons.update,
name: i18n.tc("general.updated"),
value: "updated_at",
},
];
onMounted(() => {
// Hydrate Search
// wait for stores to be hydrated
// read query params
const query = router.currentRoute.query;
if (query.search) {
state.value.search = query.search as string;
}
if (query.maxResults) {
state.value.maxResults = parseInt(query.maxResults as string);
}
if (query.orderBy) {
state.value.orderBy = query.orderBy as string;
}
if (query.orderDirection) {
state.value.orderDirection = query.orderDirection as "asc" | "desc";
}
const promises: Promise<void>[] = [];
if (query.categories) {
promises.push(
waitUntilAndExecute(
() => categories.items.value.length > 0,
() => {
const result = categories.items.value.filter((item) =>
(query.categories as string[]).includes(item.id as string)
);
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
}
)
); );
return [includesTags, includesCats, includesFoods].every((x) => x === true); }
if (query.foods) {
promises.push(
waitUntilAndExecute(
() => {
if (foods.foods.value) {
return foods.foods.value.length > 0;
}
return false;
},
() => {
const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
}
)
);
}
if (query.tags) {
promises.push(
waitUntilAndExecute(
() => tags.items.value.length > 0,
() => {
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
)
);
}
if (query.tools) {
promises.push(
waitUntilAndExecute(
() => tools.items.value.length > 0,
() => {
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
)
);
}
Promise.allSettled(promises).then(() => {
search();
}); });
}); });
const fuse = computed(() => {
return new Fuse(filteredRecipes.value, state.options);
});
const fuzzyRecipes = computed(() => {
if (searchString.value.trim() === "") {
return filteredRecipes.value;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map((x) => x.item);
});
const showRecipes = computed(() => {
if (state.sortedResults.length > 0) {
return state.sortedResults;
} else {
return fuzzyRecipes.value;
}
});
// ================================================================
// Utility Functions
function check(filterBy: string[], recipeList: string[], matchAny: boolean, exclude: boolean) {
let isMatch = true;
if (filterBy.length === 0) return isMatch;
if (recipeList) {
if (matchAny) {
isMatch = filterBy.some((t) => recipeList.includes(t)); // Checks if some items are a match
} else {
isMatch = filterBy.every((t) => recipeList.includes(t)); // Checks if every items is a match
}
return exclude ? !isMatch : isMatch;
} else;
return false;
}
function assignFuzzy(val: RecipeSummary[]) {
state.sortedResults = val;
}
function updateTagParams(params: GenericFilter) {
state.tagFilter = params;
}
function updateCatParams(params: GenericFilter) {
state.catFilter = params;
}
function updateFoodParams(params: GenericFilter) {
state.foodFilter = params;
}
const { foods } = useFoodStore();
return { return {
...toRefs(state), search,
allRecipes, reset,
assignFuzzy, state,
assignSorted, categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[],
check, tags: tags.items as unknown as NoUndefinedField<RecipeTag>[],
foods, foods: foods.foods,
searchString, tools: tools.items as unknown as NoUndefinedField<RecipeTool>[],
showRecipes,
updateCatParams, sortable,
updateFoodParams, toggleOrderDirection,
updateTagParams,
advanced, selectedCategories,
}; selectedFoods,
}, selectedTags,
head() { selectedTools,
return {
title: this.$t("search.search") as string,
}; };
}, },
}); });
</script> </script>
<style></style> <style lang="css">
.search-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.search-container {
display: flex;
justify-content: center;
}
.search-box {
width: 950px;
}
.search-button-container {
margin: 3rem auto 0 auto;
max-width: 500px;
}
</style>

View file

@ -63,7 +63,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat note: Mapped[str | None] = mapped_column(String, index=True) # Force Show Text - Overrides Concat
# Scaling Items # Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True) unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True)
@ -73,7 +73,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
quantity: Mapped[float | None] = mapped_column(Float) quantity: Mapped[float | None] = mapped_column(Float)
original_text: Mapped[str | None] = mapped_column(String) original_text: Mapped[str | None] = mapped_column(String, index=True)
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links

View file

@ -23,7 +23,7 @@ class RecipeInstruction(SqlAlchemyBase):
position: Mapped[int | None] = mapped_column(Integer, index=True) position: Mapped[int | None] = mapped_column(Integer, index=True)
type: Mapped[str | None] = mapped_column(String, default="") type: Mapped[str | None] = mapped_column(String, default="")
title: Mapped[str | None] = mapped_column(String) title: Mapped[str | None] = mapped_column(String)
text: Mapped[str | None] = mapped_column(String) text: Mapped[str | None] = mapped_column(String, index=True)
ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship( ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship(
RecipeIngredientRefLink, cascade="all, delete-orphan" RecipeIngredientRefLink, cascade="all, delete-orphan"

View file

@ -55,7 +55,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# General Recipe Properties # General Recipe Properties
name: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) name: Mapped[str] = mapped_column(sa.String, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(sa.String) description: Mapped[str | None] = mapped_column(sa.String, index=True)
image: Mapped[str | None] = mapped_column(sa.String) image: Mapped[str | None] = mapped_column(sa.String)
# Time Related Properties # Time Related Properties

View file

@ -4,7 +4,7 @@ from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4
from slugify import slugify from slugify import slugify
from sqlalchemy import and_, func, select from sqlalchemy import Select, and_, desc, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -20,13 +20,14 @@ from mealie.schema.recipe.recipe import (
RecipeCategory, RecipeCategory,
RecipePagination, RecipePagination,
RecipeSummary, RecipeSummary,
RecipeSummaryWithIngredients,
RecipeTag, RecipeTag,
RecipeTool, RecipeTool,
) )
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from ..db.models._model_base import SqlAlchemyBase
from ..schema._mealie.mealie_model import extract_uuids
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
@ -134,16 +135,59 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
) )
return self.session.execute(stmt).scalars().all() return self.session.execute(stmt).scalars().all()
def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchemyBase]) -> list[UUID] | None:
if not items:
return None
ids: list[UUID] = []
slugs: list[str] = []
for i in items:
if isinstance(i, UUID):
ids.append(i)
else:
slugs.append(i)
additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
return ids + additional_ids
def _add_search_to_query(self, query: Select, search: str) -> Select:
# I would prefer to just do this in the recipe_ingredient.any part of the main query, but it turns out
# that at least sqlite wont use indexes for that correctly anymore and takes a big hit, so prefiltering it is
ingredient_ids = (
self.session.execute(
select(RecipeIngredient.id).filter(
or_(RecipeIngredient.note.ilike(f"%{search}%"), RecipeIngredient.original_text.ilike(f"%{search}%"))
)
)
.scalars()
.all()
)
q = query.filter(
or_(
RecipeModel.name.ilike(f"%{search}%"),
RecipeModel.description.ilike(f"%{search}%"),
RecipeModel.recipe_ingredient.any(RecipeIngredient.id.in_(ingredient_ids)),
)
).order_by(desc(RecipeModel.name.ilike(f"%{search}%")))
return q
def page_all( def page_all(
self, self,
pagination: PaginationQuery, pagination: PaginationQuery,
override=None, override=None,
load_food=False,
cookbook: ReadCookBook | None = None, cookbook: ReadCookBook | None = None,
categories: list[UUID4 | str] | None = None, categories: list[UUID4 | str] | None = None,
tags: list[UUID4 | str] | None = None, tags: list[UUID4 | str] | None = None,
tools: list[UUID4 | str] | None = None, tools: list[UUID4 | str] | None = None,
foods: list[UUID4 | str] | None = None,
require_all_categories=True,
require_all_tags=True,
require_all_tools=True,
require_all_foods=True,
search: str | None = None,
) -> RecipePagination: ) -> RecipePagination:
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
pagination_result = pagination.copy()
q = select(self.model) q = select(self.model)
args = [ args = [
@ -152,57 +196,41 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
joinedload(RecipeModel.tools), joinedload(RecipeModel.tools),
] ]
item_class: type[RecipeSummary | RecipeSummaryWithIngredients]
if load_food:
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.unit)))
item_class = RecipeSummaryWithIngredients
else:
item_class = RecipeSummary
q = q.options(*args) q = q.options(*args)
fltr = self._filter_builder() fltr = self._filter_builder()
q = q.filter_by(**fltr) q = q.filter_by(**fltr)
if cookbook: if cookbook:
cb_filters = self._category_tag_filters( cb_filters = self._build_recipe_filter(
cookbook.categories, categories=extract_uuids(cookbook.categories),
cookbook.tags, tags=extract_uuids(cookbook.tags),
cookbook.tools, tools=extract_uuids(cookbook.tools),
cookbook.require_all_categories, require_all_categories=cookbook.require_all_categories,
cookbook.require_all_tags, require_all_tags=cookbook.require_all_tags,
cookbook.require_all_tools, require_all_tools=cookbook.require_all_tools,
) )
q = q.filter(*cb_filters) q = q.filter(*cb_filters)
else:
category_ids = self._uuids_for_items(categories, Category)
tag_ids = self._uuids_for_items(tags, Tag)
tool_ids = self._uuids_for_items(tools, Tool)
filters = self._build_recipe_filter(
categories=category_ids,
tags=tag_ids,
tools=tool_ids,
foods=foods,
require_all_categories=require_all_categories,
require_all_tags=require_all_tags,
require_all_tools=require_all_tools,
require_all_foods=require_all_foods,
)
q = q.filter(*filters)
if search:
q = self._add_search_to_query(q, search)
if categories: q, count, total_pages = self.add_pagination_to_query(q, pagination_result)
for category in categories:
if isinstance(category, UUID):
q = q.filter(RecipeModel.recipe_category.any(Category.id == category))
else:
q = q.filter(RecipeModel.recipe_category.any(Category.slug == category))
if tags:
for tag in tags:
if isinstance(tag, UUID):
q = q.filter(RecipeModel.tags.any(Tag.id == tag))
else:
q = q.filter(RecipeModel.tags.any(Tag.slug == tag))
if tools:
for tool in tools:
if isinstance(tool, UUID):
q = q.filter(RecipeModel.tools.any(Tool.id == tool))
else:
q = q.filter(RecipeModel.tools.any(Tool.slug == tool))
q, count, total_pages = self.add_pagination_to_query(q, pagination)
try: try:
data = self.session.execute(q).scalars().unique().all() data = self.session.execute(q).scalars().unique().all()
@ -211,10 +239,10 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
self.session.rollback() self.session.rollback()
raise e raise e
items = [item_class.from_orm(item) for item in data] items = [RecipeSummary.from_orm(item) for item in data]
return RecipePagination( return RecipePagination(
page=pagination.page, page=pagination_result.page,
per_page=pagination.per_page, per_page=pagination_result.per_page,
total=count, total=count,
total_pages=total_pages, total_pages=total_pages,
items=items, items=items,
@ -233,41 +261,46 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
) )
return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()] return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()]
def _category_tag_filters( def _build_recipe_filter(
self, self,
categories: list[CategoryBase] | None = None, categories: list[UUID4] | None = None,
tags: list[TagBase] | None = None, tags: list[UUID4] | None = None,
tools: list[RecipeTool] | None = None, tools: list[UUID4] | None = None,
foods: list[UUID4] | None = None,
require_all_categories: bool = True, require_all_categories: bool = True,
require_all_tags: bool = True, require_all_tags: bool = True,
require_all_tools: bool = True, require_all_tools: bool = True,
require_all_foods: bool = True,
) -> list: ) -> list:
fltr = [ if self.group_id:
RecipeModel.group_id == self.group_id, fltr = [
] RecipeModel.group_id == self.group_id,
]
else:
fltr = []
if categories: if categories:
cat_ids = [x.id for x in categories]
if require_all_categories: if require_all_categories:
fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in cat_ids) fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in categories)
else: else:
fltr.append(RecipeModel.recipe_category.any(Category.id.in_(cat_ids))) fltr.append(RecipeModel.recipe_category.any(Category.id.in_(categories)))
if tags: if tags:
tag_ids = [x.id for x in tags]
if require_all_tags: if require_all_tags:
fltr.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids) fltr.extend(RecipeModel.tags.any(Tag.id == tag_id) for tag_id in tags)
else: else:
fltr.append(RecipeModel.tags.any(Tag.id.in_(tag_ids))) fltr.append(RecipeModel.tags.any(Tag.id.in_(tags)))
if tools: if tools:
tool_ids = [x.id for x in tools]
if require_all_tools: if require_all_tools:
fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tool_ids) fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tools)
else: else:
fltr.append(RecipeModel.tools.any(Tool.id.in_(tool_ids))) fltr.append(RecipeModel.tools.any(Tool.id.in_(tools)))
if foods:
if require_all_foods:
fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id == food) for food in foods)
else:
fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id.in_(foods)))
return fltr return fltr
def by_category_and_tags( def by_category_and_tags(
@ -279,8 +312,13 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
require_all_tags: bool = True, require_all_tags: bool = True,
require_all_tools: bool = True, require_all_tools: bool = True,
) -> list[Recipe]: ) -> list[Recipe]:
fltr = self._category_tag_filters( fltr = self._build_recipe_filter(
categories, tags, tools, require_all_categories, require_all_tags, require_all_tools categories=extract_uuids(categories) if categories else None,
tags=extract_uuids(tags) if tags else None,
tools=extract_uuids(tools) if tools else None,
require_all_categories=require_all_categories,
require_all_tags=require_all_tags,
require_all_tools=require_all_tools,
) )
stmt = select(RecipeModel).filter(*fltr) stmt = select(RecipeModel).filter(*fltr)
return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
@ -297,7 +335,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
# See Also: # See Also:
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy # - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
filters = self._category_tag_filters(categories, tags) # type: ignore filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
stmt = ( stmt = (
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
) )

View file

@ -24,20 +24,15 @@ from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe
from mealie.schema.recipe.recipe import ( from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
CreateRecipe,
CreateRecipeByUrlBulk,
RecipePaginationQuery,
RecipeSummary,
RecipeSummaryWithIngredients,
)
from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.request_helpers import RecipeDuplicate, RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.recipe.request_helpers import RecipeDuplicate, RecipeZipTokenResponse, UpdateImageResponse
from mealie.schema.response import PaginationBase from mealie.schema.response import PaginationBase, PaginationQuery
from mealie.schema.response.pagination import RecipeSearchQuery
from mealie.schema.response.responses import ErrorResponse from mealie.schema.response.responses import ErrorResponse
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_types import ( from mealie.services.event_bus_service.event_types import (
@ -238,31 +233,37 @@ class RecipeController(BaseRecipeController):
# ================================================================================================================== # ==================================================================================================================
# CRUD Operations # CRUD Operations
@router.get("", response_model=PaginationBase[RecipeSummary | RecipeSummaryWithIngredients]) @router.get("", response_model=PaginationBase[RecipeSummary])
def get_all( def get_all(
self, self,
request: Request, request: Request,
q: RecipePaginationQuery = Depends(), q: PaginationQuery = Depends(),
cookbook: UUID4 | str | None = Query(None), search_query: RecipeSearchQuery = Depends(),
categories: list[UUID4 | str] | None = Query(None), categories: list[UUID4 | str] | None = Query(None),
tags: list[UUID4 | str] | None = Query(None), tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None),
): ):
cookbook_data: ReadCookBook | None = None cookbook_data: ReadCookBook | None = None
if cookbook: if search_query.cookbook:
cb_match_attr = "slug" if isinstance(cookbook, str) else "id" cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
cookbook_data = self.cookbooks_repo.get_one(cookbook, cb_match_attr) cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr)
if cookbook is None: if search_query.cookbook is None:
raise HTTPException(status_code=404, detail="cookbook not found") raise HTTPException(status_code=404, detail="cookbook not found")
pagination_response = self.repo.page_all( pagination_response = self.repo.page_all(
pagination=q, pagination=q,
load_food=q.load_food,
cookbook=cookbook_data, cookbook=cookbook_data,
categories=categories, categories=categories,
tags=tags, tags=tags,
tools=tools, tools=tools,
foods=foods,
require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools,
require_all_foods=search_query.require_all_foods,
search=search_query.search,
) )
# merge default pagination with the request's query params # merge default pagination with the request's query params

View file

@ -1,6 +1,7 @@
# This file is auto-generated by gen_schema_exports.py # This file is auto-generated by gen_schema_exports.py
from .mealie_model import MealieModel from .mealie_model import HasUUID, MealieModel
__all__ = [ __all__ = [
"HasUUID",
"MealieModel", "MealieModel",
] ]

View file

@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TypeVar from collections.abc import Sequence
from typing import Protocol, TypeVar
from humps.main import camelize from humps.main import camelize
from pydantic import BaseModel from pydantic import UUID4, BaseModel
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -52,3 +53,11 @@ class MealieModel(BaseModel):
val = getattr(src, field) val = getattr(src, field)
if field in self.__fields__ and (val is not None or replace_null): if field in self.__fields__ and (val is not None or replace_null):
setattr(self, field, val) setattr(self, field, val)
class HasUUID(Protocol):
id: UUID4
def extract_uuids(models: Sequence[HasUUID]) -> list[UUID4]:
return [x.id for x in models]

View file

@ -14,11 +14,7 @@ from .group_events import (
from .group_exports import GroupDataExport from .group_exports import GroupDataExport
from .group_migration import DataMigrationCreate, SupportedMigrations from .group_migration import DataMigrationCreate, SupportedMigrations
from .group_permissions import SetPermissions from .group_permissions import SetPermissions
from .group_preferences import ( from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
CreateGroupPreferences,
ReadGroupPreferences,
UpdateGroupPreferences,
)
from .group_seeder import SeederConfig from .group_seeder import SeederConfig
from .group_shopping_list import ( from .group_shopping_list import (
ShoppingListAddRecipeParams, ShoppingListAddRecipeParams,
@ -41,23 +37,19 @@ from .group_shopping_list import (
ShoppingListUpdate, ShoppingListUpdate,
) )
from .group_statistics import GroupStatistics, GroupStorage from .group_statistics import GroupStatistics, GroupStorage
from .invite_token import ( from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
CreateInviteToken, from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from .webhook import (
CreateWebhook,
ReadWebhook,
SaveWebhook,
WebhookPagination,
WebhookType,
)
__all__ = [ __all__ = [
"GroupAdminUpdate", "CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupDataExport",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupEventNotifierCreate", "GroupEventNotifierCreate",
"GroupEventNotifierOptions", "GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut", "GroupEventNotifierOptionsOut",
@ -67,13 +59,8 @@ __all__ = [
"GroupEventNotifierSave", "GroupEventNotifierSave",
"GroupEventNotifierUpdate", "GroupEventNotifierUpdate",
"GroupEventPagination", "GroupEventPagination",
"GroupDataExport",
"DataMigrationCreate", "DataMigrationCreate",
"SupportedMigrations", "SupportedMigrations",
"SetPermissions",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"SeederConfig", "SeederConfig",
"ShoppingListAddRecipeParams", "ShoppingListAddRecipeParams",
"ShoppingListCreate", "ShoppingListCreate",
@ -83,9 +70,9 @@ __all__ = [
"ShoppingListItemRecipeRefCreate", "ShoppingListItemRecipeRefCreate",
"ShoppingListItemRecipeRefOut", "ShoppingListItemRecipeRefOut",
"ShoppingListItemRecipeRefUpdate", "ShoppingListItemRecipeRefUpdate",
"ShoppingListItemsCollectionOut",
"ShoppingListItemUpdate", "ShoppingListItemUpdate",
"ShoppingListItemUpdateBulk", "ShoppingListItemUpdateBulk",
"ShoppingListItemsCollectionOut",
"ShoppingListOut", "ShoppingListOut",
"ShoppingListPagination", "ShoppingListPagination",
"ShoppingListRecipeRefOut", "ShoppingListRecipeRefOut",
@ -93,6 +80,8 @@ __all__ = [
"ShoppingListSave", "ShoppingListSave",
"ShoppingListSummary", "ShoppingListSummary",
"ShoppingListUpdate", "ShoppingListUpdate",
"GroupAdminUpdate",
"SetPermissions",
"GroupStatistics", "GroupStatistics",
"GroupStorage", "GroupStorage",
"CreateInviteToken", "CreateInviteToken",
@ -100,9 +89,4 @@ __all__ = [
"EmailInvitation", "EmailInvitation",
"ReadInviteToken", "ReadInviteToken",
"SaveInviteToken", "SaveInviteToken",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
] ]

View file

@ -7,7 +7,6 @@ from .recipe import (
RecipeCategory, RecipeCategory,
RecipeCategoryPagination, RecipeCategoryPagination,
RecipePagination, RecipePagination,
RecipePaginationQuery,
RecipeSummary, RecipeSummary,
RecipeTag, RecipeTag,
RecipeTagPagination, RecipeTagPagination,
@ -155,7 +154,6 @@ __all__ = [
"RecipeCategory", "RecipeCategory",
"RecipeCategoryPagination", "RecipeCategoryPagination",
"RecipePagination", "RecipePagination",
"RecipePaginationQuery",
"RecipeSummary", "RecipeSummary",
"RecipeTag", "RecipeTag",
"RecipeTagPagination", "RecipeTagPagination",

View file

@ -12,7 +12,7 @@ from slugify import slugify
from mealie.core.config import get_app_dirs from mealie.core.config import get_app_dirs
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase, PaginationQuery from mealie.schema.response.pagination import PaginationBase
from .recipe_asset import RecipeAsset from .recipe_asset import RecipeAsset
from .recipe_comments import RecipeCommentOut from .recipe_comments import RecipeCommentOut
@ -102,14 +102,6 @@ class RecipeSummary(MealieModel):
orm_mode = True orm_mode = True
class RecipeSummaryWithIngredients(RecipeSummary):
recipe_ingredient: list[RecipeIngredient] | None = []
class RecipePaginationQuery(PaginationQuery):
load_food: bool = False
class RecipePagination(PaginationBase): class RecipePagination(PaginationBase):
items: list[RecipeSummary] items: list[RecipeSummary]
@ -211,5 +203,4 @@ class Recipe(RecipeSummary):
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402
RecipeSummary.update_forward_refs() RecipeSummary.update_forward_refs()
RecipeSummaryWithIngredients.update_forward_refs()
Recipe.update_forward_refs() Recipe.update_forward_refs()

View file

@ -3,7 +3,7 @@ from typing import Any, Generic, TypeVar
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from humps import camelize from humps import camelize
from pydantic import BaseModel from pydantic import UUID4, BaseModel
from pydantic.generics import GenericModel from pydantic.generics import GenericModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -16,6 +16,15 @@ class OrderDirection(str, enum.Enum):
desc = "desc" desc = "desc"
class RecipeSearchQuery(MealieModel):
cookbook: UUID4 | str | None
require_all_categories: bool = False
require_all_tags: bool = False
require_all_tools: bool = False
require_all_foods: bool = False
search: str | None
class PaginationQuery(MealieModel): class PaginationQuery(MealieModel):
page: int = 1 page: int = 1
per_page: int = 50 per_page: int = 50

View file

@ -2,9 +2,11 @@ from typing import cast
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_recipes import RepositoryRecipes from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary from mealie.schema.recipe import RecipeIngredient, SaveIngredientFood, RecipeStep
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave
from mealie.schema.recipe.recipe_tool import RecipeToolSave from mealie.schema.recipe.recipe_tool import RecipeToolSave
from mealie.schema.response import OrderDirection, PaginationQuery
from tests.utils.factories import random_string from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -164,7 +166,7 @@ def test_recipe_repo_pagination_by_categories(database: AllRepositories, unique_
for recipe in recipes: for recipe in recipes:
database.recipes.create(recipe) database.recipes.create(recipe)
pagination_query = RecipePaginationQuery( pagination_query = PaginationQuery(
page=1, page=1,
per_page=-1, per_page=-1,
) )
@ -245,7 +247,7 @@ def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user:
for recipe in recipes: for recipe in recipes:
database.recipes.create(recipe) database.recipes.create(recipe)
pagination_query = RecipePaginationQuery( pagination_query = PaginationQuery(
page=1, page=1,
per_page=-1, per_page=-1,
) )
@ -324,7 +326,7 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user:
for recipe in recipes: for recipe in recipes:
database.recipes.create(recipe) database.recipes.create(recipe)
pagination_query = RecipePaginationQuery( pagination_query = PaginationQuery(
page=1, page=1,
per_page=-1, per_page=-1,
) )
@ -357,3 +359,138 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user:
tool_ids = [tool.id for tool in recipe_summary.tools] tool_ids = [tool.id for tool in recipe_summary.tools]
for tool in created_tools: for tool in created_tools:
assert tool.id in tool_ids assert tool.id in tool_ids
def test_recipe_repo_pagination_by_foods(database: AllRepositories, unique_user: TestUser):
slug1, slug2 = (random_string(10) for _ in range(2))
foods = [
SaveIngredientFood(group_id=unique_user.group_id, name=slug1),
SaveIngredientFood(group_id=unique_user.group_id, name=slug2),
]
created_foods = [database.ingredient_foods.create(food) for food in foods]
# Bootstrap the database with recipes
recipes = []
for i in range(10):
# None of the foods
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
)
# Only one of the foods
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
recipe_ingredient=[RecipeIngredient(food=created_foods[i % 2])],
),
)
# Both of the foods
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
recipe_ingredient=[RecipeIngredient(food=created_foods[0]), RecipeIngredient(food=created_foods[1])],
)
)
for recipe in recipes:
database.recipes.create(recipe)
pagination_query = PaginationQuery(
page=1,
per_page=-1,
)
# Get all recipes with only one food by UUID
food_id = created_foods[0].id
recipes_with_one_food = database.recipes.page_all(pagination_query, foods=[food_id]).items
assert len(recipes_with_one_food) == 15
# Get all recipes with both foods
recipes_with_both_foods = database.recipes.page_all(
pagination_query, foods=[food.id for food in created_foods]
).items
assert len(recipes_with_both_foods) == 10
# Get all recipes with either foods
recipes_with_either_food = database.recipes.page_all(
pagination_query, foods=[food.id for food in created_foods], require_all_foods=False
).items
assert len(recipes_with_either_food) == 20
def test_recipe_repo_search(database: AllRepositories, unique_user: TestUser):
ingredient_1 = random_string(10)
ingredient_2 = random_string(10)
name_part_1 = random_string(10)
name_1 = f"{name_part_1} soup"
name_part_2 = random_string(10)
name_2 = f"Rustic {name_part_2} stew"
name_3 = f"{ingredient_1} Soup"
description_part_1 = random_string(10)
recipes = [
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=name_1,
description=f"My favorite {description_part_1}",
recipe_ingredient=[
RecipeIngredient(note=ingredient_1),
],
),
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=name_2,
recipe_ingredient=[
RecipeIngredient(note=ingredient_2),
],
),
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=name_3,
),
]
for recipe in recipes:
database.recipes.create(recipe)
pagination_query = PaginationQuery(page=1, per_page=-1, order_by="created_at", order_direction=OrderDirection.asc)
# No hits
empty_result = database.recipes.page_all(pagination_query, search=random_string(10)).items
assert len(empty_result) == 0
# Search by title
title_result = database.recipes.page_all(pagination_query, search=name_part_2).items
assert len(title_result) == 1
assert title_result[0].name == name_2
# Search by description
description_result = database.recipes.page_all(pagination_query, search=description_part_1).items
assert len(description_result) == 1
assert description_result[0].name == name_1
# Search by ingredient
ingredient_result = database.recipes.page_all(pagination_query, search=ingredient_2).items
assert len(ingredient_result) == 1
assert ingredient_result[0].name == name_2
# Make sure title matches are ordered in front
ordered_result = database.recipes.page_all(pagination_query, search=ingredient_1).items
assert len(ordered_result) == 2
assert ordered_result[0].name == name_3
assert ordered_result[1].name == name_1

View file

@ -2,10 +2,7 @@ import json
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
from tests.utils.alembic_reader import alembic_versions
ALEMBIC_VERSIONS = [
{"version_num": "ff5f73b01a7a"},
]
def test_alchemy_exporter(): def test_alchemy_exporter():
@ -13,16 +10,16 @@ def test_alchemy_exporter():
exporter = AlchemyExporter(settings.DB_URL) exporter = AlchemyExporter(settings.DB_URL)
data = exporter.dump() data = exporter.dump()
assert data["alembic_version"] == ALEMBIC_VERSIONS assert data["alembic_version"] == alembic_versions()
assert json.dumps(data, indent=4) # Make sure data is json-serializable assert json.dumps(data, indent=4) # Make sure data is json-serializable
def test_validate_schemas(): def test_validate_schemas():
schema = { schema = {
"alembic_version": ALEMBIC_VERSIONS, "alembic_version": alembic_versions(),
} }
match = { match = {
"alembic_version": ALEMBIC_VERSIONS, "alembic_version": alembic_versions(),
} }
invalid_version = { invalid_version = {
@ -33,7 +30,7 @@ def test_validate_schemas():
assert not AlchemyExporter.validate_schemas(schema, invalid_version) assert not AlchemyExporter.validate_schemas(schema, invalid_version)
schema_with_tables = { schema_with_tables = {
"alembic_version": ALEMBIC_VERSIONS, "alembic_version": alembic_versions(),
"recipes": [ "recipes": [
{ {
"id": 1, "id": 1,
@ -41,7 +38,7 @@ def test_validate_schemas():
], ],
} }
match_with_tables = { match_with_tables = {
"alembic_version": ALEMBIC_VERSIONS, "alembic_version": alembic_versions(),
"recipes": [ "recipes": [
{ {
"id": 2, "id": 2,
@ -50,3 +47,5 @@ def test_validate_schemas():
} }
assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables) assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables)
assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables)
assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables)

View file

@ -1,14 +1,47 @@
import pytest import pathlib
# Test that alembic revisions are applicable and result in the current database from pydantic import BaseModel
# See https://github.com/sqlalchemy/alembic/issues/724 for inspiration
from tests.utils.alembic_reader import ALEMBIC_MIGRATIONS, import_file
@pytest.mark.skip("TODO: Implement") class AlembicMigration(BaseModel):
def test_alembic_revisions_are_applicable(): path: pathlib.Path
pass revision: str | None
down_revision: str | None
@pytest.mark.skip("TODO: Implement") def test_alembic_revisions_are_in_order() -> None:
def test_alembic_revisions_are_up_to_date(): # read all files
pass paths = sorted(ALEMBIC_MIGRATIONS.glob("*.py"))
# convert to sorted list of AlembicMigration
migrations: list[AlembicMigration] = []
for path in paths:
mod = import_file("alembic_version", path)
revision = getattr(mod, "revision", None)
down_revision = getattr(mod, "down_revision", None)
migrations.append(
AlembicMigration(
path=path,
revision=revision,
down_revision=down_revision,
)
)
# step through each migration and check
# - revision is in order
# - down_revision is in order
# - down_revision is the previous revision
last = None
for migration in migrations:
if last is not None:
assert (
last.revision == migration.down_revision
), f"{last.revision} != {migration.down_revision} for {migration.path}"
last = migration
last = migration

View file

@ -0,0 +1,38 @@
import importlib.util
import pathlib
from functools import lru_cache
from mealie.db.init_db import PROJECT_DIR
ALEMBIC_MIGRATIONS = PROJECT_DIR / "alembic" / "versions"
def import_file(module_name: str, file_path: pathlib.Path):
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to import {module_name} from {file_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def latest_alembic_version() -> str:
latest = sorted(ALEMBIC_MIGRATIONS.glob("*.py"))[-1] # Assumes files are named in order
mod = import_file("alembic_version", latest)
revision = getattr(mod, "revision", None)
if revision is None:
raise Exception(f"Unable to find revision in {latest}")
return revision
@lru_cache(1)
def alembic_versions():
return [
{"version_num": latest_alembic_version()},
]