mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-05 12:35:35 -04:00
feat: Recipe import progress (#7252)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const useMealieAuth = function () {
|
||||
return {
|
||||
user,
|
||||
loggedIn,
|
||||
token: auth.token,
|
||||
signIn: auth.signIn,
|
||||
signOut: auth.signOut,
|
||||
refresh: auth.refresh,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 to maintain layout -->
|
||||
{{ createStatus }}
|
||||
</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,
|
||||
|
||||
@@ -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 to maintain layout -->
|
||||
{{ createStatus }}
|
||||
</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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user