1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 05:25:26 +02:00

feat: Create Recipe From HTML or JSON (#4274)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-30 10:52:13 -05:00 committed by GitHub
parent edf420491f
commit 4c1d855690
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 408 additions and 115 deletions

View file

@ -1,7 +1,7 @@
<template>
<VJsoneditor
:value="value"
height="1500px"
:height="height"
:options="options"
:attrs="$attrs"
@input="$emit('input', $event)"
@ -20,6 +20,10 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
height: {
type: String,
default: "1500px",
},
options: {
type: Object,
default: () => ({}),

View file

@ -426,6 +426,7 @@
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
"recipe-markup-specification": "Recipe Markup Specification",
"recipe-url": "Recipe URL",
"recipe-html-or-json": "Recipe HTML or JSON",
"upload-a-recipe": "Upload a Recipe",
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.",
"url-form-hint": "Copy and paste a link from your favorite recipe website",
@ -604,10 +605,16 @@
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.",
"scrape-recipe-have-a-lot-of-recipes": "Have a lot of recipes you want to scrape at once?",
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
"import-original-keywords-as-tags": "Import original keywords as tags",
"stay-in-edit-mode": "Stay in Edit mode",
"import-from-zip": "Import from Zip",
"import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.",
"import-from-html-or-json": "Import from HTML or JSON",
"import-from-html-or-json-description": "Import a single recipe from raw HTML or JSON. This is useful if you have a recipe from a site that Mealie can't scrape normally, or from some other external source.",
"json-import-format-description-colon": "To import via JSON, it must be in valid format:",
"json-editor": "JSON Editor",
"zip-files-must-have-been-exported-from-mealie": ".zip files must have been exported from Mealie",
"create-a-recipe-by-uploading-a-scan": "Create a recipe by uploading a scan.",
"upload-a-png-image-from-a-recipe-book": "Upload a png image from a recipe book",

View file

@ -472,8 +472,15 @@ export interface SaveIngredientUnit {
groupId: string;
}
export interface ScrapeRecipe {
url: string;
includeTags?: boolean;
url: string;
}
export interface ScrapeRecipeBase {
includeTags?: boolean;
}
export interface ScrapeRecipeData {
includeTags?: boolean;
data: string;
}
export interface ScrapeRecipeTest {
url: string;

View file

@ -32,10 +32,11 @@ const routes = {
recipesCreate: `${prefix}/recipes/create`,
recipesBase: `${prefix}/recipes`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCreateFromImage: `${prefix}/recipes/create-from-image`,
recipesCreateUrl: `${prefix}/recipes/create/url`,
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create/zip`,
recipesCreateFromImage: `${prefix}/recipes/create/image`,
recipesCreateFromHtmlOrJson: `${prefix}/recipes/create/html-or-json`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
@ -134,6 +135,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
}
async createOneByHtmlOrJson(data: string, includeTags: boolean) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags });
}
async createOneByUrl(url: string, includeTags: boolean) {
return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags });
}

View file

@ -150,7 +150,8 @@ import {
mdiRotateRight,
mdiBookOpenPageVariant,
mdiFileCabinet,
mdiSilverwareForkKnife
mdiSilverwareForkKnife,
mdiCodeTags,
} from "@mdi/js";
export const icons = {
@ -192,6 +193,7 @@ export const icons = {
clockOutline: mdiClockTimeFourOutline,
codeBraces: mdiCodeJson,
codeJson: mdiCodeJson,
codeTags: mdiCodeTags,
cog: mdiCog,
commentTextMultiple: mdiCommentTextMultiple,
commentTextMultipleOutline: mdiCommentTextMultipleOutline,

View file

@ -52,6 +52,11 @@ export default defineComponent({
text: i18n.tc("recipe.bulk-url-import"),
value: "bulk",
},
{
icon: $globals.icons.codeTags,
text: i18n.tc("recipe.import-from-html-or-json"),
value: "html",
},
{
icon: $globals.icons.fileImage,
text: i18n.tc("recipe.create-from-image"),

View file

@ -0,0 +1,171 @@
<template>
<v-form ref="domUrlForm" @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, stayInEditMode)">
<div>
<v-card-title class="headline"> {{ $tc('recipe.import-from-html-or-json') }} </v-card-title>
<v-card-text>
<p>
{{ $tc("recipe.import-from-html-or-json-description") }}
</p>
<p>
{{ $tc("recipe.json-import-format-description-colon") }}
<a href="https://schema.org/Recipe" target="_blank">https://schema.org/Recipe</a>
</p>
<v-switch
v-model="isEditJSON"
:label="$tc('recipe.json-editor')"
class="mt-2"
@change="handleIsEditJson"
/>
<LazyRecipeJsonEditor
v-if="isEditJSON"
v-model="newRecipeData"
height="250px"
class="mt-10"
:options="EDITOR_OPTIONS"
/>
<v-textarea
v-else
v-model="newRecipeData"
:label="$tc('new-recipe.recipe-html-or-json')"
:prepend-inner-icon="$globals.icons.codeTags"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:hint="$tc('new-recipe.url-form-hint')"
persistent-hint
/>
<v-checkbox v-model="importKeywordsAsTags" hide-details :label="$tc('recipe.import-original-keywords-as-tags')" />
<v-checkbox v-model="stayInEditMode" hide-details :label="$tc('recipe.stay-in-edit-mode')" />
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="!newRecipeData"
large
rounded
block
type="submit"
:loading="loading"
/>
</div>
</v-card-actions>
</div>
</v-form>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
const EDITOR_OPTIONS = {
mode: "code",
search: false,
mainMenuBar: false,
};
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
isEditJSON: false,
});
const { $auth } = useContext();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const api = useUserApi();
const router = useRouter();
const tags = useTagStore();
const importKeywordsAsTags = computed({
get() {
return route.value.query.use_keywords === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.value.query, use_keywords: v ? "1" : "0" } });
},
});
const stayInEditMode = computed({
get() {
return route.value.query.edit === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.value.query, edit: v ? "1" : "0" } });
},
});
function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
}
const newRecipeData = ref<string | object | null>(null);
function handleIsEditJson() {
if (state.isEditJSON) {
if (newRecipeData.value) {
try {
newRecipeData.value = JSON.parse(newRecipeData.value as string);
} catch {
newRecipeData.value = { "data": newRecipeData.value };
}
} else {
newRecipeData.value = {};
}
} else if (newRecipeData.value && Object.keys(newRecipeData.value).length > 0) {
newRecipeData.value = JSON.stringify(newRecipeData.value);
} else {
newRecipeData.value = null;
}
}
handleIsEditJson();
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, stayInEditMode: boolean) {
if (!htmlOrJsonData || !domUrlForm.value?.validate()) {
return;
}
let dataString;
if (typeof htmlOrJsonData === "string") {
dataString = htmlOrJsonData;
} else {
dataString = JSON.stringify(htmlOrJsonData);
}
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags);
handleResponse(response, stayInEditMode, importKeywordsAsTags);
}
return {
EDITOR_OPTIONS,
domUrlForm,
importKeywordsAsTags,
stayInEditMode,
newRecipeData,
handleIsEditJson,
createFromHtmlOrJson,
...toRefs(state),
validators,
};
},
});
</script>

View file

@ -5,7 +5,13 @@
<v-card-title class="headline"> {{ $t('recipe.scrape-recipe') }} </v-card-title>
<v-card-text>
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
<p>{{ $t('recipe.scrape-recipe-have-a-lot-of-recipes') }} <a :href="bulkImporterTarget">{{ $t('recipe.scrape-recipe-suggest-bulk-importer') }}</a>.</p>
<p>
{{ $t('recipe.scrape-recipe-have-a-lot-of-recipes') }}
<a :href="bulkImporterTarget">{{ $t('recipe.scrape-recipe-suggest-bulk-importer') }}</a>.
<br />
{{ $t('recipe.scrape-recipe-have-raw-html-or-json-data') }}
<a :href="htmlOrJsonImporterTarget">{{ $t('recipe.scrape-recipe-you-can-import-from-raw-data-directly') }}</a>.
</p>
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
@ -96,6 +102,7 @@ export default defineComponent({
const tags = useTagStore();
const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`);
const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`);
function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
if (response?.status !== 201) {
@ -171,6 +178,7 @@ export default defineComponent({
return {
bulkImporterTarget,
htmlOrJsonImporterTarget,
recipeUrl,
importKeywordsAsTags,
stayInEditMode,

View file

@ -67,7 +67,7 @@ export default defineComponent({
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
const { response } = await api.upload.file("/api/recipes/create/zip", formData);
handleResponse(response);
}