feat: Optionally include URL when importing via HTML/JSON (#6709)

This commit is contained in:
Michael Genson
2025-12-12 23:20:26 -06:00
committed by GitHub
parent 24c111af7b
commit 20a6e71b31
10 changed files with 43 additions and 15 deletions

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,13 @@
import type { RequestResponse } from "~/lib/api/types/non-generated";
import type { ValidationResponse } from "~/lib/api/types/response";
import { required, email, whitespace, url, minLength, maxLength } from "~/lib/validators";
import { required, email, whitespace, url, urlOptional, minLength, maxLength } from "~/lib/validators";
export const validators = {
required,
email,
whitespace,
url,
urlOptional,
minLength,
maxLength,
};

View File

@@ -445,6 +445,7 @@
"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",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "View Scraped Data",
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
"trim-prefix-description": "Trim first character from each line",

View File

@@ -510,6 +510,7 @@ export interface ScrapeRecipeBase {
export interface ScrapeRecipeData {
includeTags?: boolean;
data: string;
url?: string | null;
}
export interface ScrapeRecipeTest {
url: string;

View File

@@ -146,8 +146,8 @@ 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 createOneByHtmlOrJson(data: string, includeTags: boolean, url: string | null = null) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags, url });
}
async createOneByUrl(url: string, includeTags: boolean) {

View File

@@ -1,2 +1,2 @@
export { scorePassword } from "./password";
export { required, email, whitespace, url, minLength, maxLength } from "./inputs";
export { required, email, whitespace, url, urlOptional, minLength, maxLength } from "./inputs";

View File

@@ -1,7 +1,7 @@
const EMAIL_REGEX
= /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export function required(v: string | undefined | null) {
return !!v || "This Field is Required";
@@ -19,6 +19,10 @@ export function url(v: string | undefined | null) {
return (!!v && URL_REGEX.test(v)) || "Must Be A Valid URL";
}
export function urlOptional(v: string | undefined | null) {
return v ? url(v) : true;
}
export function minLength(min: number) {
return (v: string | undefined | null) => (!!v && v.length >= min) || `Must Be At Least ${min} Characters`;
}

View File

@@ -1,7 +1,7 @@
<template>
<v-form
ref="domUrlForm"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags)"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, newRecipeUrl)"
>
<div>
<v-card-title class="headline">
@@ -21,14 +21,28 @@
<v-switch
v-model="isEditJSON"
:label="$t('recipe.json-editor')"
color="primary"
class="mt-2"
@change="handleIsEditJson"
/>
<v-text-field
v-model="newRecipeUrl"
:label="$t('new-recipe.recipe-url')"
:prepend-inner-icon="$globals.icons.link"
validate-on="blur"
variant="solo-filled"
clearable
rounded
:rules="[validators.urlOptional]"
:hint="$t('new-recipe.copy-and-paste-the-source-url-of-your-data-optional')"
persistent-hint
class="mt-10 mb-4"
style="max-width: 500px"
/>
<RecipeJsonEditor
v-if="isEditJSON"
v-model="newRecipeData"
height="250px"
class="mt-10"
mode="code"
:main-menu-bar="false"
/>
@@ -41,10 +55,7 @@
autofocus
variant="solo-filled"
clearable
class="rounded-lg mt-2"
rounded
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
/>
<v-checkbox
v-model="importKeywordsAsTags"
@@ -124,6 +135,7 @@ export default defineNuxtComponent({
}
const newRecipeData = ref<string | object | null>(null);
const newRecipeUrl = ref<string | null>(null);
function handleIsEditJson() {
if (state.isEditJSON) {
@@ -148,8 +160,13 @@ export default defineNuxtComponent({
}
handleIsEditJson();
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean) {
if (!htmlOrJsonData || !domUrlForm.value?.validate()) {
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, url: string | null = null) {
if (!htmlOrJsonData) {
return;
}
const isValid = await domUrlForm.value?.validate();
if (!isValid?.valid) {
return;
}
@@ -162,7 +179,7 @@ export default defineNuxtComponent({
}
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags);
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags, url);
handleResponse(response, importKeywordsAsTags);
}
@@ -172,6 +189,7 @@ export default defineNuxtComponent({
stayInEditMode,
parseRecipe,
newRecipeData,
newRecipeUrl,
handleIsEditJson,
createFromHtmlOrJson,
...toRefs(state),

View File

@@ -148,7 +148,7 @@ class RecipeController(BaseRecipeController):
async def _create_recipe_from_web(self, req: ScrapeRecipe | ScrapeRecipeData):
if isinstance(req, ScrapeRecipeData):
html = req.data
url = ""
url = req.url or ""
else:
html = None
url = req.url

View File

@@ -27,3 +27,6 @@ class ScrapeRecipe(ScrapeRecipeBase):
class ScrapeRecipeData(ScrapeRecipeBase):
data: str
"""HTML data or JSON string of a https://schema.org/Recipe object"""
url: str | None = None
"""Optional URL of the recipe source"""