mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-01 05:23:10 -05:00
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:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()}, *_]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user