feat: Added Option to Import Recipe Category During Recipe Import (#6523)

Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Gtt1229
2026-01-30 12:18:15 -05:00
committed by GitHub
parent e3e45c534e
commit e83891e3ca
11 changed files with 86 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ import { useRecipeCreatePreferences } from "~/composables/use-users/preferences"
export interface UseNewRecipeOptionsProps { export interface UseNewRecipeOptionsProps {
enableImportKeywords?: boolean; enableImportKeywords?: boolean;
enableImportCategories?: boolean;
enableStayInEditMode?: boolean; enableStayInEditMode?: boolean;
enableParseRecipe?: boolean; enableParseRecipe?: boolean;
} }
@@ -9,6 +10,7 @@ export interface UseNewRecipeOptionsProps {
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) { export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
const { const {
enableImportKeywords = true, enableImportKeywords = true,
enableImportCategories = true,
enableStayInEditMode = true, enableStayInEditMode = true,
enableParseRecipe = true, enableParseRecipe = true,
} = props; } = props;
@@ -27,6 +29,17 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
}, },
}); });
const importCategories = computed({
get() {
if (!enableImportCategories) return false;
return recipeCreatePreferences.value.importCategories;
},
set(v: boolean) {
if (!enableImportCategories) return;
recipeCreatePreferences.value.importCategories = v;
},
});
const stayInEditMode = computed({ const stayInEditMode = computed({
get() { get() {
if (!enableStayInEditMode) return false; if (!enableStayInEditMode) return false;
@@ -71,6 +84,7 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
return { return {
// Computed properties for the checkboxes // Computed properties for the checkboxes
importKeywordsAsTags, importKeywordsAsTags,
importCategories,
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
@@ -79,6 +93,7 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
// Props for conditional rendering // Props for conditional rendering
enableImportKeywords, enableImportKeywords,
enableImportCategories,
enableStayInEditMode, enableStayInEditMode,
enableParseRecipe, enableParseRecipe,
}; };

View File

@@ -63,6 +63,7 @@ export interface UserRecipeFinderPreferences {
export interface UserRecipeCreatePreferences { export interface UserRecipeCreatePreferences {
importKeywordsAsTags: boolean; importKeywordsAsTags: boolean;
importCategories: boolean;
stayInEditMode: boolean; stayInEditMode: boolean;
parseRecipe: boolean; parseRecipe: boolean;
} }
@@ -233,6 +234,7 @@ export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
"recipe-create-preferences", "recipe-create-preferences",
{ {
importKeywordsAsTags: false, importKeywordsAsTags: false,
importCategories: false,
stayInEditMode: false, stayInEditMode: false,
parseRecipe: true, parseRecipe: true,
}, },

View File

@@ -642,6 +642,7 @@
"scrape-recipe-website-being-blocked": "Website being blocked?", "scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.", "scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Import original keywords as tags", "import-original-keywords-as-tags": "Import original keywords as tags",
"import-original-categories": "Import original categories",
"stay-in-edit-mode": "Stay in Edit mode", "stay-in-edit-mode": "Stay in Edit mode",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import", "parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
"import-from-zip": "Import from Zip", "import-from-zip": "Import from Zip",

View File

@@ -146,12 +146,12 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI }); return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
} }
async createOneByHtmlOrJson(data: string, includeTags: boolean, url: string | null = null) { async createOneByHtmlOrJson(data: string, includeTags: boolean, includeCategories: boolean, url: string | null = null) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags, url }); return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags, includeCategories, url });
} }
async createOneByUrl(url: string, includeTags: boolean) { async createOneByUrl(url: string, includeTags: boolean, includeCategories: boolean) {
return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags }); return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags, includeCategories });
} }
async createManyByUrl(payload: CreateRecipeByUrlBulk) { async createManyByUrl(payload: CreateRecipeByUrlBulk) {

View File

@@ -1,7 +1,7 @@
<template> <template>
<v-form <v-form
ref="domUrlForm" ref="domUrlForm"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, newRecipeUrl)" @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, importCategories, newRecipeUrl)"
> >
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
@@ -63,6 +63,12 @@
hide-details hide-details
:label="$t('recipe.import-original-keywords-as-tags')" :label="$t('recipe.import-original-keywords-as-tags')"
/> />
<v-checkbox
v-model="importCategories"
color="primary"
hide-details
:label="$t('recipe.import-original-categories')"
/>
<v-checkbox <v-checkbox
v-model="stayInEditMode" v-model="stayInEditMode"
color="primary" color="primary"
@@ -116,6 +122,7 @@ export default defineNuxtComponent({
const { const {
importKeywordsAsTags, importKeywordsAsTags,
importCategories,
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
navigateToRecipe, navigateToRecipe,
@@ -160,7 +167,7 @@ export default defineNuxtComponent({
} }
handleIsEditJson(); handleIsEditJson();
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, url: string | null = null) { async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, importCategories: boolean, url: string | null = null) {
if (!htmlOrJsonData) { if (!htmlOrJsonData) {
return; return;
} }
@@ -179,7 +186,7 @@ export default defineNuxtComponent({
} }
state.loading = true; state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags, url); const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags, importCategories, url);
handleResponse(response, importKeywordsAsTags); handleResponse(response, importKeywordsAsTags);
} }
@@ -188,6 +195,7 @@ export default defineNuxtComponent({
importKeywordsAsTags, importKeywordsAsTags,
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
importCategories,
newRecipeData, newRecipeData,
newRecipeUrl, newRecipeUrl,
handleIsEditJson, handleIsEditJson,

View File

@@ -2,7 +2,7 @@
<div> <div>
<v-form <v-form
ref="domUrlForm" ref="domUrlForm"
@submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)" @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags, importCategories)"
> >
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
@@ -38,6 +38,12 @@
hide-details hide-details
:label="$t('recipe.import-original-keywords-as-tags')" :label="$t('recipe.import-original-keywords-as-tags')"
/> />
<v-checkbox
v-model="importCategories"
color="primary"
hide-details
:label="$t('recipe.import-original-categories')"
/>
<v-checkbox <v-checkbox
v-model="stayInEditMode" v-model="stayInEditMode"
color="primary" color="primary"
@@ -148,6 +154,7 @@ export default defineNuxtComponent({
const { const {
importKeywordsAsTags, importKeywordsAsTags,
importCategories,
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
navigateToRecipe, navigateToRecipe,
@@ -219,7 +226,7 @@ export default defineNuxtComponent({
router.replace({ query: undefined }).then(() => router.push(to)); router.replace({ query: undefined }).then(() => router.push(to));
}); });
async function createByUrl(url: string | null, importKeywordsAsTags: boolean) { async function createByUrl(url: string | null, importKeywordsAsTags: boolean, importCategories: boolean) {
if (url === null) { if (url === null) {
return; return;
} }
@@ -229,7 +236,7 @@ export default defineNuxtComponent({
return; return;
} }
state.loading = true; state.loading = true;
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags); const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags, importCategories);
handleResponse(response, importKeywordsAsTags); handleResponse(response, importKeywordsAsTags);
} }
@@ -238,6 +245,7 @@ export default defineNuxtComponent({
htmlOrJsonImporterTarget, htmlOrJsonImporterTarget,
recipeUrl, recipeUrl,
importKeywordsAsTags, importKeywordsAsTags,
importCategories: importCategories,
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
domUrlForm, domUrlForm,

View File

@@ -165,6 +165,11 @@ class RecipeController(BaseRecipeController):
recipe.tags = extras.use_tags(ctx) # type: ignore recipe.tags = extras.use_tags(ctx) # type: ignore
if req.include_categories:
ctx = ScraperContext(self.repos)
recipe.recipe_category = extras.use_categories(ctx) # type: ignore
new_recipe = self.service.create_one(recipe) new_recipe = self.service.create_one(recipe)
if new_recipe: if new_recipe:

View File

@@ -10,6 +10,7 @@ class ScrapeRecipeTest(MealieModel):
class ScrapeRecipeBase(MealieModel): class ScrapeRecipeBase(MealieModel):
include_tags: bool = False include_tags: bool = False
include_categories: bool = False
class ScrapeRecipe(ScrapeRecipeBase): class ScrapeRecipe(ScrapeRecipeBase):
@@ -19,6 +20,7 @@ class ScrapeRecipe(ScrapeRecipeBase):
"example": { "example": {
"url": "https://myfavoriterecipes.com/recipes", "url": "https://myfavoriterecipes.com/recipes",
"includeTags": True, "includeTags": True,
"includeCategories": True,
}, },
} }
) )

View File

@@ -521,8 +521,8 @@ def clean_categories(category: str | list) -> list[str]:
case str(category): case str(category):
if not category.strip(): if not category.strip():
return [] return []
# Split comma-separated categories
return [category] return [cat.strip().title() for cat in category.split(",") if cat.strip()]
case [str(), *_]: case [str(), *_]:
return [cat.strip().title() for cat in category if cat.strip()] return [cat.strip().title() for cat in category if cat.strip()]
case [{"name": str(), "slug": str()}, *_]: case [{"name": str(), "slug": str()}, *_]:

View File

@@ -4,7 +4,7 @@ from slugify import slugify
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe import TagOut from mealie.schema.recipe import TagOut
from mealie.schema.recipe.recipe_category import TagSave from mealie.schema.recipe.recipe_category import CategorySave, TagSave
class NoContextException(Exception): class NoContextException(Exception):
@@ -19,10 +19,14 @@ class ScraperContext:
class ScrapedExtras: class ScrapedExtras:
def __init__(self) -> None: def __init__(self) -> None:
self._tags: list[str] = [] self._tags: list[str] = []
self._categories: list[str] = []
def set_tags(self, tags: list[str]) -> None: def set_tags(self, tags: list[str]) -> None:
self._tags = tags self._tags = tags
def set_categories(self, categories: list[str]) -> None:
self._categories = categories
def use_tags(self, ctx: ScraperContext) -> list[TagOut]: def use_tags(self, ctx: ScraperContext) -> list[TagOut]:
if not self._tags: if not self._tags:
return [] return []
@@ -49,3 +53,30 @@ class ScrapedExtras:
tags.append(db_tag) tags.append(db_tag)
return tags return tags
def use_categories(self, ctx: ScraperContext) -> list[TagOut]:
if not self._categories:
return []
repo = ctx.repos.categories
categories = []
seen_category_slugs: set[str] = set()
for category in self._categories:
slugify_category = slugify(category)
if slugify_category in seen_category_slugs:
continue
seen_category_slugs.add(slugify_category)
# Check if category exists
if db_category := repo.get_one(slugify_category, "slug"):
categories.append(db_category)
continue
save_data = CategorySave(name=category, group_id=ctx.repos.group_id)
db_category = repo.create(save_data)
categories.append(db_category)
return categories

View File

@@ -194,6 +194,7 @@ class RecipeScraperPackage(ABCScraperStrategy):
extras = ScrapedExtras() extras = ScrapedExtras()
extras.set_tags(try_get_default(scraped_data.keywords, "keywords", "", cleaner.clean_tags)) extras.set_tags(try_get_default(scraped_data.keywords, "keywords", "", cleaner.clean_tags))
extras.set_categories(try_get_default(scraped_data.category, "recipeCategory", "", cleaner.clean_categories))
recipe = Recipe( recipe = Recipe(
name=try_get_default(scraped_data.title, "name", "No Name Found", cleaner.clean_string), name=try_get_default(scraped_data.title, "name", "No Name Found", cleaner.clean_string),