feat: Recipe import progress (#7252)

This commit is contained in:
Michael Genson
2026-03-15 16:44:19 -05:00
committed by GitHub
parent 04dd514e6a
commit c4fdab4e05
20 changed files with 491 additions and 60 deletions

View File

@@ -13,6 +13,7 @@ interface AuthStatus {
interface AuthState {
data: AuthData;
status: AuthStatus;
token: { readonly value: string | null | undefined };
signIn: (credentials: FormData, options?: { redirect?: boolean }) => Promise<void>;
signOut: (callbackUrl?: string) => Promise<void>;
refresh: () => Promise<void>;
@@ -131,6 +132,7 @@ export const useAuthBackend = function (): AuthState {
return {
data: computed(() => authUser.value),
status: computed(() => authStatus.value),
token: computed(() => tokenCookie.value),
signIn,
signOut,
refresh,

View File

@@ -47,6 +47,7 @@ export const useMealieAuth = function () {
return {
user,
loggedIn,
token: auth.token,
signIn: auth.signIn,
signOut: auth.signOut,
refresh: auth.refresh,

View File

@@ -41,6 +41,12 @@ export enum Organizer {
User = "users",
}
export enum SSEDataEventStatus {
Progress = "progress",
Done = "done",
Error = "error",
}
export type PlaceholderKeyword = "$NOW";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type LogicalOperator = "AND" | "OR";

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
/**
/* This file was automatically generated from pydantic models by running pydantic2ts.
/* Do not modify it by hand - just update the pydantic models and then re-run the script
@@ -40,6 +40,13 @@ export interface RequestQuery {
queryFilter?: string | null;
paginationSeed?: string | null;
}
export interface SSEDataEventBase {}
export interface SSEDataEventDone {
slug: string;
}
export interface SSEDataEventMessage {
message: string;
}
export interface SuccessResponse {
message: string;
error?: boolean;

View File

@@ -1,3 +1,5 @@
import { SSE } from "sse.js";
import type { SSEvent } from "sse.js";
import { BaseCRUDAPI } from "../../base/base-clients";
import { route } from "../../base";
import { CommentsApi } from "./recipe-comments";
@@ -16,7 +18,9 @@ import type {
RecipeTimelineEventOut,
RecipeTimelineEventUpdate,
} from "~/lib/api/types/recipe";
import type { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
import type { SSEDataEventDone, SSEDataEventMessage } from "~/lib/api/types/response";
import type { ApiRequestInstance, PaginationData, RequestResponse } from "~/lib/api/types/non-generated";
import { SSEDataEventStatus } from "~/lib/api/types/non-generated";
export type Parser = "nlp" | "brute" | "openai";
@@ -34,11 +38,11 @@ const routes = {
recipesBase: `${prefix}/recipes`,
recipesSuggestions: `${prefix}/recipes/suggestions`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create/url`,
recipesCreateUrl: `${prefix}/recipes/create/url/stream`,
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create/zip`,
recipesCreateFromImage: `${prefix}/recipes/create/image`,
recipesCreateFromHtmlOrJson: `${prefix}/recipes/create/html-or-json`,
recipesCreateFromHtmlOrJson: `${prefix}/recipes/create/html-or-json/stream`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
@@ -146,12 +150,65 @@ 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, includeCategories: boolean, url: string | null = null) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags, includeCategories, url });
private streamRecipeCreate(streamRoute: string, payload: object, onProgress?: (message: string) => void): Promise<RequestResponse<string>> {
return new Promise((resolve) => {
const { token } = useMealieAuth();
const sse = new SSE(streamRoute, {
headers: {
"Content-Type": "application/json",
...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
},
payload: JSON.stringify(payload),
withCredentials: true,
autoReconnect: false,
});
if (onProgress) {
sse.addEventListener(SSEDataEventStatus.Progress, (e: SSEvent) => {
const { message } = JSON.parse(e.data) as SSEDataEventMessage;
onProgress(message);
});
}
sse.addEventListener(SSEDataEventStatus.Done, (e: SSEvent) => {
const { slug } = JSON.parse(e.data) as SSEDataEventDone;
sse.close();
resolve({ response: { status: 201, data: slug } as any, data: slug, error: null });
});
sse.addEventListener(SSEDataEventStatus.Error, (e: SSEvent) => {
try {
const { message } = JSON.parse(e.data) as SSEDataEventMessage;
sse.close();
resolve({ response: null, data: null, error: new Error(message) });
}
catch {
// Not a backend error payload (e.g. XHR connection-close event); ignore
}
});
sse.stream();
});
}
async createOneByUrl(url: string, includeTags: boolean, includeCategories: boolean) {
return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags, includeCategories });
async createOneByHtmlOrJson(
data: string,
includeTags: boolean,
includeCategories: boolean,
url: string | null = null,
onProgress?: (message: string) => void,
): Promise<RequestResponse<string>> {
return this.streamRecipeCreate(routes.recipesCreateFromHtmlOrJson, { data, includeTags, includeCategories, url }, onProgress);
}
async createOneByUrl(
url: string,
includeTags: boolean,
includeCategories: boolean,
onProgress?: (message: string) => void,
): Promise<RequestResponse<string>> {
return this.streamRecipeCreate(routes.recipesCreateUrl, { url, includeTags, includeCategories }, onProgress);
}
async createManyByUrl(payload: CreateRecipeByUrlBulk) {

View File

@@ -30,6 +30,7 @@
"json-editor-vue": "^0.18.1",
"marked": "^15.0.12",
"nuxt": "^3.19.2",
"sse.js": "^2.8.0",
"vite": "^7.0.0",
"vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "^0.6.0",

View File

@@ -83,14 +83,20 @@
/>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="!newRecipeData"
rounded
block
type="submit"
:loading="loading"
/>
<div style="width: 100%" class="text-center">
<div style="width: 250px; margin: 0 auto">
<BaseButton
:disabled="!newRecipeData"
rounded
block
type="submit"
:loading="loading"
/>
</div>
<v-card-text class="py-2">
<!-- render &nbsp; to maintain layout -->
{{ createStatus }}&nbsp;
</v-card-text>
</div>
</v-card-actions>
</div>
@@ -167,6 +173,7 @@ export default defineNuxtComponent({
}
handleIsEditJson();
const createStatus = ref<string | null>(null);
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, importCategories: boolean, url: string | null = null) {
if (!htmlOrJsonData) {
return;
@@ -186,7 +193,14 @@ export default defineNuxtComponent({
}
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags, importCategories, url);
const { response } = await api.recipes.createOneByHtmlOrJson(
dataString,
importKeywordsAsTags,
importCategories,
url,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
@@ -199,6 +213,7 @@ export default defineNuxtComponent({
newRecipeData,
newRecipeUrl,
handleIsEditJson,
createStatus,
createFromHtmlOrJson,
...toRefs(state),
validators,

View File

@@ -65,14 +65,20 @@
:label="$t('recipe.parse-recipe-ingredients-after-import')"
/>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="recipeUrl === null"
rounded
block
type="submit"
:loading="loading"
/>
<div style="width: 100%" class="text-center">
<div style="width: 250px; margin: 0 auto">
<BaseButton
:disabled="recipeUrl === null"
rounded
block
type="submit"
:loading="loading"
/>
</div>
<v-card-text class="py-2">
<!-- render &nbsp; to maintain layout -->
{{ createStatus }}&nbsp;
</v-card-text>
</div>
</v-card-actions>
</div>
@@ -234,6 +240,7 @@ export default defineNuxtComponent({
router.replace({ query: undefined }).then(() => router.push(to));
});
const createStatus = ref<string | null>(null);
async function createByUrl(url: string | null, importKeywordsAsTags: boolean, importCategories: boolean) {
if (url === null) {
return;
@@ -244,7 +251,13 @@ export default defineNuxtComponent({
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags, importCategories);
const { response } = await api.recipes.createOneByUrl(
url,
importKeywordsAsTags,
importCategories,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
@@ -257,6 +270,7 @@ export default defineNuxtComponent({
stayInEditMode,
parseRecipe,
domUrlForm,
createStatus,
createByUrl,
...toRefs(state),
validators,

View File

@@ -9940,6 +9940,11 @@ srvx@^0.8.9:
resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.8.16.tgz#f2582bd747351b5b0a1c65bce8179bae83e8b2a6"
integrity sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==
sse.js@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/sse.js/-/sse.js-2.8.0.tgz#28e922720ef41f0de3312e33d23183682bec4b1e"
integrity sha512-35RyyFYpzzHZgMw9D5GxwADbL6gnntSwW/rKXcuIy1KkYCPjW6oia0moNdNRhs34oVHU1Sjgovj3l7uIEZjrKA==
stable-hash-x@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz#dfd76bfa5d839a7470125c6a6b3c8b22061793e9"