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 { RequestResponse } from "~/lib/api/types/non-generated";
import type { ValidationResponse } from "~/lib/api/types/response"; 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 = { export const validators = {
required, required,
email, email,
whitespace, whitespace,
url, url,
urlOptional,
minLength, minLength,
maxLength, maxLength,
}; };

View File

@@ -445,6 +445,7 @@
"upload-a-recipe": "Upload a Recipe", "upload-a-recipe": "Upload a Recipe",
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.", "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", "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", "view-scraped-data": "View Scraped Data",
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines", "trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
"trim-prefix-description": "Trim first character from each line", "trim-prefix-description": "Trim first character from each line",

View File

@@ -510,6 +510,7 @@ export interface ScrapeRecipeBase {
export interface ScrapeRecipeData { export interface ScrapeRecipeData {
includeTags?: boolean; includeTags?: boolean;
data: string; data: string;
url?: string | null;
} }
export interface ScrapeRecipeTest { export interface ScrapeRecipeTest {
url: string; 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 }); return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
} }
async createOneByHtmlOrJson(data: string, includeTags: boolean) { async createOneByHtmlOrJson(data: string, includeTags: boolean, url: string | null = null) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags }); return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags, url });
} }
async createOneByUrl(url: string, includeTags: boolean) { async createOneByUrl(url: string, includeTags: boolean) {

View File

@@ -1,2 +1,2 @@
export { scorePassword } from "./password"; 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 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,}))$/; = /^(([^<>()[\]\\.,;:\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) { export function required(v: string | undefined | null) {
return !!v || "This Field is Required"; 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"; 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) { export function minLength(min: number) {
return (v: string | undefined | null) => (!!v && v.length >= min) || `Must Be At Least ${min} Characters`; return (v: string | undefined | null) => (!!v && v.length >= min) || `Must Be At Least ${min} Characters`;
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<v-form <v-form
ref="domUrlForm" ref="domUrlForm"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags)" @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, newRecipeUrl)"
> >
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
@@ -21,14 +21,28 @@
<v-switch <v-switch
v-model="isEditJSON" v-model="isEditJSON"
:label="$t('recipe.json-editor')" :label="$t('recipe.json-editor')"
color="primary"
class="mt-2" class="mt-2"
@change="handleIsEditJson" @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 <RecipeJsonEditor
v-if="isEditJSON" v-if="isEditJSON"
v-model="newRecipeData" v-model="newRecipeData"
height="250px" height="250px"
class="mt-10"
mode="code" mode="code"
:main-menu-bar="false" :main-menu-bar="false"
/> />
@@ -41,10 +55,7 @@
autofocus autofocus
variant="solo-filled" variant="solo-filled"
clearable clearable
class="rounded-lg mt-2"
rounded rounded
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
/> />
<v-checkbox <v-checkbox
v-model="importKeywordsAsTags" v-model="importKeywordsAsTags"
@@ -124,6 +135,7 @@ export default defineNuxtComponent({
} }
const newRecipeData = ref<string | object | null>(null); const newRecipeData = ref<string | object | null>(null);
const newRecipeUrl = ref<string | null>(null);
function handleIsEditJson() { function handleIsEditJson() {
if (state.isEditJSON) { if (state.isEditJSON) {
@@ -148,8 +160,13 @@ export default defineNuxtComponent({
} }
handleIsEditJson(); handleIsEditJson();
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean) { async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, url: string | null = null) {
if (!htmlOrJsonData || !domUrlForm.value?.validate()) { if (!htmlOrJsonData) {
return;
}
const isValid = await domUrlForm.value?.validate();
if (!isValid?.valid) {
return; return;
} }
@@ -162,7 +179,7 @@ export default defineNuxtComponent({
} }
state.loading = true; state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags); const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags, url);
handleResponse(response, importKeywordsAsTags); handleResponse(response, importKeywordsAsTags);
} }
@@ -172,6 +189,7 @@ export default defineNuxtComponent({
stayInEditMode, stayInEditMode,
parseRecipe, parseRecipe,
newRecipeData, newRecipeData,
newRecipeUrl,
handleIsEditJson, handleIsEditJson,
createFromHtmlOrJson, createFromHtmlOrJson,
...toRefs(state), ...toRefs(state),

View File

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

View File

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