mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-21 05:53:36 -07:00
Merge branch 'mealie-next' into l10n_mealie-next
This commit is contained in:
commit
fda73758fb
20 changed files with 355 additions and 286 deletions
114
.github/workflows/locale-sync.yml
vendored
Normal file
114
.github/workflows/locale-sync.yml
vendored
Normal file
|
@ -0,0 +1,114 @@
|
|||
name: Automatic Locale Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at 2 AM UTC
|
||||
- cron: "0 2 * * 0"
|
||||
workflow_dispatch:
|
||||
# Allow manual triggering from the GitHub UI
|
||||
|
||||
permissions:
|
||||
contents: write # To checkout, commit, and push changes
|
||||
pull-requests: write # To create pull requests
|
||||
|
||||
jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
|
||||
- name: Check venv cache
|
||||
id: cache-validate
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||
rm test.py
|
||||
continue-on-error: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||
poetry install
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Run locale generation
|
||||
run: |
|
||||
cd dev/code-generation
|
||||
poetry run python main.py locales
|
||||
env:
|
||||
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and create PR
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
# Configure git
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
# Use the current branch as the base
|
||||
BASE_BRANCH="${{ github.ref_name }}"
|
||||
echo "Using base branch: $BASE_BRANCH"
|
||||
|
||||
# Create a new branch from the base branch
|
||||
BRANCH_NAME="auto-locale-sync-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
# Add and commit changes
|
||||
git add .
|
||||
git commit -m "chore: automatic locale sync"
|
||||
|
||||
# Push the branch
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
sleep 2
|
||||
|
||||
# Create PR using GitHub CLI with explicit repository
|
||||
gh pr create \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "chore: automatic locale sync" \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$BRANCH_NAME" \
|
||||
--body "## Summary
|
||||
|
||||
Automatically generated locale updates from the weekly sync job.
|
||||
|
||||
## Changes
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
run: echo "No locale changes detected, skipping PR creation"
|
|
@ -70,7 +70,7 @@ tasks:
|
|||
dev:generate:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- poetry run python dev/code-generation/main.py
|
||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
@ -13,7 +14,7 @@ from mealie.schema._mealie import MealieModel
|
|||
|
||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
|
||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY")
|
||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -23,19 +24,22 @@ class LocaleData:
|
|||
|
||||
|
||||
LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
||||
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
|
@ -53,6 +57,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
|||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||
|
@ -93,8 +98,8 @@ class CrowdinApi:
|
|||
project_id = "451976"
|
||||
api_key = API_KEY
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
api_key = api_key
|
||||
def __init__(self, api_key: str | None):
|
||||
self.api_key = api_key or API_KEY
|
||||
|
||||
@property
|
||||
def headers(self) -> dict:
|
||||
|
@ -196,7 +201,7 @@ def inject_registration_validation_values():
|
|||
|
||||
|
||||
def generate_locales_ts_file():
|
||||
api = CrowdinApi("")
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import gen_py_pytest_data_paths
|
||||
|
@ -11,15 +12,39 @@ CWD = Path(__file__).parent
|
|||
|
||||
|
||||
def main():
|
||||
items = [
|
||||
(gen_py_schema_exports.main, "schema exports"),
|
||||
(gen_ts_types.main, "frontend types"),
|
||||
(gen_ts_locales.main, "locales"),
|
||||
(gen_py_pytest_data_paths.main, "test data paths"),
|
||||
(gen_py_pytest_routes.main, "pytest routes"),
|
||||
]
|
||||
parser = argparse.ArgumentParser(description="Run code generators")
|
||||
parser.add_argument(
|
||||
"generators",
|
||||
nargs="*",
|
||||
help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
for func, name in items:
|
||||
# Define all available generators
|
||||
all_generators = {
|
||||
"schema": (gen_py_schema_exports.main, "schema exports"),
|
||||
"types": (gen_ts_types.main, "frontend types"),
|
||||
"locales": (gen_ts_locales.main, "locales"),
|
||||
"data-paths": (gen_py_pytest_data_paths.main, "test data paths"),
|
||||
"routes": (gen_py_pytest_routes.main, "pytest routes"),
|
||||
}
|
||||
|
||||
# Determine which generators to run
|
||||
if args.generators:
|
||||
# Validate requested generators
|
||||
invalid_generators = [g for g in args.generators if g not in all_generators]
|
||||
if invalid_generators:
|
||||
log.error(f"Invalid generator(s): {', '.join(invalid_generators)}")
|
||||
log.info(f"Available generators: {', '.join(all_generators.keys())}")
|
||||
return
|
||||
|
||||
generators_to_run = [(all_generators[g][0], all_generators[g][1]) for g in args.generators]
|
||||
else:
|
||||
# Run all generators (default behavior)
|
||||
generators_to_run = list(all_generators.values())
|
||||
|
||||
# Run the selected generators
|
||||
for func, name in generators_to_run:
|
||||
log.info(f"Generating {name}...")
|
||||
func()
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
@ -35,7 +34,7 @@ class CodeSlicer:
|
|||
start: int
|
||||
end: int
|
||||
|
||||
indentation: str
|
||||
indentation: str | None
|
||||
text: list[str]
|
||||
|
||||
_next_line = None
|
||||
|
@ -47,15 +46,24 @@ class CodeSlicer:
|
|||
|
||||
def push_line(self, string: str) -> None:
|
||||
self._next_line = self._next_line or self.start + 1
|
||||
self.text.insert(self._next_line, self.indentation + string + "\n")
|
||||
self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
|
||||
self._next_line += 1
|
||||
|
||||
|
||||
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str:
|
||||
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
||||
def get_indentation_of_string(line: str) -> str:
|
||||
# Extract everything before the comment
|
||||
if "//" in line:
|
||||
indentation = line.split("//")[0]
|
||||
elif "#" in line:
|
||||
indentation = line.split("#")[0]
|
||||
else:
|
||||
indentation = line
|
||||
|
||||
# Keep only the whitespace, remove any non-whitespace characters
|
||||
return "".join(c for c in indentation if c.isspace())
|
||||
|
||||
|
||||
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]:
|
||||
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
|
||||
start = None
|
||||
end = None
|
||||
indentation = None
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
|
||||

|
||||
|
||||
# {{ recipe.name }}
|
||||
{{ recipe.description }}
|
||||
|
||||
## Ingredients
|
||||
{% for ingredient in recipe.recipeIngredient %}
|
||||
- [ ] {{ ingredient }} {% endfor %}
|
||||
|
||||
## Instructions
|
||||
{% for step in recipe.recipeInstructions %}
|
||||
- [ ] {{ step.text }} {% endfor %}
|
||||
|
||||
{% for note in recipe.notes %}
|
||||
**{{ note.title }}:** {{ note.text }}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
||||
Tags: {{ recipe.tags }}
|
||||
Categories: {{ recipe.categories }}
|
||||
Original URL: {{ recipe.orgURL }}
|
|
@ -1,23 +1,23 @@
|
|||
<template>
|
||||
<div v-if="value.length > 0 || edit">
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="value.length > 0"
|
||||
v-if="model.length > 0"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in value"
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props }">
|
||||
<v-icon v-bind="props">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
|
@ -44,7 +44,7 @@
|
|||
color="error"
|
||||
icon
|
||||
top
|
||||
@click="value.splice(i, 1)"
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
|
@ -113,124 +113,109 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
newAssetDialog: false,
|
||||
fileObject: {} as File,
|
||||
newAsset: {
|
||||
name: "",
|
||||
icon: "mdi-file",
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
name: "mdi-file",
|
||||
title: i18n.t("asset.file"),
|
||||
icon: $globals.icons.file,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-pdf-box",
|
||||
title: i18n.t("asset.pdf"),
|
||||
icon: $globals.icons.filePDF,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-image",
|
||||
title: i18n.t("asset.image"),
|
||||
icon: $globals.icons.fileImage,
|
||||
},
|
||||
{
|
||||
name: "mdi-code-json",
|
||||
title: i18n.t("asset.code"),
|
||||
icon: $globals.icons.codeJson,
|
||||
},
|
||||
{
|
||||
name: "mdi-silverware-fork-knife",
|
||||
title: i18n.t("asset.recipe"),
|
||||
icon: $globals.icons.primary,
|
||||
},
|
||||
];
|
||||
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
if (!validFields()) {
|
||||
alert.error(i18n.t("asset.error-submitting-form") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
});
|
||||
|
||||
context.emit("update:modelValue", [...props.modelValue, data]);
|
||||
state.newAsset = { name: "", icon: "mdi-file" };
|
||||
state.fileObject = {} as File;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
addAsset,
|
||||
assetURL,
|
||||
assetEmbed,
|
||||
getIconDefinition,
|
||||
iconOptions,
|
||||
setFileObject,
|
||||
};
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel<RecipeAsset[]>({ required: true });
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
newAssetDialog: false,
|
||||
fileObject: {} as File,
|
||||
newAsset: {
|
||||
name: "",
|
||||
icon: "mdi-file",
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
name: "mdi-file",
|
||||
title: i18n.t("asset.file"),
|
||||
icon: $globals.icons.file,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-pdf-box",
|
||||
title: i18n.t("asset.pdf"),
|
||||
icon: $globals.icons.filePDF,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-image",
|
||||
title: i18n.t("asset.image"),
|
||||
icon: $globals.icons.fileImage,
|
||||
},
|
||||
{
|
||||
name: "mdi-code-json",
|
||||
title: i18n.t("asset.code"),
|
||||
icon: $globals.icons.codeJson,
|
||||
},
|
||||
{
|
||||
name: "mdi-silverware-fork-knife",
|
||||
title: i18n.t("asset.recipe"),
|
||||
icon: $globals.icons.primary,
|
||||
},
|
||||
];
|
||||
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
if (!validFields()) {
|
||||
alert.error(i18n.t("asset.error-submitting-form") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
});
|
||||
if (data) {
|
||||
model.value = [...model.value, data];
|
||||
}
|
||||
state.newAsset = { name: "", icon: "mdi-file" };
|
||||
state.fileObject = {} as File;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
recipes_markdown = CWD / "recipes.md"
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
|
||||

|
||||
|
||||
# {{ recipe.name }}
|
||||
{{ recipe.description }}
|
||||
|
||||
## Ingredients
|
||||
{% for ingredient in recipe.recipeIngredient %}
|
||||
- [ ] {{ ingredient }} {% endfor %}
|
||||
|
||||
## Instructions
|
||||
{% for step in recipe.recipeInstructions %}
|
||||
- [ ] {{ step.text }} {% endfor %}
|
||||
|
||||
{% for note in recipe.notes %}
|
||||
**{{ note.title }}:** {{ note.text }}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
||||
Tags: {{ recipe.tags }}
|
||||
Categories: {{ recipe.categories }}
|
||||
Original URL: {{ recipe.orgURL }}
|
|
@ -1,8 +1,5 @@
|
|||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.assets import templates
|
||||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
|
@ -38,9 +35,3 @@ class AppDirectories:
|
|||
|
||||
for dir in required_dirs:
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Bootstrap Templates
|
||||
markdown_template = self.TEMPLATE_DIR.joinpath("recipes.md")
|
||||
|
||||
if not markdown_template.exists():
|
||||
shutil.copyfile(templates.recipes_markdown, markdown_template)
|
||||
|
|
|
@ -13,7 +13,7 @@ TRANSLATIONS = CWD / "messages"
|
|||
|
||||
class Translator(Protocol):
|
||||
@abstractmethod
|
||||
def t(self, key, default=None, **kwargs):
|
||||
def t(self, key, default=None, **kwargs) -> str:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ from uuid import UUID
|
|||
import sqlalchemy as sa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
@ -22,7 +21,7 @@ from mealie.db.models.users.user_to_recipe import UserToRecipe
|
|||
from mealie.db.models.users.users import User
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, create_recipe_slug
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood
|
||||
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolOut
|
||||
|
@ -98,7 +97,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
except IntegrityError:
|
||||
self.session.rollback()
|
||||
document.name = f"{original_name} ({i})"
|
||||
document.slug = slugify(document.name)
|
||||
document.slug = create_recipe_slug(document.name)
|
||||
|
||||
if i >= max_retries:
|
||||
raise
|
||||
|
|
|
@ -32,7 +32,6 @@ class JSONBytes(JSONResponse):
|
|||
class FormatResponse(BaseModel):
|
||||
jjson: list[str] = Field(..., alias="json")
|
||||
zip: list[str]
|
||||
jinja2: list[str]
|
||||
|
||||
|
||||
class BaseRecipeController(BaseCrudController):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import shutil
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import File, HTTPException, UploadFile, status
|
||||
from pydantic import UUID4
|
||||
|
@ -24,7 +25,10 @@ class UserImageController(BaseUserController):
|
|||
"""Updates a User Image"""
|
||||
with get_temporary_path() as temp_path:
|
||||
assert_user_change_allowed(id, self.user, self.user)
|
||||
temp_img = temp_path.joinpath(profile.filename)
|
||||
|
||||
# use a generated uuid and ignore the filename so we don't
|
||||
# need to worry about sanitizing user inputs.
|
||||
temp_img = temp_path.joinpath(str(uuid4()))
|
||||
|
||||
with temp_img.open("wb") as buffer:
|
||||
shutil.copyfileobj(profile.file, buffer)
|
||||
|
|
|
@ -36,6 +36,22 @@ from .recipe_step import RecipeStep
|
|||
app_dirs = get_app_dirs()
|
||||
|
||||
|
||||
def create_recipe_slug(name: str, max_length: int = 250) -> str:
|
||||
"""Generate a slug from a recipe name, truncating to a reasonable length.
|
||||
|
||||
Args:
|
||||
name: The recipe name to create a slug from
|
||||
max_length: Maximum length for the slug (default: 250)
|
||||
|
||||
Returns:
|
||||
A truncated slug string
|
||||
"""
|
||||
generated_slug = slugify(name)
|
||||
if len(generated_slug) > max_length:
|
||||
generated_slug = generated_slug[:max_length]
|
||||
return generated_slug
|
||||
|
||||
|
||||
class RecipeTag(MealieModel):
|
||||
id: UUID4 | None = None
|
||||
group_id: UUID4 | None = None
|
||||
|
@ -229,7 +245,7 @@ class Recipe(RecipeSummary):
|
|||
if not info.data.get("name"):
|
||||
return slug
|
||||
|
||||
return slugify(info.data["name"])
|
||||
return create_recipe_slug(info.data["name"])
|
||||
|
||||
@field_validator("recipe_ingredient", mode="before")
|
||||
def validate_ingredients(recipe_ingredient):
|
||||
|
|
|
@ -9,7 +9,6 @@ from uuid import UUID, uuid4
|
|||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import UploadFile
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.core import exceptions
|
||||
from mealie.core.config import get_app_settings
|
||||
|
@ -21,7 +20,7 @@ from mealie.repos.repository_factory import AllRepositories
|
|||
from mealie.repos.repository_generic import RepositoryGeneric
|
||||
from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
|
||||
from mealie.schema.openai.recipe import OpenAIRecipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, create_recipe_slug
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_notes import RecipeNote
|
||||
from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
|
@ -332,7 +331,7 @@ class RecipeService(RecipeServiceBase):
|
|||
|
||||
new_name = dup_data.name if dup_data.name else old_recipe.name or ""
|
||||
new_recipe.id = uuid4()
|
||||
new_recipe.slug = slugify(new_name)
|
||||
new_recipe.slug = create_recipe_slug(new_name)
|
||||
new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None
|
||||
new_recipe.recipe_instructions = (
|
||||
None
|
||||
|
@ -447,7 +446,7 @@ class OpenAIRecipeService(RecipeServiceBase):
|
|||
group_id=self.user.group_id,
|
||||
household_id=self.household.id,
|
||||
name=openai_recipe.name,
|
||||
slug=slugify(openai_recipe.name),
|
||||
slug=create_recipe_slug(openai_recipe.name),
|
||||
description=openai_recipe.description,
|
||||
recipe_yield=openai_recipe.recipe_yield,
|
||||
total_time=openai_recipe.total_time,
|
||||
|
|
|
@ -2,8 +2,6 @@ import enum
|
|||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
|
||||
from mealie.services._base_service import BaseService
|
||||
|
@ -11,7 +9,6 @@ from mealie.services._base_service import BaseService
|
|||
|
||||
class TemplateType(str, enum.Enum):
|
||||
json = "json"
|
||||
jinja2 = "jinja2"
|
||||
zip = "zip"
|
||||
|
||||
|
||||
|
@ -32,7 +29,6 @@ class TemplateService(BaseService):
|
|||
Returns a list of all templates available to render.
|
||||
"""
|
||||
return {
|
||||
TemplateType.jinja2.value: [x.name for x in self.directories.TEMPLATE_DIR.iterdir() if x.is_file()],
|
||||
TemplateType.json.value: ["raw"],
|
||||
TemplateType.zip.value: ["zip"],
|
||||
}
|
||||
|
@ -65,16 +61,13 @@ class TemplateService(BaseService):
|
|||
Args:
|
||||
t_type (TemplateType): The type of template to render
|
||||
recipe (Recipe): The recipe to render
|
||||
template (str): The template to render **Required for Jinja2 Templates**
|
||||
template (str): The template to render
|
||||
"""
|
||||
t_type = self.template_type(template)
|
||||
|
||||
if t_type == TemplateType.json:
|
||||
return self._render_json(recipe)
|
||||
|
||||
if t_type == TemplateType.jinja2:
|
||||
return self._render_jinja2(recipe, template)
|
||||
|
||||
if t_type == TemplateType.zip:
|
||||
return self._render_zip(recipe)
|
||||
|
||||
|
@ -96,41 +89,8 @@ class TemplateService(BaseService):
|
|||
|
||||
return save_path
|
||||
|
||||
def _render_jinja2(self, recipe: Recipe, j2_template: str | None = None) -> Path:
|
||||
"""
|
||||
Renders a Jinja2 Template in a temporary directory and returns
|
||||
the path to the file.
|
||||
"""
|
||||
self.__check_temp(self._render_jinja2)
|
||||
|
||||
if j2_template is None:
|
||||
raise ValueError("Template must be provided for method _render_jinja2")
|
||||
|
||||
j2_path: Path = self.directories.TEMPLATE_DIR / j2_template
|
||||
|
||||
if not j2_path.is_file():
|
||||
raise FileNotFoundError(f"Template '{j2_path}' not found.")
|
||||
|
||||
with open(j2_path) as f:
|
||||
template_text = f.read()
|
||||
|
||||
template = Template(template_text)
|
||||
rendered_text = template.render(recipe=recipe.model_dump(by_alias=True))
|
||||
|
||||
save_name = f"{recipe.slug}{j2_path.suffix}"
|
||||
|
||||
if self.temp is None:
|
||||
raise ValueError("Temporary directory must be provided for method _render_jinja2")
|
||||
|
||||
save_path = self.temp.joinpath(save_name)
|
||||
|
||||
with open(save_path, "w") as f:
|
||||
f.write(rendered_text)
|
||||
|
||||
return save_path
|
||||
|
||||
def _render_zip(self, recipe: Recipe) -> Path:
|
||||
self.__check_temp(self._render_jinja2)
|
||||
self.__check_temp(self._render_zip)
|
||||
|
||||
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
||||
|
||||
|
|
|
@ -900,3 +900,50 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse
|
|||
assert recipe.id in fetched_recipe_ids
|
||||
for recipe in other_recipes:
|
||||
assert recipe.id not in fetched_recipe_ids
|
||||
|
||||
|
||||
def test_create_recipe_with_extremely_long_slug(api_client: TestClient, unique_user: TestUser):
|
||||
"""Test creating a recipe with an extremely long name that would generate a very long slug.
|
||||
This reproduces the issue where long slugs cause 500 internal server errors.
|
||||
"""
|
||||
# Create a recipe name that's extremely long like the one in the GitHub issue
|
||||
long_recipe_name = "giallozafferano-on-instagram-il-piatto-vincente-di-simone-barlaam-medaglia-d-oro-e-d-argento-a-parigi-2024-paccheri-tricolore-se-ve-li-siete-persi-dovete-assolutamente-rimediare-lulugargari-ingredienti-paccheri-320-gr-spinacini-500-gr-nocciole-50-gr-ricotta-350-gr-olio-evo-q-b-limone-non-trattato-con-buccia-edibile-q-b-menta-q-b-peperoncino-fresco-q-b-10-pomodorini-ciliegino-preparazione-saltiamo-gli-spinaci-in-padella-lasciamo-raffreddare-e-frulliamo-insieme-a-ricotta-olio-sale-pepe-e-peperoncino-fresco-cuociamo-la-pasta-al-dente-e-mantechiamo-fuori-dal-fuoco-con-la-crema-tostiamo-a-parte-noci-o-nocciole-e-frulliamo-con-scorza-di-limone-impiattiamo-i-paccheri-con-qualche-spinacino-fresco-ciuffetti-di-ricotta-pomodorini-tagliati-in-4-e-la-polvere-di-nocciole-e-limone-buon-appetito-dmtc-pr-finp-nuotoparalimpico-giallozafferano-ricette-olimpiadi-paralimpiadi-atleti-simonebarlaam-cucina-paccheri-pasta-spinaci" # noqa: E501
|
||||
|
||||
# Create the recipe
|
||||
response = api_client.post(api_routes.recipes, json={"name": long_recipe_name}, headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
created_slug = json.loads(response.text)
|
||||
|
||||
assert created_slug is not None
|
||||
assert len(created_slug) > 0
|
||||
|
||||
new_name = "Pasta"
|
||||
response = api_client.patch(
|
||||
api_routes.recipes_slug(created_slug), json={"name": new_name}, headers=unique_user.token
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
updated_recipe = json.loads(response.text)
|
||||
assert updated_recipe["name"] == new_name
|
||||
assert updated_recipe["slug"] == slugify(new_name)
|
||||
|
||||
|
||||
def test_create_recipe_slug_length_validation(api_client: TestClient, unique_user: TestUser):
|
||||
"""Test that recipe slugs are properly truncated to a reasonable length."""
|
||||
very_long_name = "A" * 500 # 500 character name
|
||||
|
||||
response = api_client.post(api_routes.recipes, json={"name": very_long_name}, headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
|
||||
created_slug = json.loads(response.text)
|
||||
|
||||
# The slug should be truncated to a reasonable length
|
||||
# Using 250 characters as a reasonable limit, leaving room for collision suffixes
|
||||
assert len(created_slug) <= 250
|
||||
|
||||
assert created_slug is not None
|
||||
assert len(created_slug) > 0
|
||||
|
||||
response = api_client.get(api_routes.recipes_slug(created_slug), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
|
|
@ -18,28 +18,9 @@ def test_get_available_exports(api_client: TestClient, unique_user: TestUser) ->
|
|||
|
||||
as_json = response.json()
|
||||
|
||||
assert "recipes.md" in as_json["jinja2"]
|
||||
assert "raw" in as_json["json"]
|
||||
|
||||
|
||||
def test_render_jinja_template(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Create Recipe
|
||||
recipe_name = random_string()
|
||||
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
slug = response.json()
|
||||
|
||||
# Render Template
|
||||
response = api_client.get(
|
||||
api_routes.recipes_slug_exports(slug) + "?template_name=recipes.md", headers=unique_user.token
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Assert Template is Rendered Correctly
|
||||
# TODO: More robust test
|
||||
assert f"# {recipe_name}" in response.text
|
||||
|
||||
|
||||
def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Create Recipe
|
||||
recipe_name = random_string()
|
||||
|
@ -61,13 +42,3 @@ def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> Non
|
|||
with zipfile.ZipFile(zip_file, "r") as zip_fp:
|
||||
with zip_fp.open(f"{slug}.json") as json_fp:
|
||||
assert json.loads(json_fp.read())["name"] == recipe_name
|
||||
|
||||
|
||||
# TODO: Allow users to upload templates to their own directory
|
||||
# def test_upload_template(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# assert False
|
||||
|
||||
|
||||
# # TODO: Allow users to upload templates to their own directory
|
||||
# def test_delete_template(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# assert False
|
||||
|
|
|
@ -3,6 +3,5 @@ from mealie.services.recipe.template_service import TemplateService, TemplateTyp
|
|||
|
||||
def test_recipe_export_types() -> None:
|
||||
ts = TemplateService()
|
||||
assert ts.template_type("recipes.md") == TemplateType.jinja2.value
|
||||
assert ts.template_type("raw") == TemplateType.json.value
|
||||
assert ts.template_type("zip") == TemplateType.zip.value
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue