Compare commits

...

46 Commits

Author SHA1 Message Date
renovate[bot]
47c6d01617 chore(deps): update dependency vitest to v4 [security] (#7723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 21:38:46 +00:00
renovate[bot]
653be9a604 chore(deps): update dependency pytest-asyncio to v1.4.0 (#7694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 16:24:28 +00:00
Hayden
2d8b74282a fix: harden recipe content against stored XSS (chips, instructions, asset media) (#7719) 2026-05-31 16:14:16 +00:00
Hayden
48752bcd06 fix: support CSV/TXT upload and add validation for Plan to Eat import (#6360) (#7622) 2026-05-31 15:59:50 +00:00
Hayden
a46620d236 chore: drop unused python dependencies aniso8601 and appdirs (#7717) 2026-05-31 15:59:10 +00:00
Hayden
3bde6df958 chore: add 5-day dependency cooling period for supply-chain hardening (#7718) 2026-05-31 15:55:15 +00:00
Brian Choromanski
e1ddc06eff feat: Added version info to backup file (#7416)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2026-05-31 15:35:52 +00:00
Zachary Schaffter
262b531add feat: warn when deleting foods used in recipes (#7117)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2026-05-31 10:42:13 -05:00
miah
364af97060 dev: Improve support for front end unit tests (#7163) 2026-05-31 10:41:52 -05:00
Brian Choromanski
7b0d1fde64 feat: Enhanced PR Lint/Validation (#7329)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2026-05-31 10:41:43 -05:00
Arsène Reymond
0af9633193 fix: add missing dependencies in package.json (#7709)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2026-05-31 14:25:00 +00:00
mealie-actions[bot]
b5987f5a46 chore(l10n): Crowdin locale sync (#7713)
Co-authored-by: GitHub Action <action@github.com>
2026-05-31 03:13:02 +00:00
mealie-commit-bot[bot]
e24187fefb chore: bump version to v3.19.2 2026-05-29 04:11:50 +00:00
Michael Genson
396fcd5ee4 fix: Ensure secret key is not empty (#7701) 2026-05-28 23:09:31 -05:00
renovate[bot]
5a3d202879 chore(deps): lock file maintenance (#7697)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 20:09:07 +00:00
renovate[bot]
62377ae7ad fix(deps): update dependency ingredient-parser-nlp to v2.7.0 (#7695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 19:34:54 +00:00
renovate[bot]
7498e22278 fix(deps): update dependency sqlalchemy to v2.0.50 (#7693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 19:34:00 +00:00
renovate[bot]
af6c9e074e fix(deps): update dependency uvicorn to v0.48.0 (#7696)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 19:33:15 +00:00
renovate[bot]
71dba654b8 chore(deps): update dependency coverage to v7.14.1 (#7691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 19:31:38 +00:00
renovate[bot]
ba69fcf824 fix(deps): update dependency fastapi to v0.136.3 (#7692)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 19:31:05 +00:00
Michael Genson
8219ac0168 dev: Set renovarte bot PRs to "immediate" (#7690) 2026-05-27 14:11:10 -05:00
mealie-commit-bot[bot]
47f66676e4 chore: bump version to v3.19.1 2026-05-27 16:49:42 +00:00
Hayden
31d9479d17 chore(l10n): New Crowdin updates (#7687) 2026-05-27 11:48:54 -05:00
Michael Genson
6a8eae7ce4 fix: Make most recipe action columns filterable (#7689) 2026-05-27 11:47:29 -05:00
Hayden
3bddfc21ce chore(l10n): New Crowdin updates (#7661)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-05-27 04:13:05 +00:00
renovate[bot]
975a16c74b chore(deps): update dependency ruff to v0.15.14 (#7678)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:38:12 +00:00
renovate[bot]
840da0e935 fix(deps): update dependency python-ldap to v3.4.7 (#7680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:38:02 +00:00
renovate[bot]
0e22f3f8fa fix(deps): update dependency orjson to v3.11.9 (#7672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:37:11 +00:00
renovate[bot]
ff67fb6a4f chore(deps): update dependency types-python-dateutil to v2.9.0.20260518 (#7669)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:10:36 +00:00
renovate[bot]
97f37d0def chore(deps): update dependency types-requests to v2.33.0.20260518 (#7671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:10:24 +00:00
renovate[bot]
37171d174b fix(deps): update dependency openai to v2.38.0 (#7677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:09:45 +00:00
renovate[bot]
f010c13661 chore(deps): update node.js to 8530f76 (#7668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:06:37 +00:00
renovate[bot]
84622af5f8 fix(deps): update dependency pydantic to v2.13.4 (#7673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 23:02:10 +00:00
renovate[bot]
024dad6663 chore(deps): lock file maintenance (#7685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 22:49:52 +00:00
renovate[bot]
f1998121aa fix(deps): update dependency pyjwt to v2.13.0 (#7682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:57:36 +00:00
renovate[bot]
94ca311616 chore(deps): update dependency mypy to v2.1.0 (#7681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:57:32 +00:00
renovate[bot]
44c4bbb9ab chore(deps): update dependency coverage to v7.14.0 (#7676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:55:43 +00:00
renovate[bot]
0c263c98c9 fix(deps): update dependency requests to v2.34.2 (#7683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:55:22 +00:00
renovate[bot]
c235dc8d4d fix(deps): update dependency uvicorn to v0.47.0 (#7684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:51:54 +00:00
renovate[bot]
1b7eda0f2c fix(deps): update dependency python-multipart to v0.0.29 (#7675)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:51:31 +00:00
renovate[bot]
f3725b7184 fix(deps): update dependency lxml to v6.1.1 (#7679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:48:28 +00:00
renovate[bot]
00a4b51ec1 fix(deps): update dependency pydantic-settings to v2.14.1 (#7674)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:46:54 +00:00
renovate[bot]
2cf042fce9 chore(deps): update dependency types-pyyaml to v6.0.12.20260518 (#7670)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:44:36 +00:00
renovate[bot]
55a8fdfee5 chore(deps): update dependency nuxt to v4.4.6 [security] (#7667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 21:07:29 +00:00
Michael Genson
1ab5323f34 dev: Disable approvals for lockfile maintenance PR creation (#7666) 2026-05-26 16:11:03 -05:00
Hayden
fb4ba490af chore(l10n): New Crowdin updates (#7660) 2026-05-24 20:56:08 -05:00
61 changed files with 4929 additions and 3261 deletions

View File

@@ -44,6 +44,7 @@
8000, // used by mkdocs
9000,
9091, // used by docker production
51204, // used for test coverage report
24678 // used by nuxt when hot-reloading using polling
],
// Use 'onCreateCommand' to run commands at the end of container creation.

View File

@@ -20,6 +20,10 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
env:
# Install from the committed lockfile; never re-resolve (see pyproject
# [tool.uv] exclude-newer cooling window).
UV_FROZEN: "1"
steps:
- uses: actions/checkout@v6

View File

@@ -14,6 +14,10 @@ permissions:
jobs:
sync-locales:
runs-on: ubuntu-latest
env:
# Install from the committed lockfile; never re-resolve (see pyproject
# [tool.uv] exclude-newer cooling window).
UV_FROZEN: "1"
steps:
- name: Generate GitHub App Token
id: app-token

View File

@@ -3,7 +3,7 @@ name: Pull Request Linter
on:
workflow_call:
pull_request:
types: [edited] # This captures the PR title changing
types: [edited, reopened] # This captures the PR title/body changing
branches:
- mealie-next
@@ -41,3 +41,50 @@ jobs:
ignoreLabels: |
bot
ignore-semantic-pull-request
validate-template:
name: Validate PR template
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check required PR template sections
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
if (pr.user.type === "Bot") {
console.log("Skipping template check for bot");
return;
}
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: ".github/pull_request_template.md",
});
const template = Buffer.from(response.data.content, "base64").toString("utf8");
const lines = template.split("\n");
const requiredHeadings = [];
let lastHeading = null;
for (const line of lines) {
if (line.startsWith("## ")) {
lastHeading = line.trim();
} else if (line.trim() === "_(REQUIRED)_" && lastHeading) {
requiredHeadings.push(lastHeading);
lastHeading = null;
}
}
const body = pr.body || "";
const missing = requiredHeadings.filter(h => !body.includes(h));
if (missing.length > 0) {
core.setFailed(`Missing headings:\n${missing.join("\n")}`);
} else {
console.log("All required headings present");
}

View File

@@ -18,6 +18,8 @@ jobs:
name: "Lint PR"
if: github.event_name == 'pull_request'
uses: ./.github/workflows/pull-request-lint.yml
permissions:
pull-requests: write
backend-tests:
name: "Backend Server Tests"

View File

@@ -13,6 +13,10 @@ jobs:
env:
PRODUCTION: false
# Install from the committed lockfile; never re-resolve. The rolling
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
# every uv command re-resolve and fail on in-window pins.
UV_FROZEN: "1"
strategy:
fail-fast: true

30
.vscode/test-block.code-snippets vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"Test Block": {
"prefix": "mtest",
"body": [
"import { mount } from \"@vue/test-utils\";",
"import { describe, expect, test, vi } from \"vitest\";",
"import { makeWrapper } from \"~/tests/utils\";",
"",
"const wrapper = () => makeWrapper(() => {",
" return ${1:composable}();",
"});",
"",
"describe(\"${TM_FILENAME_BASE/(.*)\\..+$/$1/}\", () => {",
" describe(\"${2:method}\", () => {",
" test(\"It does the thing\", () => {",
" const { ${2:method} } = wrapper();",
" const result = ${2:method}();",
" expect(result).toBe(EXPECTED);",
" });",
" });",
"});",
"",
],
"description": "Insert a test block",
"scope": "typescript",
"include": [
"**/*.test.{ts,tsx,vue}"
]
}
}

View File

@@ -7,6 +7,10 @@ env:
DEFAULT_GROUP: Home
DEFAULT_HOUSEHOLD: Family
PRODUCTION: false
# Install from the committed lockfile; never re-resolve. Required because the
# rolling `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise
# make every `uv run`/`uv sync` re-resolve and fail on in-window pins.
UV_FROZEN: "1"
API_PORT: 9000
API_DOCS: True
TOKEN_TIME: 256 # hours

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:24@sha256:050bf2bbe33c1d6754e060bec89378a79ed831f04a7bb1a53fe45e997df7b3bb \
FROM node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 \
AS frontend-builder
WORKDIR /frontend
@@ -52,6 +52,11 @@ RUN apt-get update \
RUN pip install uv
# Install from the committed lockfile; never re-resolve. The rolling
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
# `uv export` below re-resolve and fail on in-window pins.
ENV UV_FROZEN=1
WORKDIR /mealie
# copy project files here to ensure they will be cached.

View File

@@ -29,6 +29,7 @@
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
| SECURITY_USER_LOCKOUT_TIME | 24 | Time in hours for how long a users account is locked |
| ALLOWED_IFRAME_HOSTS | `""` | Comma-separated extra hostnames allowed as `<iframe>` sources in recipe content. Extends the built-in list of trusted video providers (YouTube, Vimeo). Subdomains are included automatically. Only `https` sources are permitted. Adding hosts here opts into rendering embeds from those origins to all viewers, including the public, so add only origins you trust. |
### Database

View File

@@ -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.19.0`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.19.2`
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

View File

@@ -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.19.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.19.2 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -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.19.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.19.2 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -0,0 +1,54 @@
<template>
<div>
<p>
To harden Mealie against malicious content, <code>&lt;iframe&gt;</code> embeds in recipe
instructions, notes, and descriptions are now restricted to a trusted set of hosts.
</p>
<div class="mb-2">
By default, embeds are allowed only from well-known video providers:
<ul class="ml-6">
<li>YouTube</li>
<li>Vimeo</li>
</ul>
</div>
<p>
Existing recipes that embed content from <strong>other</strong> hosts will no longer render
those embeds. The rest of the recipe is unaffected.
</p>
<div v-if="user?.admin">
<hr class="mt-2 mb-4">
<p>
As an admin, you can allow additional hosts with the <code>ALLOWED_IFRAME_HOSTS</code>
environment variable (comma-separated). It extends the built-in defaults, and only
<code>https</code> sources are permitted. See the configuration docs for details:
<br>
<v-btn
class="mt-2"
color="primary"
href="https://docs.mealie.io/documentation/getting-started/installation/backend-config/"
target="_blank"
>
Backend Configuration
</v-btn>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { AnnouncementMeta } from "~/composables/use-announcements";
const { user } = useMealieAuth();
</script>
<script lang="ts">
export const meta: AnnouncementMeta = {
title: "Recipe embeds restricted to trusted hosts",
};
</script>
<style scoped lang="css">
p {
padding-bottom: 8px;
}
</style>

View File

@@ -59,9 +59,10 @@
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p v-if="deleteTarget" class="mt-4 ml-4">
<p v-if="deleteTarget" class="mt-4 mb-0 font-weight-bold">
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
</p>
<slot name="delete-dialog-bottom" />
</v-card-text>
</BaseDialog>
@@ -88,6 +89,7 @@
</template>
</v-virtual-scroll>
</v-card>
<slot name="delete-dialog-bottom" />
</v-card-text>
</BaseDialog>
@@ -151,7 +153,7 @@ const createDialog = defineModel("createDialog", { type: Boolean, default: false
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
defineProps({
const props = defineProps({
icon: {
type: String,
required: true,
@@ -185,6 +187,10 @@ defineProps({
type: String,
default: "name",
},
onDeleteDialogOpen: {
type: Function as PropType<(items: any[]) => Promise<void>>,
default: null,
},
});
// ============================================================
@@ -212,8 +218,11 @@ const editEventHandler = (item: any) => {
const deleteTarget = ref<any>(null);
const deleteDialog = ref(false);
function deleteEventHandler(item: any) {
async function deleteEventHandler(item: any) {
deleteTarget.value = item;
if (props.onDeleteDialogOpen) {
await props.onDeleteDialogOpen([item]);
}
deleteDialog.value = true;
}
@@ -222,8 +231,11 @@ function deleteEventHandler(item: any) {
const bulkDeleteTarget = ref<Array<any>>([]);
const bulkDeleteDialog = ref(false);
function bulkDeleteEventHandler(items: Array<any>) {
async function bulkDeleteEventHandler(items: Array<any>) {
bulkDeleteTarget.value = items;
if (props.onDeleteDialogOpen) {
await props.onDeleteDialogOpen(items);
}
bulkDeleteDialog.value = true;
console.log("Bulk Delete Event Handler", items);
}

View File

@@ -25,6 +25,7 @@
<script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { truncateText as truncatePlainText } from "~/lib/sanitize/text";
export type UrlPrefixParam = "tags" | "categories" | "tools";
@@ -50,10 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
defineEmits(["item-selected"]);
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
return truncatePlainText(text, length, clamp);
}
</script>

View File

@@ -4,12 +4,8 @@
</template>
<script setup lang="ts">
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
}
import { sanitizeMarkdownHtml } from "~/lib/sanitize/markdown";
const props = defineProps({
source: {
@@ -18,48 +14,11 @@ const props = defineProps({
},
});
const ALLOWED_STYLE_TAGS = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
if (!rawHtml) {
return "";
}
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
if (data.attrName === "style") {
const styles = data.attrValue.split(";").filter((style) => {
const [property] = style.split(":");
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
});
data.attrValue = styles.join(";");
}
});
const sanitized = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
],
ALLOWED_ATTR: [
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
],
});
Object.values(DOMPurifyHook).forEach((hook) => {
DOMPurify.removeHook(hook);
});
return sanitized;
}
const { $appInfo } = useNuxtApp();
const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml);
return sanitizeMarkdownHtml(rawHtml, $appInfo?.allowedIframeHosts ?? []);
});
</script>

View File

@@ -0,0 +1,83 @@
import type { IngredientFood, RecipeSummary, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
export const MOCK_ITEM: ShoppingListItemOut = {
shoppingListId: "",
id: "",
groupId: "",
householdId: "",
display: "MOCK_ITEM",
updatedAt: "100",
position: 1,
checked: false,
createdAt: "100",
};
export const MOCK_RECIPE: RecipeSummary = {
id: "recipe-id",
name: "Recipe!",
};
export const MOCK_RECIPE2: RecipeSummary = {
...MOCK_RECIPE,
id: undefined,
name: "Recipe 2!",
};
export const MOCK_FOOD: IngredientFood = {
id: "1",
name: "food 1",
};
export const MOCK_FOOD2: IngredientFood = {
id: "2",
name: "food 2",
};
export const MOCK_LABEL: ShoppingListMultiPurposeLabelOut = {
shoppingListId: "",
labelId: "",
id: "",
label: {
name: "MOCK_LABEL",
groupId: "",
id: "",
},
};
export const MOCK_LABEL2: ShoppingListMultiPurposeLabelOut = {
shoppingListId: "",
labelId: "",
id: "",
label: {
name: "MOCK_LABEL2",
groupId: "",
id: "",
},
};
export const MOCK_SHOPPING_LIST: ShoppingListOut = {
groupId: "",
userId: "",
id: "",
householdId: "",
labelSettings: [
MOCK_LABEL,
MOCK_LABEL2,
],
listItems: [
MOCK_ITEM,
],
recipeReferences: [{
id: "",
shoppingListId: "",
recipeId: "",
recipeQuantity: 0,
recipe: MOCK_RECIPE,
}, {
id: "",
shoppingListId: "",
recipeId: "",
recipeQuantity: 0,
recipe: MOCK_RECIPE2,
}],
};

View File

@@ -0,0 +1,89 @@
import * as vueusecore from "@vueuse/core";
import { describe, expect, test, vi } from "vitest";
import type { ShoppingListItemOut } from "~/lib/api/types/household";
import { makeWrapper } from "~/tests/utils";
import { useShoppingListCopy } from "../use-shopping-list-copy";
import { MOCK_ITEM } from "./mocks";
vi.mock("@vueuse/core", { spy: true });
const mockCopy = vi.fn().mockImplementation(args => new Promise(resolve => resolve(args)));
vi.mocked(vueusecore.useClipboard).mockImplementation(() => {
return {
isSupported: computed(() => true),
copied: computed(() => true),
text: computed(() => ""),
copy: mockCopy,
};
});
const wrapper = () => makeWrapper(useShoppingListCopy);
const TEST_HEADER = "SPECIAL HEADER!";
const MOCK_LIST: { [key: string]: ShoppingListItemOut[] } = {
[TEST_HEADER]: [MOCK_ITEM],
[TEST_HEADER + "2"]: [MOCK_ITEM],
};
describe("Shopping list copy composable", () => {
describe("copyListItems", () => {
test("copies markdown lists correctly", () => {
const { copyListItems } = wrapper();
copyListItems(MOCK_LIST, "markdown");
const expected = [
"# SPECIAL HEADER!",
"- [ ] MOCK_ITEM",
"",
"# SPECIAL HEADER!2",
"- [ ] MOCK_ITEM",
].join("\n");
expect(mockCopy).toBeCalledWith(expected);
});
test("copies plain text lists correctly", () => {
const { copyListItems } = wrapper();
copyListItems(MOCK_LIST, "plain");
const expected = [
"[SPECIAL HEADER!]",
"MOCK_ITEM",
"",
"[SPECIAL HEADER!2]",
"MOCK_ITEM",
].join("\n");
expect(mockCopy).toBeCalledWith(expected);
});
});
describe("formatCopiedLabelHeading", () => {
test("copies markdown headers correctly", () => {
const { formatCopiedLabelHeading } = wrapper();
const header = formatCopiedLabelHeading("markdown", TEST_HEADER);
expect(header).toEqual(`# ${TEST_HEADER}`);
});
test("copies plain text headers correctly", () => {
const { formatCopiedLabelHeading } = wrapper();
const header = formatCopiedLabelHeading("plain", TEST_HEADER);
expect(header).toEqual(`[${TEST_HEADER}]`);
});
});
describe("formatCopiedListItem", () => {
test("copies markdown items correctly", () => {
const { formatCopiedListItem } = wrapper();
const header = formatCopiedListItem("markdown", MOCK_ITEM);
expect(header).toEqual(`- [ ] ${MOCK_ITEM.display}`);
});
test("copies plain text items correctly", () => {
const { formatCopiedListItem } = wrapper();
const header = formatCopiedListItem("plain", MOCK_ITEM);
expect(header).toEqual(MOCK_ITEM.display);
});
test("copies items without a display as empty", () => {
const { formatCopiedListItem } = wrapper();
const header = formatCopiedListItem("plain", { ...MOCK_ITEM, display: undefined });
expect(header).toEqual("");
});
});
});

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "vitest";
import type { ShoppingListOut } from "~/lib/api/types/household";
import { makeWrapper } from "~/tests/utils";
import { useShoppingListSorting } from "../use-shopping-list-sorting";
import { MOCK_FOOD, MOCK_FOOD2, MOCK_ITEM, MOCK_LABEL, MOCK_LABEL2, MOCK_SHOPPING_LIST } from "./mocks";
const wrapper = () => makeWrapper(() => {
const { t } = useI18n();
return {
t,
...useShoppingListSorting(),
};
});
describe("use-shopping-list-sorting", () => {
describe("sortItems", () => {
const { sortItems } = wrapper();
test("sorts by position first", () => {
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
const result2 = sortItems({ ...MOCK_ITEM, position: 0 }, MOCK_ITEM);
expect(result).toBe(1);
expect(result2).toBe(-1);
});
test("sorts by createdAt next", () => {
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: "0" });
const result2 = sortItems({ ...MOCK_ITEM, createdAt: "0" }, MOCK_ITEM);
expect(result).toBe(1);
expect(result2).toBe(-1);
});
test("sorts similar items into the same spot", () => {
const result = sortItems(MOCK_ITEM, MOCK_ITEM);
expect(result).toBe(0);
});
test("handles nulls", () => {
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: undefined });
const result2 = sortItems({ ...MOCK_ITEM, position: undefined }, MOCK_ITEM);
expect(result).toBe(1);
expect(result2).toBe(-1);
});
test("handles nulls", () => {
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: undefined });
const result2 = sortItems({ ...MOCK_ITEM, createdAt: undefined }, MOCK_ITEM);
expect(result).toBe(1);
expect(result2).toBe(-1);
});
});
describe("sortListItems", () => {
const { sortListItems } = wrapper();
test("sorts by position first", () => {
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: [MOCK_ITEM, { ...MOCK_ITEM, position: 0 }, { ...MOCK_ITEM, createdAt: "0" }] };
sortListItems(sortedList);
expect(sortedList.listItems).toEqual([
{ ...MOCK_ITEM, position: 0 },
{ ...MOCK_ITEM, createdAt: "0" },
MOCK_ITEM,
]);
});
test("handles nulls", () => {
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: undefined };
sortListItems(sortedList);
expect(sortedList.listItems).toEqual(undefined);
});
});
describe("updateItemsByLabel", () => {
const { updateItemsByLabel, t } = wrapper();
test("sorts by group", () => {
const sortedList = {
...MOCK_SHOPPING_LIST, listItems: [
MOCK_ITEM,
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
],
};
const result = updateItemsByLabel(sortedList);
expect(result).toEqual({
[t("shopping-list.no-label")]: [
MOCK_ITEM,
],
[MOCK_LABEL.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
],
[MOCK_LABEL2.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
],
});
});
test("ignores checked items", () => {
const sortedList = {
...MOCK_SHOPPING_LIST, listItems: [
MOCK_ITEM,
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
],
};
const result = updateItemsByLabel(sortedList);
expect(result).toEqual({
[t("shopping-list.no-label")]: [
MOCK_ITEM,
],
[MOCK_LABEL.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
],
[MOCK_LABEL2.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
],
});
});
test("returns unordered labels if no ordering is specified", () => {
const sortedList = {
...MOCK_SHOPPING_LIST,
labelSettings: undefined,
listItems: [
MOCK_ITEM,
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
],
};
const result = updateItemsByLabel(sortedList);
expect(result).toEqual({
[t("shopping-list.no-label")]: [
MOCK_ITEM,
],
[MOCK_LABEL2.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
],
[MOCK_LABEL.label.name]: [
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
],
});
});
});
describe("groupAndSortListItemsByFood", () => {
const { groupAndSortListItemsByFood } = wrapper();
test("sorts by group", () => {
const sortedList = { ...MOCK_SHOPPING_LIST };
groupAndSortListItemsByFood(sortedList);
expect(sortedList.listItems).toEqual(MOCK_SHOPPING_LIST.listItems);
});
test("groups checked items together", () => {
const sortedList: ShoppingListOut = {
...MOCK_SHOPPING_LIST, listItems: [
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2 },
],
};
groupAndSortListItemsByFood(sortedList);
expect(sortedList.listItems).toEqual([
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2, position: 1 },
]);
});
test("populates position and created at if not present", () => {
const sortedList: ShoppingListOut = {
...MOCK_SHOPPING_LIST, listItems: [
{ ...MOCK_ITEM, food: MOCK_FOOD, position: undefined },
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
],
};
groupAndSortListItemsByFood(sortedList);
expect(sortedList.listItems).toEqual([
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
{ ...MOCK_ITEM, food: MOCK_FOOD, position: 1 },
]);
});
test("handles nulls", () => {
const sortedList: ShoppingListOut = { ...MOCK_SHOPPING_LIST, listItems: undefined };
groupAndSortListItemsByFood(sortedList);
expect(sortedList.listItems).toEqual(undefined);
});
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import type { ShoppingListOut } from "~/lib/api/types/household";
import { makeWrapper } from "~/tests/utils";
import { useShoppingListState } from "../use-shopping-list-state";
import { MOCK_ITEM, MOCK_RECIPE, MOCK_RECIPE2, MOCK_SHOPPING_LIST } from "./mocks";
const wrapper = (list: ShoppingListOut = MOCK_SHOPPING_LIST) => makeWrapper(() => {
const { shoppingList, ...state } = useShoppingListState();
shoppingList.value = list;
return {
shoppingList,
...state,
};
});
describe("use-shopping-list-state", () => {
describe("checked items are sorted", () => {
const { sortCheckedItems } = wrapper();
test("by timestamp", () => {
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "200" });
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "0" });
expect(sorted).toBe(1);
expect(sorted2).toBe(-1);
});
test("by position if timestamps match", () => {
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 2 });
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
const sorted3 = sortCheckedItems({ ...MOCK_ITEM, position: undefined }, { ...MOCK_ITEM, position: undefined });
expect(sorted).toBe(1);
expect(sorted2).toBe(-1);
expect(sorted3).toBe(1);
});
});
describe("recipeMap", () => {
test("Updates to match shopping list recipe references", () => {
const { recipeMap } = wrapper();
expect(recipeMap).toEqual(new Map([
[MOCK_RECIPE.id, MOCK_RECIPE],
["", MOCK_RECIPE2],
]));
});
test("handles nulls", () => {
const { recipeMap } = wrapper({ ...MOCK_SHOPPING_LIST, recipeReferences: undefined });
expect(recipeMap).toEqual(new Map([]));
});
});
describe("checked and unchecked items", () => {
test("update appropriately", () => {
const mockCheckedItem = { ...MOCK_ITEM, checked: true };
const { listItems: { checked, unchecked } } = wrapper({
...MOCK_SHOPPING_LIST, listItems: [
MOCK_ITEM,
mockCheckedItem,
],
});
expect(unchecked[0]).toEqual(MOCK_ITEM);
expect(checked[0]).toEqual(mockCheckedItem);
});
});
});

View File

@@ -38,7 +38,7 @@ export const LOCALES = [
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 75,
progress: 76,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -52,7 +52,7 @@ export const LOCALES = [
{
name: "Slovenščina (Slovenian)",
value: "sl-SI",
progress: 56,
progress: 57,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -73,7 +73,7 @@ export const LOCALES = [
{
name: "Română (Romanian)",
value: "ro-RO",
progress: 59,
progress: 60,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -108,7 +108,7 @@ export const LOCALES = [
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 97,
progress: 98,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -150,7 +150,7 @@ export const LOCALES = [
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 56,
progress: 57,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -248,14 +248,14 @@ export const LOCALES = [
{
name: "Deutsch (German)",
value: "de-DE",
progress: 98,
progress: 99,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 99,
progress: 100,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -276,7 +276,7 @@ export const LOCALES = [
{
name: "Български (Bulgarian)",
value: "bg-BG",
progress: 71,
progress: 72,
dir: "ltr",
pluralFoodHandling: "always",
},

View File

@@ -224,8 +224,8 @@
"add-field": "Tilføj felt",
"date-created": "Oprettet",
"date-updated": "Opdateret",
"key": "Key",
"value": "Value"
"key": "Nøgle",
"value": "Værdi"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Er du sikker på, du vil slette <b>{groupName}<b/>?",
@@ -287,37 +287,37 @@
"total-households": "Husstande i Alt",
"you-must-select-a-group-before-selecting-a-household": "Du skal vælge en gruppe, før du vælger en husstand",
"ai-provider-settings": {
"ai-provider-settings": "AI Provider Settings",
"ai-provider": "AI Provider",
"ai-providers": "AI Providers",
"ai-provider-settings-description": "Configure AI providers to enable AI-powered features, such as enhanced ingredient parsing, creating recipes from videos, and more!",
"providers": "Providers",
"create-provider": "Create Provider",
"edit-provider": "Edit Provider",
"default-provider": "Default Provider",
"default-provider-description": "Required to enable AI features",
"audio-provider": "Audio Provider",
"audio-provider-description": "Enables audio transcription features, such as creating recipes from videos",
"image-provider": "Image Provider",
"image-provider-description": "Enables image recognition features, such as creating recipes from images",
"provider-name": "Provider Name",
"api-key": "API Key",
"api-key-description-create": "Your provider's API key for authentication. If your service (e.g. Ollama) doesn't use an API key, you still have to put something here.",
"api-key-description-edit": "Leave this blank unless you want to change it.",
"base-url": "Base URL",
"base-url-description": "If you're using OpenAI leave this blank. Must be an OpenAI-compatible endpoint (e.g. \"http://localhost:11434/v1\").",
"ai-provider-settings": "AI-udbyderindstillinger",
"ai-provider": "AI-udbyder",
"ai-providers": "AI-udbydere",
"ai-provider-settings-description": "Konfigurér AI-udbydere for at slå AI-funktioner til, såsom forbedret ingredienshåndtering, at oprette opskrifter fra videoer med mere.",
"providers": "Udbydere",
"create-provider": "Opret udbyder",
"edit-provider": "Redigér udbyder",
"default-provider": "Standardudbyder",
"default-provider-description": "Påkrævet for at slå AI-funktioner til",
"audio-provider": "Lydudbyder",
"audio-provider-description": "Slå lydtranskriberingsfunktioner til, såsom at oprette opskrifter fra videoer",
"image-provider": "Billedudbyder",
"image-provider-description": "Slår billedgenkendelsesfunktioner til, såsom at oprette opskrifter fra billeder",
"provider-name": "Udbydernavn",
"api-key": "API-nøgle",
"api-key-description-create": "Din udbyders API-nøgle til godkendelse. Hvis din udbyder ikke benytter en API-nøgle (eks. Ollama), skal du stadig skrive ét eller andet,",
"api-key-description-edit": "Undlad at udfylde dette, medmindre du vil ændre det.",
"base-url": "Basis-URL",
"base-url-description": "Undlad at udfylde, hvis du benytter OpenAI. Skal være et OpenAI-kompatibelt endpoint (eks. \"http://localhost:11434/v1\").",
"model": "Model",
"model-description": "Which model your AI provider should use (e.g. \"gpt-5\").",
"request-timeout-seconds": "Request Timeout (seconds)",
"provider-created": "Provider created",
"provider-updated": "Provider updated",
"provider-deleted": "Provider deleted",
"provider-create-failed": "Failed to create provider",
"provider-update-failed": "Failed to update provider",
"provider-delete-failed": "Failed to delete provider",
"request-headers": "Request Headers",
"request-params": "Request Parameters",
"no-default-provider-warning": "You have not set a default provider, so AI features are disabled"
"model-description": "Hvilken model skal din udbyder benytte (eks. \"gpt-5\")?",
"request-timeout-seconds": "Forespørgsels-time-out",
"provider-created": "Udbyder oprettet",
"provider-updated": "Udbyder opdateret",
"provider-deleted": "Udbyder slettet",
"provider-create-failed": "Kunne ikke oprette udbyder",
"provider-update-failed": "Kunne ikke opdatere udbyder",
"provider-delete-failed": "Kunne ikke slette udbyder",
"request-headers": "Forespørgsels-headers",
"request-params": "Forespørgselsparametre",
"no-default-provider-warning": "Du har ikke sat en standardudbyder, så AI-funktioner er slået fra"
}
},
"household": {
@@ -1397,7 +1397,7 @@
"already-set-up-bring-to-homepage": "Jeg er allerede oprettet, bare bringe mig til startsiden",
"common-settings-for-new-sites": "Her er nogle almindelige indstillinger for nye sites",
"setup-complete": "Opsætning færdig!",
"ai-providers-description": "Optionally configure AI providers for your group. AI providers enable features like creating recipes from images, importing recipes from videos, and enhanced ingredient parsing. You can always configure this later from your group settings.",
"ai-providers-description": "Konfigurér valgfrit AI-udbydere for din gruppe. AI-udbydere muliggør handlinger, såsom at oprette opskrifter fra billeder, importere opskrifter fra videoer, og forbedret håndtering af ingredienser. Det er altid muligt at konfigurere dette senere under dine gruppeindstillinger.",
"here-are-a-few-things-to-help-you-get-started": "Her er et par ting, der kan hjælpe dig i gang med Mealie",
"restore-from-v1-backup": "Har du en sikkerhedskopi fra en tidligere udgave af Mealie v1? Du kan gendanne den her.",
"manage-profile-or-get-invite-link": "Administrer din egen profil, eller tag et invitationslink til at dele med andre."

View File

@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Τρίτη",
"type": "Τύπος",
"undo": "Undo",
"undo": "Αναίρεση",
"update": "Ενημέρωση",
"updated": "Ενημερώθηκε",
"upload": "Ανέβασμα",
@@ -952,7 +952,7 @@
"quantity": "Ποσότητα: {0}",
"shopping-list": "Λίστα για ψώνια",
"shopping-lists": "Λίστες για ψώνια",
"add-item": "Add item",
"add-item": "Προσθήκη στοιχείου",
"food": "Τρόφιμο",
"note": "Σημείωση",
"label": "Ετικέτα",

View File

@@ -462,7 +462,7 @@
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
"plantoeat": {
"title": "Plan to Eat",
"description-long": "Mealie can import recipies from Plan to Eat."
"description-long": "Mealie can import recipes from Plan to Eat. Upload a ZIP archive, CSV, or TXT file exported from Plan to Eat."
},
"myrecipebox": {
"title": "My Recipe Box",
@@ -1144,6 +1144,8 @@
},
"data-pages": {
"foods": {
"delete-affects-recipes": "Warning: this food is used in {count} recipe(s). Deleting it will leave an empty ingredient in the recipe(s).",
"delete-affects-recipes-more": "View all {count} recipes",
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
"merge-food-example": "Merging {food1} into {food2}",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.",

View File

@@ -224,8 +224,8 @@
"add-field": "Veld toevoegen",
"date-created": "Datum aangemaakt",
"date-updated": "Datum bijgewerkt",
"key": "Key",
"value": "Value"
"key": "Sleutel",
"value": "Waarde"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Weet je zeker dat je <b>{groupName}<b/> wil verwijderen?",
@@ -287,37 +287,37 @@
"total-households": "Totaal aantal huishoudens",
"you-must-select-a-group-before-selecting-a-household": "Kies een groep voordat je een huishouden kiest",
"ai-provider-settings": {
"ai-provider-settings": "AI Provider Settings",
"ai-provider": "AI Provider",
"ai-providers": "AI Providers",
"ai-provider-settings-description": "Configure AI providers to enable AI-powered features, such as enhanced ingredient parsing, creating recipes from videos, and more!",
"providers": "Providers",
"create-provider": "Create Provider",
"edit-provider": "Edit Provider",
"default-provider": "Default Provider",
"default-provider-description": "Required to enable AI features",
"audio-provider": "Audio Provider",
"audio-provider-description": "Enables audio transcription features, such as creating recipes from videos",
"image-provider": "Image Provider",
"image-provider-description": "Enables image recognition features, such as creating recipes from images",
"provider-name": "Provider Name",
"api-key": "API Key",
"api-key-description-create": "Your provider's API key for authentication. If your service (e.g. Ollama) doesn't use an API key, you still have to put something here.",
"api-key-description-edit": "Leave this blank unless you want to change it.",
"base-url": "Base URL",
"base-url-description": "If you're using OpenAI leave this blank. Must be an OpenAI-compatible endpoint (e.g. \"http://localhost:11434/v1\").",
"ai-provider-settings": "AI-aanbieder instellingen",
"ai-provider": "AI-aanbieder",
"ai-providers": "AI-aanbieders",
"ai-provider-settings-description": "Configureer AI-aanbieders om krachtige AI-aangedreven functies, zoals verbeterde ingrediënt parsing, aanmaken van recepten op basis van video's en nog meer!",
"providers": "Aanbieders",
"create-provider": "Aanbieder aanmaken",
"edit-provider": "Aanbieder bewerken",
"default-provider": "Standaard aanbieder",
"default-provider-description": "Vereist om AI-functies in te schakelen",
"audio-provider": "Audio aanbieder",
"audio-provider-description": "Maakt audiotransscriptie functionaliteiten mogelijk, waarmee recepten op basis van video's gemaakt kunnen worden",
"image-provider": "Afbeelding aanbieder",
"image-provider-description": "Maakt beeldherkenning functionaliteiten mogelijk, waarmee recepten op basis van afbeeldingen gemaakt kunnen worden",
"provider-name": "Naam aanbieder",
"api-key": "API-sleutel",
"api-key-description-create": "Je aanbieder's API-sleutel voor authenticatie. Als je service (bijv. Ollama) geen API-sleutel gebruikt, moet je hier alsnog iets invoeren.",
"api-key-description-edit": "Laat dit leeg tenzij je het wilt wijzigen.",
"base-url": "Basis URL",
"base-url-description": "Als je OpenAI gebruikt laat je dit leeg. Moet een OpenAI-compatibel eindpunt zijn (bijv. \"http://localhost:11434/v1\").",
"model": "Model",
"model-description": "Which model your AI provider should use (e.g. \"gpt-5\").",
"request-timeout-seconds": "Request Timeout (seconds)",
"provider-created": "Provider created",
"provider-updated": "Provider updated",
"provider-deleted": "Provider deleted",
"provider-create-failed": "Failed to create provider",
"provider-update-failed": "Failed to update provider",
"provider-delete-failed": "Failed to delete provider",
"request-headers": "Request Headers",
"request-params": "Request Parameters",
"no-default-provider-warning": "You have not set a default provider, so AI features are disabled"
"model-description": "Welk model je AI-aanbieder moet gebruiken (bijv. \"gpt-5\").",
"request-timeout-seconds": "Verzoek time-out (seconden)",
"provider-created": "Aanbieder aangemaakt",
"provider-updated": "Aanbieder bijgewerkt",
"provider-deleted": "Aanbieder verwijderd",
"provider-create-failed": "Aanmaken van aanbieder mislukt",
"provider-update-failed": "Bijwerken van aanbieder mislukt",
"provider-delete-failed": "Verwijderen van aanbieder mislukt",
"request-headers": "Aanvraagheaders",
"request-params": "Aanvraag parameters",
"no-default-provider-warning": "Je hebt geen standaard aanbieder ingesteld, AI-functies zijn uitgeschakeld"
}
},
"household": {
@@ -663,7 +663,7 @@
"create-recipe-description": "Maak een nieuw recept.",
"create-recipes": "Recepten aanmaken",
"import-with-zip": "Importeer met .zip",
"create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-images": "Maak een recept op basis van een afbeelding",
"create-recipe-from-an-image-description": "Maak een recept door een afbeelding ervan te uploaden. Mealie probeert de tekst met behulp van AI uit de afbeelding te halen en er een recept uit te maken.",
"crop-and-rotate-the-image": "Snijd de afbeelding bij zodat alleen tekst zichtbaar is. En draai t plaatje zodat het leesbaar is.",
"create-from-images": "Maak recept van een afbeelding",
@@ -1397,7 +1397,7 @@
"already-set-up-bring-to-homepage": "Ik ben al ingesteld, breng me naar de startpagina",
"common-settings-for-new-sites": "Hier zijn enkele algemene instellingen voor nieuwe sites",
"setup-complete": "Installatie voltooid!",
"ai-providers-description": "Optionally configure AI providers for your group. AI providers enable features like creating recipes from images, importing recipes from videos, and enhanced ingredient parsing. You can always configure this later from your group settings.",
"ai-providers-description": "Optioneel kun je AI-aanbieders instellen voor je groep. AI-aanbieders maken functies, zoals het maken van recepten op basis van afbeeldingen, het malen van recepten op basis van video's, en verbeterde ingrediëntenparsing mogelijk. Je kunt dit later altijd instellen vanuit je groepsinstellingen.",
"here-are-a-few-things-to-help-you-get-started": "Hier zijn een aantal dingen om je op weg te helpen met Mealie",
"restore-from-v1-backup": "Heb je een back-up van een vorig exemplaar van Mealie v1? Deze kan je hier terugzetten.",
"manage-profile-or-get-invite-link": "Beheer je eigen profiel, of gebruik een uitnodigingslink om te delen met anderen."

View File

@@ -98,7 +98,7 @@
"dashboard": "Panou de control",
"delete": "Șterge",
"disabled": "Inactiv",
"done": "Done",
"done": "Gata",
"download": "Descarcă",
"duplicate": "Duplicat",
"edit": "Editează",
@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Marţi",
"type": "Tip",
"undo": "Undo",
"undo": "Anulează acțiunea",
"update": "Actualizează",
"updated": "Actualizat",
"upload": "Încarcă",
@@ -368,8 +368,8 @@
"any-household": "Orice locuință",
"no-meal-plan-defined-yet": "Nici un plan de mese definit încă",
"no-meal-planned-for-today": "Nicio masă planificată pentru astăzi",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDaysPast-hint": "Numărul de zile din trecut la încărcarea paginii",
"numberOfDaysPast-label": "Număr implicit de zile din trecut",
"numberOfDays-hint": "Număr de zile pe pagină încărcată",
"numberOfDays-label": "Zile implicite",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Numai rețetele cu aceste categorii vor fi utilizate în Planurile de mese",
@@ -675,8 +675,8 @@
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Creează o rețetă furnizând numele. Toate rețetele trebuie să aibă nume unice.",
"new-recipe-names-must-be-unique": "Numele rețetei trebuie să fie unic",
"scrape-recipe": "Importare rețetă",
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.",
"scrape-recipe-description-transcription": "You can also provide the url to a video and Mealie will attempt to transcribe it into a recipe.",
"scrape-recipe-description": "Extrage o rețetă după url. Introdu url-ul site-ului din care vrei să extragi rețeta, iar Mealie va încerca să o importe și să o adauge în colecția ta.",
"scrape-recipe-description-transcription": "Poți introduce și URL-ul unui videoclip, iar Mealie va încerca să îl transcrie într-o rețetă.",
"scrape-recipe-have-a-lot-of-recipes": "Ai mai multe rețete pe care vrei să le imporți simultan?",
"scrape-recipe-suggest-bulk-importer": "Încearcă importatorul în bulk",
"scrape-recipe-have-raw-html-or-json-data": "Ai date de tip HTML sau JSON?",
@@ -815,7 +815,7 @@
"irreversible-acknowledgment": "Înțeleg că această acțiune este ireversibilă, distructivă și poate provoca pierderea datelor",
"restore-backup": "Restaurează backup"
},
"backup-and-exports": "Backups",
"backup-and-exports": "Copii de rezervă",
"change-password": "Schimbă parola",
"current": "Versiune:",
"custom-pages": "Pagini personalizate",
@@ -928,17 +928,17 @@
"server-side-base-url-error-text": "`BASE_URL` încă este setat la valoarea implicită pe serverul API. Acest lucru va cauza probleme cu link-urile de notificări generate pe server pentru e-mailuri, etc.",
"server-side-base-url-success-text": "Adresa URL a serverului nu se potrivește cu cea implicită",
"ldap-ready": "LDAP pregătit",
"ldap-not-ready": "LDAP Not Ready",
"ldap-not-ready": "LDAP nu este pregătit\"",
"ldap-ready-error-text": "Nu toate valorile LDAP sunt configurate. Acest lucru poate fi ignorat dacă nu utilizați autentificarea cu LDAP.",
"ldap-ready-success-text": "Variabilele LDAP necesare sunt setate.",
"build": "Compilare",
"recipe-scraper-version": "Versiune \"scraper\" de rețete",
"oidc-ready": "OIDC pregătit",
"oidc-not-ready": "OIDC Not Ready",
"oidc-not-ready": "OIDC nu este pregătit",
"oidc-ready-error-text": "Nu toate valorile OIDC sunt configurate. Acest lucru poate fi ignorat dacă nu folosiți autentificarea OIDC.",
"oidc-ready-success-text": "Variabilele OIDC necesare sunt setate.",
"openai-ready": "OpenAI pregătit",
"openai-not-ready": "OpenAI Not Ready",
"openai-not-ready": "OpenAI nu este pregătit",
"openai-ready-error-text": "Nu toate valorile OpenAI sunt configurate. Acest lucru poate fi ignorat dacă nu utilizaţi caracteristicile OpenAI.",
"openai-ready-success-text": "Variabilele necesare OpenAI sunt setate."
},
@@ -946,15 +946,15 @@
"all-lists": "Toate listele",
"create-shopping-list": "Creează listă de cumpărături",
"from-recipe": "Dintr-o rețetă",
"ingredient-of-recipe": "Ingredient of {recipe}",
"ingredient-of-recipe": "Ingredient din {recipe}",
"list-name": "Nume listă",
"new-list": "Listă nouă",
"quantity": "Cantitate: {0}",
"shopping-list": "Listă de cumpărături",
"shopping-lists": "Liste de cumpărături",
"add-item": "Add item",
"add-item": "Adaugă articol",
"food": "Aliment",
"note": "Note",
"note": "Notă",
"label": "Etichetă",
"save-label": "Salvează etichetă",
"linked-item-warning": "Acest element este legat de una sau mai multe rețete. Ajustarea unităților sau a alimentelor va produce rezultate neașteptate la adăugarea sau scoaterea rețetei din listă.",
@@ -978,7 +978,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Sunteți sigur că doriți să debifați toate elementele?",
"are-you-sure-you-want-to-delete-checked-items": "Sunteți sigur că doriți să ștergeți toate elementele selectate?",
"no-shopping-lists-found": "Nu s-au găsit liste de cumpărături",
"item-checked-off": "Checked off {item}"
"item-checked-off": "{item} a fost bifat"
},
"sidebar": {
"all-recipes": "Toate reţetele",
@@ -1181,18 +1181,18 @@
"example-unit-plural": "ex: Linguri",
"example-unit-abbreviation-singular": "ex: Lg",
"example-unit-abbreviation-plural": "ex: Lg",
"standardization": "Standardization",
"standardization-description": "How this unit can be represented as a standard unit. This enables unit conversion features such as merging compatible units in shopping lists.",
"standard-unit": "Standard Unit",
"standard-quantity": "Standard Quantity",
"unit-conversion": "Unit Conversion",
"standardization": "Standardizare",
"standardization-description": "Modul în care această unitate poate fi reprezentată ca unitate standard. Activează funcții de conversie a unităților, cum ar fi combinarea unităților compatibile în listele de cumpărături.",
"standard-unit": "Unitate standard",
"standard-quantity": "Cantitate standard",
"unit-conversion": "Conversie unități",
"standard-unit-labels": {
"fluid-ounce": "fluid ounce",
"cup": "cup",
"ounce": "ounce",
"pound": "pound",
"milliliter": "milliliter",
"liter": "liter",
"fluid-ounce": "uncie fluidă",
"cup": "cană",
"ounce": "uncie",
"pound": "livră",
"milliliter": "mililitru",
"liter": "litru",
"gram": "gram",
"kilogram": "kilogram"
}
@@ -1514,10 +1514,10 @@
"max-length": "Trebuie să aibă cel mult {max} caracter | Trebuie să aibă cel mult {max} caractere"
},
"announcements": {
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Show announcements from Mealie",
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
"announcements": "Anunțuri",
"all-announcements": "Toate anunțurile",
"mark-all-as-read": "Marchează toate ca citite",
"show-announcements-from-mealie": "Afișează anunțurile de la Mealie",
"show-announcements-setting-description": "Stabilește dacă utilizatorii pot vedea anunțuri de la Mealie. Când opțiunea este activată, utilizatorii pot alege în continuare să nu le vadă în setările personale"
}
}

View File

@@ -48,6 +48,7 @@ export interface AppInfo {
oidcRedirect: boolean;
oidcProviderName: string;
tokenTime: number;
allowedIframeHosts?: string[];
}
export interface AppStartupInfo {
isFirstLogin: boolean;

View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { sanitizeMarkdownHtml } from "./markdown";
describe("sanitizeMarkdownHtml", () => {
test("returns empty string for nullish input", () => {
expect(sanitizeMarkdownHtml(null)).toEqual("");
expect(sanitizeMarkdownHtml(undefined)).toEqual("");
expect(sanitizeMarkdownHtml("")).toEqual("");
});
test("keeps allowed formatting tags", () => {
const html = sanitizeMarkdownHtml("<p>Mix <strong>flour</strong> and <em>water</em></p>");
expect(html).toContain("<strong>flour</strong>");
expect(html).toContain("<em>water</em>");
});
test("strips script tags and event handlers", () => {
const html = sanitizeMarkdownHtml("<p onclick=\"alert(1)\">hi</p><script>alert(1)</script>");
expect(html).not.toContain("script");
expect(html).not.toContain("onclick");
expect(html).not.toContain("alert");
});
test("strips img onerror payloads", () => {
const html = sanitizeMarkdownHtml("<img src=x onerror=alert(1)>");
expect(html).not.toContain("onerror");
});
// Form controls must never render in user content.
test("strips form, input, and button elements", () => {
const html = sanitizeMarkdownHtml("<form action=/x><input name=p><button>go</button></form>");
expect(html).not.toContain("<form");
expect(html).not.toContain("<input");
expect(html).not.toContain("<button");
});
test("strips iframes when no allowed hosts are configured", () => {
const html = sanitizeMarkdownHtml("<iframe src=\"https://evil.example/x\"></iframe>", []);
expect(html).not.toContain("<iframe");
});
test("strips iframes whose src host is not allowlisted", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://evil.example/x\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
test("strips non-https iframes even for an allowlisted host", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"http://www.youtube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
test("keeps iframes from an allowlisted host (incl. subdomains)", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://www.youtube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).toContain("<iframe");
expect(html).toContain("https://www.youtube.com/embed/abc");
});
test("does not allow a lookalike host to pass the suffix check", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://notyoutube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
});

View File

@@ -0,0 +1,98 @@
import DOMPurify from "isomorphic-dompurify";
enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
AfterSanitizeAttributes = "afterSanitizeAttributes",
}
const ALLOWED_STYLE_PROPERTIES = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
const BASE_ALLOWED_TAGS = [
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
];
const BASE_ALLOWED_ATTR = [
"href", "src", "alt", "height", "width", "class", "title",
"cite", "datetime", "name", "abbr", "target", "border", "start", "style",
];
// Attributes only meaningful on an <iframe>; added to the allowlist solely when iframe embeds
// are enabled via a configured host allowlist.
const IFRAME_ALLOWED_ATTR = ["allow", "allowfullscreen", "frameborder", "scrolling"];
/**
* Returns true if an iframe `src` points at one of the allowed hosts. Only https URLs are
* accepted, and a configured host matches the URL's hostname exactly or as a parent domain
* (e.g. "youtube.com" matches "www.youtube.com").
*/
function isAllowedIframeSrc(src: string, allowedHosts: string[]): boolean {
let url: URL;
try {
url = new URL(src);
}
catch {
return false;
}
if (url.protocol !== "https:") {
return false;
}
const hostname = url.hostname.toLowerCase();
return allowedHosts.some((host) => {
const allowed = host.toLowerCase();
return hostname === allowed || hostname.endsWith(`.${allowed}`);
});
}
/**
* Sanitizes pre-rendered HTML (from markdown) for display in user content such as recipe
* instructions, notes, and descriptions.
*
* Only the tags in `BASE_ALLOWED_TAGS` and attributes in `BASE_ALLOWED_ATTR` survive; everything
* else (scripts, event handlers, form controls, ...) is dropped. `style` attributes are filtered
* down to the properties in `ALLOWED_STYLE_PROPERTIES`. `<iframe>` is only kept when
* `allowedIframeHosts` is non-empty, and even then any iframe whose `src` is not an https URL on
* the host allowlist is removed.
*/
export function sanitizeMarkdownHtml(rawHtml: string | null | undefined, allowedIframeHosts: string[] = []): string {
if (!rawHtml) {
return "";
}
const allowIframe = allowedIframeHosts.length > 0;
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (_node, data) => {
if (data.attrName === "style") {
const styles = data.attrValue.split(";").filter((style) => {
const [property] = style.split(":");
return ALLOWED_STYLE_PROPERTIES.includes(property.trim().toLowerCase());
});
data.attrValue = styles.join(";");
}
});
if (allowIframe) {
DOMPurify.addHook(DOMPurifyHook.AfterSanitizeAttributes, (node) => {
if (node.nodeName === "IFRAME" && !isAllowedIframeSrc(node.getAttribute("src") || "", allowedIframeHosts)) {
node.parentNode?.removeChild(node);
}
});
}
const sanitized = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: allowIframe ? [...BASE_ALLOWED_TAGS, "iframe"] : BASE_ALLOWED_TAGS,
ALLOWED_ATTR: allowIframe ? [...BASE_ALLOWED_ATTR, ...IFRAME_ALLOWED_ATTR] : BASE_ALLOWED_ATTR,
});
Object.values(DOMPurifyHook).forEach((hook) => {
DOMPurify.removeHook(hook);
});
return sanitized;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, test, vi } from "vitest";
import { truncateText } from "./text";
describe("truncateText", () => {
test("returns short text unchanged", () => {
expect(truncateText("Dinner")).toEqual("Dinner");
});
test("truncates long text with clamp", () => {
expect(truncateText("a".repeat(25))).toEqual(`${"a".repeat(20)}...`);
});
test("respects custom length and clamp", () => {
expect(truncateText("abcdef", 3, "~")).toEqual("abc~");
});
test("does not clamp text exactly at the length boundary", () => {
expect(truncateText("abcde", 5)).toEqual("abcde");
expect(truncateText("abcdef", 5)).toEqual("abcde...");
});
// Markup in the input must be treated as plain text and never parsed into the live document.
test("does not parse or execute HTML payloads", () => {
const createElement = vi.spyOn(document, "createElement");
const payload = "<img src=x onerror=alert(1)>";
const result = truncateText(payload);
// The payload is returned verbatim (truncated only by length), proving it is treated as text.
expect(result).toEqual(`${payload.slice(0, 20)}...`);
// No DOM element is constructed, so no <img> can fire its onerror handler.
expect(createElement).not.toHaveBeenCalled();
createElement.mockRestore();
});
});

View File

@@ -0,0 +1,9 @@
/**
* Truncates plain text to `length` characters, appending `clamp` when truncated.
*
* The input is treated strictly as text and is never parsed as HTML, so markup in the input is
* returned verbatim rather than interpreted.
*/
export function truncateText(text: string, length = 20, clamp = "..."): string {
return text.length > length ? text.slice(0, length) + clamp : text;
}

View File

@@ -69,11 +69,7 @@
</template>
</v-autocomplete>
<v-alert
v-if="foods && foods.length > 0"
type="error"
class="mb-0 text-body-2"
>
<v-alert v-if="foods && foods.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
@@ -112,11 +108,7 @@
:label="$t('data-pages.foods.food-label')"
/>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkAssignTarget"
>
<v-virtual-scroll height="400" item-height="25" :items="bulkAssignTarget">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
@@ -141,6 +133,7 @@
]"
:create-form="createForm"
:edit-form="editForm"
:on-delete-dialog-open="onDeleteDialogOpen"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="foodStore.actions.deleteOne"
@@ -151,15 +144,12 @@
<template #icon>
{{ $globals.icons.externalLink }}
</template>
{{ $t('data-pages.combine') }}
{{ $t("data-pages.combine") }}
</BaseButton>
</template>
<template #[`item.label`]="{ item }">
<MultiPurposeLabel
v-if="item.label"
:label="item.label"
>
<MultiPurposeLabel v-if="item.label" :label="item.label">
{{ item.label.name }}
</MultiPurposeLabel>
</template>
@@ -171,7 +161,7 @@
</template>
<template #[`item.createdAt`]="{ item }">
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }}
{{ item.createdAt ? $d(new Date(item.createdAt)) : "" }}
</template>
<template #table-button-bottom>
@@ -179,18 +169,33 @@
<template #icon>
{{ $globals.icons.database }}
</template>
{{ $t('data-pages.seed') }}
{{ $t("data-pages.seed") }}
</BaseButton>
</template>
<template #edit-dialog-custom-action>
<BaseButton
edit
@click="aliasManagerDialog = true"
>
{{ $t('data-pages.manage-aliases') }}
<BaseButton edit @click="aliasManagerDialog = true">
{{ $t("data-pages.manage-aliases") }}
</BaseButton>
</template>
<template #delete-dialog-bottom>
<v-alert v-if="affectedRecipes.length > 0" type="warning" density="compact" class="mt-4 mb-0">
{{ $t("data-pages.foods.delete-affects-recipes", { count: affectedRecipesTotal }) }}
<ul class="mt-1 pl-5 mb-0">
<li v-for="recipe in affectedRecipes.slice(0, 5)" :key="recipe.slug">
<NuxtLink :to="recipe.url" class="text-white">{{ recipe.name }}</NuxtLink>
</li>
</ul>
<NuxtLink
v-if="affectedRecipesTotal > 5"
:to="affectedRecipesMoreLink"
class="text-white d-inline-block mt-1"
>
{{ $t("data-pages.foods.delete-affects-recipes-more", { count: affectedRecipesTotal }) }}
</NuxtLink>
</v-alert>
</template>
</GroupDataPage>
</div>
</template>
@@ -218,6 +223,7 @@ interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
interface IngredientFoodWithOnHand extends IngredientFood {
onHand: boolean;
}
const userApi = useUserApi();
const i18n = useI18n();
const auth = useMealieAuth();
@@ -274,11 +280,14 @@ const tableHeaders: TableHeaders[] = [
];
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const userGroup = computed(() => auth.user.value?.groupSlug || "");
const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}));
const foods = computed(() =>
foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}),
);
// ============================================================
// Labels
@@ -383,6 +392,9 @@ async function handleBulkAction(event: string, items: IngredientFoodWithOnHand[]
if (event === "delete-selected") {
const ids = items.map(item => item.id);
await foodStore.actions.deleteMany(ids);
affectedRecipes.value = [];
affectedRecipesTotal.value = 0;
affectedRecipesMoreLink.value = "";
}
else if (event === "assign-selected") {
bulkAssignEventHandler(items);
@@ -401,6 +413,26 @@ function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
aliasManagerDialog.value = false;
}
// ============================================================
// Delete Foods
// fetch affected recipes before confirming deletion
const affectedRecipes = ref<{ name: string; slug: string; url: string }[]>([]);
const affectedRecipesTotal = ref(0);
const affectedRecipesMoreLink = ref("");
async function onDeleteDialogOpen(items: IngredientFoodWithOnHand[]) {
const ids = items.map(item => item.id);
const { data } = await userApi.recipes.search({ foods: ids, perPage: 5 });
affectedRecipes.value = (data?.items ?? []).map(r => ({
name: r.name ?? "",
slug: r.slug ?? "",
url: `/g/${userGroup.value}/r/${r.slug}`,
}));
affectedRecipesTotal.value = data?.total ?? 0;
affectedRecipesMoreLink.value = `/g/${userGroup.value}?${ids.map(id => `foods=${id}`).join("&")}`;
}
// ============================================================
// Merge Foods

View File

@@ -337,16 +337,8 @@ const _content: Record<string, MigrationContent> = {
},
[MIGRATIONS.plantoeat]: {
text: i18n.t("migration.plantoeat.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "plantoeat-recipes-508318_10-13-2023.zip",
children: [
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
],
},
],
acceptedFileType: ".zip,.csv,.txt",
tree: false,
},
[MIGRATIONS.recipekeeper]: {
text: i18n.t("migration.recipekeeper.description-long"),

View File

@@ -0,0 +1,18 @@
import { config } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
function loadEnLocales() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../lang/messages/en-US.json") as Record<string, string>;
}
const i18n = createI18n({
locale: "en-US",
messages: {
"en-US": loadEnLocales(),
},
});
config.global.plugins = [...(config.global.plugins ?? []), i18n];
export { i18n };

View File

@@ -1,3 +1,4 @@
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
function loadEnLocales() {
@@ -14,3 +15,12 @@ export function stubI18n() {
});
return i18n.global;
}
export const makeWrapper = <T>(setup: () => T) => {
const Wrapper = {
template: "<div />",
setup,
};
const { vm } = mount(Wrapper);
return vm as unknown as ReturnType<typeof Wrapper.setup>;
};

View File

@@ -11,6 +11,7 @@ export default withNuxt({
"@stylistic/no-tabs": ["error"],
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"@typescript-eslint/no-explicit-any": "off",
"import/no-extraneous-dependencies": ["error"],
"vue/first-attribute-linebreak": "error",
"vue/html-closing-bracket-newline": "error",
"vue/max-attributes-per-line": [

View File

@@ -1,6 +1,6 @@
{
"name": "mealie",
"version": "3.19.0",
"version": "3.19.2",
"private": true,
"scripts": {
"dev": "nuxt dev",
@@ -12,6 +12,8 @@
"lint:log": "yarn lint:js --debug",
"test": "vitest",
"test:ci": "vitest --watch=false",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"cleanup": "nuxt cleanup"
},
"lint-staged": {
@@ -21,18 +23,24 @@
"@mdi/js": "^7.4.47",
"@nuxt/fonts": "^0.11.4",
"@nuxtjs/i18n": "^9.2.1",
"@sphinxxxx/color-conversion": "^2.2.2",
"@vite-pwa/nuxt": "^0.10.6",
"@vueuse/core": "^12.7.0",
"@vueuse/shared": "^14.3.0",
"axios": "^1.8.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.7",
"fuse.js": "^7.1.0",
"isomorphic-dompurify": "^3.4.0",
"json-editor-vue": "^0.18.1",
"marked": "^15.0.12",
"nuxt": "^4.4.2",
"sse.js": "^2.8.0",
"ufo": "^1.6.4",
"vue": "^3.5.35",
"vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.4.4",
"vuetify": "^4.0.5",
"vuetify-nuxt-module": "^0.19.5"
},
@@ -41,6 +49,10 @@
"@stylistic/eslint-plugin": "^5.4.0",
"@types/node": "^25.5.2",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-format": "^1.0.1",
@@ -50,8 +62,9 @@
"prettier": "^3.5.2",
"sass-embedded": "^1.85.1",
"typescript": "^5.3",
"unplugin-auto-import": "^21.0.0",
"vite-plugin-commonjs": "^0.10.4",
"vitest": "^3.0.7"
"vitest": "^4.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": {

View File

@@ -1,11 +1,26 @@
import path from "path";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
export default {
plugins: [vue()],
plugins: [
vue(),
AutoImport({
imports: ["vue", "@vueuse/core", "vue-i18n"],
dts: false,
}),
],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./app/tests/setup.ts"],
coverage: {
provider: "v8",
include: ["app/{lib,components,composables,layouts,pages}/**/*.{ts,tsx,vue}"],
exclude: ["**/*.test.*", "node_modules/**", "dist/**", "coverage/**", "**/__tests__/**"],
reporter: ["html", "text-summary"],
all: true,
},
},
resolve: {
alias: {

File diff suppressed because it is too large Load Diff

View File

@@ -179,7 +179,7 @@ def validate_file_token(token: str | None = None) -> Path:
@contextmanager
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
temp_path = app_dirs.TEMP_DIR / f"{uuid4().hex}.zip"
try:
yield temp_path
finally:

View File

@@ -33,6 +33,16 @@ class FeatureDetails(NamedTuple):
return s
DEFAULT_ALLOWED_IFRAME_HOSTS = [
"youtube.com",
"youtube-nocookie.com",
"vimeo.com",
"player.vimeo.com",
]
"""Secure-by-default hostnames permitted as `<iframe>` sources in user content. Limited to
well-known video providers. Subdomains of these hosts are also allowed (e.g. `www.youtube.com`)."""
MaskedNoneString = Annotated[
str | None,
PlainSerializer(lambda x: None if x is None else "*****", return_type=str | None),
@@ -50,13 +60,19 @@ def determine_secrets(data_dir: Path, secret: str, production: bool) -> str:
secrets_file = data_dir.joinpath(secret)
if secrets_file.is_file():
with open(secrets_file) as f:
return f.read()
else:
data_dir.mkdir(parents=True, exist_ok=True)
with open(secrets_file, "w") as f:
new_secret = secrets.token_hex(32)
f.write(new_secret)
return new_secret
existing_secret = f.read().strip()
if existing_secret:
return existing_secret
data_dir.mkdir(parents=True, exist_ok=True)
new_secret = secrets.token_hex(32)
tmp_file = secrets_file.with_suffix(".tmp")
with open(tmp_file, "w") as f:
f.write(new_secret)
f.flush()
os.fsync(f.fileno())
tmp_file.replace(secrets_file)
return new_secret
def get_secrets_dir() -> str | None:
@@ -144,6 +160,19 @@ class AppSettings(AppLoggingSettings):
ALLOW_SIGNUP: bool = False
ALLOW_PASSWORD_LOGIN: bool = True
ALLOWED_IFRAME_HOSTS: str = ""
"""Comma-separated list of additional hostnames allowed as `<iframe>` sources in user content
(recipe instructions, notes, descriptions). Extends `DEFAULT_ALLOWED_IFRAME_HOSTS`. Subdomains of
a listed host are also allowed. Adding hosts is opt-in to riskier behavior; the defaults are
limited to well-known video providers."""
@property
def allowed_iframe_hosts(self) -> list[str]:
"""The full set of hostnames permitted as `<iframe>` sources, secure defaults plus any
admin-configured additions via `ALLOWED_IFRAME_HOSTS`."""
extra = [host.strip().lower() for host in self.ALLOWED_IFRAME_HOSTS.split(",") if host.strip()]
return list(dict.fromkeys(DEFAULT_ALLOWED_IFRAME_HOSTS + extra))
DAILY_SCHEDULE_TIME: str = "23:45"
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
@@ -14,14 +14,14 @@ if TYPE_CHECKING:
class GroupRecipeAction(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_actions"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped["Group"] = relationship("Group", back_populates="recipe_actions", single_parent=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
household_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
household: Mapped["Household"] = relationship("Household", back_populates="recipe_actions")
action_type: Mapped[str] = mapped_column(String, index=True)
title: Mapped[str] = mapped_column(String, index=True)
action_type: FilterableColumn[str] = mapped_column(String, index=True)
title: FilterableColumn[str] = mapped_column(String, index=True)
url: Mapped[str] = mapped_column(String)
@auto_init()

View File

@@ -18,17 +18,17 @@
"yield": "Recoltă",
"yields": "Producţii"
},
"and-amount": "and {amount}",
"or-ingredient": "or {ingredient}",
"and-amount": "și {amount}",
"or-ingredient": "sau {ingredient}",
"create-progress": {
"creating-recipe-with-ai": "Creating recipe with AI...",
"creating-recipe-from-transcript-with-ai": "Creating recipe from transcript with AI...",
"creating-recipe-from-webpage-data": "Creating recipe from webpage data...",
"downloading-image": "Downloading image...",
"downloading-video": "Downloading video...",
"extracting-recipe-data": "Extracting recipe data...",
"fetching-webpage": "Fetching webpage...",
"transcribing-audio-with-ai": "Transcribing audio with AI..."
"creating-recipe-with-ai": "Se creează rețeta cu AI...",
"creating-recipe-from-transcript-with-ai": "Se creează rețeta din transcriere cu AI...",
"creating-recipe-from-webpage-data": "Se creează rețeta din datele paginii web...",
"downloading-image": "Se descarcă imaginea...",
"downloading-video": "Se descarcă videoclipul...",
"extracting-recipe-data": "Se extrag datele rețetei...",
"fetching-webpage": "Se preia pagina web...",
"transcribing-audio-with-ai": "Se transcrie sunetul cu AI..."
}
},
"mealplan": {

View File

@@ -6427,8 +6427,8 @@
"sugar": {
"aliases": [],
"description": "",
"name": "sugar",
"plural_name": "sugar"
"name": "ζάχαρη",
"plural_name": "ζάχαρη"
},
"brown sugar": {
"aliases": [
@@ -6542,8 +6542,8 @@
"raw sugar": {
"aliases": [],
"description": "",
"name": "raw sugar",
"plural_name": "raw sugar"
"name": "ακατέργαστη ζάχαρη",
"plural_name": "ακατέργαστη ζάχαρη"
},
"golden syrup": {
"aliases": [],
@@ -6560,8 +6560,8 @@
"liquid stevia": {
"aliases": [],
"description": "",
"name": "liquid stevia",
"plural_name": "liquid stevia"
"name": "υγρή στέβια",
"plural_name": "υγρή στέβια"
},
"grenadine": {
"aliases": [],
@@ -16087,8 +16087,8 @@
"fish oil": {
"aliases": [],
"description": "",
"name": "fish oil",
"plural_name": "fish oil"
"name": "ιχθυέλαιο",
"plural_name": "ιχθυέλαιο"
},
"lime essential oil": {
"aliases": [],
@@ -16099,8 +16099,8 @@
"probiotic": {
"aliases": [],
"description": "",
"name": "probiotic",
"plural_name": "probiotics"
"name": "προβιοτικό",
"plural_name": "προβιοτικά"
},
"activated charcoal": {
"aliases": [],
@@ -16111,8 +16111,8 @@
"egg powder": {
"aliases": [],
"description": "",
"name": "egg powder",
"plural_name": "egg powder"
"name": "σκόνη αυγών",
"plural_name": "σκόνη αυγών"
},
"reishi mushroom": {
"aliases": [],
@@ -16267,8 +16267,8 @@
"banana powder": {
"aliases": [],
"description": "",
"name": "banana powder",
"plural_name": "banana powder"
"name": "σκόνη μπανάνας",
"plural_name": "σκόνη μπανάνας"
},
"chaga mushroom powder": {
"aliases": [],

View File

@@ -3930,7 +3930,7 @@
"aliases": [],
"description": "",
"name": "andouille",
"plural_name": "andouilles"
"plural_name": "andouillette"
},
"boneless lamb": {
"aliases": [],

View File

@@ -1856,7 +1856,7 @@
"aliases": [],
"description": "",
"name": "onion seed",
"plural_name": "onion seeds"
"plural_name": "Semințe de ceapă"
},
"watermelon seed": {
"aliases": [],
@@ -1873,8 +1873,8 @@
"melon seed": {
"aliases": [],
"description": "",
"name": "melon seed",
"plural_name": "melon seeds"
"name": "Sămânță de pepene",
"plural_name": "Semințe de pepene"
},
"lotus seed": {
"aliases": [],
@@ -2038,19 +2038,19 @@
"aliases": [],
"description": "",
"name": "brânză mozzarella",
"plural_name": "mozzarella cheese"
"plural_name": "brânză mozzarella"
},
"feta cheese": {
"aliases": [],
"description": "",
"name": "feta cheese",
"plural_name": "feta cheese"
"name": "brânză feta",
"plural_name": "brânză feta"
},
"ricotta cheese": {
"aliases": [],
"description": "",
"name": "ricotta cheese",
"plural_name": "ricotta cheese"
"name": "brânză ricotta",
"plural_name": "brânză ricotta"
},
"cheddar-jack cheese": {
"aliases": [],
@@ -2067,32 +2067,32 @@
"blue cheese": {
"aliases": [],
"description": "",
"name": "blue cheese",
"plural_name": "blue cheese"
"name": "brânză cu mucegai albastru",
"plural_name": "brânzeturi cu mucegai albastru"
},
"goat cheese": {
"aliases": [],
"description": "",
"name": "goat cheese",
"plural_name": "goat cheese"
"name": "brânză de capră",
"plural_name": "brânzeturi de capră"
},
"fresh mozzarella cheese": {
"aliases": [],
"description": "",
"name": "fresh mozzarella cheese",
"plural_name": "fresh mozzarella cheese"
"name": "mozzarella proaspătă",
"plural_name": "mozzarella proaspătă"
},
"swis cheese": {
"aliases": [],
"description": "",
"name": "swis cheese",
"plural_name": "swis cheese"
"name": "brânză elvețiană",
"plural_name": "brânzeturi elvețiene"
},
"pecorino cheese": {
"aliases": [],
"description": "",
"name": "pecorino cheese",
"plural_name": "pecorino cheese"
"name": "brânză pecorino",
"plural_name": "brânză pecorino"
},
"gruyere cheese": {
"aliases": [],
@@ -2109,8 +2109,8 @@
"cottage cheese": {
"aliases": [],
"description": "",
"name": "cottage cheese",
"plural_name": "cottage cheese"
"name": "brânză de vaci",
"plural_name": "brânză de vaci"
},
"american cheese": {
"aliases": [],
@@ -2121,8 +2121,8 @@
"provolone cheese": {
"aliases": [],
"description": "",
"name": "provolone cheese",
"plural_name": "provolone cheese"
"name": "brânză provolone",
"plural_name": "brânză provolone"
},
"mexican cheese blend": {
"aliases": [],
@@ -2169,7 +2169,7 @@
"gouda cheese": {
"aliases": [],
"description": "",
"name": "gouda cheese",
"name": "brânză gouda",
"plural_name": "gouda cheese"
},
"cotija cheese": {
@@ -2187,8 +2187,8 @@
"smoked cheese": {
"aliases": [],
"description": "",
"name": "smoked cheese",
"plural_name": "smoked cheese"
"name": "brânză afumată",
"plural_name": "brânză afumată"
},
"halloumi cheese": {
"aliases": [],
@@ -2229,7 +2229,7 @@
"burrata cheese": {
"aliases": [],
"description": "",
"name": "burrata cheese",
"name": "brânză burrata",
"plural_name": "burrata cheese"
},
"havarti cheese": {
@@ -2283,8 +2283,8 @@
"raclette cheese": {
"aliases": [],
"description": "",
"name": "raclette cheese",
"plural_name": "raclette cheese"
"name": "brânză raclette",
"plural_name": "brânză raclette"
},
"colby-jack cheese": {
"aliases": [],
@@ -2457,8 +2457,8 @@
"hard goat cheese": {
"aliases": [],
"description": "",
"name": "hard goat cheese",
"plural_name": "hard goat cheese"
"name": "brânză tare de capră",
"plural_name": "brânză tare de capră"
},
"kashkaval cheese": {
"aliases": [],
@@ -2469,8 +2469,8 @@
"sheep cheese": {
"aliases": [],
"description": "",
"name": "sheep cheese",
"plural_name": "sheep cheese"
"name": "brânză de oaie",
"plural_name": "brânză de oaie"
},
"amul cheese": {
"aliases": [],
@@ -2630,49 +2630,49 @@
"aliases": [],
"description": "",
"name": "lapte",
"plural_name": "milk"
"plural_name": "lapte"
},
"heavy cream": {
"aliases": [],
"description": "",
"name": "smântână pentru frișcă",
"plural_name": "heavy cream"
"plural_name": "smântână pentru frișcă"
},
"sour cream": {
"aliases": [],
"description": "",
"name": "smântână",
"plural_name": "sour cream"
"plural_name": "smântână"
},
"buttermilk": {
"aliases": [],
"description": "",
"name": "buttermilk",
"plural_name": "buttermilk"
"name": "lapte bătut",
"plural_name": "lapte bătut"
},
"yogurt": {
"aliases": [],
"description": "",
"name": "iaurt",
"plural_name": "yogurts"
"plural_name": "iaurt"
},
"greek yogurt": {
"aliases": [],
"description": "",
"name": "greek yogurt",
"plural_name": "greek yogurts"
"name": "iaurt grecesc",
"plural_name": "iaurturi grecești"
},
"cream": {
"aliases": [],
"description": "",
"name": "cream",
"plural_name": "cream"
"name": "smântână",
"plural_name": "smântână"
},
"whipped cream": {
"aliases": [],
"description": "",
"name": "whipped cream",
"plural_name": "whipped cream"
"name": "frișcă",
"plural_name": "frișcă"
},
"ghee": {
"aliases": [
@@ -2692,7 +2692,7 @@
"aliases": [],
"description": "",
"name": "lapte condensat",
"plural_name": "condensed milk"
"plural_name": "lapte condensat"
},
"half and half": {
"aliases": [],
@@ -2710,7 +2710,7 @@
"aliases": [],
"description": "",
"name": "înghețată",
"plural_name": "ice cream"
"plural_name": "înghețată"
},
"margarine": {
"aliases": [],
@@ -2734,7 +2734,7 @@
"aliases": [],
"description": "",
"name": "lapte praf",
"plural_name": "milk powder"
"plural_name": "lapte praf"
},
"curd": {
"aliases": [],
@@ -2788,7 +2788,7 @@
"aliases": [],
"description": "",
"name": "lapte cu ciocolată",
"plural_name": "chocolate milk"
"plural_name": "lapte cu ciocolată"
},
"liquid egg substitute": {
"aliases": [],
@@ -2872,7 +2872,7 @@
"aliases": [],
"description": "",
"name": "ganache",
"plural_name": "ganaches"
"plural_name": "ganache"
},
"cajeta": {
"aliases": [],
@@ -2883,8 +2883,8 @@
"duck egg": {
"aliases": [],
"description": "",
"name": "duck egg",
"plural_name": "duck eggs"
"name": "ou de rață",
"plural_name": "ouă de rață"
},
"salted egg": {
"aliases": [],
@@ -2908,7 +2908,7 @@
"aliases": [],
"description": "",
"name": "lapte crud",
"plural_name": "raw milk"
"plural_name": "lapte crud"
},
"lime curd": {
"aliases": [],
@@ -3027,7 +3027,7 @@
"chocolate milk powder": {
"aliases": [],
"description": "",
"name": "chocolate milk powder",
"name": "lapte praf de ciocolată",
"plural_name": "chocolate milk powder"
},
"liquid rennet": {
@@ -3337,7 +3337,7 @@
"smoked tofu": {
"aliases": [],
"description": "",
"name": "smoked tofu",
"name": "tofu afumat",
"plural_name": "smoked tofus"
},
"coconut powder": {

View File

@@ -9,13 +9,13 @@
"name": "lingură",
"plural_name": "linguri",
"description": "",
"abbreviation": "tbsp"
"abbreviation": "lg"
},
"cup": {
"name": "cană",
"plural_name": "căni",
"description": "",
"abbreviation": "c"
"abbreviation": "cană"
},
"fluid-ounce": {
"name": "uncie fluidă",
@@ -30,7 +30,7 @@
"abbreviation": "pt"
},
"quart": {
"name": "quart",
"name": "sfert de galon",
"plural_name": "sferturi de galon",
"description": "",
"abbreviation": "qt"
@@ -139,8 +139,8 @@
"abbreviation": ""
},
"sprig": {
"name": "sprig",
"plural_name": "sprigs",
"name": "crenguță",
"plural_name": "crenguțe",
"description": "",
"abbreviation": ""
}

View File

@@ -43,6 +43,7 @@ def get_app_info(session: Session = Depends(generate_session)):
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
allowed_iframe_hosts=settings.allowed_iframe_hosts,
)

View File

@@ -58,6 +58,13 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
if file.exists():
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
# Force download and disable MIME sniffing so uploaded assets cannot be
# served as active content in Mealie's origin.
return FileResponse(
file,
filename=file.name,
content_disposition_type="attachment",
headers={"X-Content-Type-Options": "nosniff"},
)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -22,6 +22,7 @@ class AppInfo(MealieModel):
oidc_redirect: bool
oidc_provider_name: str
token_time: int
allowed_iframe_hosts: list[str] = []
class AppTheme(MealieModel):

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from zipfile import ZipFile
from mealie.core.config import get_app_settings
from mealie.core.settings.static import APP_VERSION
from mealie.services._base_service import BaseService
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
from mealie.services.backups_v2.backup_file import BackupFile
@@ -43,8 +44,15 @@ class BackupV2(BaseService):
def backup(self) -> Path:
# sourcery skip: merge-nested-ifs, reintroduce-else, remove-redundant-continue
timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y.%m.%d.%H.%M.%S")
short_hash = self.settings.GIT_COMMIT_HASH[:7]
if APP_VERSION == "develop":
backup_name = f"mealie_dev-{short_hash}_{timestamp}.zip"
elif APP_VERSION == "nightly":
backup_name = f"mealie_nightly-{short_hash}_{timestamp}.zip"
else:
backup_name = f"mealie_{APP_VERSION}_{timestamp}.zip"
backup_name = f"mealie_{timestamp}.zip"
backup_file = self.directories.BACKUP_DIR / backup_name
database_json = self.db_exporter.dump()

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from slugify import slugify
from mealie.pkgs.cache import cache_key
from mealie.schema.reports.reports import ReportEntryCreate
from mealie.services.scraper import cleaner
from ._migration_base import BaseMigrator
@@ -15,15 +16,23 @@ from .utils.migration_helpers import scrape_image, split_by_comma
def plantoeat_recipes(file: Path):
"""Yields all recipes inside the export file as dict"""
with tempfile.TemporaryDirectory() as tmpdir:
with zipfile.ZipFile(file) as zip_file:
zip_file.extractall(tmpdir)
"""Yields all recipes inside the export file as dict.
for name in Path(tmpdir).glob("**/[!.]*.csv"):
with open(name, newline="") as csvfile:
reader = csv.DictReader(csvfile)
yield from reader
Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file.
"""
if zipfile.is_zipfile(file):
with tempfile.TemporaryDirectory() as tmpdir:
with zipfile.ZipFile(file) as zip_file:
zip_file.extractall(tmpdir)
for name in Path(tmpdir).glob("**/[!.]*.csv"):
with open(name, newline="") as csvfile:
reader = csv.DictReader(csvfile)
yield from reader
else:
with open(file, newline="", encoding="utf-8", errors="ignore") as csvfile:
reader = csv.DictReader(csvfile)
yield from reader
def get_value_as_string_or_none(dictionary: dict, key: str):
@@ -112,7 +121,32 @@ class PlanToEatMigrator(BaseMigrator):
return recipe_dict
def _validate_archive(self) -> bool:
"""Returns False and appends a failure report entry if the file is not a ZIP, CSV, or TXT."""
if zipfile.is_zipfile(self.archive):
return True
try:
with open(self.archive, encoding="utf-8", errors="strict") as f:
f.read(512)
return True
except UnicodeDecodeError:
pass
self.report_entries.append(
ReportEntryCreate(
report_id=self.report_id,
success=False,
message="Unsupported file format. Please upload a ZIP archive, CSV file, or TXT file.",
exception="",
)
)
return False
def _migrate(self) -> None:
if not self._validate_archive():
return
recipe_image_urls = {}
recipes = []

View File

@@ -1,6 +1,6 @@
[project]
name = "mealie"
version = "3.19.0"
version = "3.19.2"
description = "A Recipe Manager"
authors = [{ name = "Hayden", email = "hay-kot@pm.me" }]
license = "AGPL-3.0-only"
@@ -9,29 +9,27 @@ dependencies = [
"Jinja2==3.1.6",
"Pillow==12.2.0",
"PyYAML==6.0.3",
"SQLAlchemy==2.0.49",
"SQLAlchemy==2.0.50",
"aiofiles==25.1.0",
"alembic==1.18.4",
"aniso8601==10.0.1",
"appdirs==1.4.4",
"apprise==1.10.0",
"bcrypt==5.0.0",
"extruct==0.18.0",
"fastapi==0.136.1",
"fastapi==0.136.3",
"httpx==0.28.1",
"lxml==6.1.0",
"orjson==3.11.8",
"pydantic==2.13.3",
"lxml==6.1.1",
"orjson==3.11.9",
"pydantic==2.13.4",
"pyhumps==3.8.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.2.2",
"python-ldap==3.4.5",
"python-multipart==0.0.27",
"python-ldap==3.4.7",
"python-multipart==0.0.29",
"python-slugify==8.0.4",
"recipe-scrapers==15.11.0",
"requests==2.33.1",
"requests==2.34.2",
"tzdata==2026.2",
"uvicorn[standard]==0.46.0",
"uvicorn[standard]==0.48.0",
"beautifulsoup4==4.14.3",
"isodate==0.7.2",
"text-unidecode==1.3",
@@ -39,14 +37,14 @@ dependencies = [
"authlib==1.7.2",
"html2text==2025.4.15",
"paho-mqtt==1.6.1",
"pydantic-settings==2.14.0",
"pydantic-settings==2.14.1",
"pillow-heif==1.3.0",
"pyjwt==2.12.1",
"openai==2.34.0",
"pyjwt==2.13.0",
"openai==2.38.0",
"typing-extensions==4.15.0",
"itsdangerous==2.2.0",
"yt-dlp==2026.3.17",
"ingredient-parser-nlp==2.6.0",
"ingredient-parser-nlp==2.7.0",
"pint==0.25.3",
"httpx-curl-cffi==0.1.5",
]
@@ -64,20 +62,20 @@ docs = [
"mkdocs-material==9.7.6",
]
dev = [
"coverage==7.13.5",
"coverage==7.14.1",
"coveragepy-lcov==0.1.2",
"mkdocs-material==9.7.6",
"mypy==2.0.0",
"mypy==2.1.0",
"pre-commit==4.6.0",
"pylint==4.0.5",
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-asyncio==1.4.0",
"rich==15.0.0",
"ruff==0.15.12",
"types-PyYAML==6.0.12.20260408",
"types-python-dateutil==2.9.0.20260408",
"ruff==0.15.14",
"types-PyYAML==6.0.12.20260518",
"types-python-dateutil==2.9.0.20260518",
"types-python-slugify==8.0.2.20240310",
"types-requests==2.33.0.20260503",
"types-requests==2.33.0.20260518",
"types-urllib3==1.26.25.14",
"pydantic-to-typescript2==1.0.6",
"freezegun==1.5.5",
@@ -178,3 +176,7 @@ max-complexity = 24 # Default is 10.
[tool.uv]
add-bounds = "exact"
# Cooling period: ignore package releases newer than 5 days to mitigate
# supply-chain attacks (compromised releases are usually caught and yanked
# within days). Evaluated at resolve time as a rolling window.
exclude-newer = "5 days"

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"prCreation": "immediate",
"lockFileMaintenance": {
"enabled": true
},
@@ -11,6 +12,8 @@
"extends": [
"config:recommended"
],
"minimumReleaseAge": "5 days",
"internalChecksFilter": "strict",
"addLabels": [
"dependencies"
],
@@ -24,6 +27,13 @@
],
"dependencyDashboardApproval": true
},
{
"description": "Always create lockfile maintenance PRs without dashboard approval",
"matchUpdateTypes": [
"lockFileMaintenance"
],
"dependencyDashboardApproval": false
},
{
"matchManagers": [
"pep621"
@@ -43,8 +53,7 @@
],
"automerge": true,
"automergeType": "pr",
"automergeStrategy": "squash",
"minimumReleaseAge": "5 days"
"automergeStrategy": "squash"
},
{
"description": "Auto-merge Docker digest and patch updates",
@@ -58,8 +67,7 @@
],
"automerge": true,
"automergeType": "pr",
"automergeStrategy": "squash",
"minimumReleaseAge": "5 days"
"automergeStrategy": "squash"
}
]
}

View File

@@ -45,6 +45,8 @@ migrations_tandoor = CWD / "migrations/tandoor.zip"
migrations_plantoeat = CWD / "migrations/plantoeat.zip"
migrations_plantoeat_csv = CWD / "migrations/plantoeat.csv"
migrations_myrecipebox = CWD / "migrations/myrecipebox.csv"
migrations_recipekeeper = CWD / "migrations/recipekeeper.zip"

View File

@@ -0,0 +1,13 @@
Title,Course,Cuisine,Main Ingredient,Description,Source,Url,Url Host,Prep Time,Cook Time,Total Time,Servings,Yield,Ingredients,Directions,Tags,Rating,Public Url,Photo Url,Private,Nutritional Score (generic),Calories,Fat,Saturated Fat,Cholesterol,Sodium,Sugar,Carbohydrate,Fiber,Protein,Cost,Created At,Updated At
Test Recipe,Main Course,American,Beans,"This is a description.
Here is new line.",Manually entered source,https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/,,75,75,150,7,1 loaf,", Heading
2 itm Test, note
, Heading2
3 pkg Two, note2
","Directions.
Will go here.","Allergen-Friendly, Cheap, Test",3,https://app.plantoeat.com/recipes/38843883,https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591,yes,,13,16,17,18,19,22,20,21,23,,2023-10-13 20:29:29,2023-10-13 20:32:48
Test Recipe2,,,,,,,,,,,,,"2 itm Test, note
3 pkg Two, note2
","Directions.
Will go here.",,,,,,,,,,,,,,,,,2023-10-13 20:29:29,2023-10-13 20:32:48
1 Title Course Cuisine Main Ingredient Description Source Url Url Host Prep Time Cook Time Total Time Servings Yield Ingredients Directions Tags Rating Public Url Photo Url Private Nutritional Score (generic) Calories Fat Saturated Fat Cholesterol Sodium Sugar Carbohydrate Fiber Protein Cost Created At Updated At
2 Test Recipe Main Course American Beans This is a description. Here is new line. Manually entered source https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/ 75 75 150 7 1 loaf , Heading 2 itm Test, note , Heading2 3 pkg Two, note2 Directions. Will go here. Allergen-Friendly, Cheap, Test 3 https://app.plantoeat.com/recipes/38843883 https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591 yes 13 16 17 18 19 22 20 21 23 2023-10-13 20:29:29 2023-10-13 20:32:48
3 Test Recipe2 2 itm Test, note 3 pkg Two, note2 Directions. Will go here. 2023-10-13 20:29:29 2023-10-13 20:32:48

View File

@@ -94,6 +94,15 @@ test_cases = [
"transFatContent",
},
),
MigrationTestData(
typ=SupportedMigrations.plantoeat,
archive=test_data.migrations_plantoeat_csv,
search_slug="test-recipe",
nutrition_filter={
"unsaturatedFatContent",
"transFatContent",
},
),
MigrationTestData(
typ=SupportedMigrations.myrecipebox,
archive=test_data.migrations_myrecipebox,
@@ -124,6 +133,7 @@ test_ids = [
"mealie_alpha_archive",
"tandoor_archive",
"plantoeat_archive",
"plantoeat_csv",
"myrecipebox_csv",
"recipekeeper_archive",
"cookn_archive",
@@ -190,6 +200,30 @@ def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUse
# TODO: validate other types of content
def test_plantoeat_rejects_invalid_file_type(api_client: TestClient, unique_user: TestUser) -> None:
# Simulate uploading a binary file (e.g. PDF) that is neither ZIP nor CSV/TXT
binary_content = bytes(range(256)) * 4 # arbitrary binary data that is not valid UTF-8
payload = {"migration_type": SupportedMigrations.plantoeat.value}
file_payload = {"archive": binary_content}
response = api_client.post(
api_routes.groups_migrations,
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
report_id = response.json()["id"]
response = api_client.get(api_routes.groups_reports_item_id(report_id), headers=unique_user.token)
assert response.status_code == 200
report = response.json()
assert report["entries"]
assert not report["entries"][0]["success"]
assert "ZIP" in report["entries"][0]["message"] or "CSV" in report["entries"][0]["message"]
def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser):
with TemporaryDirectory() as tmpdir:
with ZipFile(test_data.migrations_mealie) as zf:

View File

@@ -104,6 +104,33 @@ def test_recipe_asset_dangerous_extension_blocked(
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
def test_recipe_asset_served_as_attachment(
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
):
"""Assets must be served as downloads with MIME sniffing disabled so uploaded files cannot
execute as active content in Mealie's origin."""
recipe = recipe_ingredient_only
payload = {"name": random_string(10), "icon": "mdi-file", "extension": "txt"}
file_payload = {"file": b"<script>alert(1)</script>"}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
recipe_response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token).json()
recipe_id = recipe_response["id"]
file_name = recipe_response["assets"][0]["fileName"]
media_response = api_client.get(f"/api/media/recipes/{recipe_id}/assets/{file_name}")
assert media_response.status_code == 200
assert "attachment" in media_response.headers["content-disposition"].lower()
assert media_response.headers["x-content-type-options"] == "nosniff"
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()}

View File

@@ -1,12 +1,13 @@
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pytest
from mealie.core.config import get_app_settings
from mealie.core.settings.settings import AppSettings
from mealie.core.settings.settings import AppSettings, determine_secrets
def test_non_default_settings(monkeypatch):
@@ -26,6 +27,31 @@ def test_non_default_settings(monkeypatch):
assert app_settings.DOCS_URL is None
def test_allowed_iframe_hosts_defaults(monkeypatch):
monkeypatch.delenv("ALLOWED_IFRAME_HOSTS", raising=False)
get_app_settings.cache_clear()
app_settings = get_app_settings()
# Secure defaults are always present and never empty (empty would disable iframe embeds).
assert "youtube.com" in app_settings.allowed_iframe_hosts
assert "vimeo.com" in app_settings.allowed_iframe_hosts
def test_allowed_iframe_hosts_extends_defaults(monkeypatch):
monkeypatch.setenv("ALLOWED_IFRAME_HOSTS", " Example.com , trusted.tld ,, ")
get_app_settings.cache_clear()
app_settings = get_app_settings()
hosts = app_settings.allowed_iframe_hosts
# Configured hosts are normalized, blanks dropped, and defaults retained.
assert "example.com" in hosts
assert "trusted.tld" in hosts
assert "youtube.com" in hosts
assert "" not in hosts
# No duplicates.
assert len(hosts) == len(set(hosts))
def test_default_connection_args(monkeypatch):
monkeypatch.setenv("DB_ENGINE", "sqlite")
get_app_settings.cache_clear()
@@ -367,3 +393,42 @@ def test_sensitive_settings_mask(monkeypatch: pytest.MonkeyPatch):
for setting in sensitive_settings:
assert settings[setting] == "*****"
assert settings_json[setting] == "*****"
class DetermineSecretsTests:
def test_non_production_returns_fixed_key(self, tmp_path: Path):
result = determine_secrets(tmp_path, ".secret", production=False)
assert result == "shh-secret-test-key"
def test_generates_secret_when_file_missing(self, tmp_path: Path):
result = determine_secrets(tmp_path, ".secret", production=True)
assert result
assert (tmp_path / ".secret").read_text() == result
def test_reuses_existing_secret(self, tmp_path: Path):
(tmp_path / ".secret").write_text("existing-secret")
result = determine_secrets(tmp_path, ".secret", production=True)
assert result == "existing-secret"
def test_regenerates_when_file_is_empty(self, tmp_path: Path):
(tmp_path / ".secret").write_text("")
result = determine_secrets(tmp_path, ".secret", production=True)
assert result
assert (tmp_path / ".secret").read_text() == result
def test_regenerates_when_file_is_whitespace_only(self, tmp_path: Path):
(tmp_path / ".secret").write_text(" \n ")
result = determine_secrets(tmp_path, ".secret", production=True)
assert result
assert (tmp_path / ".secret").read_text() == result
def test_generates_unique_secrets(self, tmp_path: Path):
dir_a = tmp_path / "a"
dir_b = tmp_path / "b"
result_a = determine_secrets(dir_a, ".secret", production=True)
result_b = determine_secrets(dir_b, ".secret", production=True)
assert result_a != result_b
def test_no_tmp_file_left_after_write(self, tmp_path: Path):
determine_secrets(tmp_path, ".secret", production=True)
assert not (tmp_path / ".tmp").exists()

799
uv.lock generated

File diff suppressed because it is too large Load Diff