mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-18 15:13:35 -05:00
Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23aa61f17 | ||
|
|
cd39d0c4cb | ||
|
|
20e2d4e1a1 | ||
|
|
c09cc5a323 | ||
|
|
6d7b6bccab | ||
|
|
91fea086e5 | ||
|
|
e2fbe118a7 | ||
|
|
904e6b7d82 | ||
|
|
5aafb56c4f | ||
|
|
b4740d291d | ||
|
|
fc6dc34ace | ||
|
|
73d86f6f6b | ||
|
|
8e225ee796 | ||
|
|
ced233d361 | ||
|
|
b173172e6c | ||
|
|
a66db96eb5 | ||
|
|
dfd5abfb5d | ||
|
|
e2ae5cb5b6 | ||
|
|
634aa5cd25 | ||
|
|
23c7bd7e3d | ||
|
|
9c1ee972c9 | ||
|
|
1b9023c8c0 | ||
|
|
3a37cd6959 | ||
|
|
8da0d010a5 | ||
|
|
37f7f770a8 | ||
|
|
1cebbefd88 | ||
|
|
d55149b904 | ||
|
|
fad7acadfc | ||
|
|
a539c6cd2e | ||
|
|
7b5502d019 | ||
|
|
26d9d8fe24 | ||
|
|
b64f14aaae | ||
|
|
9b686ecd2b | ||
|
|
a956a638f4 | ||
|
|
c9d9e6822e | ||
|
|
4a563b76ad | ||
|
|
73f97c2cca | ||
|
|
75e3c99d72 | ||
|
|
217ddd8814 | ||
|
|
f2cc8dc922 | ||
|
|
b8329def91 | ||
|
|
2ae7dc3b82 | ||
|
|
510a63a71f | ||
|
|
14433819c3 | ||
|
|
96a9dbccb6 | ||
|
|
cfe20214e5 | ||
|
|
eef54879fe | ||
|
|
c789ecf0ba | ||
|
|
008f55e725 | ||
|
|
bcbe32f503 | ||
|
|
4101797c0e | ||
|
|
6110200a04 | ||
|
|
49f1e76776 | ||
|
|
24e9417d02 | ||
|
|
69d6985f3b | ||
|
|
84cdeb2398 | ||
|
|
6d439de144 | ||
|
|
1b586f8c67 | ||
|
|
f82f387146 | ||
|
|
d31c07a6c5 | ||
|
|
84372c2f4f | ||
|
|
168ac79daa | ||
|
|
22296277a8 | ||
|
|
6e006458be | ||
|
|
76a2fea076 | ||
|
|
3de4024619 | ||
|
|
194771653d | ||
|
|
24aa8f3525 | ||
|
|
fb8e318739 | ||
|
|
6255c71609 | ||
|
|
f2d1569488 | ||
|
|
987c7209fc | ||
|
|
f6dbd1f1f1 | ||
|
|
d30118899d | ||
|
|
af241dad57 | ||
|
|
b86de79c6f | ||
|
|
86e86f8c81 | ||
|
|
d795f91938 | ||
|
|
a59511cc81 | ||
|
|
a5d4cae6d0 | ||
|
|
2987cf8ba6 | ||
|
|
46b46978ff | ||
|
|
12857883a9 | ||
|
|
60fff3b5b8 | ||
|
|
b42e888929 | ||
|
|
570d6f1433 | ||
|
|
dcf410739e | ||
|
|
1929d630a1 | ||
|
|
c4c7bf2aed | ||
|
|
47034d18c5 | ||
|
|
7ebe491f74 | ||
|
|
719bd89eb1 | ||
|
|
9030c7e6b9 | ||
|
|
0202cc7ef8 | ||
|
|
381ac9bfde | ||
|
|
e9fe71c1b7 | ||
|
|
79bbc20cd6 | ||
|
|
c7be4a452a | ||
|
|
731ee8ae3d | ||
|
|
c7ae67e7cd | ||
|
|
e83891e3ca | ||
|
|
e3e45c534e | ||
|
|
279cf65673 | ||
|
|
cb44ecf394 | ||
|
|
920eeb26d6 | ||
|
|
9738d9f363 | ||
|
|
37e6123f9e | ||
|
|
0a2cabb348 | ||
|
|
447a1fb239 | ||
|
|
b5358896eb | ||
|
|
78fbbf0264 | ||
|
|
a33d8204df | ||
|
|
c8046bbdf0 | ||
|
|
329ad4d8ed | ||
|
|
4ccf649aa1 | ||
|
|
5994328a8b | ||
|
|
15b5917054 | ||
|
|
e48b150f7c | ||
|
|
adbc66316f | ||
|
|
0dc7337972 | ||
|
|
58d4b95a56 | ||
|
|
0e74bc6cd0 | ||
|
|
4866eec62d | ||
|
|
c0d659724a | ||
|
|
5f0996734a | ||
|
|
8cd0286ca1 | ||
|
|
f214e8843a | ||
|
|
66fea60341 | ||
|
|
69b4684bce | ||
|
|
b75d6812a3 | ||
|
|
ed000c2cc6 | ||
|
|
d43a2020b3 | ||
|
|
ff5e65b323 | ||
|
|
e1b07a250b | ||
|
|
e68486a0e1 | ||
|
|
271915ee23 | ||
|
|
a3d64c0761 | ||
|
|
73c664649d | ||
|
|
d887e68228 | ||
|
|
a8d3ed3310 | ||
|
|
00bd45c8f1 | ||
|
|
05003a5c6f | ||
|
|
e2be09b5d3 | ||
|
|
b81e0ac03b | ||
|
|
c5d822cded | ||
|
|
0fc66fee9a | ||
|
|
612c07e6f3 | ||
|
|
a0ac2923d6 | ||
|
|
7107c08021 | ||
|
|
a0e336edcb | ||
|
|
3e306638d0 | ||
|
|
a72641b32e | ||
|
|
f4ed9d92bf | ||
|
|
5ae35c3500 | ||
|
|
08666e6c21 | ||
|
|
5ae530a637 | ||
|
|
2b07497486 | ||
|
|
3b65642325 | ||
|
|
fdd1057e79 | ||
|
|
f1afebcc04 | ||
|
|
e711be7efa | ||
|
|
ec94b8179c | ||
|
|
a7c1d6f486 | ||
|
|
df0b792c52 | ||
|
|
1f5054fcbd | ||
|
|
ca483b9cbe | ||
|
|
03dc459162 | ||
|
|
cf8f5fe2a2 | ||
|
|
760350ef88 | ||
|
|
706d4ee0b5 | ||
|
|
5fd8545cbe | ||
|
|
3397c06db2 | ||
|
|
22df7a1ec7 | ||
|
|
e87b0c75b6 | ||
|
|
b406b7fa16 | ||
|
|
7114ed1122 | ||
|
|
70b5865dce | ||
|
|
3be7056f2c | ||
|
|
1b57310535 | ||
|
|
2b15d9a515 | ||
|
|
adc9c0b970 | ||
|
|
bec1708891 | ||
|
|
66bb545454 | ||
|
|
c1ebf04291 | ||
|
|
3166060644 | ||
|
|
bde7cf6f9d | ||
|
|
8ea9bb19f6 | ||
|
|
6d0f9b0d35 | ||
|
|
df541c1924 | ||
|
|
9af92ff397 | ||
|
|
554d50b079 | ||
|
|
a00e2e8b68 | ||
|
|
4fcfbaff3b | ||
|
|
7792f0504d | ||
|
|
3ca6c67f25 | ||
|
|
2eb0fdc863 | ||
|
|
192d48c4a6 | ||
|
|
e4f38685b3 | ||
|
|
d02023e12c | ||
|
|
64d8786d8f | ||
|
|
0971d59fa6 | ||
|
|
9b799ca441 | ||
|
|
193b823688 | ||
|
|
c64c2d25e7 | ||
|
|
8b4111d68f | ||
|
|
9d601ea4b5 | ||
|
|
95e1bbce2b | ||
|
|
7b32508201 | ||
|
|
6ed85d72d7 | ||
|
|
cd2a522f25 | ||
|
|
6bd6400aba | ||
|
|
8b92d6ee04 | ||
|
|
7cc2ed75e5 | ||
|
|
cb7f46c0ad | ||
|
|
cb12aedf72 | ||
|
|
8c35a26ab0 | ||
|
|
b2d0f46dd2 | ||
|
|
2c4b7bf611 | ||
|
|
38e542bcd3 | ||
|
|
e53452c19c | ||
|
|
13213476d8 | ||
|
|
9925450173 | ||
|
|
efb9dae681 | ||
|
|
cee93d2a87 | ||
|
|
0d4a8654c1 | ||
|
|
95b1be07bb | ||
|
|
a6fc98fc82 | ||
|
|
6f03010f6c | ||
|
|
69397c91b8 | ||
|
|
798792dcdc | ||
|
|
cc32dd9fa6 | ||
|
|
0c64eb29f9 | ||
|
|
8baa5cc315 | ||
|
|
6f3a5c6c8f | ||
|
|
778078590b | ||
|
|
53c82e5491 | ||
|
|
fef114d97f | ||
|
|
e80cbfad7f | ||
|
|
99527ce738 | ||
|
|
08ccced734 | ||
|
|
43c2c9552b | ||
|
|
db5741c7ee | ||
|
|
a1e394cf36 | ||
|
|
bdbef1ab9e | ||
|
|
e5276f6c20 | ||
|
|
20a6e71b31 | ||
|
|
24c111af7b | ||
|
|
ab4559319e | ||
|
|
2f8625ac44 | ||
|
|
dd146afa57 | ||
|
|
91d15f671e | ||
|
|
7008b13246 |
@@ -1,9 +1,10 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.12-bullseye"
|
||||
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
|
||||
# Remove outdated yarn GPG key, if it exists
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list /usr/share/keyrings/yarn-archive-keyring.gpg || true
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
@@ -13,6 +14,7 @@ RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
||||
&& chown vscode:vscode -R /home/vscode/
|
||||
|
||||
RUN npm install -g @go-task/cli
|
||||
RUN npm install -g json-schema-to-typescript
|
||||
|
||||
# Install additional OS packages
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"mypy.runUsingActiveInterpreter": true
|
||||
},
|
||||
@@ -34,6 +33,7 @@
|
||||
"ms-python.pylint",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8000, // used by mkdocs
|
||||
9000,
|
||||
9091, // used by docker production
|
||||
24678 // used by nuxt when hot-reloading using polling
|
||||
|
||||
113
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
113
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Auto-merge l10n PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'l10n')
|
||||
|
||||
steps:
|
||||
- name: Validate PR author
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [[
|
||||
"$AUTHOR" != "hay-kot" &&
|
||||
"$AUTHOR" != "github-actions[bot]" &&
|
||||
"$AUTHOR" != "mealie-actions[bot]"
|
||||
]]; then
|
||||
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Author validated: $AUTHOR"
|
||||
|
||||
- name: Validate PR size
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ADDITIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json additions --jq '.additions')
|
||||
DELETIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json deletions --jq '.deletions')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||
|
||||
if [ "$TOTAL" -gt 400 ]; then
|
||||
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate file paths
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
|
||||
|
||||
for file in $FILES; do
|
||||
# Check if file matches any allowed path
|
||||
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
|
||||
[[ "$file" =~ ^frontend/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# File doesn't match allowed paths
|
||||
echo "::error::Invalid file path: $file"
|
||||
echo "Only the following paths are allowed:"
|
||||
echo " - frontend/composables/use-locales/available-locales.ts"
|
||||
echo " - frontend/lang/"
|
||||
echo " - mealie/repos/seed/resources/*/locales/"
|
||||
exit 1
|
||||
done
|
||||
|
||||
echo "All files are in allowed paths"
|
||||
|
||||
- name: Approve PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: l10n PR from trusted author with valid file paths"
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
50
.github/workflows/docs.yml
vendored
Normal file
50
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [mealie-next]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --only-group docs --no-install-project
|
||||
|
||||
- name: Build docs
|
||||
run: uv run --no-project mkdocs build -d site
|
||||
working-directory: docs
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
11
.github/workflows/locale-sync.yml
vendored
11
.github/workflows/locale-sync.yml
vendored
@@ -15,10 +15,17 @@ jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -105,7 +112,7 @@ jobs:
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
|
||||
12
.github/workflows/publish.yml
vendored
12
.github/workflows/publish.yml
vendored
@@ -37,6 +37,17 @@ jobs:
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
hkotel/mealie
|
||||
ghcr.io/${{ github.repository }}
|
||||
# Overwrite the image.version label with our tag
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ inputs.tag }}
|
||||
|
||||
- name: Retrieve Python package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -57,5 +68,6 @@ jobs:
|
||||
hkotel/mealie:${{ inputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||
${{ inputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
|
||||
13
.github/workflows/pull-requests.yml
vendored
13
.github/workflows/pull-requests.yml
vendored
@@ -4,14 +4,19 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- mealie-next
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-lint:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
|
||||
backend-tests:
|
||||
@@ -24,6 +29,7 @@ jobs:
|
||||
|
||||
container-scanning:
|
||||
name: "Trivy Container Scanning"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/partial-trivy-container-scanning.yml
|
||||
|
||||
code-ql:
|
||||
@@ -47,7 +53,10 @@ jobs:
|
||||
|
||||
publish-image:
|
||||
name: "Publish PR Image"
|
||||
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'build-image') &&
|
||||
github.repository == 'mealie-recipes/mealie'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
51
.github/workflows/scheduled-checks.yml
vendored
51
.github/workflows/scheduled-checks.yml
vendored
@@ -40,12 +40,18 @@ jobs:
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
# This doesn't currently work for us because it creates the PR but the workflows don't run.
|
||||
# TODO: Provide a personal access token as a parameter here, that solves that problem.
|
||||
# https://github.com/peter-evans/create-pull-request
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: "Update pre-commit hooks"
|
||||
branch: "fix/update-pre-commit-hooks"
|
||||
labels: |
|
||||
@@ -54,3 +60,38 @@ jobs:
|
||||
base: mealie-next
|
||||
title: "chore(auto): Update pre-commit hooks"
|
||||
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
|
||||
|
||||
- name: Approve PR
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: Pre-commit hook updates"
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -20,6 +20,7 @@ dev/data/backups/*
|
||||
dev/data/debug/*
|
||||
dev/data/img/*
|
||||
dev/data/migration/*
|
||||
dev/data/templates/*
|
||||
dev/data/users/*
|
||||
dev/data/groups/*
|
||||
|
||||
@@ -69,8 +70,11 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# packaged output - temporarily written here by `uv build`
|
||||
/mealie-*
|
||||
|
||||
# frontend copied into Python module for packaging purposes
|
||||
/mealie/frontend/
|
||||
/mealie/frontend
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.8
|
||||
rev: v0.15.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
96
Taskfile.yml
96
Taskfile.yml
@@ -47,8 +47,6 @@ tasks:
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules/**
|
||||
|
||||
setup:py:
|
||||
desc: setup python dependencies
|
||||
@@ -61,6 +59,18 @@ tasks:
|
||||
- pyproject.toml
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
setup:e2e:
|
||||
desc: setup e2e test dependencies
|
||||
dir: tests/e2e
|
||||
run: once
|
||||
cmds:
|
||||
- yarn install
|
||||
- yarn playwright install --with-deps
|
||||
sources:
|
||||
- package.json
|
||||
- playwright.config.ts
|
||||
- yarn.lock
|
||||
|
||||
setup:
|
||||
desc: setup all dependencies
|
||||
deps:
|
||||
@@ -179,12 +189,21 @@ tasks:
|
||||
status:
|
||||
- '{{ .SKIP_PACKAGE_DEPS | default "false"}}'
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
py:package:build:
|
||||
internal: true
|
||||
deps:
|
||||
- py:package:deps
|
||||
cmds:
|
||||
- uv build --out-dir dist
|
||||
sources:
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- mealie/**
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
cmds:
|
||||
- task: py:package:build
|
||||
- task: py:package:generate-requirements
|
||||
|
||||
py:
|
||||
@@ -215,6 +234,12 @@ tasks:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn build
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:generate:
|
||||
desc: generates a static version of the frontend in frontend/dist
|
||||
@@ -223,18 +248,36 @@ tasks:
|
||||
- setup:ui
|
||||
cmds:
|
||||
- yarn generate
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:lint:
|
||||
desc: runs the frontend linter
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn lint --max-warnings=0
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:test:
|
||||
desc: runs the frontend tests
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn test
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:check:
|
||||
desc: runs all frontend checks
|
||||
@@ -263,3 +306,48 @@ tasks:
|
||||
dir: docker
|
||||
cmds:
|
||||
- docker compose -f docker-compose.yml -p mealie up -d --build
|
||||
|
||||
e2e:build-image:
|
||||
desc: builds the e2e test docker image
|
||||
deps:
|
||||
- py:package
|
||||
cmds:
|
||||
- docker build --tag mealie:e2e --file docker/Dockerfile --build-context packages=dist .
|
||||
sources:
|
||||
- docker/Dockerfile
|
||||
- dist/**
|
||||
|
||||
e2e:start-server:
|
||||
desc: Builds the image and starts the containers for e2e testing
|
||||
dir: tests/e2e/docker
|
||||
deps:
|
||||
- e2e:build-image
|
||||
vars:
|
||||
WAIT_UNTIL_HEALTHY: '{{if .WAIT_UNTIL_HEALTHY}}--wait{{else}}{{end}}'
|
||||
cmds:
|
||||
- docker compose up -d {{.WAIT_UNTIL_HEALTHY}}
|
||||
|
||||
e2e:stop-server:
|
||||
desc: Shuts down the e2e testing containers
|
||||
dir: tests/e2e/docker
|
||||
cmds:
|
||||
- docker compose down --volumes
|
||||
|
||||
e2e:test:
|
||||
desc: runs the e2e tests
|
||||
dir: tests/e2e
|
||||
deps:
|
||||
- setup:e2e
|
||||
vars:
|
||||
PREVENT_REPORT_OPEN: '{{if .PREVENT_REPORT_OPEN}}PLAYWRIGHT_HTML_OPEN=never{{else}}{{end}}'
|
||||
cmds:
|
||||
- '{{.PREVENT_REPORT_OPEN}} yarn playwright test'
|
||||
|
||||
e2e:
|
||||
desc: runs the full e2e test suite
|
||||
cmds:
|
||||
- task: e2e:start-server
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
|
||||
from requests import Response
|
||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||
|
||||
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
@@ -17,57 +19,6 @@ BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocaleData:
|
||||
name: str
|
||||
dir: str = "ltr"
|
||||
|
||||
|
||||
LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
||||
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
||||
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
|
||||
"it-IT": LocaleData(name="Italiano (Italian)"),
|
||||
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
||||
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
||||
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
|
||||
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
|
||||
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
||||
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
||||
"pl-PL": LocaleData(name="Polski (Polish)"),
|
||||
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
||||
"uk-UA": LocaleData(name="Українська (Ukrainian)"),
|
||||
"vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
|
||||
"zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
|
||||
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
||||
}
|
||||
|
||||
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
||||
export const LOCALES = [{% for locale in locales %}
|
||||
{
|
||||
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
|
||||
value: "{{ locale.locale }}",
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||
},{% endfor %}
|
||||
];
|
||||
|
||||
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
|
||||
id: str
|
||||
name: str
|
||||
locale: str
|
||||
dir: str = "ltr"
|
||||
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||
threeLettersCode: str
|
||||
twoLettersCode: str
|
||||
progress: float = 0.0
|
||||
progress: int = 0
|
||||
|
||||
|
||||
class CrowdinApi:
|
||||
@@ -117,43 +70,15 @@ class CrowdinApi:
|
||||
def get_languages(self) -> list[TargetLanguage]:
|
||||
response = self.get_project()
|
||||
tls = response.json()["data"]["targetLanguages"]
|
||||
return [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models = [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir="ltr",
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
progress: list[dict] = self.get_progress()["data"]
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_DATA:
|
||||
locale_data = LOCALE_DATA[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
|
||||
for p in progress:
|
||||
if p["data"]["languageId"] == model.id:
|
||||
model.progress = p["data"]["translationProgress"]
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
def get_progress(self) -> dict:
|
||||
def get_progress(self) -> dict[str, int]:
|
||||
response = requests.get(
|
||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||
headers=self.headers,
|
||||
)
|
||||
return response.json()
|
||||
data = response.json()["data"]
|
||||
return {p["data"]["languageId"]: p["translationProgress"] for p in data}
|
||||
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
@@ -195,8 +120,8 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_DATA.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else "ltr"
|
||||
match_data = LOCALE_CONFIG.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
|
||||
|
||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||
all_langs.append(lang_string)
|
||||
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
|
||||
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
||||
|
||||
|
||||
def _get_local_models() -> list[TargetLanguage]:
|
||||
return [
|
||||
TargetLanguage(
|
||||
id=locale,
|
||||
name=data.name,
|
||||
locale=locale,
|
||||
threeLettersCode=locale.split("-")[-1],
|
||||
twoLettersCode=locale.split("-")[-1],
|
||||
)
|
||||
for locale, data in LOCALE_CONFIG.items()
|
||||
if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later
|
||||
]
|
||||
|
||||
|
||||
def _get_local_progress() -> dict[str, int]:
|
||||
with open(CodeDest.use_locales) as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract the array content between [ and ]
|
||||
match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("Could not find LOCALES array in file")
|
||||
|
||||
# Convert JS to JSON
|
||||
array_content = match.group(1)
|
||||
|
||||
# Replace unquoted keys with quoted keys for valid JSON
|
||||
# This converts: { name: "value" } to { "name": "value" }
|
||||
json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
|
||||
|
||||
# Remove trailing commas before } and ]
|
||||
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
|
||||
|
||||
locales = json.loads(json_str)
|
||||
return {locale["value"]: locale["progress"] for locale in locales}
|
||||
|
||||
|
||||
def get_languages() -> list[TargetLanguage]:
|
||||
if API_KEY:
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
progress = api.get_progress()
|
||||
else:
|
||||
log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
|
||||
log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
|
||||
models = _get_local_models()
|
||||
progress = _get_local_progress()
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir=LocaleTextDirection.LTR,
|
||||
plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_CONFIG:
|
||||
locale_data = LOCALE_CONFIG[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
model.plural_food_handling = locale_data.plural_food_handling
|
||||
model.progress = progress.get(model.id, model.progress)
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
|
||||
def generate_locales_ts_file():
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
models = get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
||||
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
|
||||
|
||||
|
||||
def main():
|
||||
if API_KEY is None or API_KEY == "":
|
||||
log.error("CROWDIN_API_KEY is not set")
|
||||
return
|
||||
|
||||
generate_locales_ts_file()
|
||||
inject_nuxt_values()
|
||||
inject_registration_validation_values()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
|
||||
FROM node:24@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -111,7 +111,6 @@ RUN . $VENV_PATH/bin/activate \
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base AS production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ While this guide aims to simplify the migration process for developers, it's not
|
||||
|
||||
## V1 → V2
|
||||
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](../../documentation/getting-started/features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
### `updateAt` is now `updatedAt`
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Recipes extras are a key feature of the Mealie API. They allow you to create cus
|
||||
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
|
||||
|
||||
#### Shopping List and Food Extras
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Alexa, ToDoist, Trello, or any other list manager with an API.
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Todoist, Trello, or any other list manager with an API.
|
||||
|
||||
To keep shopping lists in sync, for instance, you can store your Trello list id on your Mealie shopping list: <br />
|
||||
`{"trello_list_id": "5abbe4b7ddc1b351ef961414"}`
|
||||
@@ -52,6 +52,7 @@ Many applications will keep track of the query and adjust the page parameter app
|
||||
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
|
||||
|
||||
There are a few shorthands available to reduce the number of calls for certain common requests:
|
||||
|
||||
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
|
||||
- if you want to fetch the _last_ page, set `page = -1`
|
||||
|
||||
@@ -89,6 +90,28 @@ This filter will find all recipes that don't start with the word "Test": <br>
|
||||
This filter will find all recipes that have particular slugs: <br>
|
||||
`slug IN ["pasta-fagioli", "delicious-ramen"]`
|
||||
|
||||
##### Placeholder Keywords
|
||||
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
|
||||
|
||||
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
|
||||
`lastMade <= "$NOW-30d"`
|
||||
|
||||
Supported offset operations include:
|
||||
|
||||
- `-` for subtracting a time (i.e. in the past)
|
||||
- `+` for adding a time (i.e. in the future)
|
||||
|
||||
Supported offset intervals include:
|
||||
|
||||
- `y` for years
|
||||
- `m` for months
|
||||
- `d` for days
|
||||
- `H` for hours
|
||||
- `M` for minutes
|
||||
- `S` for seconds
|
||||
|
||||
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
|
||||
|
||||
##### Nested Property filters
|
||||
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
|
||||
`user.username = "SousChef20220320"`
|
||||
@@ -96,7 +119,7 @@ When querying tables with relationships, you can filter properties on related ta
|
||||
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
|
||||
`recipe.createdAt >= "2023-02-25"`
|
||||
|
||||
This recipe filter will return all recipes that contains a particular set of tags: <br>
|
||||
This recipe filter will return all recipes that contain a particular set of tags: <br>
|
||||
`tags.name CONTAINS ALL ["Easy", "Cajun"]`
|
||||
|
||||
##### Compound Filters
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authentik](https://integrations.goauthentik.io/documentation/mealie/)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
@@ -68,7 +68,6 @@ Example configurations for several Identity Providers have been provided by the
|
||||
|
||||
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||
|
||||
|
||||
## Migration from Mealie v1.x
|
||||
|
||||
**High level changes**
|
||||
|
||||
@@ -85,13 +85,13 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
||||
|
||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
|
||||
Here you will be able to:
|
||||
|
||||
- See items already on the Shopping List
|
||||
- See linked recipes with ingredients
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Check off an item
|
||||
- Add / Change / Remove / Sort Items via the grid icon
|
||||
- Be sure if you are modifying an ingredient to click the 'Save' icon.
|
||||
@@ -103,13 +103,10 @@ Here you will be able to:
|
||||
|
||||
!!! tip
|
||||
You can use Labels to categorize your ingredients. You may want to Label by Food Type (Frozen, Fresh, etc), by Store, Tool, Recipe, or more. Play around with this to see what works best for you.
|
||||
|
||||
!!! tip
|
||||
You can toggle 'Food' on items so that if you add multiple of the same food / ingredient, Mealie will automatically combine them together. Do this by editing an item in the Shopping List and clicking the 'Apple' icon. If you then have recipes that contain "1 | cup | cheese" and "2 | cup | cheese" this would be combined to show "3 cups of cheese."
|
||||
|
||||
[See FAQ for more information](../getting-started/faq.md)
|
||||
|
||||
|
||||
|
||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||
|
||||
## Integrations
|
||||
@@ -198,7 +195,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
Groups are fully isolated instances of Mealie. Think of a group as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
|
||||
|
||||
@@ -122,17 +122,18 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
||||
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| Variables | Default | Description |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| OPENAI_CUSTOM_PROMPT_DIR | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||
|
||||
### Theming
|
||||
|
||||
@@ -235,6 +236,10 @@ The examples below provide copy-ready Docker Compose environment configurations
|
||||
THEME_DARK_ERROR: '#E57373'
|
||||
```
|
||||
|
||||
!!! info
|
||||
Browser cookies may cause the client to keep outdated settings.
|
||||
Clearing the cookies can be required for the change to take effect.
|
||||
|
||||
### Docker Secrets
|
||||
|
||||
> <super>†</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
|
||||
|
||||
@@ -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.7.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.11.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,8 +32,8 @@ theme:
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- def_list
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
max-width: 950px !important;
|
||||
}
|
||||
|
||||
.lg-container {
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => $auth.user.value?.groupSlug);
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const sections = ref([
|
||||
|
||||
@@ -73,11 +73,11 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const slug = route.params.slug as string;
|
||||
@@ -88,11 +88,11 @@ const router = useRouter();
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
if (!(auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
return auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<BaseButton
|
||||
download
|
||||
size="small"
|
||||
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
|
||||
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
@@ -83,8 +82,6 @@ const emit = defineEmits<{
|
||||
[key: string]: [];
|
||||
}>();
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
@@ -94,7 +91,7 @@ const state = reactive({
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
title: i18n.t("meal-plan.add-day-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
@@ -126,8 +123,8 @@ async function getShoppingLists() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
shoppingList: async () => {
|
||||
await getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
type: "relativeDate",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@@ -129,9 +129,9 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-if="field.fieldChoices"
|
||||
:model-value="field.values"
|
||||
:items="field.fieldOptions"
|
||||
:items="field.fieldChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@@ -144,11 +144,13 @@
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="field.value"
|
||||
type="number"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-checkbox
|
||||
@@ -167,23 +169,39 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
class="date-input"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||
:model-value="safeNewDate(field.value + 'T00:00:00')"
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||
/>
|
||||
</v-menu>
|
||||
<!--
|
||||
Relative dates are assumed to be negative intervals with a unit of days.
|
||||
The input is a *positive*, interpreted internally as a *negative* offset.
|
||||
-->
|
||||
<v-number-input
|
||||
v-else-if="field.type === 'relativeDate'"
|
||||
:model-value="parseRelativeDateOffset(field.value)"
|
||||
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
density="compact"
|
||||
inset
|
||||
:min="0"
|
||||
:precision="0"
|
||||
class="date-input"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
@@ -317,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import type {
|
||||
LogicalOperator,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
} from "~/lib/api/types/non-generated";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
@@ -339,7 +363,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
const {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
} = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -394,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
updatedField.fieldChoices = fieldDef.fieldChoices;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
|
||||
// Defaults
|
||||
switch (fields.value[index].type) {
|
||||
case "date":
|
||||
fields.value[index].value = safeNewDate("");
|
||||
break;
|
||||
case "relativeDate":
|
||||
fields.value[index].value = "$NOW-30d";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
@@ -423,12 +467,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
const relOps = getRelOps(field.type);
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||
state.datePickers[index] = false;
|
||||
fields.value[index].value = value;
|
||||
|
||||
if (field.type === "relativeDate") {
|
||||
// Value is set to an int representing the offset from $NOW
|
||||
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
|
||||
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
|
||||
}
|
||||
else {
|
||||
fields.value[index].value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||
@@ -446,12 +499,7 @@ function removeField(index: number) {
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const fieldsUpdater = useDebounceFn(() => {
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
@@ -517,6 +565,9 @@ async function initializeFields() {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
|
||||
const relOps = getRelOps(field.type);
|
||||
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
@@ -525,12 +576,15 @@ async function initializeFields() {
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
@@ -599,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
@@ -617,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
function safeNewDate(input: string): Date {
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
|
||||
*
|
||||
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
|
||||
*/
|
||||
function parseRelativeDateOffset(value: string): number {
|
||||
const defaultVal = 30;
|
||||
if (!value) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
|
||||
if (!remainder.startsWith("-")) {
|
||||
throw new Error("Invalid operator (not '-')");
|
||||
}
|
||||
|
||||
if (remainder.slice(-1) !== "d") {
|
||||
throw new Error("Invalid unit (not 'd')");
|
||||
}
|
||||
|
||||
// Slice off sign and unit
|
||||
return parseInt(remainder.slice(1, -1));
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
const multiple = fields.value.length > 1;
|
||||
const adv = state.showAdvanced;
|
||||
@@ -687,4 +785,13 @@ const config = computed(() => {
|
||||
.bg-light {
|
||||
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
||||
}
|
||||
|
||||
:deep(.date-input input) {
|
||||
text-align: end;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
:deep(.date-input .v-field__field) {
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="open" class="custom-btn-group gapped">
|
||||
<div v-if="open" class="custom-btn-group gapped ma-1">
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
@@ -126,7 +126,7 @@ withDefaults(defineProps<Props>(), {
|
||||
canEdit: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
|
||||
const emit = defineEmits(["print", "input", "save", "delete", "close", "json", "edit"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
|
||||
@@ -1,60 +1,97 @@
|
||||
<template>
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-list-item class="pr-2 pl-0">
|
||||
<v-card-title>
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
variant="plain"
|
||||
:icon="$globals.icons.create"
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="model.length > 0"
|
||||
lines="two"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
:href="!edit ? assetURL(item.fileName ?? '') : ''"
|
||||
target="_blank"
|
||||
class="pr-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-avatar size="48" rounded="lg" class="elevation-1">
|
||||
<v-img
|
||||
v-if="isImage(item.fileName)"
|
||||
:src="assetURL(item.fileName ?? '')"
|
||||
:alt="item.name"
|
||||
loading="lazy"
|
||||
cover
|
||||
/>
|
||||
<v-icon v-else size="large">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="pl-2">
|
||||
|
||||
<v-list-item-title>
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<v-menu v-if="edit" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon
|
||||
variant="plain"
|
||||
>
|
||||
<v-icon :icon="$globals.icons.dotsVertical" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="220">
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.eye"
|
||||
:title="$t('general.view')"
|
||||
target="_blank"
|
||||
/>
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.download"
|
||||
:title="$t('general.download')"
|
||||
download
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.contentCopy"
|
||||
:title="$t('general.copy')"
|
||||
@click="copyText(assetEmbed(item.fileName ?? ''))"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.delete"
|
||||
:title="$t('general.delete')"
|
||||
@click="model.splice(i, 1)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon
|
||||
size="small"
|
||||
variant="plain"
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
target="_blank"
|
||||
top
|
||||
download
|
||||
>
|
||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
top
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -68,18 +105,9 @@
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
v-model="state.newAsset.name"
|
||||
density="compact"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<div class="d-flex justify-space-between">
|
||||
@@ -92,10 +120,14 @@
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||
<v-avatar>
|
||||
<v-icon>
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -107,7 +139,6 @@
|
||||
@uploaded="setFileObject"
|
||||
/>
|
||||
</div>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
@@ -118,6 +149,7 @@
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import { useCopy } from "~/composables/use-copy";
|
||||
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
@@ -149,6 +181,7 @@ const state = reactive({
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { copyText } = useCopy();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
@@ -184,21 +217,31 @@ function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
function isImage(fileName?: string | null) {
|
||||
if (!fileName) return false;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(fileName);
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%" />`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
// If the user didn't provide a name, default to the file base name
|
||||
if (!state.newAsset.name?.trim()) {
|
||||
state.newAsset.name = fileObject.name.substring(0, fileObject.name.lastIndexOf("."));
|
||||
}
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
// Only require a file; name will fall back to the file name if empty
|
||||
return Boolean(state.fileObject?.name);
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
@@ -207,8 +250,10 @@ async function addAsset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameToUse = state.newAsset.name?.trim() || state.fileObject.name;
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
name: nameToUse,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
|
||||
@@ -130,11 +130,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -160,11 +160,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -219,7 +219,7 @@ const EVENTS = {
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
@@ -234,7 +234,7 @@ const sortLoading = ref(false);
|
||||
const randomSeed = ref(Date.now().toString());
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
@update:model-value="onMenuToggle"
|
||||
>
|
||||
@@ -24,7 +23,6 @@
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
@mouseenter="onHover"
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
@@ -127,12 +125,6 @@ const contentProps = computed(() => {
|
||||
return rest;
|
||||
});
|
||||
|
||||
function onHover() {
|
||||
if (!isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuToggle(isOpen: boolean) {
|
||||
if (isOpen && !isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
|
||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [slug: string];
|
||||
print: [];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -201,13 +202,13 @@ const newMealdateString = computed(() => {
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -295,12 +296,12 @@ const recipeRefWithScale = computed(() =>
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
auth.user.value?.admin
|
||||
&& auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = $auth.user.value;
|
||||
const user = auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ defineEmits<{
|
||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:title="$t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
>
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<v-container v-if="!filteredShoppingLists.length">
|
||||
<BasePageTitle>
|
||||
<template #title>
|
||||
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
</v-container>
|
||||
<v-card-text>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
v-for="list in filteredShoppingLists"
|
||||
:key="list.id"
|
||||
hover
|
||||
class="my-2 left-border"
|
||||
@@ -217,44 +217,43 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
|
||||
// Capture values at initialization to avoid reactive updates
|
||||
const currentHouseholdSlug = ref("");
|
||||
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListDialog: false,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
|
||||
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
if (dialog.value) {
|
||||
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
|
||||
);
|
||||
|
||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = filteredShoppingLists.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
state.shoppingListDialog = true;
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
}
|
||||
else if (!dialog.value) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
@@ -274,22 +273,26 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
// Create a local copy to avoid mutating props
|
||||
let recipeData = { ...recipe };
|
||||
if (!(recipeData.id && recipeData.name && recipeData.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipeData.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
recipeData = {
|
||||
...recipeData,
|
||||
id: data.id || "",
|
||||
name: data.name || "",
|
||||
recipeIngredient: data.recipeIngredient,
|
||||
};
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
else if (!recipeData.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
if (ing.referencedRecipe) {
|
||||
// Recursively flatten all ingredients in the referenced recipe
|
||||
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
||||
@@ -303,8 +306,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
}
|
||||
else {
|
||||
// Regular ingredient
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
return [{
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: {
|
||||
...ing,
|
||||
title: ing.title || parentTitle,
|
||||
@@ -313,7 +317,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
}
|
||||
}
|
||||
|
||||
recipe.recipeIngredient.forEach((ing) => {
|
||||
recipeData.recipeIngredient.forEach((ing) => {
|
||||
const flattened = flattenRecipeIngredients(ing, "");
|
||||
shoppingListIngredients.push(...flattened);
|
||||
});
|
||||
@@ -343,7 +347,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
@@ -357,9 +361,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
recipeId: recipeData.id,
|
||||
recipeName: recipeData.name,
|
||||
recipeScale: recipeData.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
@@ -368,7 +372,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<v-text-field
|
||||
id="arrow-search"
|
||||
@@ -32,9 +33,8 @@
|
||||
|
||||
<v-btn
|
||||
v-if="$vuetify.display.xs"
|
||||
icon
|
||||
size="x-small"
|
||||
class="rounded-circle"
|
||||
light
|
||||
@click="dialog = false"
|
||||
>
|
||||
<v-icon>
|
||||
@@ -87,7 +87,7 @@ const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(dialog, (val) => {
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
|
||||
function open() {
|
||||
|
||||
@@ -119,10 +119,10 @@ whenever(
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
|
||||
@@ -34,11 +34,11 @@ import { useLazyRecipes } from "~/composables/recipes";
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, RecipeExplorerPageSearch },
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
color="primary"
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
@@ -140,13 +141,13 @@ const emit = defineEmits<{
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const showRandomLoading = ref(false);
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
|
||||
@@ -81,11 +81,11 @@ import {
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
@@ -101,4 +101,14 @@ const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(gro
|
||||
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
|
||||
watch(
|
||||
households,
|
||||
() => {
|
||||
// if exactly one household exists, then we shouldn't be filtering by household
|
||||
if (households.value.length == 1) {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
|
||||
|
||||
async function toggleFavorite() {
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
if (!$auth.user.value) return;
|
||||
if (!auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@
|
||||
cols="12"
|
||||
class="flex-grow-0 flex-shrink-0"
|
||||
>
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
v-model="model.quantity"
|
||||
variant="solo"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
hide-details
|
||||
control-variant="stacked"
|
||||
inset
|
||||
density="compact"
|
||||
type="number"
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
@@ -38,7 +41,7 @@
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-number-input>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
@@ -55,8 +58,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredUnits"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -114,8 +117,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredFoods"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -173,7 +176,6 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="search.data.value || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('search.type-to-search')"
|
||||
@@ -224,11 +226,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
@@ -340,8 +342,8 @@ const btns = computed(() => {
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
@@ -352,8 +354,8 @@ async function createAssignFood() {
|
||||
|
||||
// Recipes
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
@@ -372,8 +374,8 @@ watch(loading, (val) => {
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
@@ -427,9 +429,6 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient?: RecipeIngredient;
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
@@ -44,8 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
|
||||
@@ -17,15 +17,13 @@
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<template v-if="!isCookMode">
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
@@ -54,7 +52,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
@@ -68,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
if (!auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
// Note: auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
|
||||
@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
|
||||
const attrs = computed(() => {
|
||||
return props.small
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
v-for="(item, key, index) in modelValue"
|
||||
:key="index"
|
||||
>
|
||||
<v-text-field
|
||||
density="compact"
|
||||
<v-number-input
|
||||
:model-value="modelValue[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
density="compact"
|
||||
autocomplete="off"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
:min="0"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,9 +162,9 @@ const state = reactive({
|
||||
},
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
@@ -166,6 +165,15 @@ const items = computed<any[]>(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="discardDialog"
|
||||
:title="$t('general.discard-changes')"
|
||||
color="warning"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.discard-changes-description") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipePageParseDialog
|
||||
:model-value="isParsing"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
@@ -15,6 +28,7 @@
|
||||
:landscape="landscape"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
@close="closeEditor"
|
||||
/>
|
||||
<RecipeJsonEditor
|
||||
v-if="isEditJSON"
|
||||
@@ -174,6 +188,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
|
||||
@@ -205,12 +220,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const display = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
|
||||
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
@@ -231,26 +245,68 @@ const notLinkedIngredients = computed(() => {
|
||||
* and prompts the user to save if they have unsaved changes.
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
const discardDialog = ref(false);
|
||||
const pendingRoute = ref<RouteLocationNormalized | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
await until(recipe.value).not.toBeNull();
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
|
||||
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
|
||||
const save = window.confirm(i18n.t("general.unsaved-changes"));
|
||||
|
||||
if (save) {
|
||||
await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
}
|
||||
function hasUnsavedChanges(): boolean {
|
||||
if (originalRecipe.value === null) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(recipe.value) !== JSON.stringify(originalRecipe.value);
|
||||
}
|
||||
|
||||
function restoreOriginalRecipe() {
|
||||
if (originalRecipe.value) {
|
||||
recipe.value = deepCopy(originalRecipe.value) as NoUndefinedField<Recipe>;
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (hasUnsavedChanges()) {
|
||||
pendingRoute.value = null;
|
||||
discardDialog.value = true;
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
restoreOriginalRecipe();
|
||||
discardDialog.value = false;
|
||||
|
||||
if (pendingRoute.value) {
|
||||
const destination = pendingRoute.value;
|
||||
pendingRoute.value = null;
|
||||
router.push(destination);
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDiscard() {
|
||||
discardDialog.value = false;
|
||||
pendingRoute.value = null;
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (isEditMode.value && hasUnsavedChanges()) {
|
||||
pendingRoute.value = to;
|
||||
discardDialog.value = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode();
|
||||
|
||||
clearPageState(recipe.value.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeInstructions.some(
|
||||
@@ -300,6 +356,8 @@ async function saveRecipe() {
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
// Update the snapshot after successful save
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
class="ml-auto mt-n7 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@close="$emit('close')"
|
||||
@json="toggleEditMode()"
|
||||
@edit="setMode(PageMode.EDIT)"
|
||||
@save="$emit('save')"
|
||||
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
defineEmits(["save", "delete"]);
|
||||
defineEmits(["save", "delete", "print", "close"]);
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
@@ -11,27 +11,27 @@
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeServings"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeServings"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.servings')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeServings')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeServings = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeYieldQuantity"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeYieldQuantity"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.yield')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeYieldQuantity = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
@@ -85,37 +85,4 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeServings;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value[property] = number;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -431,6 +431,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
|
||||
const dialog = ref(false);
|
||||
const disabledSteps = ref<number[]>([]);
|
||||
@@ -581,7 +582,7 @@ function setUsedIngredients() {
|
||||
watch(activeRefs, () => setUsedIngredients());
|
||||
|
||||
function autoSetReferences() {
|
||||
useExtractIngredientReferences(
|
||||
extractIngredientReferences(
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
|
||||
@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
|
||||
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
@@ -208,6 +208,8 @@ const props = defineProps<{
|
||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||
}>();
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
|
||||
@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
@@ -362,6 +362,8 @@ const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.scale);
|
||||
}
|
||||
|
||||
@@ -65,13 +65,13 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
:model-value="yieldQuantity"
|
||||
type="number"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
variant="underlined"
|
||||
hide-spin-buttons
|
||||
@update:model-value="recalculateScale(parseFloat($event) || 0)"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recalculateScale($event || 0)"
|
||||
/>
|
||||
<v-tooltip
|
||||
location="end"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:title="$t('recipe.edit-timeline-event')"
|
||||
:icon="$globals.icons.edit"
|
||||
can-submit
|
||||
disable-submit-on-enter
|
||||
:submit-text="$t('general.save')"
|
||||
@submit="submitEdit"
|
||||
>
|
||||
@@ -38,7 +39,6 @@
|
||||
:nudge-top="props.menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="!props.useMobileFormat"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: btnProps }">
|
||||
@@ -98,7 +98,6 @@ const props = defineProps<{
|
||||
color?: string;
|
||||
event: RecipeTimelineEventOut;
|
||||
menuIcon?: string | null;
|
||||
useMobileFormat?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["delete", "update"]);
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<v-card width="400">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="state.search"
|
||||
v-memo="[state.search]"
|
||||
v-model="searchInput"
|
||||
v-memo="[searchInput]"
|
||||
class="mb-2"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@@ -37,21 +37,29 @@
|
||||
:label="$t('search.search')"
|
||||
clearable
|
||||
/>
|
||||
<div class="d-flex py-4 px-1">
|
||||
<v-switch
|
||||
<div />
|
||||
<div class="d-flex flex-wrap py-4 px-1 align-center">
|
||||
<v-btn-toggle
|
||||
v-if="requireAll != undefined"
|
||||
v-model="requireAllValue"
|
||||
v-model="combinator"
|
||||
mandatory
|
||||
density="compact"
|
||||
hide-details
|
||||
class="my-auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
|
||||
/>
|
||||
class="my-1"
|
||||
>
|
||||
<v-btn value="hasAll">
|
||||
{{ $t('search.has-all') }}
|
||||
</v-btn>
|
||||
<v-btn value="hasAny">
|
||||
{{ $t('search.has-any') }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
class="mr-2 my-auto"
|
||||
class="my-1"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t("search.clear-selection") }}
|
||||
@@ -138,17 +146,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import type { ISearchableItem } from "~/composables/use-search";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => SelectableItem[],
|
||||
type: Array as () => ISearchableItem[],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
@@ -167,23 +171,22 @@ export default defineNuxtComponent({
|
||||
emits: ["update:requireAll", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
search: "",
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use shallowRef for better performance with arrays
|
||||
const debouncedSearch = shallowRef("");
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
|
||||
|
||||
const requireAllValue = computed({
|
||||
get: () => props.requireAll,
|
||||
const combinator = computed({
|
||||
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
||||
set: (value) => {
|
||||
context.emit("update:requireAll", value);
|
||||
context.emit("update:requireAll", value === "hasAll");
|
||||
},
|
||||
});
|
||||
|
||||
// Use shallowRef to prevent deep reactivity on large arrays
|
||||
const selected = computed({
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
get: () => props.modelValue as ISearchableItem[],
|
||||
set: (value) => {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
@@ -196,44 +199,12 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => state.search,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const items = props.items;
|
||||
const search = debouncedSearch.value;
|
||||
|
||||
if (!search || search.length < 2) { // Only filter after 2 characters
|
||||
return items;
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return items.filter(item => item.name.toLowerCase().includes(searchLower));
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selected.value.length);
|
||||
const selectedIds = computed(() => {
|
||||
return new Set(selected.value.map(item => item.id));
|
||||
});
|
||||
|
||||
const handleCheckboxClick = (item: SelectableItem) => {
|
||||
const currentSelection = selected.value;
|
||||
const isSelected = selectedIds.value.has(item.id);
|
||||
|
||||
if (isSelected) {
|
||||
selected.value = currentSelection.filter(i => i.id !== item.id);
|
||||
}
|
||||
else {
|
||||
selected.value = [...currentSelection, item];
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadioClick = (item: SelectableItem) => {
|
||||
const handleRadioClick = (item: ISearchableItem) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
}
|
||||
@@ -242,18 +213,18 @@ export default defineNuxtComponent({
|
||||
function clearSelection() {
|
||||
selected.value = [];
|
||||
selectedRadio.value = null;
|
||||
state.search = "";
|
||||
searchInput.value = "";
|
||||
}
|
||||
|
||||
return {
|
||||
requireAllValue,
|
||||
combinator,
|
||||
state,
|
||||
searchInput,
|
||||
selected,
|
||||
selectedRadio,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@change="$emit('checked', listItem)"
|
||||
@click="() => {
|
||||
listItem.checked = !listItem.checked
|
||||
$emit('checked', listItem)
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
<v-card-text class="pb-3 pt-1">
|
||||
<div class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div>
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
<v-number-input
|
||||
v-model="listItem.quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="0"
|
||||
:precision="null"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
style="width: 100px;"
|
||||
/>
|
||||
</div>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
@@ -47,25 +56,6 @@
|
||||
width="250"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-menu
|
||||
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
||||
open-on-hover
|
||||
offset-y
|
||||
start
|
||||
top
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-card max-width="350px" class="left-warning-border">
|
||||
<v-card-text>
|
||||
{{ $t("shopping-list.linked-item-warning") }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
@@ -158,6 +148,15 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue.quantity,
|
||||
() => {
|
||||
if (!props.modelValue.quantity) {
|
||||
listItem.value.quantity = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue.food,
|
||||
(newFood) => {
|
||||
|
||||
@@ -62,15 +62,15 @@ export default defineNuxtComponent({
|
||||
error: false,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { store: users } = useUserStore();
|
||||
const user = computed(() => {
|
||||
return users.value.find(user => user.id === props.userId);
|
||||
});
|
||||
|
||||
const imageURL = computed(() => {
|
||||
// Note: $auth.user is a ref now
|
||||
const authUser = $auth.user.value;
|
||||
// Note: auth.user is a ref now
|
||||
const authUser = auth.user.value;
|
||||
const key = authUser?.cacheKey ?? "";
|
||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||
});
|
||||
|
||||
@@ -102,9 +102,9 @@ export default defineNuxtComponent({
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
|
||||
@@ -106,11 +106,11 @@ export default defineNuxtComponent({
|
||||
const i18n = useI18n();
|
||||
const { $appInfo, $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const ownCookbookStore = useCookbookStore(i18n);
|
||||
@@ -152,7 +152,7 @@ export default defineNuxtComponent({
|
||||
};
|
||||
}
|
||||
|
||||
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
|
||||
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
|
||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||
if (!cookbooks.value?.length) {
|
||||
return [];
|
||||
@@ -187,7 +187,7 @@ export default defineNuxtComponent({
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -97,10 +97,10 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { loggedIn } = useLoggedInState();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const { xs, smAndUp } = useDisplay();
|
||||
|
||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $auth.signOut("/login?direct=1");
|
||||
await auth.signOut("/login?direct=1");
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
@@ -149,6 +149,6 @@ export default defineNuxtComponent({
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
z-index: 1010 !important;
|
||||
z-index: 2010 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -168,13 +168,13 @@ export default defineNuxtComponent({
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const canManage = computed(() => $auth.user.value?.canManage);
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const canManage = computed(() => auth.user.value?.canManage);
|
||||
|
||||
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
@@ -217,7 +217,7 @@ export default defineNuxtComponent({
|
||||
isAdmin,
|
||||
canManage,
|
||||
isOwnGroup,
|
||||
sessionUser: $auth.user,
|
||||
sessionUser: auth.user,
|
||||
toggleDark,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
export default defineNuxtComponent({
|
||||
setup(_, ctx) {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
const r = $auth.user.value?.advanced || false;
|
||||
const r = auth.user.value?.advanced || false;
|
||||
|
||||
return () => {
|
||||
return r ? ctx.slots.default?.() : null;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
open-on-hover
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
v-model:search="searchInput"
|
||||
item-title="name"
|
||||
return-object
|
||||
:items="items"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredItems"
|
||||
:prepend-icon="icon || $globals.icons.tags"
|
||||
auto-select-first
|
||||
clearable
|
||||
color="primary"
|
||||
hide-details
|
||||
:custom-filter="() => true"
|
||||
@keyup.enter="emitCreate"
|
||||
>
|
||||
<template
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
@@ -85,7 +85,10 @@ export default defineNuxtComponent({
|
||||
emits: ["update:modelValue", "update:item-id", "create"],
|
||||
setup(props, context) {
|
||||
const autocompleteRef = ref<HTMLInputElement>();
|
||||
const searchInput = ref("");
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
|
||||
|
||||
const itemIdVal = computed({
|
||||
get: () => {
|
||||
return props.itemId || undefined;
|
||||
@@ -123,8 +126,8 @@ export default defineNuxtComponent({
|
||||
itemVal,
|
||||
itemIdVal,
|
||||
searchInput,
|
||||
filteredItems,
|
||||
emitCreate,
|
||||
normalizeFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="max-width: 60px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model.number="quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="min"
|
||||
:max="max"
|
||||
type="number"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
name: "VInputNumber",
|
||||
props: {
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 9999,
|
||||
},
|
||||
rules: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const quantity = computed({
|
||||
get: () => {
|
||||
return Number(props.modelValue);
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
quantity,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -7,6 +7,10 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
source: {
|
||||
@@ -15,26 +19,42 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
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, {
|
||||
// List based on
|
||||
// https://support.zendesk.com/hc/en-us/articles/4408824584602-Allowing-unsafe-HTML-in-help-center-articles
|
||||
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",
|
||||
],
|
||||
ADD_ATTR: [
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
],
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
58
frontend/composables/partials/use-actions-factory.test.ts
Normal file
58
frontend/composables/partials/use-actions-factory.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useStoreActions } from "./use-actions-factory";
|
||||
import type { BaseCRUDAPI } from "~/lib/api/base/base-clients";
|
||||
|
||||
describe("useStoreActions", () => {
|
||||
const mockApi = {
|
||||
getAll: vi.fn(),
|
||||
createOne: vi.fn(),
|
||||
updateOne: vi.fn(),
|
||||
deleteOne: vi.fn(),
|
||||
} as unknown as BaseCRUDAPI<unknown, unknown, unknown>;
|
||||
|
||||
const mockStore = ref([]);
|
||||
const mockLoading = ref(false);
|
||||
|
||||
test("deleteMany calls deleteOne for each ID and refreshes once", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } });
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const ids = ["1", "2", "3"];
|
||||
await actions.deleteMany(ids);
|
||||
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledTimes(3);
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("1");
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("2");
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("3");
|
||||
|
||||
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deleteMany handles empty array", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn();
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
await actions.deleteMany([]);
|
||||
|
||||
expect(mockApi.deleteOne).not.toHaveBeenCalled();
|
||||
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deleteMany sets loading state", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn().mockResolvedValue({});
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const promise = actions.deleteMany(["1"]);
|
||||
expect(mockLoading.value).toBe(true);
|
||||
|
||||
await promise;
|
||||
expect(mockLoading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
|
||||
createOne(createData: T): Promise<T | null>;
|
||||
updateOne(updateData: T): Promise<T | null>;
|
||||
deleteOne(id: string | number): Promise<T | null>;
|
||||
deleteMany(ids: (string | number)[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,11 +166,23 @@ export function useStoreActions<T extends BoundT>(
|
||||
return response?.data || null;
|
||||
}
|
||||
|
||||
async function deleteMany(ids: (string | number)[]) {
|
||||
loading.value = true;
|
||||
for (const id of ids) {
|
||||
await api.deleteOne(id);
|
||||
}
|
||||
if (allRef?.value) {
|
||||
await refresh();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
getAll,
|
||||
refresh,
|
||||
createOne,
|
||||
updateOne,
|
||||
deleteOne,
|
||||
deleteMany,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,14 +165,14 @@ export function clearPageState(slug: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* usePageUser provides a wrapper around $auth that provides a type-safe way to
|
||||
* usePageUser provides a wrapper around auth that provides a type-safe way to
|
||||
* access the UserOut type from the context. If no user is logged in then an empty
|
||||
* object with all properties set to their zero value is returned.
|
||||
*/
|
||||
export function usePageUser(): { user: UserOut } {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
if (!$auth.user.value) {
|
||||
if (!auth.user.value) {
|
||||
return {
|
||||
user: {
|
||||
id: "",
|
||||
@@ -188,5 +188,5 @@ export function usePageUser(): { user: UserOut } {
|
||||
};
|
||||
}
|
||||
|
||||
return { user: $auth.user.value };
|
||||
return { user: auth.user.value };
|
||||
}
|
||||
|
||||
@@ -1,60 +1,82 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { useExtractIngredientReferences } from "./use-extract-ingredient-references";
|
||||
import { useLocales } from "../use-locales";
|
||||
|
||||
vi.mock("../use-locales");
|
||||
|
||||
const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
|
||||
|
||||
describe("test use extract ingredient references", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("when text empty return empty", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
|
||||
expect(result).toStrictEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when ingredient is first on a multiline, return the referenceId", () => {
|
||||
const multilineSting = "lksjdlk\nOnion";
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true);
|
||||
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when no ingredients, return empty", () => {
|
||||
const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([], [], "A sentence containing oNions");
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when an word is 2 letter of shorter, it is ignored", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
function normalize(word: string): string {
|
||||
let normalizing = word;
|
||||
@@ -18,11 +18,6 @@ function removeStartingPunctuation(word: string): string {
|
||||
return word.replace(punctuationAtBeginning, "");
|
||||
}
|
||||
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||
const searchText = parseIngredientText(ingredient);
|
||||
return searchText.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
|
||||
function isBlackListedWord(word: string) {
|
||||
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||
@@ -39,20 +34,33 @@ function isBlackListedWord(word: string) {
|
||||
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
|
||||
}
|
||||
|
||||
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
||||
const availableIngredients = recipeIngredients
|
||||
.filter(ingredient => ingredient.referenceId !== undefined)
|
||||
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
||||
export function useExtractIngredientReferences() {
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const allMatchedIngredientIds: string[] = text
|
||||
.toLowerCase()
|
||||
.split(/\s/)
|
||||
.map(normalize)
|
||||
.filter(word => word.length > 2)
|
||||
.filter(word => !isBlackListedWord(word))
|
||||
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
|
||||
.map(ingredient => ingredient.referenceId as string);
|
||||
// deduplicate
|
||||
function extractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||
const searchText = parseIngredientText(ingredient);
|
||||
return searchText.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
|
||||
return new Set<string>(allMatchedIngredientIds);
|
||||
const availableIngredients = recipeIngredients
|
||||
.filter(ingredient => ingredient.referenceId !== undefined)
|
||||
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
||||
|
||||
const allMatchedIngredientIds: string[] = text
|
||||
.toLowerCase()
|
||||
.split(/\s/)
|
||||
.map(normalize)
|
||||
.filter(word => word.length > 2)
|
||||
.filter(word => !isBlackListedWord(word))
|
||||
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
|
||||
.map(ingredient => ingredient.referenceId as string);
|
||||
// deduplicate
|
||||
|
||||
return new Set<string>(allMatchedIngredientIds);
|
||||
}
|
||||
|
||||
return {
|
||||
extractIngredientReferences,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { useFraction } from "./use-fraction";
|
||||
export { useRecipe } from "./use-recipe";
|
||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
||||
export { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { parseIngredientText } from "./use-recipe-ingredients";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useLocales } from "../use-locales";
|
||||
|
||||
vi.mock("../use-locales");
|
||||
|
||||
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
|
||||
|
||||
describe("parseIngredientText", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
({ parseIngredientText } = useIngredientTextParser());
|
||||
});
|
||||
|
||||
describe(parseIngredientText.name, () => {
|
||||
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||
quantity: 1,
|
||||
food: {
|
||||
@@ -128,4 +141,98 @@ describe(parseIngredientText.name, () => {
|
||||
|
||||
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'always' strategy uses plural food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'never' strategy never uses plural food", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "never" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
unit: undefined,
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("decimal below minimum precision shows < 0.001", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.0001,
|
||||
unit: { id: "1", name: "cup", useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< 0.001 cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< <sup>1</sup><span>⁄</span><sub>10</sub> cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator without formatting shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, false)).toEqual("< 1/10 cup salt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { useFraction } from "./use-fraction";
|
||||
import { useLocales } from "../use-locales";
|
||||
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
const FRAC_MIN_DENOM = 10;
|
||||
const DECIMAL_PRECISION = 3;
|
||||
|
||||
export function sanitizeIngredientHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
@@ -56,47 +60,90 @@ type ParsedIngredientText = {
|
||||
recipeLink?: string;
|
||||
};
|
||||
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
if (unit && !unit.fraction) {
|
||||
returnQty = Number((quantity * scale).toPrecision(3)).toString();
|
||||
}
|
||||
else {
|
||||
const fraction = frac(quantity * scale, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
returnQty += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
}
|
||||
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
|
||||
if (quantity && quantity <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
switch (pluralFoodHandling) {
|
||||
case "always":
|
||||
return true;
|
||||
case "without-unit":
|
||||
return !(quantity && hasUnit);
|
||||
case "never":
|
||||
return false;
|
||||
|
||||
default:
|
||||
// same as without-unit
|
||||
return !(quantity && hasUnit);
|
||||
}
|
||||
}
|
||||
|
||||
export function useIngredientTextParser() {
|
||||
const { locales, locale } = useLocales();
|
||||
|
||||
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||
const filteredLocales = locales.filter(lc => lc.value === locale.value);
|
||||
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
|
||||
|
||||
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
const scaledQuantity = Number((quantity * scale));
|
||||
|
||||
if (unit && !unit.fraction) {
|
||||
const minVal = 10 ** -DECIMAL_PRECISION;
|
||||
returnQty = scaledQuantity >= minVal
|
||||
? Number(scaledQuantity.toPrecision(DECIMAL_PRECISION)).toString()
|
||||
: `< ${minVal}`;
|
||||
}
|
||||
else {
|
||||
const minVal = 1 / FRAC_MIN_DENOM;
|
||||
const isUnderMinVal = !(scaledQuantity >= minVal);
|
||||
|
||||
const fraction = !isUnderMinVal ? frac(scaledQuantity, FRAC_MIN_DENOM, true) : [0, 1, FRAC_MIN_DENOM];
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
returnQty += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
if (isUnderMinVal) {
|
||||
returnQty = `< ${returnQty}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
};
|
||||
|
||||
function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
};
|
||||
|
||||
return {
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
useParsedIngredientText,
|
||||
parseIngredientText,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
}
|
||||
|
||||
@@ -5,251 +5,293 @@ export const LOCALES = [
|
||||
value: "zh-TW",
|
||||
progress: 9,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "简体中文 (Chinese simplified)",
|
||||
value: "zh-CN",
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Tiếng Việt (Vietnamese)",
|
||||
value: "vi-VN",
|
||||
progress: 1,
|
||||
progress: 2,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Українська (Ukrainian)",
|
||||
value: "uk-UA",
|
||||
progress: 99,
|
||||
progress: 83,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Türkçe (Turkish)",
|
||||
value: "tr-TR",
|
||||
progress: 36,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 67,
|
||||
progress: 61,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "српски (Serbian)",
|
||||
value: "sr-SP",
|
||||
progress: 9,
|
||||
progress: 16,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 41,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenčina (Slovak)",
|
||||
value: "sk-SK",
|
||||
progress: 46,
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 46,
|
||||
progress: 44,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 41,
|
||||
progress: 44,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Português (Portuguese)",
|
||||
value: "pt-PT",
|
||||
progress: 40,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Português do Brasil (Brazilian Portuguese)",
|
||||
value: "pt-BR",
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Polski (Polish)",
|
||||
value: "pl-PL",
|
||||
progress: 53,
|
||||
progress: 49,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 41,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 55,
|
||||
progress: 60,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Latviešu (Latvian)",
|
||||
value: "lv-LV",
|
||||
progress: 36,
|
||||
progress: 35,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Lietuvių (Lithuanian)",
|
||||
value: "lt-LT",
|
||||
progress: 27,
|
||||
progress: 30,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "한국어 (Korean)",
|
||||
value: "ko-KR",
|
||||
progress: 9,
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "日本語 (Japanese)",
|
||||
value: "ja-JP",
|
||||
progress: 36,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 47,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 44,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 47,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 29,
|
||||
progress: 30,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "עברית (Hebrew)",
|
||||
value: "he-IL",
|
||||
progress: 73,
|
||||
progress: 64,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Galego (Galician)",
|
||||
value: "gl-ES",
|
||||
progress: 39,
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 69,
|
||||
progress: 67,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français canadien (Canadian French)",
|
||||
value: "fr-CA",
|
||||
progress: 99,
|
||||
progress: 83,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Belge (Belgian)",
|
||||
value: "fr-BE",
|
||||
progress: 40,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Suomi (Finnish)",
|
||||
value: "fi-FI",
|
||||
progress: 41,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Eesti (Estonian)",
|
||||
value: "et-EE",
|
||||
progress: 47,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "American English",
|
||||
value: "en-US",
|
||||
progress: 100.0,
|
||||
progress: 100,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 44,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 97,
|
||||
progress: 85,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 52,
|
||||
progress: 65,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 42,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 38,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 49,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "العربية (Arabic)",
|
||||
value: "ar-SA",
|
||||
progress: 23,
|
||||
progress: 25,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Afrikaans (Afrikaans)",
|
||||
value: "af-ZA",
|
||||
progress: 26,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { LocaleObject } from "@nuxtjs/i18n";
|
||||
import { LOCALES } from "./available-locales";
|
||||
import { useGlobalI18n } from "../use-global-i18n";
|
||||
|
||||
export const useLocales = () => {
|
||||
const i18n = useI18n();
|
||||
const i18n = useGlobalI18n();
|
||||
const { current: vuetifyLocale } = useLocale();
|
||||
|
||||
const locale = computed<LocaleObject["code"]>({
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export const useLoggedInState = function () {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const loggedIn = computed(() => $auth.loggedIn.value);
|
||||
const loggedIn = computed(() => auth.loggedIn.value);
|
||||
const isOwnGroup = computed(() => {
|
||||
if (!route.params.groupSlug) {
|
||||
return loggedIn.value;
|
||||
}
|
||||
else {
|
||||
return loggedIn.value && $auth.user.value?.groupSlug === route.params.groupSlug;
|
||||
return loggedIn.value && auth.user.value?.groupSlug === route.params.groupSlug;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRecipeCreatePreferences } from "~/composables/use-users/preferences"
|
||||
|
||||
export interface UseNewRecipeOptionsProps {
|
||||
enableImportKeywords?: boolean;
|
||||
enableImportCategories?: boolean;
|
||||
enableStayInEditMode?: boolean;
|
||||
enableParseRecipe?: boolean;
|
||||
}
|
||||
@@ -9,6 +10,7 @@ export interface UseNewRecipeOptionsProps {
|
||||
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
const {
|
||||
enableImportKeywords = true,
|
||||
enableImportCategories = true,
|
||||
enableStayInEditMode = true,
|
||||
enableParseRecipe = true,
|
||||
} = props;
|
||||
@@ -27,6 +29,17 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
const importCategories = computed({
|
||||
get() {
|
||||
if (!enableImportCategories) return false;
|
||||
return recipeCreatePreferences.value.importCategories;
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (!enableImportCategories) return;
|
||||
recipeCreatePreferences.value.importCategories = v;
|
||||
},
|
||||
});
|
||||
|
||||
const stayInEditMode = computed({
|
||||
get() {
|
||||
if (!enableStayInEditMode) return false;
|
||||
@@ -71,6 +84,7 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
return {
|
||||
// Computed properties for the checkboxes
|
||||
importKeywordsAsTags,
|
||||
importCategories,
|
||||
stayInEditMode,
|
||||
parseRecipe,
|
||||
|
||||
@@ -79,6 +93,7 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
|
||||
// Props for conditional rendering
|
||||
enableImportKeywords,
|
||||
enableImportCategories,
|
||||
enableStayInEditMode,
|
||||
enableParseRecipe,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, PlaceholderKeyword, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface FieldLogicalOperator {
|
||||
label: string;
|
||||
@@ -11,6 +11,11 @@ export interface FieldRelationalOperator {
|
||||
value: RelationalKeyword | RelationalOperator;
|
||||
}
|
||||
|
||||
export interface FieldPlaceholderKeyword {
|
||||
label: string;
|
||||
value: PlaceholderKeyword;
|
||||
}
|
||||
|
||||
export interface OrganizerBase {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -22,6 +27,7 @@ export type FieldType
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "relativeDate"
|
||||
| RecipeOrganizer;
|
||||
|
||||
export type FieldValue
|
||||
@@ -41,8 +47,8 @@ export interface FieldDefinition {
|
||||
label: string;
|
||||
type: FieldType;
|
||||
|
||||
// only for select/organizer fields
|
||||
fieldOptions?: SelectableItem[];
|
||||
// Select/Organizer
|
||||
fieldChoices?: SelectableItem[];
|
||||
}
|
||||
|
||||
export interface Field extends FieldDefinition {
|
||||
@@ -50,10 +56,10 @@ export interface Field extends FieldDefinition {
|
||||
logicalOperator?: FieldLogicalOperator;
|
||||
value: FieldValue;
|
||||
relationalOperatorValue: FieldRelationalOperator;
|
||||
relationalOperatorOptions: FieldRelationalOperator[];
|
||||
relationalOperatorChoices: FieldRelationalOperator[];
|
||||
rightParenthesis?: string;
|
||||
|
||||
// only for select/organizer fields
|
||||
// Select/Organizer
|
||||
values: FieldValue[];
|
||||
organizers: OrganizerBase[];
|
||||
}
|
||||
@@ -161,6 +167,36 @@ export function useQueryFilterBuilder() {
|
||||
};
|
||||
});
|
||||
|
||||
const placeholderKeywords = computed<Record<PlaceholderKeyword, FieldPlaceholderKeyword>>(() => {
|
||||
const NOW = {
|
||||
label: "Now",
|
||||
value: "$NOW",
|
||||
} as FieldPlaceholderKeyword;
|
||||
|
||||
return {
|
||||
$NOW: NOW,
|
||||
};
|
||||
});
|
||||
|
||||
const relativeDateRelOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
|
||||
const ops = { ...relOps.value };
|
||||
|
||||
ops[">="] = { ...relOps.value[">="], label: i18n.t("query-filter.relational-operators.is-newer-than") };
|
||||
ops["<="] = { ...relOps.value["<="], label: i18n.t("query-filter.relational-operators.is-older-than") };
|
||||
|
||||
return ops;
|
||||
});
|
||||
|
||||
function getRelOps(fieldType: FieldType): typeof relOps | typeof relativeDateRelOps {
|
||||
switch (fieldType) {
|
||||
case "relativeDate":
|
||||
return relativeDateRelOps;
|
||||
|
||||
default:
|
||||
return relOps;
|
||||
}
|
||||
}
|
||||
|
||||
function isOrganizerType(type: FieldType): type is Organizer {
|
||||
return (
|
||||
type === Organizer.Category
|
||||
@@ -173,10 +209,14 @@ export function useQueryFilterBuilder() {
|
||||
};
|
||||
|
||||
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
|
||||
const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field;
|
||||
let operatorOptions: FieldRelationalOperator[];
|
||||
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorOptions = [
|
||||
const updatedField = {
|
||||
logicalOperator: logOps.value.AND,
|
||||
...field,
|
||||
} as Field;
|
||||
|
||||
let operatorChoices: FieldRelationalOperator[];
|
||||
if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorChoices = [
|
||||
relOps.value["IN"],
|
||||
relOps.value["NOT IN"],
|
||||
relOps.value["CONTAINS ALL"],
|
||||
@@ -185,7 +225,7 @@ export function useQueryFilterBuilder() {
|
||||
else {
|
||||
switch (updatedField.type) {
|
||||
case "string":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value["LIKE"],
|
||||
@@ -193,7 +233,7 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "number":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -203,10 +243,10 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "boolean":
|
||||
operatorOptions = [relOps.value["="]];
|
||||
operatorChoices = [relOps.value["="]];
|
||||
break;
|
||||
case "date":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -215,13 +255,20 @@ export function useQueryFilterBuilder() {
|
||||
relOps.value["<="],
|
||||
];
|
||||
break;
|
||||
case "relativeDate":
|
||||
operatorChoices = [
|
||||
// "<=" is first since "older than" is the most common operator
|
||||
relativeDateRelOps.value["<="],
|
||||
relativeDateRelOps.value[">="],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
operatorOptions = [relOps.value["="], relOps.value["<>"]];
|
||||
operatorChoices = [relOps.value["="], relOps.value["<>"]];
|
||||
}
|
||||
}
|
||||
updatedField.relationalOperatorOptions = operatorOptions;
|
||||
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorOptions[0];
|
||||
updatedField.relationalOperatorChoices = operatorChoices;
|
||||
if (!operatorChoices.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorChoices[0];
|
||||
}
|
||||
|
||||
if (resetValue) {
|
||||
@@ -271,7 +318,7 @@ export function useQueryFilterBuilder() {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (field.values?.length) {
|
||||
let val: string;
|
||||
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
|
||||
@@ -316,7 +363,8 @@ export function useQueryFilterBuilder() {
|
||||
|
||||
return {
|
||||
logOps,
|
||||
relOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
|
||||
117
frontend/composables/use-search.ts
Normal file
117
frontend/composables/use-search.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
export interface IAlias {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ISearchableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases?: IAlias[] | undefined;
|
||||
}
|
||||
|
||||
interface ISearchItemInternal extends ISearchableItem {
|
||||
aliasesText?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ISearchOptions {
|
||||
debounceMs?: number;
|
||||
maxWaitMs?: number;
|
||||
minSearchLength?: number;
|
||||
fuseOptions?: Partial<IFuseOptions<ISearchItemInternal>>;
|
||||
}
|
||||
|
||||
export function useSearch<T extends ISearchableItem>(
|
||||
items: ComputedRef<T[]> | Ref<T[]> | T[],
|
||||
options: ISearchOptions = {},
|
||||
) {
|
||||
const {
|
||||
debounceMs = 0,
|
||||
maxWaitMs = 1500,
|
||||
minSearchLength = 1,
|
||||
fuseOptions: customFuseOptions = {},
|
||||
} = options;
|
||||
|
||||
// State
|
||||
const search = ref("");
|
||||
const debouncedSearch = shallowRef("");
|
||||
|
||||
// Flatten item aliases to include as searchable text
|
||||
const searchItems = computed(() => {
|
||||
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||
return itemsArray.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
aliasesText: item.aliases ? item.aliases.map(a => a.name).join(" ") : "",
|
||||
} as ISearchItemInternal;
|
||||
});
|
||||
});
|
||||
|
||||
// Default Fuse options
|
||||
const defaultFuseOptions: IFuseOptions<ISearchItemInternal> = {
|
||||
keys: [
|
||||
{ name: "name", weight: 3 },
|
||||
{ name: "pluralName", weight: 3 },
|
||||
{ name: "abbreviation", weight: 2 },
|
||||
{ name: "pluralAbbreviation", weight: 2 },
|
||||
{ name: "aliasesText", weight: 1 },
|
||||
],
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 1,
|
||||
findAllMatches: false,
|
||||
};
|
||||
|
||||
// Merge custom options with defaults
|
||||
const fuseOptions = computed(() => ({
|
||||
...defaultFuseOptions,
|
||||
...customFuseOptions,
|
||||
}));
|
||||
|
||||
// Debounce search input
|
||||
watchDebounced(
|
||||
() => search.value,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: debounceMs, maxWait: maxWaitMs, immediate: false },
|
||||
);
|
||||
|
||||
// Initialize Fuse instance
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(searchItems.value || [], fuseOptions.value);
|
||||
});
|
||||
|
||||
// Compute filtered results
|
||||
const filtered = computed(() => {
|
||||
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||
const searchTerm = debouncedSearch.value.trim();
|
||||
|
||||
// If no search query or less than minSearchLength characters, return all items
|
||||
if (!searchTerm || searchTerm.length < minSearchLength) {
|
||||
return itemsArray;
|
||||
}
|
||||
|
||||
if (!itemsArray || itemsArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = fuse.value.search(searchTerm);
|
||||
return results.map(result => result.item as T);
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
search.value = "";
|
||||
debouncedSearch.value = "";
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
debouncedSearch,
|
||||
filtered,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
import { ActivityKey } from "~/lib/api/types/activity";
|
||||
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface UserPrintPreferences {
|
||||
imagePosition: string;
|
||||
@@ -63,6 +63,7 @@ export interface UserRecipeFinderPreferences {
|
||||
|
||||
export interface UserRecipeCreatePreferences {
|
||||
importKeywordsAsTags: boolean;
|
||||
importCategories: boolean;
|
||||
stayInEditMode: boolean;
|
||||
parseRecipe: boolean;
|
||||
}
|
||||
@@ -233,6 +234,7 @@ export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
|
||||
"recipe-create-preferences",
|
||||
{
|
||||
importKeywordsAsTags: false,
|
||||
importCategories: false,
|
||||
stayInEditMode: false,
|
||||
parseRecipe: true,
|
||||
},
|
||||
|
||||
@@ -6,10 +6,10 @@ const loading = ref(false);
|
||||
const ready = ref(false);
|
||||
|
||||
export const useUserSelfRatings = function () {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
async function refreshUserRatings() {
|
||||
if (!$auth.user.value || loading.value) {
|
||||
if (!auth.user.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useUserSelfRatings = function () {
|
||||
loading.value = true;
|
||||
const api = useUserApi();
|
||||
|
||||
const userId = $auth.user.value?.id || "";
|
||||
const userId = auth.user.value?.id || "";
|
||||
await api.users.setRating(userId, slug, rating, isFavorite);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -34,6 +34,9 @@ const normalizeLigatures = replaceAllBuilder(new Map([
|
||||
["st", "st"],
|
||||
]));
|
||||
|
||||
/**
|
||||
* @deprecated prefer fuse.js/use-search.ts
|
||||
*/
|
||||
export const normalize = (str: string) => {
|
||||
if (!str) {
|
||||
return "";
|
||||
@@ -45,6 +48,9 @@ export const normalize = (str: string) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated prefer fuse.js/use-search.ts
|
||||
*/
|
||||
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
|
||||
const normalizedValue = normalize(value);
|
||||
const normalizeQuery = normalize(query);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { RequestResponse } from "~/lib/api/types/non-generated";
|
||||
import type { ValidationResponse } from "~/lib/api/types/response";
|
||||
import { required, email, whitespace, url, minLength, maxLength } from "~/lib/validators";
|
||||
import { required, email, whitespace, url, urlOptional, minLength, maxLength } from "~/lib/validators";
|
||||
|
||||
export const validators = {
|
||||
required,
|
||||
email,
|
||||
whitespace,
|
||||
url,
|
||||
urlOptional,
|
||||
minLength,
|
||||
maxLength,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"about-mealie": "Meer oor Mealie",
|
||||
"api-docs": "API Dokumentasie",
|
||||
"api-port": "API Poort",
|
||||
"application-mode": "Applikasie modues",
|
||||
"application-mode": "Applikasie Modus",
|
||||
"database-type": "Databasis Tipe",
|
||||
"database-url": "Databasis URL",
|
||||
"default-group": "Standaard groep",
|
||||
"default-group": "Standaard Groep",
|
||||
"default-household": "Default Household",
|
||||
"demo": "Demonstrasie",
|
||||
"demo-status": "Demonstrasie Status",
|
||||
@@ -65,7 +65,7 @@
|
||||
"something-went-wrong": "Iets het verkeerd geloop!",
|
||||
"subscribed-events": "Ingetekende Gebeure",
|
||||
"test-message-sent": "Toets Boodskap Gestuur",
|
||||
"message-sent": "Message Sent",
|
||||
"message-sent": "Boodskap Gestuur",
|
||||
"new-notification": "Nuwe kennisgewing",
|
||||
"event-notifiers": "Gebeurteniskennisgewers",
|
||||
"apprise-url-skipped-if-blank": "Apprise URL (oorgeslaan indien leeg)",
|
||||
@@ -84,12 +84,12 @@
|
||||
"label-events": "Label Events"
|
||||
},
|
||||
"general": {
|
||||
"add": "Add",
|
||||
"add": "Voeg by",
|
||||
"cancel": "Kanselleer",
|
||||
"clear": "Maak skoon",
|
||||
"close": "Maak toe",
|
||||
"confirm": "Bevestig",
|
||||
"confirm-how-does-everything-look": "How does everything look?",
|
||||
"confirm-how-does-everything-look": "Hoe lyk alles?",
|
||||
"confirm-delete-generic": "Is jy seker jy wil dit uitvee?",
|
||||
"copied_message": "Gekopieër!",
|
||||
"create": "Skep",
|
||||
@@ -120,9 +120,9 @@
|
||||
"json": "JSON",
|
||||
"keyword": "Sleutelwoord",
|
||||
"link-copied": "Skakel gekopieer",
|
||||
"loading": "Loading",
|
||||
"loading": "Laai tans",
|
||||
"loading-events": "Besig om gebeurtenisse te laai",
|
||||
"loading-recipe": "Loading recipe...",
|
||||
"loading-recipe": "Laai tans resep...",
|
||||
"loading-ocr-data": "Loading OCR data...",
|
||||
"loading-recipes": "Besig om resepte te laai",
|
||||
"message": "Boodskap",
|
||||
@@ -134,7 +134,7 @@
|
||||
"no-recipe-found": "Geen resep gevind nie",
|
||||
"ok": "OK",
|
||||
"options": "Opsies:",
|
||||
"plural-name": "Plural Name",
|
||||
"plural-name": "Meervoudsnaam",
|
||||
"print": "Druk",
|
||||
"print-preferences": "Drukvoorkeure",
|
||||
"random": "Willekeurig",
|
||||
@@ -148,23 +148,23 @@
|
||||
"save": "Stoor",
|
||||
"settings": "Verstellings",
|
||||
"share": "Deel",
|
||||
"show-all": "Show All",
|
||||
"show-all": "Wys Alles",
|
||||
"shuffle": "Skommel",
|
||||
"sort": "Sorteer",
|
||||
"sort-ascending": "Sort Ascending",
|
||||
"sort-descending": "Sort Descending",
|
||||
"sort-ascending": "Sorteer Oplopend",
|
||||
"sort-descending": "Sorteer Aflopend",
|
||||
"sort-alphabetically": "Alfabeties",
|
||||
"status": "Status",
|
||||
"subject": "Onderwerp",
|
||||
"submit": "Dien in",
|
||||
"success-count": "Sukses: {count}",
|
||||
"sunday": "Sondag",
|
||||
"system": "System",
|
||||
"system": "Sisteem",
|
||||
"templates": "Sjablone:",
|
||||
"test": "Toets",
|
||||
"themes": "Temas",
|
||||
"thursday": "Donderdag",
|
||||
"title": "Title",
|
||||
"title": "Titel",
|
||||
"token": "Token",
|
||||
"tuesday": "Dinsdag",
|
||||
"type": "Tipe",
|
||||
@@ -179,12 +179,12 @@
|
||||
"units": "Eenhede",
|
||||
"back": "Terug",
|
||||
"next": "Volgende",
|
||||
"start": "Start",
|
||||
"start": "Begin",
|
||||
"toggle-view": "Wissel aansig",
|
||||
"date": "Datum",
|
||||
"id": "Id",
|
||||
"owner": "Eienaar",
|
||||
"change-owner": "Change Owner",
|
||||
"change-owner": "Verander Eienaar",
|
||||
"date-added": "Datum bygevoeg",
|
||||
"none": "Geen",
|
||||
"run": "Hardloop",
|
||||
@@ -212,14 +212,16 @@
|
||||
"upload-file": "Laai dokument op",
|
||||
"created-on-date": "Geskep op: {0}",
|
||||
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
|
||||
"clipboard-copy-failure": "Failed to copy to the clipboard.",
|
||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||
"organizers": "Organizers",
|
||||
"caution": "Caution",
|
||||
"show-advanced": "Show Advanced",
|
||||
"add-field": "Add Field",
|
||||
"date-created": "Date Created",
|
||||
"date-updated": "Date Updated"
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "Kon nie kopieer na die knipbord toe nie.",
|
||||
"confirm-delete-generic-items": "Is jy seker jy wil die volgende items verwyder?",
|
||||
"organizers": "Organiseerders",
|
||||
"caution": "Versigtig",
|
||||
"show-advanced": "Wys uitgebreide",
|
||||
"add-field": "Voeg veld by",
|
||||
"date-created": "Datum Geskep",
|
||||
"date-updated": "Datum Opgedateer"
|
||||
},
|
||||
"group": {
|
||||
"are-you-sure-you-want-to-delete-the-group": "Is jy seker jy wil <b>{groupName}<b/> uitvee?",
|
||||
@@ -326,11 +328,11 @@
|
||||
"mealplan-households-description": "If no household is selected, recipes can be added from any household",
|
||||
"any-category": "Any Category",
|
||||
"any-tag": "Any Tag",
|
||||
"any-household": "Any Household",
|
||||
"any-household": "Enide Huishouding",
|
||||
"no-meal-plan-defined-yet": "Nog geen maaltydplan opgestel nie",
|
||||
"no-meal-planned-for-today": "Geen maaltyd beplan vir vandag nie",
|
||||
"numberOfDays-hint": "Number of days on page load",
|
||||
"numberOfDays-label": "Default Days",
|
||||
"numberOfDays-label": "Standaard dae",
|
||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Slegs resepte met hierdie kategorieë sal in maaltydplanne gebruik word",
|
||||
"planner": "Beplanner",
|
||||
"quick-week": "Vinnige week",
|
||||
@@ -343,8 +345,8 @@
|
||||
"lunch": "Middagete",
|
||||
"dinner": "Aandete",
|
||||
"snack": "Snack",
|
||||
"drink": "Drink",
|
||||
"dessert": "Dessert",
|
||||
"drink": "Drank",
|
||||
"dessert": "Nagereg",
|
||||
"type-any": "Enige",
|
||||
"day-any": "Enige",
|
||||
"editor": "Editor",
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "Resepreëls",
|
||||
"applies-to-all-days": "Van toepassing op alle dae",
|
||||
"applies-on-days": "Van toepassing op {0}s",
|
||||
"meal-plan-settings": "Maaltydplan verstellings"
|
||||
"meal-plan-settings": "Maaltydplan verstellings",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migrasiedata is uitgevee",
|
||||
@@ -441,10 +445,11 @@
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak jou resepdata in. Elke reël sal as 'n item in 'n lys hanteer word",
|
||||
"recipe-markup-specification": "Resep formaat spesifikasie",
|
||||
"recipe-url": "Resep URL",
|
||||
"recipe-html-or-json": "Recipe HTML or JSON",
|
||||
"recipe-html-or-json": "Resep HTML of JSON",
|
||||
"upload-a-recipe": "Laai 'n resep op",
|
||||
"upload-individual-zip-file": "Laai 'n .zip-lêer op wat vanaf 'n ander Mealie-instansie uitgevoer is.",
|
||||
"url-form-hint": "Kopieer en plak 'n skakel vanaf jou gunstelingresepwebwerf",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
|
||||
"view-scraped-data": "Bekyk opgespoorde data",
|
||||
"trim-whitespace-description": "Knip voorste en agterste witspasie sowel as leë reëls",
|
||||
"trim-prefix-description": "Knip die eerste karakter van elke reël af",
|
||||
@@ -452,8 +457,8 @@
|
||||
"import-by-url": "Voer 'n resep vanaf 'n webwerf in",
|
||||
"create-manually": "Skep 'n resep met die hand",
|
||||
"make-recipe-image": "Maak dit die prentjie vir hierdie resep",
|
||||
"add-food": "Add Food",
|
||||
"add-recipe": "Add Recipe"
|
||||
"add-food": "Voeg Voedsel",
|
||||
"add-recipe": "Voeg Resep By"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Bladsy nie gevind nie",
|
||||
@@ -520,8 +525,8 @@
|
||||
"recipe-deleted": "Resep uitgevee",
|
||||
"recipe-image": "Resep foto",
|
||||
"recipe-image-updated": "Resep foto is opgedateer",
|
||||
"delete-image": "Delete Recipe Image",
|
||||
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||
"delete-image": "Verwyder Resep Beeld",
|
||||
"delete-image-confirmation": "Is jy seker jy wil dié beeld van die resep verwyder?",
|
||||
"recipe-image-deleted": "Recipe image deleted",
|
||||
"recipe-name": "Resepnaam",
|
||||
"recipe-settings": "Resep verstellings",
|
||||
@@ -555,7 +560,7 @@
|
||||
"add-to-plan": "Voeg by plan",
|
||||
"add-to-timeline": "Voeg by tydlyn",
|
||||
"recipe-added-to-list": "Resep by lys gevoeg",
|
||||
"recipes-added-to-list": "Recipes added to list",
|
||||
"recipes-added-to-list": "Resepte toegevoeg tot lys",
|
||||
"successfully-added-to-list": "Successfully added to list",
|
||||
"recipe-added-to-mealplan": "Resep is by die maaltydplan gevoeg",
|
||||
"failed-to-add-recipes-to-list": "Failed to add recipe to list",
|
||||
@@ -568,7 +573,7 @@
|
||||
"choose-unit": "Kies 'n eenheid",
|
||||
"press-enter-to-create": "Druk Enter om te skep",
|
||||
"choose-food": "Keuse van kos",
|
||||
"choose-recipe": "Choose Recipe",
|
||||
"choose-recipe": "Kies Resep",
|
||||
"notes": "Notas",
|
||||
"toggle-section": "Wissel afdeling",
|
||||
"see-original-text": "Sien oorspronklike teks",
|
||||
@@ -639,6 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Bly in redigeer modus",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Voer vanaf zip in",
|
||||
@@ -659,7 +665,7 @@
|
||||
"report-deletion-failed": "Kon nie verslag uitvee nie",
|
||||
"recipe-debugger": "Resep debugger",
|
||||
"recipe-debugger-description": "Gryp die URL van die resep wat jy wil debug en plak dit hier. Die URL sal deur die resepskraper geskraap word en die resultate sal vertoon word. As jy nie enige data terugstuur sien nie, word die webwerf wat jy probeer skraap nie deur Mealie of sy skraperbiblioteek ondersteun nie.",
|
||||
"use-openai": "Use OpenAI",
|
||||
"use-openai": "Gebruik OpenAI",
|
||||
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
||||
"debug": "Debug",
|
||||
"tree-view": "Boomstruktuur",
|
||||
@@ -670,8 +676,8 @@
|
||||
"upload-image": "Laai prent",
|
||||
"screen-awake": "Hou die skerm aan",
|
||||
"remove-image": "Verwyder prent",
|
||||
"nextStep": "Next step",
|
||||
"recipe-actions": "Recipe Actions",
|
||||
"nextStep": "Volgende stap",
|
||||
"recipe-actions": "Resep Aksies",
|
||||
"parser": {
|
||||
"ingredient-parser": "Ingredient Parser",
|
||||
"explanation": "To use the ingredient parser, click the 'Parse All' button to start the process. Once the processed ingredients are available, you can review the items and verify that they were parsed correctly. The model's confidence score is displayed on the right of the item title. This score is an average of all the individual scores and may not always be completely accurate.",
|
||||
@@ -681,7 +687,7 @@
|
||||
"brute-parser": "Brute Parser",
|
||||
"openai-parser": "OpenAI Parser",
|
||||
"parse-all": "Parse All",
|
||||
"no-unit": "No unit",
|
||||
"no-unit": "Geen eenheid",
|
||||
"missing-unit": "Create missing unit: {unit}",
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
@@ -692,12 +698,12 @@
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
"delete-item": "Verwyder Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
"not-linked-ingredients": "Bykomende Bestanddele",
|
||||
"upload-another-image": "Upload another image",
|
||||
"upload-images": "Upload images",
|
||||
"upload-images": "Laai beelde op",
|
||||
"upload-more-images": "Upload more images",
|
||||
"set-as-cover-image": "Set as recipe cover image",
|
||||
"cover-image": "Cover image",
|
||||
@@ -889,7 +895,7 @@
|
||||
"oidc-ready": "OIDC Klar",
|
||||
"oidc-ready-error-text": "Ikke alle OIDC værdier er konfigureret. Dette kan ignoreres hvis du ikke bruger OIDC godkendelse.",
|
||||
"oidc-ready-success-text": "Krævede OIDC variabler er udfyldt.",
|
||||
"openai-ready": "OpenAI Ready",
|
||||
"openai-ready": "OpenAI Gereed",
|
||||
"openai-ready-error-text": "Not all OpenAI Values are configured. This can be ignored if you are not using OpenAI features.",
|
||||
"openai-ready-success-text": "Required OpenAI variables are all set."
|
||||
},
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "is greater than",
|
||||
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||
"is-less-than": "is less than",
|
||||
"is-less-than-or-equal-to": "is less than or equal to"
|
||||
"is-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "contains all of",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "This Field is Required",
|
||||
"invalid-email": "Email Must Be Valid",
|
||||
"invalid-url": "Must Be A Valid URL",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Must Be At Least {min} Characters",
|
||||
"max-length": "Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@
|
||||
"upload-file": "تحميل الملف",
|
||||
"created-on-date": "تم الإنشاء في {0}",
|
||||
"unsaved-changes": "لديك تغييرات غير محفوظة. هل تريد الحفظ قبل المغادرة؟ حسنًا للحفظ، قم بإلغاء تجاهل التغييرات.",
|
||||
"discard-changes": "إلغاء التغييرات",
|
||||
"discard-changes-description": "لديك تغييرات غير محفوظة. هل أنت متأكد من أنك تريد تجاهلها؟",
|
||||
"clipboard-copy-failure": "فشل في النسخ إلى الحافظة.",
|
||||
"confirm-delete-generic-items": "هل أنت متأكد أنك تريد حذف المجموعات التالية؟",
|
||||
"organizers": "المنظمون",
|
||||
@@ -342,9 +344,9 @@
|
||||
"breakfast": "الإفطار",
|
||||
"lunch": "الغداء",
|
||||
"dinner": "العشاء",
|
||||
"snack": "Snack",
|
||||
"drink": "Drink",
|
||||
"dessert": "Dessert",
|
||||
"snack": "وجبة خفيفة",
|
||||
"drink": "مشروب",
|
||||
"dessert": "حلوى",
|
||||
"type-any": "أي",
|
||||
"day-any": "أي",
|
||||
"editor": "المحرر",
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "قواعد الوصفات",
|
||||
"applies-to-all-days": "ينطبق على جميع الأيام",
|
||||
"applies-on-days": "يطبق على أيام {0}",
|
||||
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية"
|
||||
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "حذف بيانات الهجرة",
|
||||
@@ -438,22 +442,23 @@
|
||||
"github-issues": "مشاكل GitHub",
|
||||
"google-ld-json-info": "معرف Google + معلومات json",
|
||||
"must-be-a-valid-url": "يجب أن يكون عنوان URL صالحًا",
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "لصق بيانات الوصفة الخاصة بك. سيتم التعامل مع كل سطر كعنصر في قائمة",
|
||||
"recipe-markup-specification": "Recipe Markup Specification",
|
||||
"recipe-url": "رابط الوصفة",
|
||||
"recipe-html-or-json": "وصفة HTML أو JSON",
|
||||
"upload-a-recipe": "تحميل وصفة",
|
||||
"upload-individual-zip-file": "تحميل مِلَفّ zip فردي تم تصديره من مثيل Malie آخر.",
|
||||
"url-form-hint": "نسخ ولصق رابط من موقعك المفضل للوصفة",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "نسخ ولصق عنوان URL المصدر لبياناتك (اختياري)",
|
||||
"view-scraped-data": "عرض البيانات المحللة",
|
||||
"trim-whitespace-description": "قص المسافات البيضاء البادئة واللاحقة وكذلك الأسطر الفارغة",
|
||||
"trim-prefix-description": "قص الحرف الأول من كل سطر",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
||||
"split-by-numbered-line-description": "محاولات تقسيم فقرة عن طريق مطابقة أنماط '1)' أو '1.'",
|
||||
"import-by-url": "استيراد وصفة عن طريق عنوان URL",
|
||||
"create-manually": "إنشاء وصفة يدوياً",
|
||||
"make-recipe-image": "اجعل هذه صورة الوصفة",
|
||||
"add-food": "Add Food",
|
||||
"add-recipe": "Add Recipe"
|
||||
"add-food": "إضافة طعام",
|
||||
"add-recipe": "إضافة وصفة"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404: لم يتم العثور على الصفحة",
|
||||
@@ -483,7 +488,7 @@
|
||||
"comment": "أضف تعليق ",
|
||||
"comments": "التعليقات",
|
||||
"delete-confirmation": "هل انت متأكد من رغبتك بحذف هذه الوصفة؟",
|
||||
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
|
||||
"admin-delete-confirmation": "أنت على وشك حذف وصفة ليست لك استخدام أذونات المشرف. هل أنت متأكد؟",
|
||||
"delete-recipe": "حذف الوصفة",
|
||||
"description": "الوصف",
|
||||
"disable-amount": "إيقاف إظهار كميات المكونات",
|
||||
@@ -520,9 +525,9 @@
|
||||
"recipe-deleted": "تم حذف الوصفة",
|
||||
"recipe-image": "صورة الوصفة",
|
||||
"recipe-image-updated": "تم تحديث صورة الوصفة",
|
||||
"delete-image": "Delete Recipe Image",
|
||||
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||
"recipe-image-deleted": "Recipe image deleted",
|
||||
"delete-image": "حذف صورة الوصفة",
|
||||
"delete-image-confirmation": "هل أنت متأكد أنك تريد حذف صورة الوصفة هذه؟",
|
||||
"recipe-image-deleted": "تم حذف صورة الوصفة",
|
||||
"recipe-name": "اسم الوصفة",
|
||||
"recipe-settings": "إعدادات الوصفة",
|
||||
"recipe-update-failed": "فشل تحديث الوصفة",
|
||||
@@ -555,10 +560,10 @@
|
||||
"add-to-plan": "أضف إلى الخُطة",
|
||||
"add-to-timeline": "إضافة إلى الخط الزمني",
|
||||
"recipe-added-to-list": "تم إضافة الوصفة إلى القائمة",
|
||||
"recipes-added-to-list": "Recipes added to list",
|
||||
"successfully-added-to-list": "Successfully added to list",
|
||||
"recipe-added-to-mealplan": "Recipe added to mealplan",
|
||||
"failed-to-add-recipes-to-list": "Failed to add recipe to list",
|
||||
"recipes-added-to-list": "تم إضافة الوصفات إلى القائمة",
|
||||
"successfully-added-to-list": "تمت الإضافة إلى القائمة بنجاح",
|
||||
"recipe-added-to-mealplan": "تم إضافة الوصفة إلى خطة الوجبات",
|
||||
"failed-to-add-recipes-to-list": "فشل في إضافة وصفة إلى القائمة",
|
||||
"failed-to-add-recipe-to-mealplan": "فشل في إضافة الوصفة إلى خطة الوجبة",
|
||||
"failed-to-add-to-list": "فشل في الإضافة إلى القائمة",
|
||||
"yield": "العائد",
|
||||
@@ -568,13 +573,13 @@
|
||||
"choose-unit": "اختر الوحدة",
|
||||
"press-enter-to-create": "",
|
||||
"choose-food": "اختيار الطعام",
|
||||
"choose-recipe": "Choose Recipe",
|
||||
"choose-recipe": "اختر وصفة",
|
||||
"notes": "ملاحظات",
|
||||
"toggle-section": "",
|
||||
"see-original-text": "عرض النص الأصلي",
|
||||
"original-text-with-value": "النص الأصلي: {originalText}",
|
||||
"ingredient-linker": "رابط المكون",
|
||||
"unlinked": "Not linked yet",
|
||||
"unlinked": "غير مرتبط بعد",
|
||||
"linked-to-other-step": "مرتبط بخطوة أخرى",
|
||||
"auto": "تلقائي",
|
||||
"cook-mode": "وضع الطبخ",
|
||||
@@ -596,23 +601,23 @@
|
||||
"made-this": "لقد طبخت هذا",
|
||||
"how-did-it-turn-out": "كيف كانت النتيجة؟",
|
||||
"user-made-this": "{user} طبخ هذه",
|
||||
"made-for-recipe": "Made for {recipe}",
|
||||
"added-to-timeline": "Added to timeline",
|
||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||
"failed-to-update-recipe": "Failed to update recipe",
|
||||
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
|
||||
"made-for-recipe": "صُنع لـ {recipe}",
|
||||
"added-to-timeline": "تمت الإضافة إلى الجدول الزمني",
|
||||
"failed-to-add-to-timeline": "فشلت الإضافة إلى الجدول الزمني",
|
||||
"failed-to-update-recipe": "فشل تحديث الوصفة",
|
||||
"added-to-timeline-but-failed-to-add-image": "تمت الإضافة إلى الجدول الزمني، ولكن فشل في إضافة صورة",
|
||||
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
|
||||
"message-key": "مفتاح الرساله",
|
||||
"parse": "تحليل",
|
||||
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
|
||||
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
|
||||
"attach-images-hint": "إرفاق الصور عن طريق سحبها وإسقاطها في المحرر",
|
||||
"drop-image": "وضع الصورة",
|
||||
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
|
||||
"recipes-with-units-or-foods-defined-cannot-be-parsed": "Recipes with units or foods defined cannot be parsed.",
|
||||
"parse-ingredients": "تحليل المكونات",
|
||||
"edit-markdown": "تعديل Markdown",
|
||||
"recipe-creation": "إنشاء الوصفة",
|
||||
"select-one-of-the-various-ways-to-create-a-recipe": "Select one of the various ways to create a recipe",
|
||||
"select-one-of-the-various-ways-to-create-a-recipe": "اختر واحدة من الطرق المختلفة لإنشاء وصفة",
|
||||
"looking-for-migrations": "هل تبحث عن نقل المعلومات؟",
|
||||
"import-with-url": "الاستيراد باستخدام URL",
|
||||
"create-recipe": "إنشاء وصفة",
|
||||
@@ -622,14 +627,14 @@
|
||||
"create-recipe-from-an-image": "إنشاء وصفة عن طريق صورة",
|
||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||
"create-from-images": "Create from Images",
|
||||
"should-translate-description": "Translate the recipe into my language",
|
||||
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
|
||||
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
|
||||
"create-from-images": "إنشاء عن طريق صور",
|
||||
"should-translate-description": "ترجمة الوصفة إلى لغتي",
|
||||
"please-wait-image-procesing": "الرجاء الانتظار، تتم معالجة الصورة. قد يستغرق هذا بعض الوقت.",
|
||||
"please-wait-images-processing": "الرجاء الانتظار، يتم معالجة الصور. قد يستغرق هذا بعض الوقت.",
|
||||
"bulk-url-import": "الاستيراد باستخدام أكثر من URL ",
|
||||
"debug-scraper": "تصحيح أخطاء المحلل\n",
|
||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",
|
||||
"new-recipe-names-must-be-unique": "New recipe names must be unique",
|
||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "قم بإنشاء وصفة عن طريق تقديم الاسم. يجب أن يكون لجميع الوصفات أسماء فريدة.",
|
||||
"new-recipe-names-must-be-unique": "يجب أن تكون أسماء الوصفات فريدة",
|
||||
"scrape-recipe": "تحليل الوصفة",
|
||||
"scrape-recipe-description": "أضف الوصفة عن طريق الرابط. قم بتوفير عنوان الURL للموقع الذي تريد أخذ الوصفة منه، وسيحاول Mealie إستخراج الوصفة من ذلك الموقع وإضافتها إلى مجموعتك.",
|
||||
"scrape-recipe-have-a-lot-of-recipes": "هل لديك الكثير من الوصفات التي تريد أن تحللها في نفس الوقت؟",
|
||||
@@ -639,6 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "استيراد الكلمات المفتاحية الأصلية كوسوم",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "البقاء في وضع التعديل",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "استيراد من ملف Zip",
|
||||
@@ -692,15 +698,15 @@
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
"delete-item": "حذف العنصر"
|
||||
},
|
||||
"reset-servings-count": "إعادة تعيين عدد الحصص",
|
||||
"not-linked-ingredients": "مكونات إضافية",
|
||||
"upload-another-image": "Upload another image",
|
||||
"upload-images": "Upload images",
|
||||
"upload-more-images": "Upload more images",
|
||||
"set-as-cover-image": "Set as recipe cover image",
|
||||
"cover-image": "Cover image",
|
||||
"upload-another-image": "رفع صورة أخرى",
|
||||
"upload-images": "رفع الصور",
|
||||
"upload-more-images": "رفع المزيد من الصور",
|
||||
"set-as-cover-image": "تعيين كصورة غلاف الوصفة",
|
||||
"cover-image": "صورة الغلاف",
|
||||
"include-linked-recipes": "Include Linked Recipes",
|
||||
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||
"toggle-recipe": "Toggle Recipe"
|
||||
@@ -741,7 +747,7 @@
|
||||
"advanced": "الإعدادات المتقدمة",
|
||||
"auto-search": "البحث التلقائي",
|
||||
"no-results": "لم يتم العثور على نتائج",
|
||||
"type-to-search": "Type to search..."
|
||||
"type-to-search": "اكتب للبحث ..."
|
||||
},
|
||||
"settings": {
|
||||
"add-a-new-theme": "إضافة سمة جديدة",
|
||||
@@ -775,8 +781,8 @@
|
||||
"custom-pages": "الصفحات الخاصة",
|
||||
"edit-page": "تعديل الصفحة",
|
||||
"events": "الأحداث",
|
||||
"first-day-of-week": "First day of the week",
|
||||
"group-settings-updated": "Group Settings Updated",
|
||||
"first-day-of-week": "اليوم الأول من الأسبوع",
|
||||
"group-settings-updated": "تم تحديث إعدادات المجموعة",
|
||||
"homepage": {
|
||||
"all-categories": "جميع التصنيفات",
|
||||
"card-per-section": "Card Per Section",
|
||||
@@ -798,29 +804,29 @@
|
||||
"remove-existing-entries-matching-imported-entries": "Remove existing entries matching imported entries",
|
||||
"set-new-time": "تعيين وقت جديد",
|
||||
"settings-update-failed": "فشل تحديث الإعدادات",
|
||||
"settings-updated": "Settings updated",
|
||||
"settings-updated": "تم تحديث الإعدادات",
|
||||
"site-settings": "إعدادات الموقع",
|
||||
"theme": {
|
||||
"accent": "Accent",
|
||||
"accent": "لون التمييز",
|
||||
"dark": "الوضع الليلي",
|
||||
"default-to-system": "مطابقة وضع الجهاز",
|
||||
"error": "حدث خطأ",
|
||||
"error-creating-theme-see-log-file": "Error creating theme. See log file.",
|
||||
"error-deleting-theme": "Error deleting theme",
|
||||
"error-updating-theme": "Error updating theme",
|
||||
"info": "Info",
|
||||
"error-creating-theme-see-log-file": "خطأ في إنشاء السمة. راجع ملف السجل.",
|
||||
"error-deleting-theme": "خطأ في حذف السمة",
|
||||
"error-updating-theme": "خطأ في تحديث السمة",
|
||||
"info": "معلومات",
|
||||
"light": "الوضع النهاري",
|
||||
"primary": "رئيسي",
|
||||
"secondary": "ثانوي",
|
||||
"success": "Success",
|
||||
"success": "تم بنجاح",
|
||||
"switch-to-dark-mode": "التحويل إلى الوضع الليلي",
|
||||
"switch-to-light-mode": "التحويل إلى الوضع النهاري",
|
||||
"theme-deleted": "Theme deleted",
|
||||
"theme-name": "Theme Name",
|
||||
"theme-name-is-required": "Theme Name is required.",
|
||||
"theme-saved": "Theme Saved",
|
||||
"theme-updated": "Theme updated",
|
||||
"warning": "Warning",
|
||||
"theme-deleted": "تم حذف السمة",
|
||||
"theme-name": "اسم السمة",
|
||||
"theme-name-is-required": "اسم السمة مطلوب.",
|
||||
"theme-saved": "تم حفظ السمة",
|
||||
"theme-updated": "تم تحديث السمة",
|
||||
"warning": "تحذير",
|
||||
"light-mode": "الوضع النهاري",
|
||||
"dark-mode": "الوضع الليلي"
|
||||
},
|
||||
@@ -857,7 +863,7 @@
|
||||
"bug-report": "الإبلاغ عن الأخطاء البرمجية",
|
||||
"bug-report-information": "Use this information to report a bug. Providing details of your instance to developers is the best way to get your issues resolved quickly.",
|
||||
"tracker": "Tracker",
|
||||
"configuration": "Configuration",
|
||||
"configuration": "الإعدادات",
|
||||
"docker-volume": "Docker Volume",
|
||||
"docker-volume-help": "Mealie requires that the frontend container and the backend share the same docker volume or storage. This ensures that the frontend container can properly access the images and assets stored on disk.",
|
||||
"volumes-are-misconfigured": "Volumes are misconfigured.",
|
||||
@@ -1112,21 +1118,21 @@
|
||||
"seed-dialog-text": "Seed the database with common units based on your local language.",
|
||||
"combine-unit-description": "Combining the selected units will merge the Source Unit and Target Unit into a single unit. The {source-unit-will-be-deleted} and all of the references to the Source Unit will be updated to point to the Target Unit.",
|
||||
"combine-unit": "دمج الوحدة",
|
||||
"source-unit": "Source Unit",
|
||||
"target-unit": "Target Unit",
|
||||
"merging-unit-into-unit": "Merging {0} into {1}",
|
||||
"source-unit": "الوحدة المصدر",
|
||||
"target-unit": "الوحدة الهدف",
|
||||
"merging-unit-into-unit": "دمج {0} مع {1}",
|
||||
"create-unit": "إنشاء وحدة",
|
||||
"abbreviation": "Abbreviation",
|
||||
"abbreviation": "الاختصار",
|
||||
"plural-abbreviation": "Plural Abbreviation",
|
||||
"description": "Description",
|
||||
"description": "الوصف",
|
||||
"display-as-fraction": "Display as Fraction",
|
||||
"use-abbreviation": "Use Abbreviation",
|
||||
"edit-unit": "تعديل الوحدة",
|
||||
"unit-data": "بيانات الوحدة",
|
||||
"use-abbv": "Use Abbv.",
|
||||
"fraction": "Fraction",
|
||||
"example-unit-singular": "ex: Tablespoon",
|
||||
"example-unit-plural": "ex: Tablespoons",
|
||||
"example-unit-singular": "مثال: ملعقة",
|
||||
"example-unit-plural": "مثال: ملاعق",
|
||||
"example-unit-abbreviation-singular": "ex: Tbsp",
|
||||
"example-unit-abbreviation-plural": "ex: Tbsps"
|
||||
},
|
||||
@@ -1144,18 +1150,18 @@
|
||||
"the-following-recipes-selected-length-will-be-exported": "The following recipes ({0}) will be exported.",
|
||||
"settings-chosen-explanation": "Settings chosen here, excluding the locked option, will be applied to all selected recipes.",
|
||||
"selected-length-recipe-s-settings-will-be-updated": "{count} recipe(s) settings will be updated.",
|
||||
"recipe-data": "Recipe Data",
|
||||
"recipe-data": "بيانات الوصفة",
|
||||
"recipe-data-description": "استخدم هذا القسم لإدارة البيانات المرتبطة بوصفاتك. يمكنك تنفيذ العديد من الإجراءات بالجملة على وصفاتك بما في ذلك التصدير والحذف وتعيين الوسوم وتعيين التصنيفات.",
|
||||
"recipe-columns": "Recipe Columns",
|
||||
"recipe-columns": "أعمدة الوصفة",
|
||||
"data-exports-description": "This section provides links to available exports that are ready to download. These exports do expire, so be sure to grab them while they're still available.",
|
||||
"data-exports": "Data Exports",
|
||||
"data-exports": "صادرات البيانات",
|
||||
"tag": "وسم",
|
||||
"categorize": "Categorize",
|
||||
"update-settings": "Update Settings",
|
||||
"tag-recipes": "وسم الوصفات",
|
||||
"categorize-recipes": "Categorize Recipes",
|
||||
"export-recipes": "Export Recipes",
|
||||
"delete-recipes": "Delete Recipes",
|
||||
"categorize-recipes": "تصنيف الوصفات",
|
||||
"export-recipes": "تصدير الوصفات",
|
||||
"delete-recipes": "حذف الوصفات",
|
||||
"source-unit-will-be-deleted": "Source Unit will be deleted"
|
||||
},
|
||||
"recipe-actions": {
|
||||
@@ -1165,15 +1171,15 @@
|
||||
"action-type": "Action Type"
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
"manage-aliases": "إدارة الأسماء المستعارة",
|
||||
"seed-data": "Seed Data",
|
||||
"seed": "Seed",
|
||||
"data-management": "Data Management",
|
||||
"data-management-description": "Select which data set you want to make changes to.",
|
||||
"select-data": "Select Data",
|
||||
"select-language": "Select Language",
|
||||
"columns": "Columns",
|
||||
"combine": "Combine",
|
||||
"select-data": " اختر البيانات",
|
||||
"select-language": "اختر اللغة",
|
||||
"columns": "الأعمدة",
|
||||
"combine": "دمج",
|
||||
"categories": {
|
||||
"edit-category": "تعديل التصنيف",
|
||||
"new-category": "تصنيف جديد",
|
||||
@@ -1203,15 +1209,15 @@
|
||||
"account-details": "تفاصيل الحساب"
|
||||
},
|
||||
"validation": {
|
||||
"group-name-is-taken": "Group name is taken",
|
||||
"group-name-is-taken": "اسم المجموعة مأخوذ",
|
||||
"username-is-taken": "اسم المستخدم مستعمل",
|
||||
"email-is-taken": "Email is taken",
|
||||
"this-field-is-required": "This Field is Required"
|
||||
"email-is-taken": "هذا البريد الإلكتروني مأخوذ",
|
||||
"this-field-is-required": "هذا الحقل مطلوب"
|
||||
},
|
||||
"export": {
|
||||
"export": "Export",
|
||||
"file-name": "File Name",
|
||||
"size": "Size",
|
||||
"export": "تصدير",
|
||||
"file-name": "اسم الملف",
|
||||
"size": "الحجم",
|
||||
"link-expires": "Link Expires"
|
||||
},
|
||||
"recipe-share": {
|
||||
@@ -1340,12 +1346,12 @@
|
||||
"profile": {
|
||||
"welcome-user": "مرحبًا 👋، {0}!",
|
||||
"description": "Manage your profile, recipes, and group settings.",
|
||||
"invite-link": "Invite Link",
|
||||
"get-invite-link": "Get Invite Link",
|
||||
"invite-link": "رابط الدعوة",
|
||||
"get-invite-link": "الحصول على رابط الدعوة",
|
||||
"get-public-link": "Get Public Link",
|
||||
"account-summary": "Account Summary",
|
||||
"account-summary-description": "Here's a summary of your group's information.",
|
||||
"group-statistics": "Group Statistics",
|
||||
"account-summary": "ملخص الحساب",
|
||||
"account-summary-description": "إليك ملخص لمعلومات مجموعتك.",
|
||||
"group-statistics": "إحصائيات المجموعة",
|
||||
"group-statistics-description": "Your Group Statistics provide some insight how you're using Mealie.",
|
||||
"household-statistics": "Household Statistics",
|
||||
"household-statistics-description": "Your Household Statistics provide some insight how you're using Mealie.",
|
||||
@@ -1357,32 +1363,32 @@
|
||||
"user-settings-description": "إدارة تفضيلاتك، وتغيير كلمة المرور الخاصة بك، وتحديث بريدك الإلكتروني.",
|
||||
"api-tokens-description": "Manage your API Tokens for access from external applications.",
|
||||
"group-description": "These items are shared within your group. Editing one of them will change it for the whole group!",
|
||||
"group-settings": "Group Settings",
|
||||
"group-settings": "إعدادات المجموعة",
|
||||
"group-settings-description": "Manage your common group settings, like privacy settings.",
|
||||
"household-description": "These items are shared within your household. Editing one of them will change it for the whole household!",
|
||||
"household-settings": "Household Settings",
|
||||
"household-settings": "إعدادات الأسرة",
|
||||
"household-settings-description": "Manage your household settings, like mealplan and privacy settings.",
|
||||
"cookbooks-description": "إدارة مجموعة من تصنيفات الوصفات وإنشاء صفحات لها.",
|
||||
"members": "Members",
|
||||
"members": "الأعضاء",
|
||||
"members-description": "See who's in your household and manage their permissions.",
|
||||
"webhooks-description": "Setup webhooks that trigger on days that you have have mealplan scheduled.",
|
||||
"notifiers": "Notifiers",
|
||||
"notifiers-description": "Setup email and push notifications that trigger on specific events.",
|
||||
"manage-data": "Manage Data",
|
||||
"manage-data": "إدارة البيانات",
|
||||
"manage-data-description": "إدارة بيانات Mealie الخاصة بك؛ الأطعمة، الوحدات، التصنيفات، الوسوم وأكثر من ذلك.",
|
||||
"data-migrations": "Data Migrations",
|
||||
"data-migrations-description": "Migrate your existing data from other applications like Nextcloud Recipes and Chowdown.",
|
||||
"email-sent": "Email Sent",
|
||||
"error-sending-email": "Error Sending Email",
|
||||
"personal-information": "Personal Information",
|
||||
"preferences": "Preferences",
|
||||
"email-sent": "تم إرسال البريد الإلكتروني",
|
||||
"error-sending-email": "خطأ في إرسال البريد الإلكتروني",
|
||||
"personal-information": "المعلومات الشخصية",
|
||||
"preferences": "التفضيلات",
|
||||
"show-advanced-description": "Show advanced features (API Keys, Webhooks, and Data Management)",
|
||||
"back-to-profile": "Back to Profile",
|
||||
"back-to-profile": "العودة إلى الملف الشخصي",
|
||||
"looking-for-privacy-settings": "Looking for Privacy Settings?",
|
||||
"manage-your-api-tokens": "Manage Your API Tokens",
|
||||
"manage-user-profile": "إدارة الملف الشخصي للمستخدم",
|
||||
"manage-cookbooks": "إدارة كتب الطبخ",
|
||||
"manage-members": "Manage Members",
|
||||
"manage-members": "إدارة الأعضاء",
|
||||
"manage-webhooks": "Manage Webhooks",
|
||||
"manage-notifiers": "Manage Notifiers",
|
||||
"manage-data-migrations": "Manage Data Migrations"
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "أكبر من",
|
||||
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
|
||||
"is-less-than": "أقل من",
|
||||
"is-less-than-or-equal-to": "أقل من أو يساوي"
|
||||
"is-less-than-or-equal-to": "أقل من أو يساوي",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "هو",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "يحتوي على كل من",
|
||||
"is-like": "هو مثل",
|
||||
"is-not-like": "ليس مثل"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "هذا الحقل مطلوب",
|
||||
"invalid-email": "يجب أن يكون البريد الإلكتروني صالحاً",
|
||||
"invalid-url": "يجب أن يكون عنوان URL صالحًا",
|
||||
"no-whitespace": "لا يسمح باستخدام المسافات",
|
||||
"min-length": "يجب أن يكون على الأقل {min} أحرف",
|
||||
"max-length": "يجب أن يكون على الأكثر {max} أحرف"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
"sort-alphabetically": "По азбучен ред",
|
||||
"status": "състояние",
|
||||
"subject": "Относно",
|
||||
"submit": "Изпрати",
|
||||
"submit": "Потвърди",
|
||||
"success-count": "Успешни: {count}",
|
||||
"sunday": "Неделя",
|
||||
"system": "В хронологичен ред",
|
||||
@@ -212,11 +212,13 @@
|
||||
"upload-file": "Качване на файл",
|
||||
"created-on-date": "Добавена на {0}",
|
||||
"unsaved-changes": "Имате незапазени промени. Желаете ли да ги запазите преди да излезете? Натиснете Ок за запазване и Отказ за отхвърляне на промените.",
|
||||
"discard-changes": "Отхвърляне на промените",
|
||||
"discard-changes-description": "Имате незаписани промени, сигурни ли сте, че искате да ги отмените?",
|
||||
"clipboard-copy-failure": "Линкът към рецептата е копиран в клипборда.",
|
||||
"confirm-delete-generic-items": "Сигурни ли сте, че желаете да изтриете следните елементи?",
|
||||
"organizers": "Органайзер",
|
||||
"caution": "Внимание",
|
||||
"show-advanced": "Покажи разширени",
|
||||
"show-advanced": "Разширени настройки",
|
||||
"add-field": "Добави поле",
|
||||
"date-created": "Дата на създаване",
|
||||
"date-updated": "Дата на актуализация"
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "Правила на рецептата",
|
||||
"applies-to-all-days": "Прилага се за всички дни",
|
||||
"applies-on-days": "Всеки/всяка {0}",
|
||||
"meal-plan-settings": "Настройки на плана за хранене"
|
||||
"meal-plan-settings": "Настройки на плана за хранене",
|
||||
"add-all-to-list": "Добавяне на всички към списъка за пазаруване",
|
||||
"add-day-to-list": "Добавяне на ден към списъка за пазаруване"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Данните за мигриране са премахнати",
|
||||
@@ -445,6 +449,7 @@
|
||||
"upload-a-recipe": "Качи рецепта",
|
||||
"upload-individual-zip-file": "Качи като индивидуален .zip файлов формат от друга инстанция на Mealie.",
|
||||
"url-form-hint": "Копирай и постави линк от твоя любим сайт за рецепти",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "Копирайте и поставете URL адреса на източника на вашите данни (по избор)",
|
||||
"view-scraped-data": "Виж събраните данни",
|
||||
"trim-whitespace-description": "Премахни интервалите в началото и края на текста, също така и празните редове",
|
||||
"trim-prefix-description": "Премахни първия символ от всеки ред",
|
||||
@@ -639,6 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
|
||||
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
||||
"import-original-categories": "Импортиране на оригиналните категории",
|
||||
"stay-in-edit-mode": "Остани в режим на редакция",
|
||||
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
|
||||
"import-from-zip": "Импортирай от Zip",
|
||||
@@ -1177,17 +1183,17 @@
|
||||
"categories": {
|
||||
"edit-category": "Редактиране на категория",
|
||||
"new-category": "Нова категория",
|
||||
"category-data": "Категория за данните"
|
||||
"category-data": "Категории"
|
||||
},
|
||||
"tags": {
|
||||
"new-tag": "Нов етикет",
|
||||
"edit-tag": "Редакция на етикет",
|
||||
"tag-data": "Данни на етикета"
|
||||
"tag-data": "Етикети"
|
||||
},
|
||||
"tools": {
|
||||
"new-tool": "Нов инструмент",
|
||||
"edit-tool": "Редактирай инструмента",
|
||||
"tool-data": "Данни на инструмента"
|
||||
"tool-data": "Прибори"
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "е по-голямо от",
|
||||
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
|
||||
"is-less-than": "е по-малко от",
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на"
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на",
|
||||
"is-older-than": "е по-стар от",
|
||||
"is-newer-than": "е по-нов от"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "е",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "съдържа всички от",
|
||||
"is-like": "е като",
|
||||
"is-not-like": "не е като"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "преди дни|преди ден|преди дни"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Това поле е задължително",
|
||||
"invalid-email": "Email адресът трябва да бъде валиден",
|
||||
"invalid-url": "Линкът трябва да е валиден",
|
||||
"no-whitespace": "Не са позволени интервали",
|
||||
"min-length": "Трябва да съдържа поне {min} знака",
|
||||
"max-length": "Трябва да съдържа най-много {max} знака"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"new-notification": "Nova notificació",
|
||||
"event-notifiers": "Notificacions d'esdeveniments",
|
||||
"apprise-url-skipped-if-blank": "Apprise URL (si es deixa buit, s'ignorarà)",
|
||||
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
|
||||
"apprise-url-is-left-intentionally-blank": "Com que les URLs de Apprise contenen informació sensible, aquest camp es deixa intencionadament en blanc a l'editar. Si vols actualitzar la URL, per favor, introdueix-ne una nova ací, si no, deixa-ho en blanc per a mantenir la URL actual.",
|
||||
"enable-notifier": "Habilita la notificació",
|
||||
"what-events": "Què esdeveniments vols que utilitzen aquest notificador?",
|
||||
"user-events": "Esdeveniments d'usuari",
|
||||
@@ -81,7 +81,7 @@
|
||||
"category-events": "Esdeveniments de les categories",
|
||||
"when-a-new-user-joins-your-group": "Quan un nou usuari s'afegeix al grup",
|
||||
"recipe-events": "Esdeveniments de receptes",
|
||||
"label-events": "Label Events"
|
||||
"label-events": "Etiquetar Esdeveniments"
|
||||
},
|
||||
"general": {
|
||||
"add": "Afegeix",
|
||||
@@ -212,6 +212,8 @@
|
||||
"upload-file": "Puja un fitxer",
|
||||
"created-on-date": "Creat el: {0}",
|
||||
"unsaved-changes": "Tens canvis que no estan guardats. Vols guardar-los abans de sortir? Clica d'acord per guardar-los o cancel·lar per descartar els canvis.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "No s'ha pogut copiar al porta-retalls.",
|
||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||
"organizers": "Organitzadors",
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "Normes per la recepta",
|
||||
"applies-to-all-days": "Aplica a tots els dies",
|
||||
"applies-on-days": "S'aplicarà en {0}s",
|
||||
"meal-plan-settings": "Opcions de planificació de menús"
|
||||
"meal-plan-settings": "Opcions de planificació de menús",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "S'han suprimit les dades migrades",
|
||||
@@ -403,7 +407,7 @@
|
||||
"title": "Tandoor Recipes"
|
||||
},
|
||||
"cookn": {
|
||||
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||
"description-long": "Mealie pot importar receptes des de DVO Cook'n K3. Exporta un receptari o menú en el format \"Cook'n\", canvia la extensió d'exportació a .zip, i després puja el .zip a sota.",
|
||||
"title": "DVO Cook'n X3"
|
||||
},
|
||||
"recipe-data-migrations": "Migració de receptes",
|
||||
@@ -445,6 +449,7 @@
|
||||
"upload-a-recipe": "Puja una recepta",
|
||||
"upload-individual-zip-file": "Puja només un arxiu zip, exportat d'altre Mealie.",
|
||||
"url-form-hint": "Copia i enganxa l'enllaç del teu lloc web de receptes preferit",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "Copia i enganxa la URL font a les teues dades (opcional)",
|
||||
"view-scraped-data": "Visualitza les dades recuperades",
|
||||
"trim-whitespace-description": "Elimina els espais a principi i final; i elimina les línies buides",
|
||||
"trim-prefix-description": "Elimina el primer caràcter de cada línia",
|
||||
@@ -596,10 +601,10 @@
|
||||
"made-this": "Ho he fet",
|
||||
"how-did-it-turn-out": "Com ha sortit?",
|
||||
"user-made-this": "{user} ha fet això",
|
||||
"made-for-recipe": "Made for {recipe}",
|
||||
"added-to-timeline": "Added to timeline",
|
||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||
"failed-to-update-recipe": "Failed to update recipe",
|
||||
"made-for-recipe": "Fet per a {recipe}",
|
||||
"added-to-timeline": "Afegit a la cronologia",
|
||||
"failed-to-add-to-timeline": "No s'ha pogut afegir a la cronologia",
|
||||
"failed-to-update-recipe": "No s'ha pogut actualitzar la recepta",
|
||||
"added-to-timeline-but-failed-to-add-image": "S'ha afegit a la línia de temps, però no s'ha pogut afegir la imatge",
|
||||
"api-extras-description": "Els extres de receptes són una funcionalitat clau de l'API de Mealie. Permeten crear parells clau/valor JSON personalitzats dins una recepta, per referenciar-los des d'aplicacions de tercers. Pots emprar aquestes claus per proveir informació, per exemple per a desencadenar automatitzacions o missatges personlitzats per a propagar al teu dispositiu desitjat.",
|
||||
"message-key": "Clau del missatge",
|
||||
@@ -636,9 +641,10 @@
|
||||
"scrape-recipe-suggest-bulk-importer": "Prova l'importador a granel",
|
||||
"scrape-recipe-have-raw-html-or-json-data": "Teniu dades HTML o JSON pla?",
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Podeu importar directament des de les dades planes",
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"scrape-recipe-website-being-blocked": "S'està bloquejant el lloc web?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Prova important l'HTML directament.",
|
||||
"import-original-keywords-as-tags": "Importa les paraules clau originals com a tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Segueix en el mode d'edició",
|
||||
"parse-recipe-ingredients-after-import": "Analitza els ingredients de la recepta després d'importar",
|
||||
"import-from-zip": "Importa des d'un ZIP",
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "és més gran que",
|
||||
"is-greater-than-or-equal-to": "és més gran o igual a",
|
||||
"is-less-than": "és menys que",
|
||||
"is-less-than-or-equal-to": "és menor o igual a"
|
||||
"is-less-than-or-equal-to": "és menor o igual a",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "és",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "conté tots de",
|
||||
"is-like": "és com",
|
||||
"is-not-like": "no és com"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Aquest camp és obligatori",
|
||||
"invalid-email": "El correu electrònic ha de ser vàlid",
|
||||
"invalid-url": "La URL ha de ser vàlida",
|
||||
"no-whitespace": "No es permeten espais en blanc",
|
||||
"min-length": "Ha de tenir almenys {min} caràcters",
|
||||
"max-length": "Ha de tenir com a màxim {max} caràcters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"api-docs": "Dokumentace API",
|
||||
"api-port": "Port API",
|
||||
"application-mode": "Režim aplikace",
|
||||
"database-type": "Database Type",
|
||||
"database-url": "Adresa URL databáze",
|
||||
"database-type": "Typ databáze",
|
||||
"database-url": "URL databáze",
|
||||
"default-group": "Výchozí skupina",
|
||||
"default-household": "Výchozí domácnost",
|
||||
"demo": "Demo",
|
||||
@@ -102,7 +102,7 @@
|
||||
"duplicate": "Duplikovat",
|
||||
"edit": "Upravit",
|
||||
"enabled": "Povoleno",
|
||||
"exception": "Vyjimka",
|
||||
"exception": "Výjimka",
|
||||
"failed-count": "Chyba: {count}",
|
||||
"failure-uploading-file": "Nahrávání souboru se nezdařilo",
|
||||
"favorites": "Oblíbené",
|
||||
@@ -192,7 +192,7 @@
|
||||
"a-name-is-required": "Název je povinný",
|
||||
"delete-with-name": "Odstranit {name}",
|
||||
"confirm-delete-generic-with-name": "Opravdu chcete smazat {name}?",
|
||||
"confirm-delete-own-admin-account": "Prosím, vemte na vědomí, že se pokoušíte odstranit svůj vlastní účet správce! Tato akce nemůže být odvolána a trvale smaže váš účet?",
|
||||
"confirm-delete-own-admin-account": "Prosím, vezměte na vědomí, že se pokoušíte odstranit svůj vlastní účet správce! Tato akce nemůže být odvolána a trvale smaže váš účet?",
|
||||
"organizer": "Organizátor",
|
||||
"transfer": "Přenos",
|
||||
"copy": "Kopírovat",
|
||||
@@ -212,6 +212,8 @@
|
||||
"upload-file": "Nahrát soubor",
|
||||
"created-on-date": "Vytvořeno dne: {0}",
|
||||
"unsaved-changes": "Máte neuložené změny. Chcete je uložit před odchodem? Klikněte Okay pro uložení, Cancel pro smazání změn.",
|
||||
"discard-changes": "Zahodit změny",
|
||||
"discard-changes-description": "Máte neuložené změny. Určitě je chcete zahodit?",
|
||||
"clipboard-copy-failure": "Zkopírování do schránky se nezdařilo.",
|
||||
"confirm-delete-generic-items": "Opravdu chcete smazat následující položky?",
|
||||
"organizers": "Organizace",
|
||||
@@ -292,7 +294,7 @@
|
||||
"manage-households": "Spravovat domácnosti",
|
||||
"admin-household-management": "Administrátorská správa domácnosti",
|
||||
"admin-household-management-text": "Změny v této domácnosti budou okamžitě zohledněny.",
|
||||
"household-id-value": "Id domácnosti: {0}",
|
||||
"household-id-value": "ID domácnosti: {0}",
|
||||
"private-household": "Soukromá domácnost",
|
||||
"private-household-description": "Nastavení domácnosti na soukromou zakáže všechny možnosti veřejného zobrazení. To má přednost před individuálním nastavením veřejného zobrazení",
|
||||
"lock-recipe-edits-from-other-households": "Uzamknout úpravy receptů z ostatních domácností",
|
||||
@@ -342,9 +344,9 @@
|
||||
"breakfast": "Snídaně",
|
||||
"lunch": "Oběd",
|
||||
"dinner": "Večeře",
|
||||
"snack": "Snack",
|
||||
"drink": "Drink",
|
||||
"dessert": "Dessert",
|
||||
"snack": "Svačina",
|
||||
"drink": "Nápoje",
|
||||
"dessert": "Dezerty",
|
||||
"type-any": "Libovolné",
|
||||
"day-any": "Libovolný",
|
||||
"editor": "Editor",
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "Pravidla receptu",
|
||||
"applies-to-all-days": "Použije se na všechny dny",
|
||||
"applies-on-days": "Platí pro {0}",
|
||||
"meal-plan-settings": "Nastavení jídelníčku"
|
||||
"meal-plan-settings": "Nastavení jídelníčku",
|
||||
"add-all-to-list": "Přidat vše do seznamu",
|
||||
"add-day-to-list": "Přidat den do seznamu"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Data z migrace byla smazána",
|
||||
@@ -403,7 +407,7 @@
|
||||
"title": "Recepty Tandoor"
|
||||
},
|
||||
"cookn": {
|
||||
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||
"description-long": "Mealie může importovat recept z DVO Cook'n X3. Exportujte kuchařku nebo menu ve formátu \"Cook'n\", přejmenujte rozšíření exportu na .zip, poté nahrajte .zip níže.",
|
||||
"title": "DVO Cook'n X3"
|
||||
},
|
||||
"recipe-data-migrations": "Migrace dat receptů",
|
||||
@@ -435,7 +439,7 @@
|
||||
"error-details": "Pouze webové stránky obsahující Id+json nebo mikrodata mohou být přeneseny do Mealie. Většina hlavních webových stránek s recepty tuto datovou strukturu podporuje. Pokud vaše stránka nemůže být převedena ale json data jsou uvedena v logu, prosím nahlašte nám chybu do githubu a přiložte URL a data.",
|
||||
"error-title": "Vypadá to, že se nám nic nepodařilo najít",
|
||||
"from-url": "Přenést recept",
|
||||
"github-issues": "Hlášení chyb na Githubu",
|
||||
"github-issues": "Hlášení chyb na GitHubu",
|
||||
"google-ld-json-info": "Google ld+json Info",
|
||||
"must-be-a-valid-url": "Musí být validní URL",
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Vložte data receptu. Každý řádek bude považován za položku v seznamu",
|
||||
@@ -445,6 +449,7 @@
|
||||
"upload-a-recipe": "Nahrát recept",
|
||||
"upload-individual-zip-file": "Nahrát individuální .zip soubor exportovaný z jiné instance Mealie.",
|
||||
"url-form-hint": "Zkopírujte a vložte odkaz z vaší oblíbené stránky s recepty",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "Zkopírujte a vložte zdrojovou adresu URL vašich dat (volitelné)",
|
||||
"view-scraped-data": "Zobrazit scrapovaná data",
|
||||
"trim-whitespace-description": "Oříznout počáteční a koncové mezery stejně jako prázdné řádky",
|
||||
"trim-prefix-description": "Oříznout první znak z každé řádky",
|
||||
@@ -452,8 +457,8 @@
|
||||
"import-by-url": "Importovat recept podle URL",
|
||||
"create-manually": "Vytvořit recept ručně",
|
||||
"make-recipe-image": "Nastavit jako obrázek receptu",
|
||||
"add-food": "Add Food",
|
||||
"add-recipe": "Add Recipe"
|
||||
"add-food": "Přidat jídlo",
|
||||
"add-recipe": "Přidat recept"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Stránka nebyla nalezena",
|
||||
@@ -502,7 +507,7 @@
|
||||
"insert-below": "Vložit pod",
|
||||
"instructions": "Postup",
|
||||
"key-name-required": "Je vyžadován název klíče",
|
||||
"landscape-view-coming-soon": "Landscape View (Coming Soon)",
|
||||
"landscape-view-coming-soon": "Horizontální orientace (Coming Soon)",
|
||||
"milligrams": "miligramy",
|
||||
"new-key-name": "Nový název klíče",
|
||||
"no-white-space-allowed": "Prázdná místa nejsou povolena",
|
||||
@@ -520,9 +525,9 @@
|
||||
"recipe-deleted": "Recept smazán",
|
||||
"recipe-image": "Obrázek receptu",
|
||||
"recipe-image-updated": "Obrázek receptu aktualizován",
|
||||
"delete-image": "Delete Recipe Image",
|
||||
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||
"recipe-image-deleted": "Recipe image deleted",
|
||||
"delete-image": "Smazat recept",
|
||||
"delete-image-confirmation": "Opravdu chcete smazat tento recept?",
|
||||
"recipe-image-deleted": "Recept smazán",
|
||||
"recipe-name": "Název receptu",
|
||||
"recipe-settings": "Nastavení receptu",
|
||||
"recipe-update-failed": "Aktualizace receptu se nezdařila",
|
||||
@@ -549,7 +554,7 @@
|
||||
"join-the-conversation": "Připojit se ke konverzaci",
|
||||
"add-recipe-to-mealplan": "Přidat recept do jídelníčku",
|
||||
"entry-type": "Typ položky",
|
||||
"date-format-hint": "Formát data MM/DD/YYYY",
|
||||
"date-format-hint": "Formát data MM/DD/RRRR",
|
||||
"date-format-hint-yyyy-mm-dd": "Formát RRRR-MM-DD",
|
||||
"add-to-list": "Přidat na seznam",
|
||||
"add-to-plan": "Přidat do jídelníčku",
|
||||
@@ -568,7 +573,7 @@
|
||||
"choose-unit": "Vybrat jednotku",
|
||||
"press-enter-to-create": "Stiskněte enter pro vytvoření",
|
||||
"choose-food": "Zvolte jídlo",
|
||||
"choose-recipe": "Choose Recipe",
|
||||
"choose-recipe": "Vybrat recept",
|
||||
"notes": "Poznámky",
|
||||
"toggle-section": "Přidat/odebrat název sekce",
|
||||
"see-original-text": "Zobrazit původní text",
|
||||
@@ -596,7 +601,7 @@
|
||||
"made-this": "Toto jsem uvařil",
|
||||
"how-did-it-turn-out": "Jak to dopadlo?",
|
||||
"user-made-this": "{user} udělal toto",
|
||||
"made-for-recipe": "Made for {recipe}",
|
||||
"made-for-recipe": "Vytvořeno pro {recipe}",
|
||||
"added-to-timeline": "Přidáno na časovou osu",
|
||||
"failed-to-add-to-timeline": "Přidání na časovou osu selhalo",
|
||||
"failed-to-update-recipe": "Aktualizace receptu selhala",
|
||||
@@ -631,16 +636,17 @@
|
||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Vytvořte recept zadáním názvu. Všechny recepty musí mít jedinečná jména.",
|
||||
"new-recipe-names-must-be-unique": "Názvy receptů musí být jedinečné",
|
||||
"scrape-recipe": "Zpracovat recept",
|
||||
"scrape-recipe-description": "Zpracovat recept na url. Uveďte adresu url pro str8nku, kterou chcete zpracovat a Mealie se pokusí zpracovat recept z tohoto webu a přidat jej do vaší sbírky.",
|
||||
"scrape-recipe-description": "Zpracovat recept z URL. Uveďte adresu URL pro stránku, kterou chcete zpracovat a Mealie se pokusí zpracovat recept z tohoto webu a přidat jej do vaší sbírky.",
|
||||
"scrape-recipe-have-a-lot-of-recipes": "Máte spoustu receptů, které chcete zpracovat najednou?",
|
||||
"scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import",
|
||||
"scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?",
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"scrape-recipe-website-being-blocked": "Webové stránky jsou blokovány?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Zkuste namísto toho importovat raw HTML.",
|
||||
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
|
||||
"import-original-categories": "Importovat původní kategorie",
|
||||
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"parse-recipe-ingredients-after-import": "Po importu analyzovat ingredience receptu",
|
||||
"import-from-zip": "Importovat ze zipu",
|
||||
"import-from-zip-description": "Importovat jeden recept, který byl exportován z jiné instance Mealie.",
|
||||
"import-from-html-or-json": "Importovat z HTML nebo JSON",
|
||||
@@ -687,12 +693,12 @@
|
||||
"this-unit-could-not-be-parsed-automatically": "Tuto jednotku nelze analyzovat automaticky",
|
||||
"this-food-could-not-be-parsed-automatically": "Toto jídlo nelze analyzovat automaticky",
|
||||
"no-food": "Žádné jídlo",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
"review-parsed-ingredients": "Zkontrolovat analyzované ingredience",
|
||||
"confidence-score": "Skóre spolehlivosti",
|
||||
"ingredient-parser-description": "Vaše suroviny byly úspěšně analyzovány. Prosím zkontrolujte ingredience, o kterých si nejsme jisti.",
|
||||
"ingredient-parser-final-review-description": "Jakmile budou všechny ingredience zkontrolovány, budete mít ještě jednu šanci zkontrolovat všechny ingredience před použitím změn ve vašem receptu.",
|
||||
"add-text-as-alias-for-item": "Přidat \"{text}\" jako alias pro {item}",
|
||||
"delete-item": "Odstranit položku"
|
||||
},
|
||||
"reset-servings-count": "Resetovat počet porcí",
|
||||
"not-linked-ingredients": "Další ingredience",
|
||||
@@ -701,9 +707,9 @@
|
||||
"upload-more-images": "Nahrát více obrázků",
|
||||
"set-as-cover-image": "Nastavit recept jako úvodní obrázek",
|
||||
"cover-image": "Úvodní obrázek",
|
||||
"include-linked-recipes": "Include Linked Recipes",
|
||||
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||
"toggle-recipe": "Toggle Recipe"
|
||||
"include-linked-recipes": "Zahrnout připojené recepty",
|
||||
"include-linked-recipe-ingredients": "Zahrnout připojené ingredience",
|
||||
"toggle-recipe": "Přepnout na recept/jídlo"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "Vyhledávač receptů",
|
||||
@@ -741,7 +747,7 @@
|
||||
"advanced": "Pokročilé",
|
||||
"auto-search": "Automatické vyhledávání",
|
||||
"no-results": "Nebyly nalezeny žádné výsledky",
|
||||
"type-to-search": "Type to search..."
|
||||
"type-to-search": "Zadejte hledaný výraz..."
|
||||
},
|
||||
"settings": {
|
||||
"add-a-new-theme": "Přidat nový motiv",
|
||||
@@ -753,7 +759,7 @@
|
||||
"restore-success": "Obnovení bylo úspěšné",
|
||||
"restore-fail": "Obnovení se nezdařilo. Pro více informací zkontrolujte protokoly serveru",
|
||||
"backup-tag": "Štítek zálohy",
|
||||
"create-heading": "Create a Backup",
|
||||
"create-heading": "Vytvořit zálohy",
|
||||
"delete-backup": "Smazat zálohu",
|
||||
"error-creating-backup-see-log-file": "Chyba při vytváření zálohy. Viz log soubor",
|
||||
"full-backup": "Úplná záloha",
|
||||
@@ -1041,7 +1047,7 @@
|
||||
"users-header": "UŽIVATELÉ",
|
||||
"users": "Uživatelé",
|
||||
"user-not-found": "Uživatel nebyl nalezen",
|
||||
"webhook-time": "Čas Webhooku",
|
||||
"webhook-time": "Čas webhooku",
|
||||
"webhooks-enabled": "Povolené webhooky",
|
||||
"you-are-not-allowed-to-create-a-user": "Nemáte oprávnění k vytvoření uživatele",
|
||||
"you-are-not-allowed-to-delete-this-user": "Nemáte oprávnění k odstranění tohoto uživatele",
|
||||
@@ -1067,7 +1073,7 @@
|
||||
"user-details": "Detaily uživatele",
|
||||
"user-name": "Uživatelské jméno",
|
||||
"authentication-method": "Metoda ověření",
|
||||
"authentication-method-hint": "This specifies how a user will authenticate with Mealie. If you're not sure, choose 'Mealie",
|
||||
"authentication-method-hint": "Toto specifikuje, jak se uživatel přihlásí do Mealie. Pokud si nejste jistí, vyberte 'Mealie'",
|
||||
"permissions": "Oprávnění",
|
||||
"administrator": "Správce",
|
||||
"user-can-invite-other-to-group": "Uživatel může pozvat ostatní do skupiny",
|
||||
@@ -1076,12 +1082,12 @@
|
||||
"user-can-organize-group-data": "Uživatel může organizovat data skupiny",
|
||||
"enable-advanced-features": "Povolit pokročilé funkce",
|
||||
"it-looks-like-this-is-your-first-time-logging-in": "Vypadá to, že toto je vaše první přihlášení.",
|
||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Už to nechcete vidět? Nezapomeňte si změnit svůj e-mail v uživatelském nastavení!",
|
||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Už toto nechcete vidět? Nezapomeňte si změnit svůj e-mail v uživatelském nastavení!",
|
||||
"forgot-password": "Zapomenuté heslo",
|
||||
"forgot-password-text": "Zadejte prosím svou e-mailovou adresu a my vám zašleme odkaz pro obnovení hesla.",
|
||||
"changes-reflected-immediately": "Změny tohoto uživatele budou okamžitě zohledněny.",
|
||||
"default-activity": "Default Activity",
|
||||
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
|
||||
"default-activity": "Výchozí aktivita",
|
||||
"default-activity-hint": "Vyberte stránku, na kterou chcete přejít po přihlášení z tohoto zařízení"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "přeloženo",
|
||||
@@ -1094,7 +1100,7 @@
|
||||
"foods": {
|
||||
"merge-dialog-text": "Zkombinování zvolených potravin způsobí smazání zdrojové potraviny a veškeré odkazy na ni budou přesměrovány do cílové potraviny.",
|
||||
"merge-food-example": "Sloučení {food1} do {food2}",
|
||||
"seed-dialog-text": "Naplňte databázi potravinami z vašeho jazyka. Tímto vytvoříte přes 200 běžných potravin, které můžete použít k organizaci vaší databáze. Potravny jsou přeloženy skrze komunitní úsilí.",
|
||||
"seed-dialog-text": "Naplňte databázi potravinami z vašeho jazyka. Tímto vytvoříte přes 200 běžných potravin, které můžete použít k organizaci vaší databáze. Potraviny jsou přeloženy skrze komunitní úsilí.",
|
||||
"seed-dialog-warning": "Již v databázi máte nějaká data. Tato akce neodstraní duplicity, budete je muset odstranit ručně.",
|
||||
"combine-food": "Kombinovat jídlo",
|
||||
"source-food": "Zdrojové jídlo",
|
||||
@@ -1307,7 +1313,7 @@
|
||||
},
|
||||
"ingredients-natural-language-processor": "Zpracování přirozeného jazyka přísad",
|
||||
"ingredients-natural-language-processor-explanation": "Mealie používá podmíněná náhodná pole (CRF) pro rozbor a zpracování ingrediencí. Model používaný pro ingredience je založen na datovém souboru s více než 100 000 ingrediencemi sestaveného New York Times. Vzhledem k tomu, že model je natrénován pouze v angličtině, můžete mít při používání variabilní výsledky. Tato stránka slouží jako hřiště pro testování tohoto modelu.",
|
||||
"ingredients-natural-language-processor-explanation-2": "Není to dokonalé, ale obecně to přináší skvělé výsledky a je dobrým výchozím bodem pro ruční zpracování ingrediencí do jednotlivých polí. Alternativně můžete také použít procesor \"Brute\", který používá metodu porovnávání vzorců pro idenfikaci ingrediencí.",
|
||||
"ingredients-natural-language-processor-explanation-2": "Není to dokonalé, ale obecně to přináší skvělé výsledky a je dobrým výchozím bodem pro ruční zpracování ingrediencí do jednotlivých polí. Alternativně můžete také použít procesor \"Brute\", který používá metodu porovnávání vzorců pro identifikaci ingrediencí.",
|
||||
"nlp": "ZPJ",
|
||||
"brute": "Brute",
|
||||
"openai": "OpenAI",
|
||||
@@ -1379,9 +1385,9 @@
|
||||
"show-advanced-description": "Zobrazit pokročilé funkce (API klíče, Webhooky a správa dat)",
|
||||
"back-to-profile": "Zpět na profil",
|
||||
"looking-for-privacy-settings": "Hledáte nastavení ochrany soukromí?",
|
||||
"manage-your-api-tokens": "Správa API tokenů",
|
||||
"manage-user-profile": "Správa uživatelského profilu",
|
||||
"manage-cookbooks": "Správa kuchařek",
|
||||
"manage-your-api-tokens": "Spravovat API tokeny",
|
||||
"manage-user-profile": "Spravovat uživatelský profil",
|
||||
"manage-cookbooks": "Spravovat kuchařky",
|
||||
"manage-members": "Spravovat členy",
|
||||
"manage-webhooks": "Spravovat webhooky",
|
||||
"manage-notifiers": "Spravovat oznámení",
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "je větší než",
|
||||
"is-greater-than-or-equal-to": "je větší než nebo rovno",
|
||||
"is-less-than": "je menší než",
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno"
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "je",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "obsahuje všechny z",
|
||||
"is-like": "je jako",
|
||||
"is-not-like": "není jako"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Toto pole je povinné",
|
||||
"invalid-email": "E-mail musí být platný",
|
||||
"invalid-url": "Musí být platná URL adresa",
|
||||
"no-whitespace": "Mezery nejsou povoleny",
|
||||
"min-length": "Musí být alespoň {min} znaků",
|
||||
"max-length": "Musí být nejvíce {max} znaků"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,38 @@
|
||||
"about": {
|
||||
"about": "Om",
|
||||
"about-mealie": "Om Mealie",
|
||||
"api-docs": "API dokumentation",
|
||||
"api-port": "API port",
|
||||
"api-docs": "API-dokumentation",
|
||||
"api-port": "API-port",
|
||||
"application-mode": "Applikationstilstand",
|
||||
"database-type": "Database-type",
|
||||
"database-url": "Database-url",
|
||||
"database-url": "Database-URL",
|
||||
"default-group": "Standardgruppe",
|
||||
"default-household": "Standard Husstand",
|
||||
"default-household": "Standardhusstand",
|
||||
"demo": "Demo",
|
||||
"demo-status": "Demo status",
|
||||
"demo-status": "Demo-status",
|
||||
"development": "Udvikling",
|
||||
"docs": "Dokumenter",
|
||||
"docs": "Dokumentation",
|
||||
"download-log": "Download log",
|
||||
"download-recipe-json": "Senest hentede JSON",
|
||||
"github": "GitHub",
|
||||
"log-lines": "Log-linjer",
|
||||
"log-lines": "Loglinjer",
|
||||
"not-demo": "Ikke demo",
|
||||
"portfolio": "Portefølje",
|
||||
"production": "Produktion",
|
||||
"support": "Hjælp",
|
||||
"version": "Version",
|
||||
"unknown-version": "ukendt",
|
||||
"sponsor": "Sponsor"
|
||||
"sponsor": "Sponsorér"
|
||||
},
|
||||
"asset": {
|
||||
"assets": "Ekstramateriale",
|
||||
"assets": "Aktiver",
|
||||
"code": "Kode",
|
||||
"file": "Fil",
|
||||
"image": "Billede",
|
||||
"new-asset": "Nyt aktiv",
|
||||
"pdf": "PDF",
|
||||
"recipe": "Opskrift",
|
||||
"show-assets": "Vis ekstramateriale",
|
||||
"show-assets": "Vis aktiver",
|
||||
"error-submitting-form": "Fejl ved indsendelse af data"
|
||||
},
|
||||
"category": {
|
||||
@@ -51,11 +51,11 @@
|
||||
"category": "Kategori"
|
||||
},
|
||||
"events": {
|
||||
"apprise-url": "Apprise URL",
|
||||
"apprise-url": "Apprise-URL",
|
||||
"database": "Database",
|
||||
"delete-event": "Slet hændelse",
|
||||
"delete-event": "Slet begivenhed",
|
||||
"event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
|
||||
"event-deleted": "Hændelse slettet",
|
||||
"event-deleted": "Begivenhed slettet",
|
||||
"event-updated": "Hændelse opdateret",
|
||||
"new-notification-form-description": "Mealie bruger Apprise biblioteket for at generere notifikationer. De giver mange muligheder for notifikationer til tjenester. Kig i deres wiki for en gennemgående guide til, hvordan en URL oprettes i din situation. Hvis muligt, kan valget af din type af notifikation omfatte flere ekstrafunktioner.",
|
||||
"new-version": "Ny opdatering er tilgængelig!",
|
||||
@@ -212,6 +212,8 @@
|
||||
"upload-file": "Upload fil",
|
||||
"created-on-date": "Oprettet den: {0}",
|
||||
"unsaved-changes": "Du har ændringer som ikke er gemt. Vil du gemme før du forlader? Vælg \"Okay\" for at gemme, eller \"Annullér\" for at kassere ændringer.",
|
||||
"discard-changes": "Kassér ændringer",
|
||||
"discard-changes-description": "Du har ændringer, der ikke er gemt. Er du sikker på, at du vil kassere dem?",
|
||||
"clipboard-copy-failure": "Kopiering til udklipsholderen mislykkedes.",
|
||||
"confirm-delete-generic-items": "Er du sikker på at du ønsker at slette de valgte emner?",
|
||||
"organizers": "Organisatorer",
|
||||
@@ -343,7 +345,7 @@
|
||||
"lunch": "Frokost",
|
||||
"dinner": "Aftensmad",
|
||||
"snack": "Snack",
|
||||
"drink": "Drink",
|
||||
"drink": "Drik",
|
||||
"dessert": "Dessert",
|
||||
"type-any": "Alle",
|
||||
"day-any": "Alle",
|
||||
@@ -367,7 +369,9 @@
|
||||
"recipe-rules": "Opskriftsregler",
|
||||
"applies-to-all-days": "Gælder for alle dage",
|
||||
"applies-on-days": "Gælder for {0}e",
|
||||
"meal-plan-settings": "Indstillinger for madplanlægning"
|
||||
"meal-plan-settings": "Indstillinger for madplanlægning",
|
||||
"add-all-to-list": "Tilføj alle til liste",
|
||||
"add-day-to-list": "Tilføj dag til liste"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migreringsdata fjernet",
|
||||
@@ -445,6 +449,7 @@
|
||||
"upload-a-recipe": "Upload en opskrift",
|
||||
"upload-individual-zip-file": "Upload en individuel .zip-fil, eksporteret fra en anden Mealie-instans.",
|
||||
"url-form-hint": "Kopiér og indsæt et link fra din foretrukne opskrifts hjemmeside",
|
||||
"copy-and-paste-the-source-url-of-your-data-optional": "Kopiér og indsæt kilde-URL'en til dine data (valgfrit)",
|
||||
"view-scraped-data": "Vis dataudtræk",
|
||||
"trim-whitespace-description": "Fjern indledende og efterfølgende mellemrum samt blanke linjer",
|
||||
"trim-prefix-description": "Beskær første tegn fra hver linje",
|
||||
@@ -561,9 +566,9 @@
|
||||
"failed-to-add-recipes-to-list": "Kunne ikke tilføje opskrift til listen",
|
||||
"failed-to-add-recipe-to-mealplan": "Kunne ikke tilføje opskrift til madplanen",
|
||||
"failed-to-add-to-list": "Kunne ikke tilføje opskrift til listen",
|
||||
"yield": "Portioner",
|
||||
"yields-amount-with-text": "{amount} {text}",
|
||||
"yield-text": "Portionsenhed (eks. pers./stk.)",
|
||||
"yield": "Mængde",
|
||||
"yields-amount-with-text": "Mængde {amount} {text}",
|
||||
"yield-text": "Hvor mange enheder giver opskriften?",
|
||||
"quantity": "Antal",
|
||||
"choose-unit": "Vælg enhed",
|
||||
"press-enter-to-create": "Tryk enter for at oprette",
|
||||
@@ -636,9 +641,10 @@
|
||||
"scrape-recipe-suggest-bulk-importer": "Prøv masseimport",
|
||||
"scrape-recipe-have-raw-html-or-json-data": "Har rå HTML- eller JSON-data?",
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kan importere direkte fra rå data",
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"scrape-recipe-website-being-blocked": "Bliver hjemmesiden blokeret?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Forsøg at importere den rå HTML i stedet.",
|
||||
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
|
||||
"import-original-categories": "Importér originale kategorier",
|
||||
"stay-in-edit-mode": "Bliv i redigeringstilstand",
|
||||
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
|
||||
"import-from-zip": "Importer fra zip-fil",
|
||||
@@ -658,14 +664,14 @@
|
||||
"bulk-import-process-has-failed": "Der opstod en fejl ved import af opskrifter",
|
||||
"report-deletion-failed": "Sletning af rapport mislykkedes",
|
||||
"recipe-debugger": "Fejlsøgning af opskrifter",
|
||||
"recipe-debugger-description": "Indsæt URL'en på hjemmesiden, der indeholder den opskrift, du vil fejlsøge. URL-adressen vil blive læst og resultaterne vil blive vist. Hvis ingen data bliver vist, er indhentning af opskrifter fra hjemmesiden endnu ikke understøttet af Mealie.",
|
||||
"recipe-debugger-description": "Indsæt URL'en på hjemmesiden, der indeholder den opskrift, du vil fejlsøge på. URL-adressen vil blive læst og resultaterne vil blive vist. Hvis ingen data bliver vist, er indhentning af opskrifter fra hjemmesiden endnu ikke understøttet af Mealie.",
|
||||
"use-openai": "Brug OpenAI",
|
||||
"recipe-debugger-use-openai-description": "Brug OpenAI til at fortolke resultaterne i stedet for at stole på scraper biblioteket. Når du opretter en opskrift via URL, gøres dette automatisk, hvis skraberbiblioteket fejler, men du kan teste det manuelt her.",
|
||||
"recipe-debugger-use-openai-description": "Brug OpenAI til at fortolke resultaterne i stedet for at stole på scraperbiblioteket. Når du opretter en opskrift via URL, gøres dette automatisk, hvis skraberbiblioteket fejler, men du kan teste det manuelt her.",
|
||||
"debug": "Fejlsøgning",
|
||||
"tree-view": "Træ visning",
|
||||
"recipe-servings": "Opskrift Servinger",
|
||||
"recipe-yield": "Udbytte af opskrift",
|
||||
"recipe-yield-text": "Portioner",
|
||||
"recipe-yield-text": "Mængde",
|
||||
"unit": "Enhed",
|
||||
"upload-image": "Upload billede",
|
||||
"screen-awake": "Hold skærmen tændt",
|
||||
@@ -1307,7 +1313,7 @@
|
||||
},
|
||||
"ingredients-natural-language-processor": "Ingredienser Naturlig Sprogprocessor",
|
||||
"ingredients-natural-language-processor-explanation": "Mealie bruger Conditional Random Fields felter (CRF'er) til berarbejdning af ingredienser. Den model, der anvendes til ingredienser er baseret ud fra et datasæt på over 100.000 ingredienser fra et datasæt udarbejdet af New York Times. Bemærk, at da modellen kun er trænet på engelsk, kan du have forskellige resultater, når du bruger modellen på andre sprog. På denne side kan du teste modellen.",
|
||||
"ingredients-natural-language-processor-explanation-2": "Det er ikke perfekt, men giver generelt gode resultater og er et godt udgangspunkt for manuel redigering af ingredienser i individuelle felter. Alternativt kan du også bruge \"Brute\" metoden, der bruger en mønstermatchende teknik til at identificere ingredienser.",
|
||||
"ingredients-natural-language-processor-explanation-2": "Det er ikke perfekt, men giver generelt gode resultater og er et godt udgangspunkt for manuel redigering af ingredienser i individuelle felter. Alternativt kan du også bruge \"Brute\"-metoden, der bruger en mønstermatchende teknik til at identificere ingredienser.",
|
||||
"nlp": "NLP",
|
||||
"brute": "Brute",
|
||||
"openai": "OpenAI",
|
||||
@@ -1416,7 +1422,9 @@
|
||||
"is-greater-than": "er større end",
|
||||
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
|
||||
"is-less-than": "er mindre end (Automatic Translation)",
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)"
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "er",
|
||||
@@ -1426,6 +1434,17 @@
|
||||
"contains-all-of": "indeholder alle af",
|
||||
"is-like": "er ligesom",
|
||||
"is-not-like": "er ikke som"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Dette felt er påkrævet",
|
||||
"invalid-email": "E-mailen skal være gyldig",
|
||||
"invalid-url": "URL'en skal være gyldig",
|
||||
"no-whitespace": "Mellemrum er ikke tilladt",
|
||||
"min-length": "Der skal mindst være {min} tegn",
|
||||
"max-length": "Der må højst være {max} tegn"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user