mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-20 20:05:37 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc4851ef5 | ||
|
|
d9e933d5ae | ||
|
|
0a07835338 | ||
|
|
7a85ea6ae9 | ||
|
|
c4c60f1645 | ||
|
|
9f7ba8dc08 | ||
|
|
c4799ceb9e | ||
|
|
828be095a2 | ||
|
|
18718fb647 | ||
|
|
fb545962dd | ||
|
|
781a08ef54 | ||
|
|
a7a08b6b11 | ||
|
|
bd296c3eaf | ||
|
|
8aa016e57b | ||
|
|
480574eb3d | ||
|
|
0573d6fc9c | ||
|
|
f8d08c6785 | ||
|
|
e6368174f0 | ||
|
|
2252875050 | ||
|
|
54c62ec491 | ||
|
|
af79a751fb | ||
|
|
6e2c849412 |
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec \
|
||||
FROM node:24@sha256:33cf7f057918860b043c307751ef621d74ac96f875b79b6724dcebf2dfd0db6d \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.15.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.16.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.15.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.15.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -19,6 +19,7 @@ theme:
|
||||
custom_dir: docs/overrides
|
||||
features:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- navigation.top
|
||||
- navigation.instant
|
||||
- navigation.expand
|
||||
|
||||
@@ -20,16 +20,12 @@
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
.v-theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-navigation-drawer {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-card {
|
||||
background-color: #1e1e1e !important;
|
||||
.v-theme--dark .v-navigation-drawer {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
|
||||
.left-border {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.privateGroup"
|
||||
v-model="local.privateGroup"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.showAnnouncements"
|
||||
v-model="local.showAnnouncements"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
@@ -40,4 +40,6 @@
|
||||
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||
|
||||
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||
const local = reactive({ ...preferences.value });
|
||||
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<v-checkbox v-model="local.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<v-checkbox v-model="local.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.showAnnouncements"
|
||||
v-model="local.showAnnouncements"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
v-model="local.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-title="name"
|
||||
@@ -48,7 +48,7 @@
|
||||
</BaseCardSectionTitle>
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<v-checkbox v-model="local[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
@@ -61,6 +61,9 @@
|
||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||
const local = reactive({ ...preferences.value });
|
||||
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
type Preference = {
|
||||
|
||||
@@ -41,19 +41,14 @@
|
||||
>
|
||||
<v-select
|
||||
v-if="index"
|
||||
:model-value="field.logicalOperator"
|
||||
:model-value="field.logicalOperator?.value"
|
||||
:items="[logOps.AND, logOps.OR]"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- left parenthesis -->
|
||||
@@ -67,14 +62,9 @@
|
||||
:model-value="field.leftParenthesis"
|
||||
:items="['', '(', '((', '(((']"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- field name -->
|
||||
@@ -84,19 +74,14 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
chips
|
||||
:model-value="field.label"
|
||||
:items="fieldDefs"
|
||||
variant="underlined"
|
||||
item-title="label"
|
||||
item-value="label"
|
||||
class="text-center"
|
||||
@update:model-value="setField(index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- relational operator -->
|
||||
@@ -107,19 +92,14 @@
|
||||
>
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:model-value="field.relationalOperatorValue?.value"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- field value -->
|
||||
@@ -275,18 +255,9 @@
|
||||
:model-value="field.rightParenthesis"
|
||||
:items="['', ')', '))', ')))']"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- field actions -->
|
||||
<v-col
|
||||
/>
|
||||
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
||||
:cols="config.items.fieldActions.cols(index)"
|
||||
:sm="config.items.fieldActions.sm(index)"
|
||||
@@ -723,9 +694,6 @@ const config = computed(() => {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end py-0",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
items: {
|
||||
icon: {
|
||||
cols: (_index: number) => 2,
|
||||
|
||||
@@ -371,14 +371,18 @@ async function parseIngredients() {
|
||||
}
|
||||
state.loading.parser = true;
|
||||
try {
|
||||
const ingsAsString = props.ingredients
|
||||
.filter(ing => !ing.referencedRecipe)
|
||||
.map(ing => ingredientToParserString(ing));
|
||||
const filteredIngredients = props.ingredients.filter(ing => !ing.referencedRecipe);
|
||||
const ingsAsString = filteredIngredients.map(ing => ingredientToParserString(ing));
|
||||
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to parse ingredients");
|
||||
}
|
||||
parsedIngs.value = data;
|
||||
|
||||
// Restore section titles from original ingredients — the parser doesn't return them
|
||||
data.forEach((parsed, index) => {
|
||||
parsed.ingredient.title = filteredIngredients[index]?.title || "";
|
||||
});
|
||||
|
||||
const parsed = data ?? [];
|
||||
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
|
||||
input: ing.note || "",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div style="height: 100%;">
|
||||
<v-row class="my-0 mx-7">
|
||||
<v-row class="mb-0 mt-3 mx-7">
|
||||
<v-spacer />
|
||||
<v-col class="text-right">
|
||||
<!-- Filters -->
|
||||
@@ -44,6 +44,7 @@
|
||||
:model-value="option.checked"
|
||||
color="primary"
|
||||
readonly
|
||||
hide-details
|
||||
@click="toggleEventTypeOption(option.value)"
|
||||
>
|
||||
<template #label>
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||
{{ event.subject }}
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
|
||||
<RecipeTimelineContextMenu
|
||||
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"
|
||||
|
||||
@@ -457,13 +457,8 @@ async function seedData() {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
seedFoods(),
|
||||
seedUnits(),
|
||||
seedLabels(),
|
||||
];
|
||||
|
||||
await Promise.all(tasks);
|
||||
await seedLabels();
|
||||
await Promise.all([seedFoods(), seedUnits()]);
|
||||
}
|
||||
|
||||
async function submitCommonSettings() {
|
||||
|
||||
@@ -68,6 +68,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
warning: theme?.darkWarning ?? "#FF6D00",
|
||||
error: theme?.darkError ?? "#EF5350",
|
||||
background: "#1E1E1E",
|
||||
surface: "#1E1E1E",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,12 @@ export default defineNuxtConfig({
|
||||
content: "Mealie is a recipe management app for your kitchen.",
|
||||
},
|
||||
],
|
||||
script: [
|
||||
{
|
||||
innerHTML: `(function(){try{var d=localStorage.getItem('vueuse-color-scheme');var m=d==='dark'||(d!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.style.backgroundColor=m?'#1E1E1E':'#FFFFFF'}catch(e){}})()`,
|
||||
type: "text/javascript",
|
||||
},
|
||||
],
|
||||
link: [
|
||||
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
|
||||
{ rel: "shortcut icon", type: "image/png", href: "/icons/icon-x64.png" },
|
||||
@@ -228,151 +234,7 @@ export default defineNuxtConfig({
|
||||
periodicSyncForUpdates: 120,
|
||||
},
|
||||
includeAssets: ["favicon.ico", "apple-touch-icon.png", "safari-pinned-tab.svg"],
|
||||
manifest: {
|
||||
name: "Mealie",
|
||||
short_name: "Mealie",
|
||||
id: "/",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#FFFFFF",
|
||||
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325",
|
||||
description: "Mealie is a recipe management and meal planning app",
|
||||
lang: "en",
|
||||
display_override: [
|
||||
"standalone",
|
||||
"minimal-ui",
|
||||
"browser",
|
||||
"window-controls-overlay",
|
||||
],
|
||||
categories: ["food", "lifestyle"],
|
||||
prefer_related_applications: false,
|
||||
handle_links: "preferred",
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "auto"],
|
||||
},
|
||||
edge_side_panel: {
|
||||
preferred_width: 400,
|
||||
},
|
||||
share_target: {
|
||||
action: "/r/create/url",
|
||||
method: "GET",
|
||||
enctype: "application/x-www-form-urlencoded",
|
||||
params: {
|
||||
text: "recipe_import_url",
|
||||
},
|
||||
},
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/android-chrome-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/android-chrome-maskable-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/icons/android-chrome-maskable-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/home-narrow.png",
|
||||
sizes: "1600x2420",
|
||||
form_factor: "narrow",
|
||||
label: "Home Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/recipe-narrow.png",
|
||||
sizes: "1600x2420",
|
||||
form_factor: "narrow",
|
||||
label: "Recipe Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/editor-narrow.png",
|
||||
sizes: "1600x2420",
|
||||
form_factor: "narrow",
|
||||
label: "Editor Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/parser-narrow.png",
|
||||
sizes: "1600x2420",
|
||||
form_factor: "narrow",
|
||||
label: "Parser Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/home-wide.png",
|
||||
sizes: "2560x1460",
|
||||
form_factor: "wide",
|
||||
label: "Home Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/recipe-wide.png",
|
||||
sizes: "2560x1460",
|
||||
form_factor: "wide",
|
||||
label: "Recipe Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/editor-wide.png",
|
||||
sizes: "2560x1460",
|
||||
form_factor: "wide",
|
||||
label: "Editor Page",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/parser-wide.png",
|
||||
sizes: "2560x1460",
|
||||
form_factor: "wide",
|
||||
label: "Parser Page",
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: "Shopping Lists",
|
||||
short_name: "Shopping Lists",
|
||||
description: "Open the shopping lists",
|
||||
url: "/shopping-lists",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/mdiFormatListChecks-192x192.png",
|
||||
sizes: "192x192",
|
||||
},
|
||||
{
|
||||
src: "/icons/mdiFormatListChecks-96x96.png",
|
||||
sizes: "96x96",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Meal Planner",
|
||||
short_name: "Meal Planner",
|
||||
description: "Open the meal planner",
|
||||
url: "/household/mealplan/planner/view",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/mdiCalendarMultiselect-192x192.png",
|
||||
sizes: "192x192",
|
||||
},
|
||||
{
|
||||
src: "/icons/mdiCalendarMultiselect-96x96.png",
|
||||
sizes: "96x96",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: false, // This is served via the backend, see mealie/routes/spa/manifest.py
|
||||
},
|
||||
|
||||
// Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mealie",
|
||||
"version": "3.15.0",
|
||||
"version": "3.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
"axios": "^1.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"isomorphic-dompurify": "^3.4.0",
|
||||
"json-editor-vue": "^0.18.1",
|
||||
"marked": "^15.0.12",
|
||||
"nuxt": "^4.4.2",
|
||||
@@ -55,8 +55,6 @@
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"resolutions": {
|
||||
"esbuild": ">=0.25.0",
|
||||
"glob": ">=10.5.0",
|
||||
"js-yaml": ">=4.1.1",
|
||||
"node-forge": ">=1.3.2"
|
||||
}
|
||||
|
||||
4468
frontend/yarn.lock
4468
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,12 @@ router = APIRouter(prefix="/backups")
|
||||
|
||||
@controller(router)
|
||||
class AdminBackupController(BaseAdminController):
|
||||
def _backup_path(self, name) -> Path:
|
||||
return get_app_dirs().BACKUP_DIR / name
|
||||
def _backup_path(self, name: str) -> Path:
|
||||
backup_dir = get_app_dirs().BACKUP_DIR
|
||||
candidate = (backup_dir / name).resolve()
|
||||
if not candidate.is_relative_to(backup_dir.resolve()):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
return candidate
|
||||
|
||||
@router.get("", response_model=AllBackups)
|
||||
def get_all(self):
|
||||
@@ -86,7 +90,7 @@ class AdminBackupController(BaseAdminController):
|
||||
app_dirs = get_app_dirs()
|
||||
dest = app_dirs.BACKUP_DIR.joinpath(f"{name}.zip")
|
||||
|
||||
if dest.absolute().parent != app_dirs.BACKUP_DIR:
|
||||
if dest.resolve().parent != app_dirs.BACKUP_DIR.resolve():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with dest.open("wb") as buffer:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
|
||||
@@ -25,9 +26,12 @@ class AdminDebugController(BaseAdminController):
|
||||
|
||||
with get_temporary_path() as temp_path:
|
||||
if image:
|
||||
with temp_path.joinpath(image.filename).open("wb") as buffer:
|
||||
if not image.filename:
|
||||
return DebugResponse(success=False, response="Invalid image filename")
|
||||
safe_filename = Path(image.filename).name
|
||||
local_image_path = temp_path.joinpath(safe_filename)
|
||||
with local_image_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(image.file, buffer)
|
||||
local_image_path = temp_path.joinpath(image.filename)
|
||||
local_images = [OpenAILocalImage(filename=os.path.basename(local_image_path), path=local_image_path)]
|
||||
else:
|
||||
local_images = None
|
||||
|
||||
@@ -17,7 +17,7 @@ class ImageType(StrEnum):
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/images/{file_name}")
|
||||
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
|
||||
async def get_recipe_img(recipe_id: UUID4, file_name: ImageType = ImageType.original):
|
||||
"""
|
||||
Takes in a recipe id, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production
|
||||
@@ -32,7 +32,7 @@ async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.origin
|
||||
|
||||
@router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}")
|
||||
async def get_recipe_timeline_event_img(
|
||||
recipe_id: str, timeline_event_id: str, file_name: ImageType = ImageType.original
|
||||
recipe_id: UUID4, timeline_event_id: UUID4, file_name: ImageType = ImageType.original
|
||||
):
|
||||
"""
|
||||
Takes in a recipe id and event timeline id, returns the static image. This route is proxied in the docker image
|
||||
@@ -51,7 +51,11 @@ async def get_recipe_timeline_event_img(
|
||||
@router.get("/{recipe_id}/assets/{file_name}")
|
||||
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
||||
"""Returns a recipe asset"""
|
||||
file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name)
|
||||
asset_dir = Recipe.directory_from_id(recipe_id).joinpath("assets")
|
||||
file = asset_dir.joinpath(file_name).resolve()
|
||||
|
||||
if not file.is_relative_to(asset_dir.resolve()):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if file.exists():
|
||||
return FileResponse(file)
|
||||
|
||||
@@ -11,7 +11,11 @@ router = APIRouter(prefix="/users")
|
||||
async def get_user_image(user_id: UUID4, file_name: str):
|
||||
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production"""
|
||||
recipe_image = PrivateUser.get_directory(user_id) / file_name
|
||||
user_dir = PrivateUser.get_directory(user_id)
|
||||
recipe_image = (user_dir / file_name).resolve()
|
||||
|
||||
if not recipe_image.is_relative_to(user_dir.resolve()):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if recipe_image.exists():
|
||||
return FileResponse(recipe_image, media_type="image/webp")
|
||||
|
||||
@@ -16,6 +16,7 @@ from mealie.core.config import get_app_settings
|
||||
from mealie.core.dependencies.dependencies import try_get_current_user
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.routes.spa.manifest import serve_manifest
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
|
||||
@@ -251,4 +252,5 @@ def mount_spa(app: FastAPI):
|
||||
|
||||
app.get("/g/{group_slug}/r/{recipe_slug}", include_in_schema=False)(serve_recipe_with_meta)
|
||||
app.get("/g/{group_slug}/shared/r/{token_id}", include_in_schema=False)(serve_shared_recipe_with_meta)
|
||||
app.get("/manifest.webmanifest", include_in_schema=False)(serve_manifest)
|
||||
app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")
|
||||
|
||||
125
mealie/routes/spa/manifest.py
Normal file
125
mealie/routes/spa/manifest.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
|
||||
def serve_manifest():
|
||||
settings = get_app_settings()
|
||||
sub_path = urlparse(settings.BASE_URL).path or "/"
|
||||
|
||||
manifest = {
|
||||
"name": "Mealie",
|
||||
"short_name": "Mealie",
|
||||
"id": "/",
|
||||
"start_url": sub_path,
|
||||
"scope": sub_path,
|
||||
"display": "standalone",
|
||||
"background_color": "#1E1E1E",
|
||||
"theme_color": settings.theme.light_primary,
|
||||
"description": "Mealie is a recipe management and meal planning app",
|
||||
"lang": "en",
|
||||
"display_override": ["standalone", "minimal-ui", "browser", "window-controls-overlay"],
|
||||
"categories": ["food", "lifestyle"],
|
||||
"prefer_related_applications": False,
|
||||
"handle_links": "preferred",
|
||||
"launch_handler": {"client_mode": ["focus-existing", "auto"]},
|
||||
"edge_side_panel": {"preferred_width": 400},
|
||||
"share_target": {
|
||||
"action": "/r/create/url",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"text": "recipe_import_url"},
|
||||
},
|
||||
"icons": [
|
||||
{"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||
{
|
||||
"src": "/icons/android-chrome-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/home-narrow.png",
|
||||
"sizes": "1600x2420",
|
||||
"form_factor": "narrow",
|
||||
"label": "Home Page",
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/recipe-narrow.png",
|
||||
"sizes": "1600x2420",
|
||||
"form_factor": "narrow",
|
||||
"label": "Recipe Page",
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/editor-narrow.png",
|
||||
"sizes": "1600x2420",
|
||||
"form_factor": "narrow",
|
||||
"label": "Editor Page",
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/parser-narrow.png",
|
||||
"sizes": "1600x2420",
|
||||
"form_factor": "narrow",
|
||||
"label": "Parser Page",
|
||||
},
|
||||
{"src": "/screenshots/home-wide.png", "sizes": "2560x1460", "form_factor": "wide", "label": "Home Page"},
|
||||
{
|
||||
"src": "/screenshots/recipe-wide.png",
|
||||
"sizes": "2560x1460",
|
||||
"form_factor": "wide",
|
||||
"label": "Recipe Page",
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/editor-wide.png",
|
||||
"sizes": "2560x1460",
|
||||
"form_factor": "wide",
|
||||
"label": "Editor Page",
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/parser-wide.png",
|
||||
"sizes": "2560x1460",
|
||||
"form_factor": "wide",
|
||||
"label": "Parser Page",
|
||||
},
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Shopping Lists",
|
||||
"short_name": "Shopping Lists",
|
||||
"description": "Open the shopping lists",
|
||||
"url": "/shopping-lists",
|
||||
"icons": [
|
||||
{"src": "/icons/mdiFormatListChecks-192x192.png", "sizes": "192x192"},
|
||||
{"src": "/icons/mdiFormatListChecks-96x96.png", "sizes": "96x96"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Meal Planner",
|
||||
"short_name": "Meal Planner",
|
||||
"description": "Open the meal planner",
|
||||
"url": "/household/mealplan/planner/view",
|
||||
"icons": [
|
||||
{"src": "/icons/mdiCalendarMultiselect-192x192.png", "sizes": "192x192"},
|
||||
{"src": "/icons/mdiCalendarMultiselect-96x96.png", "sizes": "96x96"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return Response(
|
||||
content=json.dumps(manifest),
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
@@ -272,8 +272,8 @@ class BaseMigrator(BaseService):
|
||||
recipe = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
|
||||
return recipe
|
||||
|
||||
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4):
|
||||
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4, extraction_root: Path | None = None):
|
||||
try:
|
||||
import_image(src, recipe_id)
|
||||
import_image(src, recipe_id, extraction_root=extraction_root)
|
||||
except UnidentifiedImageError as e:
|
||||
self.logger.error(f"Failed to import image for {slug}: {e}")
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
from .utils.migration_alias import MigrationAlias
|
||||
from .utils.migration_helpers import MigrationReaders, split_by_comma
|
||||
from .utils.migration_helpers import MigrationReaders, safe_local_path, split_by_comma
|
||||
|
||||
|
||||
class ChowdownMigrator(BaseMigrator):
|
||||
@@ -60,8 +60,10 @@ class ChowdownMigrator(BaseMigrator):
|
||||
continue
|
||||
|
||||
if r.image:
|
||||
cd_image = image_dir.joinpath(r.image)
|
||||
cd_image = safe_local_path(image_dir.joinpath(r.image), image_dir)
|
||||
else:
|
||||
cd_image = None
|
||||
except StopIteration:
|
||||
continue
|
||||
if cd_image:
|
||||
self.import_image(slug, cd_image, recipe_id)
|
||||
self.import_image(slug, cd_image, recipe_id, extraction_root=image_dir)
|
||||
|
||||
@@ -11,7 +11,7 @@ from mealie.services.parser_services._base import DataMatcher
|
||||
from mealie.services.parser_services.parser_utils.string_utils import extract_quantity_from_string
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
from .utils.migration_helpers import format_time
|
||||
from .utils.migration_helpers import format_time, safe_local_path
|
||||
|
||||
|
||||
class DSVParser:
|
||||
@@ -157,15 +157,21 @@ class CooknMigrator(BaseMigrator):
|
||||
if _media_type != "":
|
||||
# Determine file extension based on media type
|
||||
_extension = _media_type.split("/")[-1]
|
||||
_old_image_path = os.path.join(db.directory, str(_media_id))
|
||||
new_image_path = f"{_old_image_path}.{_extension}"
|
||||
_old_image_path = Path(db.directory) / str(_media_id)
|
||||
new_image_path = _old_image_path.with_suffix(f".{_extension}")
|
||||
if safe_local_path(_old_image_path, db.directory) is None:
|
||||
return None
|
||||
if safe_local_path(new_image_path, db.directory) is None:
|
||||
return None
|
||||
# Rename the file if it exists and has no extension
|
||||
if os.path.exists(_old_image_path) and not os.path.exists(new_image_path):
|
||||
if _old_image_path.exists() and not new_image_path.exists():
|
||||
os.rename(_old_image_path, new_image_path)
|
||||
if Path(new_image_path).exists():
|
||||
return new_image_path
|
||||
if new_image_path.exists():
|
||||
return str(new_image_path)
|
||||
else:
|
||||
return os.path.join(db.directory, str(_media_id))
|
||||
candidate = Path(db.directory) / str(_media_id)
|
||||
if safe_local_path(candidate, db.directory) is not None:
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
def _parse_ingredients(self, _recipe_id: str, db: DSVParser) -> list[RecipeIngredient]:
|
||||
@@ -388,14 +394,14 @@ class CooknMigrator(BaseMigrator):
|
||||
recipe = recipe_lookup.get(slug)
|
||||
if recipe:
|
||||
if recipe.image:
|
||||
self.import_image(slug, recipe.image, recipe_id)
|
||||
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
|
||||
else:
|
||||
index_len = len(slug.split("-")[-1])
|
||||
recipe = recipe_lookup.get(slug[: -(index_len + 1)])
|
||||
if recipe:
|
||||
self.logger.warning("Duplicate recipe (%s) found! Saved as copy...", recipe.name)
|
||||
if recipe.image:
|
||||
self.import_image(slug, recipe.image, recipe_id)
|
||||
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
|
||||
else:
|
||||
self.logger.warning("Failed to lookup recipe! (%s)", slug)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from mealie.schema.reports.reports import ReportEntryCreate
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
from .utils.migration_alias import MigrationAlias
|
||||
from .utils.migration_helpers import import_image
|
||||
from .utils.migration_helpers import import_image, safe_local_path
|
||||
|
||||
|
||||
def parse_recipe_tags(tags: list) -> list[str]:
|
||||
@@ -52,7 +52,9 @@ class CopyMeThatMigrator(BaseMigrator):
|
||||
# the recipe image tag has no id, so we parse it directly
|
||||
if tag.name == "img" and "recipeImage" in tag.get("class", []):
|
||||
if image_path := tag.get("src"):
|
||||
recipe_dict["image"] = str(source_dir.joinpath(image_path))
|
||||
safe = safe_local_path(source_dir.joinpath(image_path), source_dir)
|
||||
if safe is not None:
|
||||
recipe_dict["image"] = str(safe)
|
||||
|
||||
continue
|
||||
|
||||
@@ -120,4 +122,4 @@ class CopyMeThatMigrator(BaseMigrator):
|
||||
except StopIteration:
|
||||
continue
|
||||
|
||||
import_image(r.image, recipe_id)
|
||||
import_image(r.image, recipe_id, extraction_root=source_dir)
|
||||
|
||||
@@ -97,4 +97,4 @@ class NextcloudMigrator(BaseMigrator):
|
||||
if status:
|
||||
nc_dir = nextcloud_dirs[slug]
|
||||
if nc_dir.image:
|
||||
self.import_image(slug, nc_dir.image, recipe_id)
|
||||
self.import_image(slug, nc_dir.image, recipe_id, extraction_root=base_dir)
|
||||
|
||||
@@ -84,6 +84,6 @@ class PaprikaMigrator(BaseMigrator):
|
||||
temp_file.write(image.read())
|
||||
temp_file.flush()
|
||||
path = Path(temp_file.name)
|
||||
self.import_image(slug, path, recipe_id)
|
||||
self.import_image(slug, path, recipe_id, extraction_root=path.parent)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to import image for {slug}: {e}")
|
||||
|
||||
@@ -8,7 +8,7 @@ from mealie.services.scraper import cleaner
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
from .utils.migration_alias import MigrationAlias
|
||||
from .utils.migration_helpers import parse_iso8601_duration
|
||||
from .utils.migration_helpers import parse_iso8601_duration, safe_local_path
|
||||
|
||||
|
||||
def clean_instructions(instructions: list[str]) -> list[str]:
|
||||
@@ -30,7 +30,9 @@ def parse_recipe_div(recipe, image_path):
|
||||
elif item.name == "div":
|
||||
meta[item["itemprop"]] = list(item.stripped_strings)
|
||||
elif item.name == "img":
|
||||
meta[item["itemprop"]] = str(image_path / item["src"])
|
||||
safe = safe_local_path(image_path / item["src"], image_path)
|
||||
if safe is not None:
|
||||
meta[item["itemprop"]] = str(safe)
|
||||
else:
|
||||
meta[item["itemprop"]] = item.string
|
||||
# merge nutrition keys into their own dict.
|
||||
@@ -107,4 +109,4 @@ class RecipeKeeperMigrator(BaseMigrator):
|
||||
except StopIteration:
|
||||
continue
|
||||
|
||||
self.import_image(slug, recipe.image, recipe_id)
|
||||
self.import_image(slug, recipe.image, recipe_id, extraction_root=source_dir)
|
||||
|
||||
@@ -132,4 +132,4 @@ class TandoorMigrator(BaseMigrator):
|
||||
except StopIteration:
|
||||
continue
|
||||
|
||||
self.import_image(slug, r.image, recipe_id)
|
||||
self.import_image(slug, r.image, recipe_id, extraction_root=source_dir)
|
||||
|
||||
@@ -8,6 +8,7 @@ import yaml
|
||||
from PIL import UnidentifiedImageError
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
|
||||
@@ -100,16 +101,45 @@ def glob_walker(directory: Path, glob_str: str, return_parent=True) -> list[Path
|
||||
return matches
|
||||
|
||||
|
||||
def import_image(src: str | Path, recipe_id: UUID4):
|
||||
"""Read the successful migrations attribute and for each import the image
|
||||
appropriately into the image directory. Minification is done in mass
|
||||
after the migration occurs.
|
||||
def safe_local_path(candidate: str | Path, root: Path) -> Path | None:
|
||||
"""
|
||||
Returns the resolved path only if it is safely contained within root.
|
||||
|
||||
Returns ``None`` for any path that would escape the root directory,
|
||||
including ``../../`` traversal sequences and absolute paths outside root.
|
||||
Symlinks are followed by ``resolve()``, so a symlink pointing outside root
|
||||
is also rejected.
|
||||
"""
|
||||
try:
|
||||
# OSError: symlink resolution failure; ValueError: null bytes on some platforms
|
||||
resolved = Path(candidate).resolve()
|
||||
if resolved.is_relative_to(root.resolve()):
|
||||
return resolved
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def import_image(src: str | Path, recipe_id: UUID4, extraction_root: Path | None = None):
|
||||
"""Import a local image file into the recipe image directory.
|
||||
|
||||
May raise an UnidentifiedImageError if the file is not a recognised format.
|
||||
|
||||
If extraction_root is provided, the src path must be contained within it.
|
||||
Paths that escape the extraction_root are silently rejected to prevent
|
||||
arbitrary local file reads via archive-controlled image paths.
|
||||
"""
|
||||
|
||||
if isinstance(src, str):
|
||||
src = Path(src)
|
||||
|
||||
if extraction_root is not None:
|
||||
if safe_local_path(src, extraction_root) is None:
|
||||
root_logger.get_logger().warning(
|
||||
"Rejected image path outside extraction root: %s (root: %s)", src, extraction_root
|
||||
)
|
||||
return
|
||||
|
||||
if not src.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -129,19 +129,24 @@ class OpenAIService(BaseService):
|
||||
"""
|
||||
tree = name.split(".")
|
||||
relative_path = Path(*tree[:-1], tree[-1] + ".txt")
|
||||
default_prompt_file = Path(self.PROMPTS_DIR, relative_path)
|
||||
|
||||
default_prompt_file = (self.PROMPTS_DIR / relative_path).resolve()
|
||||
if not default_prompt_file.is_relative_to(self.PROMPTS_DIR.resolve()):
|
||||
raise ValueError(f"Invalid prompt name '{name}': resolves outside prompts directory")
|
||||
|
||||
try:
|
||||
# Only include custom files if the custom_dir is configured, is a directory, and the prompt file exists
|
||||
custom_dir = Path(self.custom_prompt_dir) if self.custom_prompt_dir else None
|
||||
custom_dir = Path(self.custom_prompt_dir).resolve() if self.custom_prompt_dir else None
|
||||
if custom_dir and not custom_dir.is_dir():
|
||||
custom_dir = None
|
||||
except Exception:
|
||||
custom_dir = None
|
||||
|
||||
if custom_dir:
|
||||
custom_prompt_file = Path(custom_dir, relative_path)
|
||||
if custom_prompt_file.exists():
|
||||
custom_prompt_file = (custom_dir / relative_path).resolve()
|
||||
if not custom_prompt_file.is_relative_to(custom_dir):
|
||||
logger.warning(f"Custom prompt file resolves outside custom dir, skipping: {custom_prompt_file}")
|
||||
elif custom_prompt_file.exists():
|
||||
logger.debug(f"Found valid custom prompt file: {custom_prompt_file}")
|
||||
return [custom_prompt_file, default_prompt_file]
|
||||
else:
|
||||
|
||||
@@ -99,7 +99,12 @@ class RecipeDataService(BaseService):
|
||||
with open(image_path, "ab") as f:
|
||||
shutil.copyfileobj(file_data, f)
|
||||
|
||||
try:
|
||||
self.minifier.minify(image_path)
|
||||
except Exception:
|
||||
# Remove the partially-written file so corrupt images don't persist on disk.
|
||||
image_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return image_path
|
||||
|
||||
|
||||
@@ -325,9 +325,11 @@ class RecipeService(RecipeServiceBase):
|
||||
with get_temporary_path() as temp_path:
|
||||
local_images: list[Path] = []
|
||||
for image in images:
|
||||
with temp_path.joinpath(image.filename).open("wb") as buffer:
|
||||
safe_filename = Path(image.filename).name
|
||||
image_path = temp_path.joinpath(safe_filename)
|
||||
with image_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(image.file, buffer)
|
||||
local_images.append(temp_path.joinpath(image.filename))
|
||||
local_images.append(image_path)
|
||||
|
||||
recipe_data = await openai_recipe_service.build_recipe_from_images(
|
||||
local_images, translate_language=translate_language
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mealie"
|
||||
version = "3.15.0"
|
||||
version = "3.16.0"
|
||||
description = "A Recipe Manager"
|
||||
authors = [{ name = "Hayden", email = "hay-kot@pm.me" }]
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -19,14 +19,14 @@ dependencies = [
|
||||
"extruct==0.18.0",
|
||||
"fastapi==0.135.3",
|
||||
"httpx==0.28.1",
|
||||
"lxml==6.0.2",
|
||||
"lxml==6.0.4",
|
||||
"orjson==3.11.8",
|
||||
"pydantic==2.12.5",
|
||||
"pyhumps==3.8.0",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.2.2",
|
||||
"python-ldap==3.4.5",
|
||||
"python-multipart==0.0.24",
|
||||
"python-multipart==0.0.26",
|
||||
"python-slugify==8.0.4",
|
||||
"recipe-scrapers==15.11.0",
|
||||
"requests==2.33.1",
|
||||
@@ -36,13 +36,13 @@ dependencies = [
|
||||
"isodate==0.7.2",
|
||||
"text-unidecode==1.3",
|
||||
"rapidfuzz==3.14.5",
|
||||
"authlib==1.6.9",
|
||||
"authlib==1.6.11",
|
||||
"html2text==2025.4.15",
|
||||
"paho-mqtt==1.6.1",
|
||||
"pydantic-settings==2.13.1",
|
||||
"pillow-heif==1.3.0",
|
||||
"pyjwt==2.12.1",
|
||||
"openai==2.30.0",
|
||||
"openai==2.31.0",
|
||||
"typing-extensions==4.15.0",
|
||||
"itsdangerous==2.2.0",
|
||||
"yt-dlp==2026.3.17",
|
||||
@@ -73,7 +73,7 @@ dev = [
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"rich==15.0.0",
|
||||
"ruff==0.15.9",
|
||||
"ruff==0.15.10",
|
||||
"types-PyYAML==6.0.12.20260408",
|
||||
"types-python-dateutil==2.9.0.20260408",
|
||||
"types-python-slugify==8.0.2.20240310",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"enabledManagers": [
|
||||
"pep621",
|
||||
"dockerfile",
|
||||
|
||||
Reference in New Issue
Block a user