diff --git a/.dockerignore b/.dockerignore index a89a5353..8e31edd3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,19 @@ -# Docker ignore (include only web files) +# General files +.git +.github + +# KitchenOwl +icons/ docs/ -fedora/ -ios/ -android/ -linux/ -macos/ -windows/ -.github/ +kitchenowl/fedora/ +kitchenowl/ios/ +kitchenowl/android/ +kitchenowl/linux/ +kitchenowl/macos/ +kitchenowl/windows/ +backend/upload/ +backend/database.db # .gitignore here: # Miscellaneous @@ -84,60 +90,15 @@ unlinked_spec.ds **/android/key.properties *.jks -# iOS/XCode related -**/ios/**/*.mode1v3 -**/ios/**/*.mode2v3 -**/ios/**/*.moved-aside -**/ios/**/*.pbxuser -**/ios/**/*.perspectivev3 -**/ios/**/*sync/ -**/ios/**/.sconsign.dblite -**/ios/**/.tags* -**/ios/**/.vagrant/ -**/ios/**/DerivedData/ -**/ios/**/Icon? -**/ios/**/Pods/ -**/ios/**/.symlinks/ -**/ios/**/profile -**/ios/**/xcuserdata -**/ios/.generated/ -**/ios/Flutter/.last_build_id -**/ios/Flutter/App.framework -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/ephemeral -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* - -# macOS -**/macos/Flutter/GeneratedPluginRegistrant.swift -**/macos/Flutter/ephemeral - # Coverage coverage/ # Symbols app.*.symbols -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock - # MkDocs site/ -# KitchenOwl -icons/ - # Development .devcontainer diff --git a/.github/workflows/deploy_backend_docker_hub.yml b/.github/workflows/deploy_backend_docker_hub.yml new file mode 100644 index 00000000..308501c2 --- /dev/null +++ b/.github/workflows/deploy_backend_docker_hub.yml @@ -0,0 +1,70 @@ +name: CI deploy backend to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for tags + push: + branches: [ main ] + paths: + - backend/** + tags: + - "v*" + - "beta-v*" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + - name: decide docker tags + id: dockertag + run: | + if [[ $REF == "refs/tags/v"* ]] + then + echo "tags=$BASE_TAG:latest, $BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + elif [[ $REF == "refs/tags/beta-v"* ]] + then + echo "tags=$BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + else + echo "tags=$BASE_TAG:dev" >> $GITHUB_ENV + fi + env: + REF: ${{ github.ref }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-backend + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v5 + with: + context: ./ + file: backend/Dockerfile + platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 + push: true + tags: ${{ env.tags }} + # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache + # cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/deploy_docker_hub.yml b/.github/workflows/deploy_docker_hub.yml index 54152be8..d2337670 100644 --- a/.github/workflows/deploy_docker_hub.yml +++ b/.github/workflows/deploy_docker_hub.yml @@ -6,13 +6,8 @@ on: push: branches: [ main ] paths: - - lib/** - - web/** - - assets/** - - Dockerfile - - entrypoint.sh - - pubspec.yaml - - default.conf.template + - kitchenowl/** + - backend/** tags: - "v*" - "beta-v*" @@ -45,10 +40,7 @@ jobs: fi env: REF: ${{ github.ref }} - BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web - - - name: Tags - run: echo ${{ env.tags }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl - name: Login to Docker Hub uses: docker/login-action@v3 @@ -69,11 +61,11 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} - cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache,mode=max + # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache + # cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 7aced200..4c3c2d21 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -6,6 +6,7 @@ on: paths: - docs/** - mkdocs.yml + - docs-requirements.txt workflow_dispatch: jobs: diff --git a/.github/workflows/deploy_play_store.yml b/.github/workflows/deploy_play_store.yml index 4834e12f..f8908c1e 100644 --- a/.github/workflows/deploy_play_store.yml +++ b/.github/workflows/deploy_play_store.yml @@ -26,12 +26,12 @@ jobs: with: channel: stable - run: flutter config --no-analytics - - run: flutter doctor -v # Checkout code and get packages. - name: Checkout code uses: actions/checkout@v4 - run: flutter packages get + working-directory: kitchenowl/android # Decide track internal|beta|production (not in use yet) - name: Decide track @@ -55,7 +55,7 @@ jobs: with: ruby-version: "3.2" bundler-cache: true - working-directory: android + working-directory: kitchenowl/android - name: Configure Keystore run: | @@ -69,11 +69,11 @@ jobs: KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - working-directory: android + working-directory: kitchenowl/android # Build and deploy with Fastlane (by default, to internal track) 🚀. # Naturally, promote_to_production only deploys. - run: bundle exec fastlane ${{ github.event.inputs.lane || 'internal' }} env: PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }} - working-directory: android + working-directory: kitchenowl/android diff --git a/.github/workflows/deploy_web_docker_hub.yml b/.github/workflows/deploy_web_docker_hub.yml new file mode 100644 index 00000000..0030776e --- /dev/null +++ b/.github/workflows/deploy_web_docker_hub.yml @@ -0,0 +1,70 @@ +name: CI deploy web to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push events of tags + push: + branches: [ main ] + paths: + - kitchenowl/** + tags: + - "v*" + - "beta-v*" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + - name: decide docker tags + id: dockertag + run: | + if [[ $REF == "refs/tags/v"* ]] + then + echo "tags=$BASE_TAG:latest, $BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + elif [[ $REF == "refs/tags/beta-v"* ]] + then + echo "tags=$BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + else + echo "tags=$BASE_TAG:dev" >> $GITHUB_ENV + fi + env: + REF: ${{ github.ref }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v5 + with: + context: ./ + file: kitchenowl/Dockerfile + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 + push: true + tags: ${{ env.tags }} + cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache,mode=max + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..44064c6a --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,46 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Pytesting + +on: + push: + branches: [ main ] + paths: + - backend/** + pull_request: + branches: [ main ] + paths: + - backend/** + +jobs: + build: + + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: backend/requirements.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 app tests --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 app tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725a669b..a2f1af09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,31 +52,31 @@ jobs: - os: macos-latest target: macOS build_target: macos - build_path: build/macos/Build/Products/Release + build_path: kitchenowl/build/macos/Build/Products/Release asset_extension: .zip asset_content_type: application/zip - os: windows-latest target: Windows build_target: windows - build_path: build\windows\runner\Release + build_path: kitchenowl/build\windows\runner\Release asset_extension: .zip asset_content_type: application/zip - os: ubuntu-latest target: Linux build_target: linux - build_path: build/linux/x64/release/bundle + build_path: kitchenowl/build/linux/x64/release/bundle asset_extension: .tar.gz asset_content_type: application/gzip - os: ubuntu-latest target: Android build_target: apk - build_path: build/app/outputs/flutter-apk + build_path: kitchenowl/build/app/outputs/flutter-apk asset_extension: .apk asset_content_type: application/vnd.android.package-archive - os: ubuntu-latest target: Debian build_target: linux - build_path: build/debian/release + build_path: kitchenowl/build/debian/release asset_extension: .deb asset_content_type: application/vnd.debian.binary-package - os: ubuntu-latest @@ -85,13 +85,13 @@ jobs: options: --group-add 135 target: Fedora build_target: linux - build_path: build/fedora/release + build_path: kitchenowl/build/fedora/release asset_extension: .rpm asset_content_type: application/x-rpm - os: ubuntu-latest target: Web build_target: web --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ - build_path: build/web + build_path: kitchenowl/build/web asset_extension: .tar.gz asset_content_type: application/gzip # Disable fail-fast as we want results from all even if one fails. @@ -133,7 +133,6 @@ jobs: git clone https://github.com/flutter/flutter.git -b stable echo "/usr/local/src/flutter/bin" >> $GITHUB_PATH export PATH="$PATH:/usr/local/src/flutter/bin" - flutter doctor working-directory: /usr/local/src @@ -164,10 +163,11 @@ jobs: KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - working-directory: android + working-directory: kitchenowl/android # Build the application. - run: flutter build -v ${{ matrix.build_target }} --release + working-directory: kitchenowl # Package the build. - name: Copy VC redistributables to release directory for Windows @@ -176,6 +176,7 @@ jobs: Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\msvcp140.dll') . Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\vcruntime140.dll') . Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\vcruntime140_1.dll') . + working-directory: kitchenowl - name: Rename build for Android if: matrix.target == 'Android' run: mv app-release.apk $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.apk @@ -195,7 +196,7 @@ jobs: - name: Package build for debian if: matrix.target == 'Debian' run: ./build.sh - working-directory: debian + working-directory: kitchenowl/debian - name: Rename build for debian if: matrix.target == 'Debian' run: mv kitchenowl.deb $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.deb @@ -203,7 +204,7 @@ jobs: - name: Package build for Fedora if: matrix.target == 'Fedora' run: ./build.sh - working-directory: fedora + working-directory: kitchenowl/fedora - name: Rename build for fedora if: matrix.target == 'Fedora' run: mv KitchenOwl.x86_64.rpm $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.rpm diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 45ca2d3d..3f3f4cdc 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,10 +3,14 @@ name: Quality on: push: branches: [main] + paths: + - kitchenowl/** pull_request: types: - opened - synchronize + paths: + - kitchenowl/** jobs: analyze: @@ -19,10 +23,14 @@ jobs: with: channel: stable - run: flutter packages get + working-directory: kitchenowl # Run analyze - run: flutter analyze + working-directory: kitchenowl - uses: leancodepl/dart-problem-matcher@main + with: + working-directory: kitchenowl test: name: Tests runs-on: ubuntu-latest @@ -34,13 +42,15 @@ jobs: with: channel: stable - run: flutter packages get + working-directory: kitchenowl # Run tests - run: flutter test --machine > test-results.json + working-directory: kitchenowl # upload test results - uses: actions/upload-artifact@v3 if: success() || failure() # run this step even if previous step failed with: name: test-results - path: test-results.json + path: kitchenowl/test-results.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index addf4584..c3ad90fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,12 +27,6 @@ The `description` is a descriptive summary of the change the PR will make. - All PRs should be rebased (with main) and commits squashed prior to the final merge process - One PR per fix or feature -### Setup & Install -- [Install flutter](https://flutter.dev/docs/get-started/install) -- Install dependencies: `flutter packages get` -- Create empty environment file: `touch .env` -- Run app: `flutter run` - ### Git Commit Message Style This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format. diff --git a/Dockerfile b/Dockerfile index 7e2b7b9d..44120127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ------------ -# BUILDER +# WEB BUILDER # ------------ -FROM --platform=$BUILDPLATFORM debian:latest AS builder +FROM --platform=$BUILDPLATFORM debian:latest AS app_builder # Install dependencies RUN apt-get update -y @@ -36,11 +36,11 @@ RUN flutter upgrade RUN flutter doctor -v # Copy the app files to the container -COPY .metadata l10n.yaml pubspec.yaml /usr/local/src/app/ -COPY lib /usr/local/src/app/lib -COPY web /usr/local/src/app/web -COPY assets /usr/local/src/app/assets -COPY fonts /usr/local/src/app/fonts +COPY kitchenowl/.metadata kitchenowl/l10n.yaml kitchenowl/pubspec.yaml /usr/local/src/app/ +COPY kitchenowl/lib /usr/local/src/app/lib +COPY kitchenowl/web /usr/local/src/app/web +COPY kitchenowl/assets /usr/local/src/app/assets +COPY kitchenowl/fonts /usr/local/src/app/fonts # Set the working directory to the app files within the container WORKDIR /usr/local/src/app @@ -51,20 +51,59 @@ RUN flutter packages get # Build the app for the web RUN flutter build web --release --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ +# ------------ +# BACKEND BUILDER +# ------------ +FROM python:3.11-slim as backend_builder + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + gcc g++ libffi-dev libpcre3-dev build-essential cargo \ + libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ + autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev libsqlite3-dev + +# Create virtual enviroment +RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel +ENV PATH="/opt/venv/bin:$PATH" + +COPY backend/requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ + +RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger', download_dir='/opt/venv/nltk_data')" + # ------------ # RUNNER # ------------ -FROM nginx:stable-alpine +FROM python:3.11-slim as runner + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + libxml2 libpcre3 curl \ + && rm -rf /var/lib/apt/lists/* + +# Use virtual enviroment +COPY --from=backend_builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +# Setup Frontend RUN mkdir -p /var/www/web/kitchenowl -COPY --from=builder /usr/local/src/app/build/web /var/www/web/kitchenowl -COPY docker-entrypoint-custom.sh /docker-entrypoint.d/01-kitchenowl-customization.sh -COPY default.conf.template /etc/nginx/templates/ +COPY --from=app_builder /usr/local/src/app/build/web /var/www/web/kitchenowl + +# Setup KitchenOwl Backend +COPY backend/wsgi.ini backend/wsgi.py backend/entrypoint.sh backend/manage.py backend/manage_default_items.py backend/upgrade_default_items.py /usr/src/kitchenowl/ +COPY backend/app /usr/src/kitchenowl/app +COPY backend/templates /usr/src/kitchenowl/templates +COPY backend/migrations /usr/src/kitchenowl/migrations +WORKDIR /usr/src/kitchenowl +VOLUME ["/data"] + +HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 -HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1 +ENV STORAGE_PATH='/data' +ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' +ENV DEBUG='False' -# Set ENV -ENV BACK_URL='back:5000' +RUN chmod u+x ./entrypoint.sh -# Expose the web server -EXPOSE 80 \ No newline at end of file +CMD ["--ini", "wsgi.ini:web", "--gevent", "200"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 6636d350..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - flutter/** - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - library_private_types_in_public_api: false - use_build_context_synchronously: false - no_leading_underscores_for_local_identifiers: false - # prefer_single_quotes: true diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..e09b94ad --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,21 @@ +# General files +.git +.github +database.db +upload/ +docs + +# Development +.devcontainer +.vscode + +# Test related files +.tox +tests + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..b2113a84 --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,3 @@ +[flake8] +per-file-ignores = + __init__.py: F401, F403 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..acf90311 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,149 @@ +*.db +*.db-journal +upload/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ +*.code-workspace + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..f291269f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,52 @@ +# ------------ +# BUILDER +# ------------ +FROM python:3.11-slim as builder + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + gcc g++ libffi-dev libpcre3-dev build-essential cargo \ + libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ + autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev libsqlite3-dev + +# Create virtual enviroment +RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ + +RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger', download_dir='/opt/venv/nltk_data')" + +# ------------ +# RUNNER +# ------------ +FROM python:3.11-slim as runner + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + libxml2 libpcre3 curl \ + && rm -rf /var/lib/apt/lists/* + +# Use virtual enviroment +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Setup KitchenOwl +COPY wsgi.ini wsgi.py entrypoint.sh manage.py manage_default_items.py upgrade_default_items.py /usr/src/kitchenowl/ +COPY app /usr/src/kitchenowl/app +COPY templates /usr/src/kitchenowl/templates +COPY migrations /usr/src/kitchenowl/migrations +WORKDIR /usr/src/kitchenowl +VOLUME ["/data"] + +HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 + +ENV STORAGE_PATH='/data' +ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' +ENV DEBUG='False' + +RUN chmod u+x ./entrypoint.sh + +CMD ["wsgi.ini", "--gevent", "200"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/backend/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..71177f11 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,16 @@ +## Contributing + +Take a look at the general contribution rules [here](../CONTRIBUTING.md). + +### Requirements +- Python 3.11+ + +### Setup & Install +- If you haven't already, switch to the backend folder `cd backend` +- Create a python environment `python3 -m venv venv` +- Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) +- Install dependencies `pip3 install -r requirements.txt` +- Initialize/Upgrade the SQLite database with `flask db upgrade` +- Initialize/Upgrade requirements for the recipe scraper `python -c "import nltk; nltk.download('averaged_perceptron_tagger')"` +- Run debug server with `python3 wsgi.py` or without debugging `flask run` +- The backend should be reachable at `localhost:5000` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..86516729 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,7 @@ +from app.config import app, jwt, socketio, celery_app +from app.config import db +from app.config import scheduler +from app.controller import * +from app.sockets import * +from app.jobs import * +from app.api import * diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 00000000..8e794351 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +from . import register_controller diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py new file mode 100644 index 00000000..e5d7b314 --- /dev/null +++ b/backend/app/api/register_controller.py @@ -0,0 +1,47 @@ +from flask import Blueprint +from app.config import app +import app.controller as api + +# Register Endpoints +apiv1 = Blueprint("api", __name__) + +api.household.register_blueprint(api.export, url_prefix="//export") +api.household.register_blueprint(api.importBP, url_prefix="//import") +api.household.register_blueprint( + api.categoryHousehold, url_prefix="//category" +) +api.household.register_blueprint( + api.plannerHousehold, url_prefix="//planner" +) +api.household.register_blueprint( + api.expenseHousehold, url_prefix="//expense" +) +api.household.register_blueprint( + api.itemHousehold, url_prefix="//item" +) +api.household.register_blueprint( + api.recipeHousehold, url_prefix="//recipe" +) +api.household.register_blueprint( + api.shoppinglistHousehold, url_prefix="//shoppinglist" +) +api.household.register_blueprint(api.tagHousehold, url_prefix="//tag") + +apiv1.register_blueprint( + api.health, url_prefix="/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V" +) +apiv1.register_blueprint(api.auth, url_prefix="/auth") +apiv1.register_blueprint(api.household, url_prefix="/household") +apiv1.register_blueprint(api.category, url_prefix="/category") +apiv1.register_blueprint(api.expense, url_prefix="/expense") +apiv1.register_blueprint(api.item, url_prefix="/item") +apiv1.register_blueprint(api.onboarding, url_prefix="/onboarding") +apiv1.register_blueprint(api.recipe, url_prefix="/recipe") +apiv1.register_blueprint(api.settings, url_prefix="/settings") +apiv1.register_blueprint(api.shoppinglist, url_prefix="/shoppinglist") +apiv1.register_blueprint(api.tag, url_prefix="/tag") +apiv1.register_blueprint(api.user, url_prefix="/user") +apiv1.register_blueprint(api.upload, url_prefix="/upload") +apiv1.register_blueprint(api.analytics, url_prefix="/analytics") + +app.register_blueprint(apiv1, url_prefix="/api") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 00000000..8897d3b3 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,301 @@ +from datetime import timedelta +from http import client +from celery import Celery, Task +from flask_socketio import SocketIO +from sqlalchemy import MetaData +from sqlalchemy.engine import URL +from sqlalchemy.event import listen +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from prometheus_client import multiprocess +from prometheus_client.core import CollectorRegistry +from prometheus_flask_exporter import PrometheusMetrics +from werkzeug.exceptions import MethodNotAllowed +from app.errors import ( + NotFoundRequest, + UnauthorizedRequest, + ForbiddenRequest, + InvalidUsage, +) +from app.util import KitchenOwlJSONProvider +from oic.oic import Client +from oic.oic.message import RegistrationResponse +from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from flask import Flask, jsonify, request +from flask_basicauth import BasicAuth +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy +from flask_bcrypt import Bcrypt +from flask_jwt_extended import JWTManager +from flask_apscheduler import APScheduler +import sqlite_icu +import os + + +MIN_FRONTEND_VERSION = 71 +BACKEND_VERSION = 93 + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(APP_DIR) + +STORAGE_PATH = os.getenv("STORAGE_PATH", PROJECT_DIR) +UPLOAD_FOLDER = STORAGE_PATH + "/upload" +ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif"} + +FRONT_URL = os.getenv("FRONT_URL") + +PRIVACY_POLICY_URL = os.getenv("PRIVACY_POLICY_URL") +OPEN_REGISTRATION = os.getenv("OPEN_REGISTRATION", "False").lower() == "true" +EMAIL_MANDATORY = os.getenv("EMAIL_MANDATORY", "False").lower() == "true" + +COLLECT_METRICS = os.getenv("COLLECT_METRICS", "False").lower() == "true" + +DB_URL = URL.create( + os.getenv("DB_DRIVER", "sqlite"), + username=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + host=os.getenv("DB_HOST"), + database=os.getenv("DB_NAME", STORAGE_PATH + "/database.db"), +) +MESSAGE_BROKER = os.getenv("MESSAGE_BROKER") + +JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) +JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID") +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET") +OIDC_ISSUER = os.getenv("OIDC_ISSUER") + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") + +APPLE_CLIENT_ID = os.getenv("APPLE_CLIENT_ID") +APPLE_CLIENT_SECRET = os.getenv("APPLE_CLIENT_SECRET") + +SUPPORTED_LANGUAGES = { + "en": "English", + "en_AU": "Australian English", + "cs": "čeština", + "da": "Dansk", + "de": "Deutsch", + "el": "Ελληνικά", + "es": "Español", + "fi": "Suomi", + "fr": "Français", + "hu": "Magyar nyelv", + "id": "Bahasa Indonesia", + "it": "Italiano", + "nb_NO": "Bokmål", + "nl": "Nederlands", + "pl": "Polski", + "pt": "Português", + "pt_BR": "Português Brasileiro", + "ru": "русский язык", + "sv": "Svenska", + "tr": "Türkçe", + "zh_Hans": "简化字", +} + +Flask.json_provider_class = KitchenOwlJSONProvider + +app = Flask(__name__) + +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER +app.config["MAX_CONTENT_LENGTH"] = 32 * 1000 * 1000 # 32MB max upload +app.config["SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret") +# SQLAlchemy +app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +# JWT +app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret") +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES +app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES +if COLLECT_METRICS: + # BASIC_AUTH + app.config["BASIC_AUTH_USERNAME"] = os.getenv("METRICS_USER", "kitchenowl") + app.config["BASIC_AUTH_PASSWORD"] = os.getenv("METRICS_PASSWORD", "ZqQtidgC5n3YXb") + +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +metadata = MetaData(naming_convention=convention) + +db = SQLAlchemy(app, metadata=metadata) +migrate = Migrate(app, db, render_as_batch=True) +bcrypt = Bcrypt(app) +jwt = JWTManager(app) +socketio = SocketIO( + app, + json=app.json, + logger=app.logger, + cors_allowed_origins=FRONT_URL, + message_queue=MESSAGE_BROKER, +) +api_spec = APISpec( + title="KitchenOwl", + version="v" + str(BACKEND_VERSION), + openapi_version="3.0.2", + info={ + "description": "WIP KitchenOwl API documentation", + "termsOfService": "https://kitchenowl.org/privacy/", + "contact": { + "name": "API Support", + "url": "https://kitchenowl.org/imprint/", + "email": "support@kitchenowl.org", + }, + "license": { + "name": "AGPL 3.0", + "url": "https://github.com/TomBursch/kitchenowl/blob/main/LICENSE", + }, + }, + servers=[ + { + "url": "https://app.kitchenowl.org/api", + "description": "Official KitchenOwl server instance", + } + ], + externalDocs={ + "description": "Find more info at the official documentation", + "url": "https://docs.kitchenowl.org", + }, + plugins=[MarshmallowPlugin()], +) +oidc_clients = {} +if FRONT_URL: + if OIDC_CLIENT_ID and OIDC_CLIENT_SECRET and OIDC_ISSUER: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config(OIDC_ISSUER) + client.store_registration_info( + RegistrationResponse( + client_id=OIDC_CLIENT_ID, client_secret=OIDC_CLIENT_SECRET + ) + ) + oidc_clients["custom"] = client + if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config("https://accounts.google.com/") + client.store_registration_info( + RegistrationResponse( + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + ) + ) + oidc_clients["google"] = client + if APPLE_CLIENT_ID and APPLE_CLIENT_SECRET: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config("https://appleid.apple.com/") + client.store_registration_info( + RegistrationResponse( + client_id=APPLE_CLIENT_ID, + client_secret=APPLE_CLIENT_SECRET, + ) + ) + oidc_clients["apple"] = client + + +if COLLECT_METRICS: + basic_auth = BasicAuth(app) + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry, path="/tmp") + metrics = PrometheusMetrics( + app, + registry=registry, + path="/metrics/", + metrics_decorator=basic_auth.required, + group_by="endpoint", + ) + metrics.info("app_info", "Application info", version=BACKEND_VERSION) + +scheduler = None +celery_app = None +if not MESSAGE_BROKER: + scheduler = APScheduler() + scheduler.api_enabled = False + scheduler.init_app(app) + scheduler.start() +else: + + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery( + app.name + "_tasks", + broker=MESSAGE_BROKER, + task_cls=FlaskTask, + task_ignore_result=True, + ) + celery_app.set_default() + app.extensions["celery"] = celery_app + + +# Load ICU extension for sqlite +if DB_URL.drivername == "sqlite": + + def load_extension(conn, unused): + conn.enable_load_extension(True) + conn.load_extension(sqlite_icu.extension_path().replace(".so", "")) + conn.enable_load_extension(False) + + with app.app_context(): + listen(db.engine, "connect", load_extension) + + +@app.after_request +def add_cors_headers(response): + if not request.referrer: + return response + r = request.referrer[:-1] + if app.debug or FRONT_URL and r == FRONT_URL: + response.headers.add("Access-Control-Allow-Origin", r) + response.headers.add("Access-Control-Allow-Credentials", "true") + response.headers.add("Access-Control-Allow-Headers", "Content-Type") + response.headers.add("Access-Control-Allow-Headers", "Cache-Control") + response.headers.add("Access-Control-Allow-Headers", "X-Requested-With") + response.headers.add("Access-Control-Allow-Headers", "Authorization") + response.headers.add( + "Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE" + ) + return response + + +@app.errorhandler(Exception) +def unhandled_exception(e: Exception): + if type(e) is NotFoundRequest: + app.logger.info(e) + return "Requested resource not found", 404 + if type(e) is ForbiddenRequest: + app.logger.warning(e) + return "Request forbidden", 403 + if type(e) is InvalidUsage: + app.logger.warning(e) + return "Request invalid", 400 + if type(e) is UnauthorizedRequest: + app.logger.warning(e) + return "Request unauthorized", 401 + if type(e) is MethodNotAllowed: + app.logger.warning(e) + return "The method is not allowed for the requested URL", 405 + app.logger.error(e, exc_info=e) + return "Something went wrong", 500 + + +@app.errorhandler(404) +def not_found(error): + return "Requested resource not found", 404 + + +@socketio.on_error_default +def default_socket_error_handler(e): + app.logger.error(e) + + +@app.route("/api/openapi", methods=["GET"]) +def swagger(): + return jsonify(api_spec.to_dict()) diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py new file mode 100644 index 00000000..5ace091a --- /dev/null +++ b/backend/app/controller/__init__.py @@ -0,0 +1,16 @@ +from .auth import * +from .item import * +from .user import * +from .recipe import * +from .shoppinglist import * +from .planner import * +from .onboarding import * +from .exportimport import * +from .settings import * +from .expense import * +from .tag import * +from .upload import * +from .household import * +from .category import * +from .health_controller import health +from .analytics import * diff --git a/backend/app/controller/analytics/__init__.py b/backend/app/controller/analytics/__init__.py new file mode 100644 index 00000000..6bc6ec12 --- /dev/null +++ b/backend/app/controller/analytics/__init__.py @@ -0,0 +1 @@ +from .analytics_controller import analytics diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py new file mode 100644 index 00000000..aee41ec7 --- /dev/null +++ b/backend/app/controller/analytics/analytics_controller.py @@ -0,0 +1,64 @@ +from datetime import datetime +import os +from app.helpers import server_admin_required +from app.models import User, Token, Household, OIDCLink +from app.config import JWT_REFRESH_TOKEN_EXPIRES, UPLOAD_FOLDER +from app import db +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required + + +analytics = Blueprint("analytics", __name__) + + +@analytics.route("", methods=["GET"]) +@jwt_required() +@server_admin_required() +def getBaseAnalytics(): + statvfs = os.statvfs(UPLOAD_FOLDER) + return jsonify( + { + "users": { + "total": User.count(), + "verified": User.query.filter(User.email_verified == True).count(), + "active": db.session.query(Token.user_id) + .filter(Token.type == "refresh") + .group_by(Token.user_id) + .count(), + "online": db.session.query(Token.user_id) + .filter(Token.type == "access") + .group_by(Token.user_id) + .count(), + "old": User.query.filter( + User.created_at <= datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + ).count(), + "old_active": User.query.filter( + User.created_at <= datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + ) + .filter( + User.id.in_( + db.session.query(Token.user_id) + .filter(Token.type == "refresh") + .group_by(Token.user_id) + .subquery() + .select() + ) + ) + .count(), + "linked_account": db.session.query(OIDCLink.user_id) + .group_by(OIDCLink.user_id) + .count(), + }, + "free_storage": statvfs.f_frsize * statvfs.f_bavail, + "available_storage": statvfs.f_frsize * statvfs.f_blocks, + "households": { + "total": Household.count(), + "expense_feature": Household.query.filter( + Household.expenses_feature == True + ).count(), + "planner_feature": Household.query.filter( + Household.planner_feature == True + ).count(), + }, + } + ) diff --git a/backend/app/controller/auth/__init__.py b/backend/app/controller/auth/__init__.py new file mode 100644 index 00000000..c17007ec --- /dev/null +++ b/backend/app/controller/auth/__init__.py @@ -0,0 +1 @@ +from .auth_controller import auth diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py new file mode 100644 index 00000000..e6bb7031 --- /dev/null +++ b/backend/app/controller/auth/auth_controller.py @@ -0,0 +1,365 @@ +from datetime import datetime +import re +import uuid +import gevent + +from oic import rndstr +from oic.oic.message import AuthorizationResponse +from oic.oauth2.message import ErrorResponse +from app.helpers import validate_args +from flask import jsonify, Blueprint, request +from flask_jwt_extended import current_user, jwt_required, get_jwt +from app.models import User, Token, OIDCLink, OIDCRequest, ChallengeMailVerify +from app.errors import NotFoundRequest, UnauthorizedRequest, InvalidUsage +from app.service import mail +from .schemas import Login, Signup, CreateLongLivedToken, GetOIDCLoginUrl, LoginOIDC +from app.config import EMAIL_MANDATORY, FRONT_URL, jwt, OPEN_REGISTRATION, oidc_clients + +auth = Blueprint("auth", __name__) + + +# Callback function to check if a JWT exists in the database blocklist +@jwt.token_in_blocklist_loader +def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: + jti = jwt_payload["jti"] + token = Token.find_by_jti(jti) + if token is not None: + token.last_used_at = datetime.utcnow() + token.save() + + return token is None + + +# Register a callback function that takes whatever object is passed in as the +# identity when creating JWTs and converts it to a JSON serializable format. +@jwt.user_identity_loader +def user_identity_lookup(user: User): + return user.id + + +# Register a callback function that loads a user from your database whenever +# a protected route is accessed. This should return any python object on a +# successful lookup, or None if the lookup failed for any reason (for example +# if the user has been deleted from the database). +@jwt.user_lookup_loader +def user_lookup_callback(_jwt_header, jwt_data) -> User: + identity = jwt_data["sub"] + return User.find_by_id(identity) + + +@auth.route("", methods=["POST"]) +@validate_args(Login) +def login(args): + username = args["username"].lower().replace(" ", "") + user = User.find_by_username(username) + if not user or not user.check_password(args["password"]): + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp with wrong username or password".format( + request.remote_addr + ) + ) + device = "Unkown" + if "device" in args: + device = args["device"] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) + + +if OPEN_REGISTRATION: + + @auth.route("signup", methods=["POST"]) + @validate_args(Signup) + def signup(args): + username = args["username"].lower().replace(" ", "") + user = User.find_by_username(username) + if user: + return "Request invalid: username", 400 + if "email" in args: + user = User.find_by_email(args["email"]) + if user: + return "Request invalid: email", 400 + + user = User.create( + username=username, + name=args["name"], + password=args["password"], + email=args["email"] if "email" in args else None, + ) + if "email" in args and mail.mailConfigured(): + gevent.spawn(mail.sendVerificationMail, user.id, ChallengeMailVerify.create_challenge(user)) + + device = "Unkown" + if "device" in args: + device = args["device"] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) + + +@auth.route("/refresh", methods=["GET"]) +@jwt_required(refresh=True) +def refresh(): + user = current_user + if not user: + raise UnauthorizedRequest( + message="Unauthorized: IP {} refresh attemp with wrong username or password".format( + request.remote_addr + ) + ) + + refreshModel = Token.find_by_jti(get_jwt()["jti"]) + # Refresh token rotation + refreshToken, refreshModel = Token.create_refresh_token( + user, oldRefreshToken=refreshModel + ) + + # Create access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) + + +@auth.route("", methods=["DELETE"]) +@jwt_required() +def logout(): + jwt = get_jwt() + token = Token.find_by_jti(jwt["jti"]) + if not token: + raise UnauthorizedRequest( + message="Unauthorized: IP {}".format(request.remote_addr) + ) + + if token.type == "access": + token.refresh_token.delete_token_familiy() + else: + token.delete() + + return jsonify({"msg": "DONE"}) + + +@auth.route("llt", methods=["POST"]) +@jwt_required() +@validate_args(CreateLongLivedToken) +def createLongLivedToken(args): + user = current_user + if not user: + raise UnauthorizedRequest( + message="Unauthorized: IP {}".format(request.remote_addr) + ) + + llToken, _ = Token.create_longlived_token(user, args["device"]) + + return jsonify({"longlived_token": llToken}) + + +@auth.route("llt/", methods=["DELETE"]) +@jwt_required() +def deleteLongLivedToken(id): + user = current_user + if not user: + raise UnauthorizedRequest( + message="Unauthorized: IP {}".format(request.remote_addr) + ) + + token = Token.find_by_id(id) + if token.user_id != user.id or token.type != "llt": + raise UnauthorizedRequest( + message="Unauthorized: IP {}".format(request.remote_addr) + ) + + token.delete() + + return jsonify({"msg": "DONE"}) + + +if FRONT_URL and len(oidc_clients) > 0: + + @auth.route("oidc", methods=["GET"]) + @jwt_required(optional=True) + @validate_args(GetOIDCLoginUrl) + def getOIDCLoginUrl(args): + provider = args["provider"] if "provider" in args else "custom" + if not provider in oidc_clients: + raise NotFoundRequest() + client = oidc_clients[provider] + if not client: + raise UnauthorizedRequest( + message="Unauthorized: IP {} get login url for unknown OIDC provider".format( + request.remote_addr + ) + ) + state = rndstr() + nonce = rndstr() + redirect_uri = ( + "kitchenowl://" + if "kitchenowl_scheme" in args and args["kitchenowl_scheme"] + else FRONT_URL + ) + "/signin/redirect" + args = { + "client_id": client.client_id, + "response_type": "code", + "scope": ["openid", "profile", "email"], + "nonce": nonce, + "state": state, + "redirect_uri": redirect_uri, + } + + auth_req = client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(client.authorization_endpoint) + OIDCRequest( + state=state, + provider=provider, + nonce=nonce, + redirect_uri=redirect_uri, + user_id=current_user.id if current_user else None, + ).save() + return jsonify({"login_url": login_url, "state": state, "nonce": nonce}) + + @auth.route("callback", methods=["POST"]) + @jwt_required(optional=True) + @validate_args(LoginOIDC) + def loginWithOIDC(args): + # Validate oidc login + oidc_request = OIDCRequest.find_by_state(args["state"]) + if not oidc_request: + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp with unknown OIDC state".format( + request.remote_addr + ) + ) + provider = oidc_request.provider + client = oidc_clients[provider] + if not client: + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp with unknown OIDC provider".format( + request.remote_addr + ) + ) + + if oidc_request.user != current_user: + if not current_user: + return "Request invalid: user not signed in for link request", 400 + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for a different account".format( + request.remote_addr + ) + ) + + client.parse_response( + AuthorizationResponse, + info={"code": args["code"], "state": oidc_request.state}, + sformat="dict", + ) + + tokenResponse = client.do_access_token_request( + scope=["openid", "profile", "email"], + state=oidc_request.state, + request_args={ + "code": args["code"], + "redirect_uri": oidc_request.redirect_uri, + }, + authn_method="client_secret_basic", + ) + if isinstance(tokenResponse, ErrorResponse): + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for OIDC failed".format( + request.remote_addr + ) + ) + userinfo = tokenResponse["id_token"] + if userinfo["nonce"] != oidc_request.nonce: + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for OIDC failed: mismatched nonce".format( + request.remote_addr + ) + ) + oidc_request.delete() + + # find user or create one + oidcLink = OIDCLink.find_by_ids(userinfo["sub"], provider) + if current_user: + if oidcLink and oidcLink.user_id != current_user.id: + return ( + "Request invalid: oidc account already linked with other kitchenowl account", + 400, + ) + if oidcLink: + return jsonify({"msg": "DONE"}) + + if provider in map(lambda l: l.provider, current_user.oidc_links): + return "Request invalid: provider already linked with account", 400 + + oidcLink = OIDCLink( + sub=userinfo["sub"], provider=provider, user_id=current_user.id + ).save() + oidcLink.user = current_user + if not oidcLink: + if "email" in userinfo: + if User.find_by_email(userinfo["email"].strip()): + return "Request invalid: email", 400 + elif EMAIL_MANDATORY: + return "Request invalid: email", 400 + + username = ( + userinfo["preferred_username"].lower().replace(" ", "").replace("@", "") + if "preferred_username" in userinfo + else None + ) + if not username or User.find_by_username(username): + username = ( + userinfo["name"].lower().replace(" ", "").replace("@", "") + if "name" in userinfo + else None + ) + if not username or User.find_by_username(username): + username = userinfo["sub"].lower().replace(" ", "").replace("@", "") + if not username or User.find_by_username(username): + username = uuid.uuid4().hex + newUser = User( + username=username, + name=userinfo["name"].strip() + if "name" in userinfo + else userinfo["sub"], + email=userinfo["email"].strip() if "email" in userinfo else None, + email_verified=userinfo["email_verified"] + if "email_verified" in userinfo + else False, + photo=userinfo["picture"] if "picture" in userinfo else None, + ).save() + oidcLink = OIDCLink( + sub=userinfo["sub"], provider=provider, user_id=newUser.id + ).save() + oidcLink.user = newUser + + user: User = oidcLink.user + + # Don't login already logged in user + if current_user: + return jsonify({"msg": "DONE"}) + + # login user + device = "Unkown" + if "device" in args: + device = args["device"] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py new file mode 100644 index 00000000..bc321cea --- /dev/null +++ b/backend/app/controller/auth/schemas.py @@ -0,0 +1,77 @@ +from marshmallow import fields, Schema + +from app.config import EMAIL_MANDATORY + + +class Login(Schema): + username = fields.String(required=True, validate=lambda a: a and not a.isspace()) + password = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + device = fields.String( + required=False, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + + +class Signup(Schema): + username = fields.String( + required=True, validate=lambda a: a and not a.isspace() and not "@" in a + ) + email = fields.String( + required=EMAIL_MANDATORY, + validate=lambda a: a and not a.isspace() and "@" in a, + load_only=True, + ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + password = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + device = fields.String( + required=False, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + + +class CreateLongLivedToken(Schema): + device = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + + +class GetOIDCLoginUrl(Schema): + provider = fields.String( + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + kitchenowl_scheme = fields.Boolean( + required=False, + default=False, + load_only=True, + ) + + +class LoginOIDC(Schema): + state = fields.String( + validate=lambda a: a and not a.isspace(), + required=True, + load_only=True, + ) + code = fields.String( + validate=lambda a: a and not a.isspace(), + required=True, + load_only=True, + ) + device = fields.String( + validate=lambda a: a and not a.isspace(), + required=False, + load_only=True, + ) diff --git a/backend/app/controller/category/__init__.py b/backend/app/controller/category/__init__.py new file mode 100644 index 00000000..0e723fcb --- /dev/null +++ b/backend/app/controller/category/__init__.py @@ -0,0 +1 @@ +from .category_controller import category, categoryHousehold diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py new file mode 100644 index 00000000..865fa89a --- /dev/null +++ b/backend/app/controller/category/category_controller.py @@ -0,0 +1,86 @@ +from app.helpers import validate_args, authorize_household +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app.models import Category +from .schemas import AddCategory, DeleteCategory, UpdateCategory + +category = Blueprint("category", __name__) +categoryHousehold = Blueprint("category", __name__) + + +@categoryHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllCategories(household_id): + return jsonify([e.obj_to_dict() for e in Category.all_by_ordering(household_id)]) + + +@category.route("/", methods=["GET"]) +@jwt_required() +def getCategory(id): + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + return jsonify(category.obj_to_dict()) + + +@categoryHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddCategory) +def addCategory(args, household_id): + category = Category() + category.name = args["name"] + category.household_id = household_id + category.save() + return jsonify(category.obj_to_dict()) + + +@category.route("/", methods=["POST", "PATCH"]) +@jwt_required() +@validate_args(UpdateCategory) +def updateCategory(args, id): + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + + if "name" in args: + category.name = args["name"] + if "ordering" in args and category.ordering != args["ordering"]: + category.reorder(args["ordering"]) + category.save() + + if "merge_category_id" in args and args["merge_category_id"] != id: + mergeCategory = Category.find_by_id(args["merge_category_id"]) + if mergeCategory: + category.merge(mergeCategory) + + return jsonify(category.obj_to_dict()) + + +@category.route("/", methods=["DELETE"]) +@jwt_required() +def deleteCategoryById(id): + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + + category.delete() + return jsonify({"msg": "DONE"}) + + +@categoryHousehold.route("", methods=["DELETE"]) +@jwt_required() +@authorize_household() +@validate_args(DeleteCategory) +def deleteCategoryByName(args, household_id): + if "name" in args: + category = Category.find_by_name(args["name"], household_id) + if category: + category.delete() + return jsonify({"msg": "DONE"}) + raise NotFoundRequest() diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py new file mode 100644 index 00000000..bef43cd0 --- /dev/null +++ b/backend/app/controller/category/schemas.py @@ -0,0 +1,23 @@ +from marshmallow import fields, Schema, EXCLUDE + + +class AddCategory(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class UpdateCategory(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + ordering = fields.Integer(validate=lambda i: i >= 0) + + # if set this merges the specified category into this category thus combining them to one + merge_category_id = fields.Integer( + validate=lambda a: a > 0, + allow_none=True, + ) + + +class DeleteCategory(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) diff --git a/backend/app/controller/expense/__init__.py b/backend/app/controller/expense/__init__.py new file mode 100644 index 00000000..c93aa969 --- /dev/null +++ b/backend/app/controller/expense/__init__.py @@ -0,0 +1 @@ +from .expense_controller import expense, expenseHousehold diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py new file mode 100644 index 00000000..740237cd --- /dev/null +++ b/backend/app/controller/expense/expense_controller.py @@ -0,0 +1,388 @@ +import calendar +from datetime import datetime, timezone, timedelta +from dateutil.relativedelta import relativedelta +from sqlalchemy.sql.expression import desc +from sqlalchemy import or_ +from app.errors import NotFoundRequest +from flask import jsonify, Blueprint +from flask_jwt_extended import current_user, jwt_required +from sqlalchemy import func +from app import db +from app.helpers import validate_args, authorize_household, RequiredRights +from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember +from app.service.recalculate_balances import recalculateBalances +from app.service.file_has_access_or_download import file_has_access_or_download +from .schemas import ( + GetExpenses, + AddExpense, + UpdateExpense, + AddExpenseCategory, + UpdateExpenseCategory, + GetExpenseOverview, +) + +expense = Blueprint("expense", __name__) +expenseHousehold = Blueprint("expense", __name__) + + +@expenseHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +@validate_args(GetExpenses) +def getAllExpenses(args, household_id): + filter = [Expense.household_id == household_id] + if "startAfterId" in args: + filter.append(Expense.id < args["startAfterId"]) + if "startAfterDate" in args: + filter.append( + Expense.date + < datetime.fromtimestamp(args["startAfterDate"] / 1000, timezone.utc) + ) + if "endBeforeDate" in args: + filter.append( + Expense.date + > datetime.fromtimestamp(args["endBeforeDate"] / 1000, timezone.utc) + ) + + if "view" in args and args["view"] == 1: + subquery = ( + db.session.query(ExpensePaidFor.expense_id) + .filter(ExpensePaidFor.user_id == current_user.id) + .scalar_subquery() + ) + filter.append(Expense.id.in_(subquery)) + + if "filter" in args: + if None in args["filter"]: + filter.append( + or_( + Expense.category_id == None, Expense.category_id.in_(args["filter"]) + ) + ) + else: + filter.append(Expense.category_id.in_(args["filter"])) + + return jsonify( + [ + e.obj_to_full_dict() + for e in Expense.query.order_by(desc(Expense.date)) + .filter(*filter) + .join(Expense.category, isouter=True) + .limit(30) + .all() + ] + ) + + +@expense.route("/", methods=["GET"]) +@jwt_required() +def getExpenseById(id): + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + expense.checkAuthorized() + return jsonify(expense.obj_to_full_dict()) + + +@expenseHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddExpense) +def addExpense(args, household_id): + member = HouseholdMember.find_by_ids(household_id, args["paid_by"]["id"]) + if not member: + raise NotFoundRequest() + expense = Expense() + expense.name = args["name"] + expense.amount = args["amount"] + expense.household_id = household_id + if "date" in args: + expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) + if "photo" in args and args["photo"] != expense.photo: + expense.photo = file_has_access_or_download(args["photo"], expense.photo) + if "category" in args: + if args["category"] is not None: + category = ExpenseCategory.find_by_id(args["category"]) + expense.category = category + if "exclude_from_statistics" in args: + expense.exclude_from_statistics = args["exclude_from_statistics"] + expense.paid_by_id = member.user_id + expense.save() + member.expense_balance = (member.expense_balance or 0) + expense.amount + member.save() + factor_sum = 0 + for user_data in args["paid_for"]: + if HouseholdMember.find_by_ids(household_id, user_data["id"]): + factor_sum += user_data["factor"] + for user_data in args["paid_for"]: + member_for = HouseholdMember.find_by_ids(household_id, user_data["id"]) + if member_for: + con = ExpensePaidFor( + factor=user_data["factor"], + ) + con.user_id = member_for.user_id + con.expense = expense + con.save() + member_for.expense_balance = (member_for.expense_balance or 0) - ( + con.factor / factor_sum + ) * expense.amount + member_for.save() + return jsonify(expense.obj_to_dict()) + + +@expense.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateExpense) +def updateExpense(args, id): # noqa: C901 + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + expense.checkAuthorized() + + if "name" in args: + expense.name = args["name"] + if "amount" in args: + expense.amount = args["amount"] + if "date" in args: + expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) + if "photo" in args and args["photo"] != expense.photo: + expense.photo = file_has_access_or_download(args["photo"], expense.photo) + if "category" in args: + if args["category"] is not None: + category = ExpenseCategory.find_by_id(args["category"]) + expense.category = category + else: + expense.category = None + if "exclude_from_statistics" in args: + expense.exclude_from_statistics = args["exclude_from_statistics"] + if "paid_by" in args: + member = HouseholdMember.find_by_ids( + expense.household_id, args["paid_by"]["id"] + ) + if member: + expense.paid_by_id = member.user_id + expense.save() + if "paid_for" in args: + for con in expense.paid_for: + user_ids = [e["id"] for e in args["paid_for"]] + if con.user.id not in user_ids: + con.delete() + for user_data in args["paid_for"]: + member = HouseholdMember.find_by_ids(expense.household_id, user_data["id"]) + if member: + con = ExpensePaidFor.find_by_ids(expense.id, member.user_id) + if con: + if "factor" in user_data and user_data["factor"]: + con.factor = user_data["factor"] + else: + con = ExpensePaidFor( + factor=user_data["factor"], + ) + con.expense = expense + con.user_id = member.user_id + con.save() + recalculateBalances(expense.household_id) + return jsonify(expense.obj_to_dict()) + + +@expense.route("/", methods=["DELETE"]) +@jwt_required() +def deleteExpenseById(id): + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + expense.checkAuthorized() + + expense.delete() + recalculateBalances(expense.household_id) + return jsonify({"msg": "DONE"}) + + +@expenseHousehold.route("/recalculate-balances") +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +def calculateBalances(household_id): + recalculateBalances(household_id) + + +@expenseHousehold.route("/categories", methods=["GET"]) +@jwt_required() +@authorize_household() +def getExpenseCategories(household_id): + return jsonify( + [ + e.obj_to_dict() + for e in ExpenseCategory.all_from_household_by_name(household_id) + ] + ) + + +@expenseHousehold.route("/overview", methods=["GET"]) +@jwt_required() +@authorize_household() +@validate_args(GetExpenseOverview) +def getExpenseOverview(args, household_id): + thisMonthStart = datetime.utcnow().date().replace(day=1) + + steps = args["steps"] if "steps" in args else 5 + frame = args["frame"] if args["frame"] != None else 2 + page = args["page"] if "page" in args and args["page"] != None else 0 + + factor = 1 + by_category_query = ( + Expense.query.filter( + Expense.household_id == household_id, + Expense.exclude_from_statistics == False, + ) + .group_by(Expense.category_id, ExpenseCategory.id) + .join(Expense.category, isouter=True) + ) + + groupByStr = "YYYY-MM" if "postgresql" in db.engine.name else "%Y-%m" + if frame < 3: + groupByStr += "-DD" if "postgresql" in db.engine.name else "-%d" + if frame < 1: + groupByStr += " HH24" if "postgresql" in db.engine.name else" %H" + + by_subframe_query = Expense.query.filter( + Expense.household_id == household_id, + Expense.exclude_from_statistics == False, + ).group_by( + func.to_char(Expense.date, groupByStr).label("day") + if "postgresql" in db.engine.name + else func.strftime(groupByStr, Expense.date) + ) + + if "view" in args and args["view"] == 1: + filterQuery = ( + db.session.query(ExpensePaidFor.expense_id) + .filter(ExpensePaidFor.user_id == current_user.id) + .scalar_subquery() + ) + + s1 = ( + ExpensePaidFor.query.with_entities( + ExpensePaidFor.expense_id.label("expense_id"), + func.sum(ExpensePaidFor.factor).label("total"), + ) + .group_by(ExpensePaidFor.expense_id) + .subquery() + ) + s2 = ( + ExpensePaidFor.query.with_entities( + ExpensePaidFor.expense_id.label("expense_id"), + (ExpensePaidFor.factor.cast(db.Float) / s1.c.total).label("factor"), + ) + .filter(ExpensePaidFor.user_id == current_user.id) + .join(s1, ExpensePaidFor.expense_id == s1.c.expense_id) + .subquery() + ) + + factor = s2.c.factor + + by_category_query = by_category_query.filter(Expense.id.in_(filterQuery)).join( + s2 + ) + by_subframe_query = by_subframe_query.filter(Expense.id.in_(filterQuery)).join( + s2 + ) + + def getFilterForStepAgo(stepAgo: int): + start = None + end = None + if frame == 0: # daily + start = datetime.utcnow().date() - timedelta(days=stepAgo) + end = start + timedelta(hours=24) + elif frame == 1: # weekly + start = datetime.utcnow().date() - relativedelta( + days=7, weekday=calendar.MONDAY, weeks=stepAgo + ) + end = start + timedelta(days=7) + elif frame == 2: # monthly + start = thisMonthStart - relativedelta(months=stepAgo) + end = start + relativedelta(months=1) + elif frame == 3: # yearly + start = datetime.utcnow().date().replace(day=1, month=1) - relativedelta( + years=stepAgo + ) + end = start + relativedelta(years=1) + + return Expense.date >= start, Expense.date <= end + + def getOverviewForStepAgo(stepAgo: int): + return { + "by_category": { + (e.id or -1): (float(e.balance) or 0) + for e in by_category_query.with_entities( + ExpenseCategory.id.label("id"), + func.sum(Expense.amount * factor).label("balance"), + ) + .filter(*getFilterForStepAgo(stepAgo)) + .all() + }, + "by_subframe": { + e.day: (float(e.balance) or 0) + for e in by_subframe_query.with_entities( + func.to_char(Expense.date, groupByStr).label("day") + if "postgresql" in db.engine.name + else func.strftime(groupByStr, Expense.date).label("day"), + func.sum(Expense.amount * factor).label("balance"), + ) + .filter(*getFilterForStepAgo(stepAgo)) + .all() + }, + } + + byStep = { + i: getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps) + } + + return jsonify(byStep) + + +@expenseHousehold.route("/categories", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddExpenseCategory) +def addExpenseCategory(args, household_id): + category = ExpenseCategory() + category.name = args["name"] + category.color = args["color"] + category.household_id = household_id + category.save() + return jsonify(category.obj_to_dict()) + + +@expense.route("/categories/", methods=["DELETE"]) +@jwt_required() +def deleteExpenseCategoryById(id): + category = ExpenseCategory.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + category.delete() + return jsonify({"msg": "DONE"}) + + +@expense.route("/categories/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateExpenseCategory) +def updateExpenseCategory(args, id): + category = ExpenseCategory.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + + if "name" in args: + category.name = args["name"] + if "color" in args: + category.color = args["color"] + + category.save() + + if "merge_category_id" in args and args["merge_category_id"] != id: + mergeCategory = ExpenseCategory.find_by_id(args["merge_category_id"]) + if mergeCategory: + category.merge(mergeCategory) + + return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py new file mode 100644 index 00000000..08af3593 --- /dev/null +++ b/backend/app/controller/expense/schemas.py @@ -0,0 +1,82 @@ +from marshmallow import fields, Schema + +from app.util import MultiDictList + + +class CustomInteger(fields.Integer): + def _deserialize(self, value, attr, data, **kwargs): + if not value: + return None + return super()._deserialize(value, attr, data, **kwargs) + + +class GetExpenses(Schema): + view = fields.Integer() + startAfterId = fields.Integer(validate=lambda a: a >= 0) + startAfterDate = fields.Integer(validate=lambda a: a >= 0) + endBeforeDate = fields.Integer(validate=lambda a: a >= 0) + filter = MultiDictList(CustomInteger(allow_none=True)) + + +class AddExpense(Schema): + class User(Schema): + id = fields.Integer(required=True, validate=lambda a: a > 0) + name = fields.String(validate=lambda a: a and not a.isspace()) + factor = fields.Integer(load_default=1) + + name = fields.String(required=True) + amount = fields.Float(required=True) + date = fields.Integer() + photo = fields.String() + category = fields.Integer(allow_none=True) + paid_by = fields.Nested(User(), required=True) + paid_for = fields.List( + fields.Nested(User()), required=True, validate=lambda a: len(a) > 0 + ) + exclude_from_statistics = fields.Boolean() + + +class UpdateExpense(Schema): + class User(Schema): + id = fields.Integer(required=True, validate=lambda a: a > 0) + name = fields.String(validate=lambda a: a and not a.isspace()) + factor = fields.Integer(load_default=1) + + name = fields.String() + amount = fields.Float() + date = fields.Integer() + photo = fields.String() + category = fields.Integer(allow_none=True) + paid_by = fields.Nested(User()) + paid_for = fields.List(fields.Nested(User())) + exclude_from_statistics = fields.Boolean() + + +class AddExpenseCategory(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) + + +class UpdateExpenseCategory(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) + + # if set this merges the specified category into this category thus combining them to one + merge_category_id = fields.Integer( + validate=lambda a: a > 0, + allow_none=True, + ) + + +class GetExpenseOverview(Schema): + # household = 0, personal = 1 + view = fields.Integer() + # daily = 0, weekly = 1, montly = 2, yearly = 3 + frame = fields.Integer(validate=lambda a: a >= 0 and a <= 3) + # how many frames are looked at + steps = fields.Integer(validate=lambda a: a > 0) + # used for pagination (i.e. start of steps, now=0) + page = fields.Integer( + validate=lambda a: a >= 0, + allow_none=True, + ) diff --git a/backend/app/controller/exportimport/__init__.py b/backend/app/controller/exportimport/__init__.py new file mode 100644 index 00000000..a155c887 --- /dev/null +++ b/backend/app/controller/exportimport/__init__.py @@ -0,0 +1,2 @@ +from .export_controller import export +from .import_controller import importBP diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py new file mode 100644 index 00000000..cdd082dc --- /dev/null +++ b/backend/app/controller/exportimport/export_controller.py @@ -0,0 +1,46 @@ +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required +from app.errors import NotFoundRequest +from app.helpers import authorize_household +from app.models import Item, Recipe, Household + +export = Blueprint("export", __name__) + + +@export.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getExportAll(household_id): + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + + return household.obj_to_export_dict() + + +@export.route("/items", methods=["GET"]) +@jwt_required() +@authorize_household() +def getExportItems(household_id): + return jsonify( + { + "items": [ + e.obj_to_export_dict() + for e in Item.all_from_household_by_name(household_id) + ] + } + ) + + +@export.route("/recipes", methods=["GET"]) +@jwt_required() +@authorize_household() +def getExportRecipes(household_id): + return jsonify( + { + "recipes": [ + e.obj_to_export_dict() + for e in Recipe.all_from_household_by_name(household_id) + ] + } + ) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py new file mode 100644 index 00000000..8cdae92b --- /dev/null +++ b/backend/app/controller/exportimport/import_controller.py @@ -0,0 +1,53 @@ +import time +from app.config import app +from app.models import Household +from app.service.importServices import ( + importItem, + importRecipe, + importExpense, + importShoppinglist, +) +from app.service.recalculate_balances import recalculateBalances +from .schemas import ImportSchema +from app.helpers import validate_args, authorize_household +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required + +importBP = Blueprint("import", __name__) + + +@importBP.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(ImportSchema) +def importData(args, household_id): + household = Household.find_by_id(household_id) + if not household: + return + + app.logger.info("Starting import...") + + t0 = time.time() + if "items" in args: + for item in args["items"]: + importItem(household, item) + + if "recipes" in args: + for recipe in args["recipes"]: + importRecipe( + household_id, + recipe, + args["recipe_overwrite"] if "recipe_overwrite" in args else False, + ) + + if "expenses" in args: + for expense in args["expenses"]: + importExpense(household, expense) + recalculateBalances(household.id) + + if "shoppinglists" in args: + for shoppinglist in args["shoppinglists"]: + importShoppinglist(household, shoppinglist) + + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py new file mode 100644 index 00000000..47a077ed --- /dev/null +++ b/backend/app/controller/exportimport/schemas.py @@ -0,0 +1,64 @@ +from marshmallow import EXCLUDE, fields, Schema + + +class ImportSchema(Schema): + class Meta: + unknown = EXCLUDE + + class Item(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + category = fields.String(validate=lambda a: a and not a.isspace()) + icon = fields.String() + + class Recipe(Schema): + class Meta: + unknown = EXCLUDE + + class RecipeItem(Schema): + name = fields.String( + required=True, validate=lambda a: a and not a.isspace() + ) + optional = fields.Boolean(load_default=False) + description = fields.String(load_default="") + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(load_default="") + time = fields.Integer(allow_none=True) + cook_time = fields.Integer(allow_none=True) + prep_time = fields.Integer(allow_none=True) + yields = fields.Integer(allow_none=True) + source = fields.String(allow_none=True) + photo = fields.String(allow_none=True) + items = fields.List(fields.Nested(RecipeItem)) + tags = fields.List(fields.String()) + + class Expense(Schema): + class Meta: + unknown = EXCLUDE + + class PaidFor(Schema): + username = fields.String( + required=True, validate=lambda a: a and not a.isspace() + ) + factor = fields.Integer(load_default=1) + + class Category(Schema): + name = fields.String( + required=True, validate=lambda a: a and not a.isspace() + ) + color = fields.Integer(allow_none=True) + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + amount = fields.Float(required=True) + date = fields.Integer() + paid_by = fields.String(required=True, validate=lambda a: a and not a.isspace()) + paid_for = fields.List(fields.Nested(PaidFor)) + photo = fields.String(allow_none=True) + category = fields.Nested(Category) + + items = fields.List(fields.Nested(Item)) + recipes = fields.List(fields.Nested(Recipe)) + recipe_overwrite = fields.Boolean() + expenses = fields.List(fields.Nested(Expense)) + member = fields.List(fields.String()) + shoppinglists = fields.List(fields.String()) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py new file mode 100644 index 00000000..d3991ed0 --- /dev/null +++ b/backend/app/controller/health_controller.py @@ -0,0 +1,34 @@ +from flask import jsonify, Blueprint +from app.config import ( + BACKEND_VERSION, + MIN_FRONTEND_VERSION, + PRIVACY_POLICY_URL, + OPEN_REGISTRATION, + EMAIL_MANDATORY, +) +from app.models import Settings +from app.config import SUPPORTED_LANGUAGES, oidc_clients + +health = Blueprint("health", __name__) + + +@health.route("", methods=["GET"]) +def get_health(): + info = { + "msg": "OK", + "version": BACKEND_VERSION, + "min_frontend_version": MIN_FRONTEND_VERSION, + "oidc_provider": list(oidc_clients.keys()) + } + if PRIVACY_POLICY_URL: + info["privacy_policy"] = PRIVACY_POLICY_URL + if OPEN_REGISTRATION: + info["open_registration"] = True + if EMAIL_MANDATORY: + info["email_mandatory"] = True + return jsonify(info) + + +@health.route("/supported-languages", methods=["GET"]) +def getSupportedLanguages(): + return jsonify(SUPPORTED_LANGUAGES) diff --git a/backend/app/controller/household/__init__.py b/backend/app/controller/household/__init__.py new file mode 100644 index 00000000..ec59be65 --- /dev/null +++ b/backend/app/controller/household/__init__.py @@ -0,0 +1 @@ +from .household_controller import household diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py new file mode 100644 index 00000000..f1f08530 --- /dev/null +++ b/backend/app/controller/household/household_controller.py @@ -0,0 +1,149 @@ +import gevent +from app.config import SUPPORTED_LANGUAGES +from app.helpers import validate_args, authorize_household, RequiredRights +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import current_user, jwt_required +from app.models import Household, HouseholdMember, Shoppinglist, User +from app.service.import_language import importLanguage +from app.service.file_has_access_or_download import file_has_access_or_download +from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember +from flask_socketio import close_room + +household = Blueprint("household", __name__) + + +@household.route("", methods=["GET"]) +@jwt_required() +def getUserHouseholds(): + return jsonify( + [ + e.household.obj_to_dict() + for e in HouseholdMember.find_by_user(current_user.id) + ] + ) + + +@household.route("/", methods=["GET"]) +@jwt_required() +@authorize_household() +def getHousehold(household_id): + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + return jsonify(household.obj_to_dict()) + + +@household.route("", methods=["POST"]) +@jwt_required() +@validate_args(AddHousehold) +def addHousehold(args): + household = Household() + household.name = args["name"] + if "photo" in args and args["photo"] != household.photo: + household.photo = file_has_access_or_download(args["photo"], household.photo) + if "language" in args and args["language"] in SUPPORTED_LANGUAGES: + household.language = args["language"] + if "planner_feature" in args: + household.planner_feature = args["planner_feature"] + if "expenses_feature" in args: + household.expenses_feature = args["expenses_feature"] + if "view_ordering" in args: + household.view_ordering = args["view_ordering"] + household.save() + + member = HouseholdMember() + member.household_id = household.id + member.user_id = current_user.id + member.owner = True + member.save() + + if "member" in args: + for uid in args["member"]: + if uid == current_user.id: + continue + if not User.find_by_id(uid): + continue + member = HouseholdMember() + member.household_id = household.id + member.user_id = uid + member.save() + + Shoppinglist(name="Default", household_id=household.id).save() + + if household.language: + gevent.spawn(importLanguage, household.id, household.language, True) + + return jsonify(household.obj_to_dict()) + + +@household.route("/", methods=["POST"]) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +@validate_args(UpdateHousehold) +def updateHousehold(args, household_id): + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + + if "name" in args: + household.name = args["name"] + if "photo" in args and args["photo"] != household.photo: + household.photo = file_has_access_or_download(args["photo"], household.photo) + if ( + "language" in args + and not household.language + and args["language"] in SUPPORTED_LANGUAGES + ): + household.language = args["language"] + gevent.spawn(importLanguage, household.id, household.language) + if "planner_feature" in args: + household.planner_feature = args["planner_feature"] + if "expenses_feature" in args: + household.expenses_feature = args["expenses_feature"] + if "view_ordering" in args: + household.view_ordering = args["view_ordering"] + + household.save() + return jsonify(household.obj_to_dict()) + + +@household.route("/", methods=["DELETE"]) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +def deleteHouseholdById(household_id): + if Household.delete_by_id(household_id): + close_room(household_id) + return jsonify({"msg": "DONE"}) + + +@household.route("//member/", methods=["PUT"]) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +@validate_args(UpdateHouseholdMember) +def putHouseholdMember(args, household_id, user_id): + hm = HouseholdMember.find_by_ids(household_id, user_id) + if not hm: + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + hm = HouseholdMember() + hm.household_id = household_id + hm.user_id = user_id + + if "admin" in args: + hm.admin = args["admin"] + + hm.save() + + return jsonify(hm.obj_to_user_dict()) + + +@household.route("//member/", methods=["DELETE"]) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN_OR_SELF) +def deleteHouseholdMember(household_id, user_id): + hm = HouseholdMember.find_by_ids(household_id, user_id) + if hm: + hm.delete() + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py new file mode 100644 index 00000000..9bb52c2c --- /dev/null +++ b/backend/app/controller/household/schemas.py @@ -0,0 +1,33 @@ +from marshmallow import fields, Schema, EXCLUDE + + +class AddHousehold(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + photo = fields.String() + language = fields.String() + planner_feature = fields.Boolean() + expenses_feature = fields.Boolean() + view_ordering = fields.List(fields.String) + member = fields.List(fields.Integer) + + +class UpdateHousehold(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.String(validate=lambda a: a and not a.isspace()) + photo = fields.String() + language = fields.String() + planner_feature = fields.Boolean() + expenses_feature = fields.Boolean() + view_ordering = fields.List(fields.String) + + +class UpdateHouseholdMember(Schema): + class Meta: + unknown = EXCLUDE + + admin = fields.Boolean() diff --git a/backend/app/controller/item/__init__.py b/backend/app/controller/item/__init__.py new file mode 100644 index 00000000..04c4b1c0 --- /dev/null +++ b/backend/app/controller/item/__init__.py @@ -0,0 +1 @@ +from .item_controller import item, itemHousehold diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py new file mode 100644 index 00000000..61927cb0 --- /dev/null +++ b/backend/app/controller/item/item_controller.py @@ -0,0 +1,102 @@ +from app.helpers import validate_args, authorize_household +from flask import jsonify, Blueprint +from app.errors import InvalidUsage, NotFoundRequest +import app.util.description_splitter as description_splitter +from flask_jwt_extended import jwt_required +from app.models import Item, RecipeItems, Recipe, Category +from .schemas import SearchByNameRequest, UpdateItem + +item = Blueprint("item", __name__) +itemHousehold = Blueprint("item", __name__) + + +@itemHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllItems(household_id): + return jsonify( + [e.obj_to_dict() for e in Item.all_from_household_by_name(household_id)] + ) + + +@item.route("/", methods=["GET"]) +@jwt_required() +def getItem(id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + return jsonify(item.obj_to_dict()) + + +@item.route("//recipes", methods=["GET"]) +@jwt_required() +def getItemRecipes(id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + recipe = ( + RecipeItems.query.filter(RecipeItems.item_id == id) + .join(RecipeItems.recipe) # noqa + .order_by(Recipe.name) + .all() + ) + return jsonify([e.obj_to_recipe_dict() for e in recipe]) + + +@item.route("/", methods=["DELETE"]) +@jwt_required() +def deleteItemById(id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + item.delete() + return jsonify({"msg": "DONE"}) + + +@itemHousehold.route("/search", methods=["GET"]) +@jwt_required() +@authorize_household() +@validate_args(SearchByNameRequest) +def searchItemByName(args, household_id): + query, description = description_splitter.split(args["query"]) + return jsonify( + [ + e.obj_to_dict() | {"description": description} + for e in Item.search_name(query, household_id) + ] + ) + + +@item.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateItem) +def updateItem(args, id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + + if "category" in args: + if not args["category"]: + item.category = None + elif "id" in args["category"]: + item.category = Category.find_by_id(args["category"]["id"]) + else: + raise InvalidUsage() + if "icon" in args: + item.icon = args["icon"] + if "name" in args and args["name"] != item.name: + newName: str = args["name"].strip() + if not Item.find_by_name(item.household_id, newName): + item.name = newName + item.save() + + if "merge_item_id" in args and args["merge_item_id"] != id: + mergeItem = Item.find_by_id(args["merge_item_id"]) + if mergeItem: + item.merge(mergeItem) + + return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py new file mode 100644 index 00000000..c0fefb4e --- /dev/null +++ b/backend/app/controller/item/schemas.py @@ -0,0 +1,33 @@ +from marshmallow import fields, Schema, EXCLUDE + + +class SearchByNameRequest(Schema): + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class UpdateItem(Schema): + class Meta: + unknown = EXCLUDE + + class Category(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.Integer(required=True, validate=lambda a: a > 0) + name = fields.String(validate=lambda a: not a or a and not a.isspace()) + + category = fields.Nested(Category(), allow_none=True) + icon = fields.String( + validate=lambda a: not a or not a.isspace(), + allow_none=True, + ) + name = fields.String( + validate=lambda a: not a or not a.isspace(), + allow_none=True, + ) + + # if set this merges the specified item into this item thus combining them to one + merge_item_id = fields.Integer( + validate=lambda a: a > 0, + allow_none=True, + ) diff --git a/backend/app/controller/onboarding/__init__.py b/backend/app/controller/onboarding/__init__.py new file mode 100644 index 00000000..1334d479 --- /dev/null +++ b/backend/app/controller/onboarding/__init__.py @@ -0,0 +1 @@ +from .onboarding_controller import onboarding diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py new file mode 100644 index 00000000..196b5c8a --- /dev/null +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -0,0 +1,33 @@ +from app.helpers import validate_args +from flask import jsonify, Blueprint +from app.models import User, Token +from .schemas import OnboardSchema + +onboarding = Blueprint("onboarding", __name__) + + +@onboarding.route("", methods=["GET"]) +def isOnboarding(): + onboarding = User.count() == 0 + return jsonify({"onboarding": onboarding}) + + +@onboarding.route("", methods=["POST"]) +@validate_args(OnboardSchema) +def onboard(args): + if User.count() > 0: + return jsonify({"msg": "Onboarding not allowed"}), 403 + + user = User.create(args["username"], args["password"], args["name"], admin=True) + + device = "Unkown" + if "device" in args: + device = args["device"] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py new file mode 100644 index 00000000..ecc5af6e --- /dev/null +++ b/backend/app/controller/onboarding/schemas.py @@ -0,0 +1,20 @@ +from marshmallow import fields, Schema +from app.config import SUPPORTED_LANGUAGES + + +class OnboardSchema(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + username = fields.String( + required=True, + validate=lambda a: a and not a.isspace() and not "@" in a, + ) + password = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + device = fields.String( + required=False, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) diff --git a/backend/app/controller/planner/__init__.py b/backend/app/controller/planner/__init__.py new file mode 100644 index 00000000..efb760d7 --- /dev/null +++ b/backend/app/controller/planner/__init__.py @@ -0,0 +1 @@ +from .planner_controller import plannerHousehold diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py new file mode 100644 index 00000000..14cb5fe4 --- /dev/null +++ b/backend/app/controller/planner/planner_controller.py @@ -0,0 +1,113 @@ +from app.errors import NotFoundRequest +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required +from app import db +from app.helpers import validate_args, authorize_household +from app.models import Recipe, RecipeHistory, Planner +from .schemas import AddPlannedRecipe, RemovePlannedRecipe + +plannerHousehold = Blueprint("planner", __name__) + + +@plannerHousehold.route("/recipes", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllPlannedRecipes(household_id): + plannedRecipes = ( + db.session.query(Planner.recipe_id) + .filter(Planner.household_id == household_id) + .group_by(Planner.recipe_id) + .scalar_subquery() + ) + recipes = ( + Recipe.query.filter(Recipe.id.in_(plannedRecipes)).order_by(Recipe.name).all() + ) + return jsonify([e.obj_to_full_dict() for e in recipes]) + + +@plannerHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getPlanner(household_id): + plans = Planner.all_from_household(household_id) + return jsonify([e.obj_to_full_dict() for e in plans]) + + +@plannerHousehold.route("/recipe", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddPlannedRecipe) +def addPlannedRecipe(args, household_id): + recipe = Recipe.find_by_id(args["recipe_id"]) + if not recipe: + raise NotFoundRequest() + day = args["day"] if "day" in args else -1 + planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) + if not planner: + if day >= 0: + old = Planner.find_by_day(household_id, recipe_id=recipe.id, day=-1) + if old: + old.delete() + elif len(recipe.plans) > 0: + return jsonify(recipe.obj_to_dict()) + planner = Planner() + planner.recipe_id = recipe.id + planner.household_id = household_id + planner.day = day + if "yields" in args: + planner.yields = args["yields"] + planner.save() + + RecipeHistory.create_added(recipe, household_id) + + return jsonify(recipe.obj_to_dict()) + + +@plannerHousehold.route("/recipe/", methods=["DELETE"]) +@jwt_required() +@authorize_household() +@validate_args(RemovePlannedRecipe) +def removePlannedRecipeById(args, household_id, id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + + day = args["day"] if "day" in args else -1 + planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) + if planner: + planner.delete() + RecipeHistory.create_dropped(recipe, household_id) + return jsonify(recipe.obj_to_dict()) + + +@plannerHousehold.route("/recent-recipes", methods=["GET"]) +@jwt_required() +@authorize_household() +def getRecentRecipes(household_id): + recipes = RecipeHistory.get_recent(household_id) + return jsonify([e.recipe.obj_to_full_dict() for e in recipes]) + + +@plannerHousehold.route("/suggested-recipes", methods=["GET"]) +@jwt_required() +@authorize_household() +def getSuggestedRecipes(household_id): + # all suggestions + suggested_recipes = Recipe.find_suggestions(household_id) + # remove recipes on recent list + recents = [e.recipe.id for e in RecipeHistory.get_recent(household_id)] + suggested_recipes = [s for s in suggested_recipes if s.id not in recents] + # limit suggestions number to maximally 9 + if len(suggested_recipes) > 9: + suggested_recipes = suggested_recipes[:9] + return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) + + +@plannerHousehold.route("/refresh-suggested-recipes", methods=["GET", "POST"]) +@jwt_required() +@authorize_household() +def getRefreshedSuggestedRecipes(household_id): + # re-compute suggestion ranking + Recipe.compute_suggestion_ranking(household_id) + # return suggested recipes + return getSuggestedRecipes(household_id=household_id) diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py new file mode 100644 index 00000000..40e6a090 --- /dev/null +++ b/backend/app/controller/planner/schemas.py @@ -0,0 +1,24 @@ +from marshmallow import fields, Schema, EXCLUDE +from marshmallow.validate import Range + + +class AddPlannedRecipe(Schema): + class Meta: + unknown = EXCLUDE + + recipe_id = fields.Integer( + required=True, + ) + day = fields.Integer( + validate=Range(min=0, min_inclusive=True, max=6, max_inclusive=True) + ) + yields = fields.Integer() + + +class RemovePlannedRecipe(Schema): + class Meta: + unknown = EXCLUDE + + day = fields.Integer( + validate=Range(min=0, min_inclusive=True, max=6, max_inclusive=True) + ) diff --git a/backend/app/controller/recipe/__init__.py b/backend/app/controller/recipe/__init__.py new file mode 100644 index 00000000..d8cf238a --- /dev/null +++ b/backend/app/controller/recipe/__init__.py @@ -0,0 +1 @@ +from .recipe_controller import recipe, recipeHousehold diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py new file mode 100644 index 00000000..1d87ba50 --- /dev/null +++ b/backend/app/controller/recipe/recipe_controller.py @@ -0,0 +1,253 @@ +import re + +from app.errors import NotFoundRequest, InvalidUsage +from app.models.recipe import RecipeItems, RecipeTags +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required +from app.helpers import validate_args, authorize_household +from app.models import Recipe, Item, Tag +from recipe_scrapers import scrape_me +from recipe_scrapers._exceptions import SchemaOrgException, NoSchemaFoundInWildMode +from ingredient_parser import parse_ingredient + +from app.service.file_has_access_or_download import file_has_access_or_download +from .schemas import ( + SearchByNameRequest, + AddRecipe, + UpdateRecipe, + GetAllFilterRequest, + ScrapeRecipe, +) + +recipe = Blueprint("recipe", __name__) +recipeHousehold = Blueprint("recipe", __name__) + + +@recipeHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllRecipes(household_id): + return jsonify( + [e.obj_to_full_dict() for e in Recipe.all_from_household_by_name(household_id)] + ) + + +@recipe.route("/", methods=["GET"]) +@jwt_required() +def getRecipeById(id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + recipe.checkAuthorized() + return jsonify(recipe.obj_to_full_dict()) + + +@recipeHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddRecipe) +def addRecipe(args, household_id): + recipe = Recipe() + recipe.name = args["name"] + recipe.description = args["description"] + recipe.household_id = household_id + if "time" in args: + recipe.time = args["time"] + if "cook_time" in args: + recipe.cook_time = args["cook_time"] + if "prep_time" in args: + recipe.prep_time = args["prep_time"] + if "yields" in args: + recipe.yields = args["yields"] + if "source" in args: + recipe.source = args["source"] + if "photo" in args and args["photo"] != recipe.photo: + recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) + recipe.save() + if "items" in args: + for recipeItem in args["items"]: + item = Item.find_by_name(household_id, recipeItem["name"]) + if not item: + item = Item.create_by_name(household_id, recipeItem["name"]) + con = RecipeItems( + description=recipeItem["description"], optional=recipeItem["optional"] + ) + con.item = item + con.recipe = recipe + con.save() + if "tags" in args: + for tagName in args["tags"]: + tag = Tag.find_by_name(household_id, tagName) + if not tag: + tag = Tag.create_by_name(household_id, tagName) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() + return jsonify(recipe.obj_to_full_dict()) + + +@recipe.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateRecipe) +def updateRecipe(args, id): # noqa: C901 + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + recipe.checkAuthorized() + + if "name" in args: + recipe.name = args["name"] + if "description" in args: + recipe.description = args["description"] + if "time" in args: + recipe.time = args["time"] + if "cook_time" in args: + recipe.cook_time = args["cook_time"] + if "prep_time" in args: + recipe.prep_time = args["prep_time"] + if "yields" in args: + recipe.yields = args["yields"] + if "source" in args: + recipe.source = args["source"] + if "photo" in args and args["photo"] != recipe.photo: + recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) + recipe.save() + if "items" in args: + for con in recipe.items: + item_names = [e["name"] for e in args["items"]] + if con.item.name not in item_names: + con.delete() + for recipeItem in args["items"]: + item = Item.find_by_name(recipe.household_id, recipeItem["name"]) + if not item: + item = Item.create_by_name(recipe.household_id, recipeItem["name"]) + con = RecipeItems.find_by_ids(recipe.id, item.id) + if con: + if "description" in recipeItem: + con.description = recipeItem["description"] + if "optional" in recipeItem: + con.optional = recipeItem["optional"] + else: + con = RecipeItems( + description=recipeItem["description"], + optional=recipeItem["optional"], + ) + con.item = item + con.recipe = recipe + con.save() + if "tags" in args: + for con in recipe.tags: + if con.tag.name not in args["tags"]: + con.delete() + for recipeTag in args["tags"]: + tag = Tag.find_by_name(recipe.household_id, recipeTag) + if not tag: + tag = Tag.create_by_name(recipe.household_id, recipeTag) + con = RecipeTags.find_by_ids(recipe.id, tag.id) + if not con: + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() + return jsonify(recipe.obj_to_full_dict()) + + +@recipe.route("/", methods=["DELETE"]) +@jwt_required() +def deleteRecipeById(id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + recipe.checkAuthorized() + recipe.delete() + return jsonify({"msg": "DONE"}) + + +@recipeHousehold.route("/search", methods=["GET"]) +@jwt_required() +@authorize_household() +@validate_args(SearchByNameRequest) +def searchRecipeByName(args, household_id): + if "only_ids" in args and args["only_ids"]: + return jsonify([e.id for e in Recipe.search_name(household_id, args["query"])]) + return jsonify( + [e.obj_to_full_dict() for e in Recipe.search_name(household_id, args["query"])] + ) + + +@recipeHousehold.route("/filter", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(GetAllFilterRequest) +def getAllFiltered(args, household_id): + return jsonify( + [ + e.obj_to_full_dict() + for e in Recipe.all_by_name_with_filter(household_id, args["filter"]) + ] + ) + + +@recipeHousehold.route("/scrape", methods=["GET", "POST"]) +@jwt_required() +@authorize_household() +@validate_args(ScrapeRecipe) +def scrapeRecipe(args, household_id): + try: + scraper = scrape_me(args["url"], wild_mode=True) + except NoSchemaFoundInWildMode: + return "Unsupported website", 400 + recipe = Recipe() + recipe.name = scraper.title() + try: + recipe.time = int(scraper.total_time()) + except (NotImplementedError, ValueError, SchemaOrgException): + pass + try: + recipe.cook_time = int(scraper.cook_time()) + except (NotImplementedError, ValueError, SchemaOrgException): + pass + try: + recipe.prep_time = int(scraper.prep_time()) + except (NotImplementedError, ValueError, SchemaOrgException): + pass + try: + yields = re.search(r"\d*", scraper.yields()) + if yields: + recipe.yields = int(yields.group()) + except (NotImplementedError, ValueError, SchemaOrgException): + pass + description = "" + try: + description = scraper.description() + "\n\n" + except (NotImplementedError, ValueError, SchemaOrgException): + pass + try: + description = description + scraper.instructions() + except (NotImplementedError, ValueError, SchemaOrgException): + pass + recipe.description = description + recipe.photo = scraper.image() + recipe.source = args["url"] + items = {} + for ingredient in scraper.ingredients(): + parsed = parse_ingredient(ingredient) + name = parsed.name.text if parsed.name else ingredient + item = Item.find_by_name(household_id, name) + if item: + description = f"{parsed.amount[0].quantity if len(parsed.amount) > 0 else ''} {parsed.amount[0].unit if len(parsed.amount) > 0 else ''}" + # description = description + (" " if description else "") + (parsed.comment.text if parsed.comment else "") # Usually cooking instructions + + items[ingredient] = item.obj_to_dict() | { + "description": description, + "optional": False, + } + else: + items[ingredient] = None + return jsonify( + { + "recipe": recipe.obj_to_dict(), + "items": items, + } + ) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py new file mode 100644 index 00000000..201c9575 --- /dev/null +++ b/backend/app/controller/recipe/schemas.py @@ -0,0 +1,63 @@ +from marshmallow import fields, Schema + + +class AddRecipe(Schema): + class RecipeItem(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(load_default="") + optional = fields.Boolean(load_default=True) + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(validate=lambda a: a is not None) + time = fields.Integer(validate=lambda a: a >= 0) + cook_time = fields.Integer(validate=lambda a: a >= 0) + prep_time = fields.Integer(validate=lambda a: a >= 0) + yields = fields.Integer(validate=lambda a: a >= 0) + source = fields.String() + photo = fields.String() + items = fields.List(fields.Nested(RecipeItem())) + tags = fields.List(fields.String()) + + +class UpdateRecipe(Schema): + class RecipeItem(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String() + optional = fields.Boolean(load_default=True) + + name = fields.String(validate=lambda a: a and not a.isspace()) + description = fields.String(validate=lambda a: a is not None) + time = fields.Integer(validate=lambda a: a >= 0) + cook_time = fields.Integer(validate=lambda a: a >= 0) + prep_time = fields.Integer(validate=lambda a: a >= 0) + yields = fields.Integer(validate=lambda a: a >= 0) + source = fields.String() + photo = fields.String() + items = fields.List(fields.Nested(RecipeItem())) + tags = fields.List(fields.String()) + + +class SearchByNameRequest(Schema): + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) + only_ids = fields.Boolean( + default=False, + ) + + +class GetAllFilterRequest(Schema): + filter = fields.List(fields.String()) + + +class AddItemByName(Schema): + name = fields.String(required=True) + description = fields.String() + + +class RemoveItem(Schema): + item_id = fields.Integer( + required=True, + ) + + +class ScrapeRecipe(Schema): + url = fields.String(required=True, validate=lambda a: a and not a.isspace()) diff --git a/backend/app/controller/settings/__init__.py b/backend/app/controller/settings/__init__.py new file mode 100644 index 00000000..8f434e83 --- /dev/null +++ b/backend/app/controller/settings/__init__.py @@ -0,0 +1 @@ +from .settings_controller import settings diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py new file mode 100644 index 00000000..872163e9 --- /dev/null +++ b/backend/app/controller/settings/schemas.py @@ -0,0 +1,5 @@ +from marshmallow import fields, Schema + + +class SetSettingsSchema(Schema): + pass diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py new file mode 100644 index 00000000..a96d3d56 --- /dev/null +++ b/backend/app/controller/settings/settings_controller.py @@ -0,0 +1,22 @@ +from .schemas import SetSettingsSchema +from app.helpers import validate_args, server_admin_required +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required +from app.models import Settings + +settings = Blueprint("settings", __name__) + + +@settings.route("", methods=["POST"]) +@jwt_required() +@server_admin_required() +def setSettings(): + settings = Settings.get() + settings.save() + return jsonify(settings.obj_to_dict()) + + +@settings.route("", methods=["GET"]) +@jwt_required() +def getSettings(): + return jsonify(Settings.get().obj_to_dict()) diff --git a/backend/app/controller/shoppinglist/__init__.py b/backend/app/controller/shoppinglist/__init__.py new file mode 100644 index 00000000..7f126cdc --- /dev/null +++ b/backend/app/controller/shoppinglist/__init__.py @@ -0,0 +1 @@ +from .shoppinglist_controller import shoppinglist, shoppinglistHousehold diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py new file mode 100644 index 00000000..69486170 --- /dev/null +++ b/backend/app/controller/shoppinglist/schemas.py @@ -0,0 +1,59 @@ +from marshmallow import fields, Schema, EXCLUDE + + +class AddItemByName(Schema): + name = fields.String(required=True) + description = fields.String() + + +class AddRecipeItems(Schema): + class RecipeItem(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.Integer(required=True) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(load_default="") + optional = fields.Boolean(load_default=True) + + items = fields.List(fields.Nested(RecipeItem)) + + +class CreateList(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class UpdateList(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + + +class GetItems(Schema): + orderby = fields.Integer() + + +class GetRecentItems(Schema): + limit = fields.Integer(load_default=9, validate=lambda x: x > 0 and x <= 60) + + +class UpdateDescription(Schema): + description = fields.String(required=True) + + +class RemoveItem(Schema): + item_id = fields.Integer( + required=True, + ) + removed_at = fields.Integer() + + +class RemoveItems(Schema): + class RecipeItem(Schema): + class Meta: + unknown = EXCLUDE + + item_id = fields.Integer( + required=True, + ) + removed_at = fields.Integer() + + items = fields.List(fields.Nested(RecipeItem)) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py new file mode 100644 index 00000000..056b1732 --- /dev/null +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -0,0 +1,387 @@ +from flask import jsonify, Blueprint +from flask_jwt_extended import current_user, jwt_required +from app import db +from app.models import ( + Item, + Shoppinglist, + History, + Status, + Association, + ShoppinglistItems, +) +from app.helpers import validate_args, authorize_household +from .schemas import ( + RemoveItem, + UpdateDescription, + AddItemByName, + CreateList, + AddRecipeItems, + GetItems, + UpdateList, + GetRecentItems, + RemoveItems, +) +from app.errors import NotFoundRequest, InvalidUsage +from datetime import datetime, timedelta, timezone +import app.util.description_merger as description_merger +from app import socketio + + +shoppinglist = Blueprint("shoppinglist", __name__) +shoppinglistHousehold = Blueprint("shoppinglist", __name__) + + +@shoppinglistHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(CreateList) +def createShoppinglist(args, household_id): + return jsonify( + Shoppinglist(name=args["name"], household_id=household_id).save().obj_to_dict() + ) + + +@shoppinglistHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getShoppinglists(household_id): + shoppinglists = Shoppinglist.all_from_household(household_id) + return jsonify([e.obj_to_dict() for e in shoppinglists]) + + +@shoppinglist.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateList) +def updateShoppinglist(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + if "name" in args: + shoppinglist.name = args["name"] + + shoppinglist.save() + return jsonify(shoppinglist.obj_to_dict()) + + +@shoppinglist.route("/", methods=["DELETE"]) +@jwt_required() +def deleteShoppinglist(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + if shoppinglist.isDefault(): + raise InvalidUsage() + shoppinglist.delete() + + return jsonify({"msg": "DONE"}) + + +@shoppinglist.route("//item/", methods=["POST", "PUT"]) +@jwt_required() +@validate_args(UpdateDescription) +def updateItemDescription(args, id, item_id): + con = ShoppinglistItems.find_by_ids(id, item_id) + if not con: + shoppinglist = Shoppinglist.find_by_id(id) + item = Item.find_by_id(item_id) + if not item or not shoppinglist: + raise NotFoundRequest() + if shoppinglist.household_id != item.household_id: + raise InvalidUsage() + con = ShoppinglistItems() + con.shoppinglist = shoppinglist + con.item = item + con.created_by = current_user.id + con.shoppinglist.checkAuthorized() + + con.description = args["description"] or "" + con.save() + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": con.shoppinglist.obj_to_dict(), + }, + to=con.shoppinglist.household_id, + ) + return jsonify(con.obj_to_item_dict()) + + +@shoppinglist.route("//items", methods=["GET"]) +@jwt_required() +@validate_args(GetItems) +def getAllShoppingListItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + orderby = [Item.name] + if "orderby" in args: + if args["orderby"] == 1: + orderby = [Item.ordering == 0, Item.ordering] + elif args["orderby"] == 2: + orderby = [Item.name] + + items = ( + ShoppinglistItems.query.filter(ShoppinglistItems.shoppinglist_id == id) + .join(ShoppinglistItems.item) + .order_by(*orderby, Item.name) + .all() + ) + return jsonify([e.obj_to_item_dict() for e in items]) + + +@shoppinglist.route("//recent-items", methods=["GET"]) +@jwt_required() +@validate_args(GetRecentItems) +def getRecentItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + items = History.get_recent(id, args["limit"]) + return jsonify( + [e.item.obj_to_dict() | {"description": e.description} for e in items] + ) + + +def getSuggestionsBasedOnLastAddedItems(id, item_count): + suggestions = [] + + # subquery for item ids which are on the shoppinglist + subquery = ( + db.session.query(ShoppinglistItems.item_id) + .filter(ShoppinglistItems.shoppinglist_id == id) + .subquery() + ) + + # suggestion based on recently added items + ten_minutes_back = datetime.now() - timedelta(minutes=10) + recently_added = ( + History.query.filter( + History.shoppinglist_id == id, + History.status == Status.ADDED, + History.created_at > ten_minutes_back, + ) + .order_by(History.created_at.desc()) + .limit(3) + ) + + for recent in recently_added: + assocs = ( + Association.query.filter( + Association.antecedent_id == recent.id, + Association.consequent_id.notin_(subquery), + ) + .order_by(Association.lift.desc()) + .limit(item_count) + ) + for rule in assocs: + suggestions.append(rule.consequent) + item_count -= 1 + + return suggestions + + +def getSuggestionsBasedOnFrequency(id, item_count): + suggestions = [] + + # subquery for item ids which are on the shoppinglist + subquery = ( + db.session.query(ShoppinglistItems.item_id) + .filter(ShoppinglistItems.shoppinglist_id == id) + .subquery() + ) + + # suggestion based on overall frequency + if item_count > 0: + suggestions = ( + Item.query.filter(Item.id.notin_(subquery)) + .order_by(Item.support.desc(), Item.name) + .limit(item_count) + ) + return suggestions + + +@shoppinglist.route("//suggested-items", methods=["GET"]) +@jwt_required() +def getSuggestedItems(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + item_suggestion_count = 9 + suggestions = [] + + suggestions += getSuggestionsBasedOnLastAddedItems(id, item_suggestion_count) + suggestions += getSuggestionsBasedOnFrequency( + id, item_suggestion_count - len(suggestions) + ) + + return jsonify([item.obj_to_dict() for item in suggestions]) + + +@shoppinglist.route("//add-item-by-name", methods=["POST"]) +@jwt_required() +@validate_args(AddItemByName) +def addShoppinglistItemByName(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + item = Item.find_by_name(shoppinglist.household_id, args["name"]) + if not item: + item = Item.create_by_name(shoppinglist.household_id, args["name"]) + + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if not con: + description = args["description"] if "description" in args else "" + con = ShoppinglistItems(description=description) + con.created_by = current_user.id + con.item = item + con.shoppinglist = shoppinglist + con.save() + + History.create_added(shoppinglist, item, description) + + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) + + return jsonify(item.obj_to_dict()) + + +@shoppinglist.route("//item", methods=["DELETE"]) +@jwt_required() +@validate_args(RemoveItem) +def removeShoppinglistItem(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + con = removeShoppinglistItem( + shoppinglist, + args["item_id"], + args["removed_at"] if "removed_at" in args else None, + ) + if con: + socketio.emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) + + return jsonify({"msg": "DONE"}) + + +@shoppinglist.route("//items", methods=["DELETE"]) +@jwt_required() +@validate_args(RemoveItems) +def removeShoppinglistItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + for arg in args["items"]: + con = removeShoppinglistItem( + shoppinglist, + arg["item_id"], + arg["removed_at"] if "removed_at" in arg else None, + ) + if con: + socketio.emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) + + return jsonify({"msg": "DONE"}) + + +def removeShoppinglistItem( + shoppinglist: Shoppinglist, item_id: int, removed_at: int = None +) -> ShoppinglistItems: + item = Item.find_by_id(item_id) + if not item: + return None + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if not con: + return None + description = con.description + con.delete() + + removed_at_datetime = None + if removed_at: + removed_at_datetime = datetime.fromtimestamp(removed_at / 1000, timezone.utc) + + History.create_dropped(shoppinglist, item, description, removed_at_datetime) + return con + + +@shoppinglist.route("//recipeitems", methods=["POST"]) +@jwt_required() +@validate_args(AddRecipeItems) +def addRecipeItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + try: + for recipeItem in args["items"]: + item = Item.find_by_id(recipeItem["id"]) + if item: + description = recipeItem["description"] + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if con: + # merge descriptions + con.description = description_merger.merge( + con.description, description + ) + db.session.add(con) + else: + con = ShoppinglistItems(description=description) + con.created_by = current_user.id + con.item = item + con.shoppinglist = shoppinglist + db.session.add(con) + + db.session.add( + History.create_added_without_save(shoppinglist, item, description) + ) + + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) + + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/tag/__init__.py b/backend/app/controller/tag/__init__.py new file mode 100644 index 00000000..b7d7715a --- /dev/null +++ b/backend/app/controller/tag/__init__.py @@ -0,0 +1 @@ +from .tag_controller import tag, tagHousehold diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py new file mode 100644 index 00000000..f0e2de7c --- /dev/null +++ b/backend/app/controller/tag/schemas.py @@ -0,0 +1,15 @@ +from marshmallow import fields, Schema + + +class AddTag(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class UpdateTag(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + + # if set this merges the specified tag into this tag thus combining them to one + merge_tag_id = fields.Integer( + validate=lambda a: a > 0, + allow_none=True, + ) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py new file mode 100644 index 00000000..749f1205 --- /dev/null +++ b/backend/app/controller/tag/tag_controller.py @@ -0,0 +1,90 @@ +from app.helpers import validate_args, authorize_household +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app.models import Tag, RecipeTags, Recipe +from .schemas import AddTag, UpdateTag + +tag = Blueprint("tag", __name__) +tagHousehold = Blueprint("tag", __name__) + + +@tagHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllTags(household_id): + return jsonify( + [e.obj_to_dict() for e in Tag.all_from_household_by_name(household_id)] + ) + + +@tag.route("/", methods=["GET"]) +@jwt_required() +def getTag(id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + return jsonify(tag.obj_to_dict()) + + +@tag.route("//recipes", methods=["GET"]) +@jwt_required() +def getTagRecipes(id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + + tags = ( + RecipeTags.query.filter(RecipeTags.tag_id == id) + .join(RecipeTags.recipe) + .order_by(Recipe.name) + .all() + ) + return jsonify([e.recipe.obj_to_dict() for e in tags]) + + +@tagHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddTag) +def addTag(args, household_id): + tag = Tag() + tag.name = args["name"] + tag.household_id = household_id + tag.save() + return jsonify(tag.obj_to_dict()) + + +@tag.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateTag) +def updateTag(args, id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + + if "name" in args: + tag.name = args["name"] + + tag.save() + + if "merge_tag_id" in args and args["merge_tag_id"] != id: + mergeTag = Tag.find_by_id(args["merge_tag_id"]) + if mergeTag: + tag.merge(mergeTag) + + return jsonify(tag.obj_to_dict()) + + +@tag.route("/", methods=["DELETE"]) +@jwt_required() +def deleteTagById(id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + tag.delete() + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/upload/__init__.py b/backend/app/controller/upload/__init__.py new file mode 100644 index 00000000..96ccd2d8 --- /dev/null +++ b/backend/app/controller/upload/__init__.py @@ -0,0 +1 @@ +from .upload_controller import upload diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py new file mode 100644 index 00000000..7b2a7b55 --- /dev/null +++ b/backend/app/controller/upload/upload_controller.py @@ -0,0 +1,75 @@ +import os +import uuid + +from flask import jsonify, Blueprint, send_from_directory, request +from flask_jwt_extended import current_user, jwt_required +from werkzeug.utils import secure_filename +import blurhash +from PIL import Image + +from app.config import UPLOAD_FOLDER +from app.errors import ForbiddenRequest, NotFoundRequest +from app.models import File +from app.util.filename_validator import allowed_file + +upload = Blueprint("upload", __name__) + + +@upload.route("", methods=["POST"]) +@jwt_required() +def upload_file(): + if "file" not in request.files: + return jsonify({"msg": "missing file"}) + + file = request.files["file"] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if file.filename == "": + return jsonify({"msg": "missing filename"}) + + if file and allowed_file(file.filename): + filename = secure_filename( + str(uuid.uuid4()) + "." + file.filename.rsplit(".", 1)[1].lower() + ) + file.save(os.path.join(UPLOAD_FOLDER, filename)) + blur = None + try: + with Image.open(os.path.join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode(image, x_components=4, y_components=3) + except FileNotFoundError: + return None + except Exception: + pass + f = File(filename=filename, blur_hash=blur, created_by=current_user.id).save() + return jsonify(f.obj_to_dict()) + + raise Exception("Invalid usage.") + + +@upload.route("", methods=["GET"]) +@jwt_required() +def download_file(filename): + filename = secure_filename(filename) + f: File = File.query.filter(File.filename == filename).first() + + if not f: + raise NotFoundRequest() + + if f.household or f.recipe: + household_id = None + if f.household: + household_id = f.household.id + if f.recipe: + household_id = f.recipe.household_id + if f.expense: + household_id = f.expense.household_id + f.checkAuthorized(household_id=household_id) + elif f.created_by and current_user and f.created_by == current_user.id: + pass # created by user can access his pictures + elif f.profile_picture: + pass # profile pictures are public + else: + raise ForbiddenRequest() + + return send_from_directory(UPLOAD_FOLDER, filename) diff --git a/backend/app/controller/user/__init__.py b/backend/app/controller/user/__init__.py new file mode 100644 index 00000000..0c152bcb --- /dev/null +++ b/backend/app/controller/user/__init__.py @@ -0,0 +1 @@ +from .user_controller import user diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py new file mode 100644 index 00000000..ab449a71 --- /dev/null +++ b/backend/app/controller/user/schemas.py @@ -0,0 +1,67 @@ +from marshmallow import fields, Schema + +from app.config import EMAIL_MANDATORY + + +class CreateUser(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + username = fields.String( + required=True, + validate=lambda a: a and not a.isspace() and not "@" in a, + load_only=True, + ) + email = fields.String( + required=False, + validate=lambda a: a and not a.isspace() and "@" in a, + load_only=True, + ) + password = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + + +class UpdateUser(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + photo = fields.String() + username = fields.String( + validate=lambda a: a and not a.isspace() and not "@" in a, + load_only=True, + ) + email = fields.String( + validate=lambda a: a and not a.isspace() and "@" in a, + load_only=True, + ) + password = fields.String( + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + admin = fields.Boolean( + load_only=True, + ) + + +class SearchByNameRequest(Schema): + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class ConfirmMail(Schema): + token = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class ResetPassword(Schema): + token = fields.String(required=True, validate=lambda a: a and not a.isspace()) + password = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + + +class ForgotPassword(Schema): + email = fields.String( + required=True, + validate=lambda a: a and not a.isspace() and "@" in a, + load_only=True, + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py new file mode 100644 index 00000000..7e476e47 --- /dev/null +++ b/backend/app/controller/user/user_controller.py @@ -0,0 +1,193 @@ +import gevent +from sqlalchemy import desc +from app.errors import NotFoundRequest, UnauthorizedRequest +from app.helpers.server_admin_required import server_admin_required +from app.helpers import validate_args +from flask import jsonify, Blueprint +from flask_jwt_extended import current_user, jwt_required +from app.models import User, ChallengeMailVerify, ChallengePasswordReset +from app.service import mail +from app.service.file_has_access_or_download import file_has_access_or_download +from .schemas import ( + CreateUser, + ResetPassword, + UpdateUser, + SearchByNameRequest, + ConfirmMail, + ForgotPassword, +) + + +user = Blueprint("user", __name__) + + +@user.route("/all", methods=["GET"]) +@jwt_required() +@server_admin_required() +def getAllUsers(): + return jsonify([e.obj_to_dict(include_email=True) for e in User.query.order_by(desc(User.admin), User.username).all()]) + + +@user.route("", methods=["GET"]) +@jwt_required() +def getLoggedInUser(): + return jsonify(current_user.obj_to_full_dict()) + + +@user.route("/", methods=["GET"]) +@jwt_required() +@server_admin_required() +def getUserById(id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + return jsonify(user.obj_to_dict(include_email=True)) + + +@user.route("", methods=["DELETE"]) +@jwt_required() +def deleteUser(): + if not current_user: + raise UnauthorizedRequest(message="Cannot delete this user") + current_user.delete() + return jsonify({"msg": "DONE"}) + + +@user.route("/", methods=["DELETE"]) +@jwt_required() +@server_admin_required() +def deleteUserById(id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + user.delete() + return jsonify({"msg": "DONE"}) + + +@user.route("", methods=["POST"]) +@jwt_required() +@validate_args(UpdateUser) +def updateUser(args): + user: User = current_user + if not user: + raise NotFoundRequest() + if "name" in args: + user.name = args["name"].strip() + if "password" in args: + user.set_password(args["password"]) + if "email" in args and args["email"].strip() != user.email: + if user.find_by_email(args["email"].strip()): + return "Request invalid: email", 400 + user.email = args["email"].strip() + user.email_verified = False + ChallengeMailVerify.delete_by_user(user) + if mail.mailConfigured(): + gevent.spawn(mail.sendVerificationMail, user.id, ChallengeMailVerify.create_challenge(user)) + if "photo" in args and user.photo != args["photo"]: + user.photo = file_has_access_or_download(args["photo"], user.photo) + user.save() + return jsonify({"msg": "DONE"}) + + +@user.route("/", methods=["POST"]) +@jwt_required() +@server_admin_required() +@validate_args(UpdateUser) +def updateUserById(args, id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + if "name" in args: + user.name = args["name"].strip() + if "password" in args: + user.set_password(args["password"]) + if "email" in args and args["email"].strip() != user.email: + if user.find_by_email(args["email"].strip()): + return "Request invalid: email", 400 + user.email = args["email"].strip() + user.email_verified = True + ChallengeMailVerify.delete_by_user(user) + if "photo" in args and user.photo != args["photo"]: + user.photo = file_has_access_or_download(args["photo"], user.photo) + if "admin" in args: + user.admin = args["admin"] + user.save() + return jsonify({"msg": "DONE"}) + + +@user.route("/new", methods=["POST"]) +@jwt_required() +@server_admin_required() +@validate_args(CreateUser) +def createUser(args): + User.create( + args["username"].replace(" ", ""), + args["password"], + args["name"], + email=args["email"] if "email" in args else None, + ) + return jsonify({"msg": "DONE"}) + + +@user.route("/search", methods=["GET"]) +@jwt_required() +@validate_args(SearchByNameRequest) +def searchUser(args): + return jsonify([e.obj_to_dict() for e in User.search_name(args["query"])]) + + +@user.route("/resend-verification-mail", methods=["POST"]) +@jwt_required() +def resendVerificationMail(): + user: User = current_user + if not user: + raise NotFoundRequest() + + if not mail.mailConfigured(): + raise Exception("Mail service not configured") + + if not user.email_verified: + mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) + return jsonify({"msg": "DONE"}) + + +@user.route("/confirm-mail", methods=["POST"]) +@validate_args(ConfirmMail) +def confirmMail(args): + challenge = ChallengeMailVerify.find_by_challenge(args["token"]) + if not challenge: + raise NotFoundRequest() + user: User = challenge.user + user.email_verified = True + user.save() + ChallengeMailVerify.delete_by_user(user) + + return jsonify({"msg": "DONE"}) + + +@user.route("/reset-password", methods=["POST"]) +@validate_args(ResetPassword) +def resetPassword(args): + challenge = ChallengePasswordReset.find_by_challenge(args["token"]) + if not challenge: + raise NotFoundRequest() + user: User = challenge.user + user.set_password(args["password"]) + user.save() + ChallengePasswordReset.delete_by_user(user) + return jsonify({"msg": "DONE"}) + + +@user.route("/forgot-password", methods=["POST"]) +@validate_args(ForgotPassword) +def forgotPassword(args): + if not mail.mailConfigured(): + raise Exception("Mail service not configured") + + user = User.find_by_email(args["email"]) + if not user: + return jsonify({"msg": "DONE"}) + + # if user.email_verified: + mail.sendPasswordResetMail(user, ChallengePasswordReset.create_challenge(user)) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py new file mode 100644 index 00000000..90d1eeee --- /dev/null +++ b/backend/app/errors/__init__.py @@ -0,0 +1,26 @@ +from flask import request + + +class InvalidUsage(Exception): + def __init__(self, message="Invalid usage"): + super(InvalidUsage, self).__init__(message) + self.message = message + + +class UnauthorizedRequest(Exception): + def __init__(self, message=""): + message = message or "Authorization required. IP {}".format(request.remote_addr) + super(UnauthorizedRequest, self).__init__(message) + self.message = message + + +class ForbiddenRequest(Exception): + def __init__(self, message="Request forbidden"): + super(ForbiddenRequest, self).__init__(message) + self.message = message + + +class NotFoundRequest(Exception): + def __init__(self, message="Requested resource not found"): + super(NotFoundRequest, self).__init__(message) + self.message = message diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py new file mode 100644 index 00000000..94a29af7 --- /dev/null +++ b/backend/app/helpers/__init__.py @@ -0,0 +1,8 @@ +from .db_model_mixin import DbModelMixin +from .db_model_authorize_mixin import DbModelAuthorizeMixin +from .timestamp_mixin import TimestampMixin +from .validate_args import validate_args +from .validate_socket_args import validate_socket_args +from .server_admin_required import server_admin_required +from .authorize_household import authorize_household, RequiredRights +from .socket_jwt_required import socket_jwt_required diff --git a/backend/app/helpers/authorize_household.py b/backend/app/helpers/authorize_household.py new file mode 100644 index 00000000..d4c3b0e4 --- /dev/null +++ b/backend/app/helpers/authorize_household.py @@ -0,0 +1,53 @@ +from functools import wraps +from enum import Enum +from flask_jwt_extended import current_user +from app.errors import UnauthorizedRequest, ForbiddenRequest +from app.models import HouseholdMember + + +class RequiredRights(Enum): + MEMBER = 1 + ADMIN = 2 + ADMIN_OR_SELF = 3 + + +def authorize_household(required: RequiredRights = RequiredRights.MEMBER) -> any: + def wrapper(func): + @wraps(func) + def decorator(*args, **kwargs): + if not "household_id" in kwargs: + raise Exception("Wrong usage of authorize_household") + if required == RequiredRights.ADMIN_OR_SELF and not "user_id" in kwargs: + raise Exception("Wrong usage of authorize_household") + if not current_user: + raise UnauthorizedRequest() + + if current_user.admin: + return func(*args, **kwargs) # case server admin + if ( + required == RequiredRights.ADMIN_OR_SELF + and current_user.id == kwargs["user_id"] + ): + return func(*args, **kwargs) # case ressource deals with self + + member = HouseholdMember.find_by_ids( + kwargs["household_id"], current_user.id + ) + if required == RequiredRights.MEMBER and member: + return func(*args, **kwargs) # case member + + if ( + ( + required == RequiredRights.ADMIN + or required == RequiredRights.ADMIN_OR_SELF + ) + and member + and (member.admin or member.owner) + ): + return func(*args, **kwargs) # case admin + + raise ForbiddenRequest() + + return decorator + + return wrapper diff --git a/backend/app/helpers/db_list_type.py b/backend/app/helpers/db_list_type.py new file mode 100644 index 00000000..60e95cae --- /dev/null +++ b/backend/app/helpers/db_list_type.py @@ -0,0 +1,18 @@ +from sqlalchemy.types import String, TypeDecorator +import json + + +# Represents a List in the DataBase (i.e. [e1, e2, e3, ...]) +class DbListType(TypeDecorator): + impl = String + + def process_bind_param(self, value, dialect): + if type(value) is list: + return json.dumps(value) + else: + return "[]" + + def process_result_value(self, value, dialect) -> set: + if type(value) is str: + return json.loads(value) + return list() diff --git a/backend/app/helpers/db_model_authorize_mixin.py b/backend/app/helpers/db_model_authorize_mixin.py new file mode 100644 index 00000000..592917db --- /dev/null +++ b/backend/app/helpers/db_model_authorize_mixin.py @@ -0,0 +1,21 @@ +from flask_jwt_extended import current_user +from app.errors import UnauthorizedRequest, ForbiddenRequest +import app + + +class DbModelAuthorizeMixin(object): + def checkAuthorized(self, requires_admin=False, household_id: int = None): + """ + Checks if current user ist authorized to access this model. Throws and unauthorized exception if not + IMPORTANT: requires household_id + """ + if not household_id and not hasattr(self, "household_id"): + raise Exception("Wrong usage of authorize_household") + if not current_user: + raise UnauthorizedRequest() + member = app.models.household.HouseholdMember.find_by_ids( + household_id or self.household_id, current_user.id + ) + if not current_user.admin: + if not member or requires_admin and not (member.admin or member.owner): + raise ForbiddenRequest() diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py new file mode 100644 index 00000000..e3b0d436 --- /dev/null +++ b/backend/app/helpers/db_model_mixin.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from typing import Self +from app import db + + +class DbModelMixin(object): + def save(self) -> Self: + """ + Persist changes to current instance in db + """ + try: + db.session.add(self) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + return self + + def delete(self): + """ + Delete this instance of model from db + """ + db.session.delete(self) + db.session.commit() + + def obj_to_dict( + self, skip_columns: list[str] | None = None, include_columns: list[str] | None = None + ) -> dict: + d = {} + for column in self.__table__.columns: + d[column.name] = getattr(self, column.name) + + for column_name in skip_columns or []: + del d[column_name] + + for column in self.__table__.columns: + if not include_columns: + break + + if column.name in d and column.name not in include_columns: + del d[column.name] + + return d + + @classmethod + def get_column_names(cls) -> list[str]: + return list(cls.__table__.columns.keys()) + + @classmethod + def find_by_id(cls, target_id: int) -> Self: + """ + Find the row with specified id + """ + return cls.query.filter(cls.id == target_id).first() + + @classmethod + def delete_by_id(cls, target_id: int) -> bool: + mc = cls.find_by_id(target_id) + if mc: + mc.delete() + return True + return False + + @classmethod + def all(cls) -> list[Self]: + """ + Return all instances of model + """ + return cls.query.order_by(cls.id).all() + + @classmethod + def all_by_name(cls) -> list[Self]: + """ + Return all instances of model ordered by name + IMPORTANT: requires name column + """ + return cls.query.order_by(cls.name).all() + + @classmethod + def all_from_household(cls, household_id: int) -> list[Self]: + """ + Return all instances of model + IMPORTANT: requires household_id column + """ + return cls.query.filter(cls.household_id == household_id).order_by(cls.id).all() + + @classmethod + def all_from_household_by_name(cls, household_id: int) -> list[Self]: + """ + Return all instances of model + IMPORTANT: requires household_id and name column + """ + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.name).all() + ) + + @classmethod + def count(cls) -> int: + return cls.query.count() diff --git a/backend/app/helpers/db_set_type.py b/backend/app/helpers/db_set_type.py new file mode 100644 index 00000000..31e8c194 --- /dev/null +++ b/backend/app/helpers/db_set_type.py @@ -0,0 +1,18 @@ +from sqlalchemy.types import String, TypeDecorator +import json + + +# Represents a Set in the DataBase (i.e. {e1, e2, e3, ...}) +class DbSetType(TypeDecorator): + impl = String + + def process_bind_param(self, value, dialect): + if type(value) is set: + return json.dumps(list(value)) + else: + return "[]" + + def process_result_value(self, value, dialect) -> set: + if type(value) is str: + return set(json.loads(value)) + return set() diff --git a/backend/app/helpers/server_admin_required.py b/backend/app/helpers/server_admin_required.py new file mode 100644 index 00000000..935db58f --- /dev/null +++ b/backend/app/helpers/server_admin_required.py @@ -0,0 +1,21 @@ +from flask import request +from functools import wraps +from flask_jwt_extended import current_user +from app.errors import ForbiddenRequest + + +def server_admin_required(): + def wrapper(func): + @wraps(func) + def decorator(*args, **kwargs): + if not current_user or not current_user.admin: + raise ForbiddenRequest( + message="Elevated rights required. IP {}".format( + request.remote_addr + ) + ) + return func(*args, **kwargs) + + return decorator + + return wrapper diff --git a/backend/app/helpers/socket_jwt_required.py b/backend/app/helpers/socket_jwt_required.py new file mode 100644 index 00000000..971fd716 --- /dev/null +++ b/backend/app/helpers/socket_jwt_required.py @@ -0,0 +1,25 @@ +from functools import wraps +from flask import request + +from flask_jwt_extended import verify_jwt_in_request +from flask_socketio import disconnect + + +def socket_jwt_required( + optional: bool = False, + fresh: bool = False, + refresh: bool = False, +): + def wrapper(fn): + @wraps(fn) + def decorator(*args, **kwargs): + try: + verify_jwt_in_request(optional, fresh, refresh) + except: + disconnect() + return + return fn(*args, **kwargs) + + return decorator + + return wrapper diff --git a/backend/app/helpers/timestamp_mixin.py b/backend/app/helpers/timestamp_mixin.py new file mode 100644 index 00000000..9a92f44c --- /dev/null +++ b/backend/app/helpers/timestamp_mixin.py @@ -0,0 +1,19 @@ +from datetime import datetime +from sqlalchemy import Column, DateTime + + +class TimestampMixin(object): + """ + Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps + """ + + #: Timestamp for when this instance was created in UTC + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + #: Timestamp for when this instance was last updated in UTC + updated_at = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + created_at._creation_order = 9998 + updated_at._creation_order = 9999 diff --git a/backend/app/helpers/validate_args.py b/backend/app/helpers/validate_args.py new file mode 100644 index 00000000..a5c42572 --- /dev/null +++ b/backend/app/helpers/validate_args.py @@ -0,0 +1,30 @@ +from marshmallow.exceptions import ValidationError +from app.errors import InvalidUsage +from flask import request +from functools import wraps + + +def validate_args(schema_cls): + def validate(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if not schema_cls: + raise Exception("Invalid usage. Schema class missing") + + if request.method == "GET": + request_data = request.args + load_fn = schema_cls().load + else: + request_data = request.data.decode("utf-8") + load_fn = schema_cls().loads + + try: + arguments = load_fn(request_data) + except ValidationError as exc: + raise InvalidUsage("{}".format(exc)) + + return func(arguments, *args, **kwargs) + + return func_wrapper + + return validate diff --git a/backend/app/helpers/validate_socket_args.py b/backend/app/helpers/validate_socket_args.py new file mode 100644 index 00000000..5e690e4f --- /dev/null +++ b/backend/app/helpers/validate_socket_args.py @@ -0,0 +1,23 @@ +from marshmallow.exceptions import ValidationError +from app.errors import InvalidUsage +from flask import request +from functools import wraps + + +def validate_socket_args(schema_cls): + def validate(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if not schema_cls: + raise Exception("Invalid usage. Schema class missing") + + try: + arguments = schema_cls().load(args[0]) + except ValidationError as exc: + raise InvalidUsage("{}".format(exc)) + + return func(arguments, **kwargs) + + return func_wrapper + + return validate diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py new file mode 100644 index 00000000..986f9ca9 --- /dev/null +++ b/backend/app/jobs/__init__.py @@ -0,0 +1 @@ +from . import jobs diff --git a/backend/app/jobs/cluster_shoppings.py b/backend/app/jobs/cluster_shoppings.py new file mode 100644 index 00000000..fd59ea71 --- /dev/null +++ b/backend/app/jobs/cluster_shoppings.py @@ -0,0 +1,45 @@ +from app import app +from app.models import History + +import time +from dbscan1d.core import DBSCAN1D +import numpy as np + + +def clusterShoppings(shoppinglist_id: int) -> list: + dropped = History.find_dropped_by_shoppinglist_id(shoppinglist_id) + + if len(dropped) == 0: + app.logger.info("no history to investigate") + return None + + # determine shopping instances via clustering + times = [int(time.mktime(d.created_at.timetuple())) for d in dropped] + + timestamps = np.array(times) + # time distance for items to be considered in one shopping action (in seconds) + eps = 600 + # minimum size for clusters to be accepted + min_samples = 5 + dbs = DBSCAN1D(eps=eps, min_samples=min_samples) + labels = dbs.fit_predict(timestamps) + + if len(labels) == 0: + app.logger.info("no shopping instances identified") + return None + + # extract indices of clusters into lists + cluster_count = max(labels) + 1 + clusters = [[] for i in range(cluster_count)] + for i in range(len(labels)): + label = labels[i] + if labels[i] > -1: + clusters[label].append(i) + + # indices to list of itemlists for each found shopping instance + shopping_instances = [[dropped[i].item_id for i in cluster] for cluster in clusters] + + # remove duplicates in the instances + shopping_instances = [list(set(instance)) for instance in shopping_instances] + + return shopping_instances diff --git a/backend/app/jobs/item_ordering.py b/backend/app/jobs/item_ordering.py new file mode 100644 index 00000000..d9c41b3f --- /dev/null +++ b/backend/app/jobs/item_ordering.py @@ -0,0 +1,89 @@ +from app import app, db +from app.models import Item +import copy + + +def findItemOrdering(shopping_instances): + # sort the items according to each shopping course + sorter = ItemSort() + for items in shopping_instances: + sorter.updateMatrix(items) + order = sorter.topologicalSort() + + # store the ordering directly in each item + for ord in range(len(order)): + item_id = order[ord] + item = Item.find_by_id(item_id) + if item: + item.ordering = ord + 1 + db.session.add(item) + + # commit changes to db + db.session.commit() + + app.logger.info("new ordering was determined and stored in the database") + + +class ItemSort: + def __init__(self): + # stores the costs for ordering + self.matrix = [] + # gives all items an index + self.indices = [] + # stores index for each item (duplicates indices for faster access) + self.item_dict = {} + + # determines decay rate (must be between 0 and 1) + self.decay = 0.75 + + def updateMatrix(self, lst: list): + # extend matrix for unseed items + for item in lst: + if item not in self.indices: + self.item_dict[item] = len(self.indices) + self.indices.append(item) + for row in self.matrix: + row.append(0) + self.matrix.append([0 for i in range(len(self.indices))]) + + # cost of ranking in current list + cost = (1 - self.decay) / len(lst) + + # iterate the current list + for i in range(len(lst)): + index = self.item_dict[lst[i]] + + # decay old costs with factor decay + self.matrix[index] = list(map(lambda x: x * self.decay, self.matrix[index])) + + # increase incoming cost for all preceeding items in the current list + predecessors = lst[:i] + for pred in predecessors: + predIndex = self.item_dict[pred] + self.matrix[index][predIndex] += cost + + def topologicalSort(self) -> list: + mtx = copy.deepcopy(self.matrix) + order = [] + + for iter in range(len(mtx)): + # cost of an item is the sum of its incoming costs + costs = list(map(sum, mtx)) + + # determine item minimal costs + minIndex = 0 + for i in range(1, len(costs)): + if costs[i] < costs[minIndex]: + minIndex = i + order.append(minIndex) + + # remove influence of minimal item + for row in mtx: + row[minIndex] = 0 + + # remove current minimal item from minimal spot + # (maximal normal cost is 1, thus 2 is larger than all unconsidered items) + mtx[minIndex][minIndex] = 2 + + # convert the indices to items + return list(map(lambda index: self.indices[index], order)) diff --git a/backend/app/jobs/item_suggestions.py b/backend/app/jobs/item_suggestions.py new file mode 100644 index 00000000..b85d9176 --- /dev/null +++ b/backend/app/jobs/item_suggestions.py @@ -0,0 +1,70 @@ +from app import app, db +from app.models import Item, Association + +import pandas as pd +from mlxtend.frequent_patterns import apriori +from mlxtend.preprocessing import TransactionEncoder +from mlxtend.frequent_patterns import association_rules as arule + + +def findItemSuggestions(shopping_instances): + if not shopping_instances or len(shopping_instances) == 0: + return + + # prepare data set + te = TransactionEncoder() + te_ary = te.fit_transform(shopping_instances) + store = pd.DataFrame(te_ary, columns=te.columns_) + + # compute the frequent itemsets with minimal support 0.1 + frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True, max_len=2) + app.logger.info("apriori finished") + + # extract support for single items + single_items = frequent_itemsets[frequent_itemsets["itemsets"].apply(len) == 1] + single_items.insert( + 0, "single", [list(tup)[0] for tup in single_items["itemsets"]], False + ) + + # store support values + for index, row in single_items.iterrows(): + item_id = row["single"] + item = Item.find_by_id(item_id) + if item: + item.support = row["support"] + db.session.add(item) + + # commit changes to db + db.session.commit() + app.logger.info("frequency of single items was stored") + + # compute all association rules with lift > 1.2 and confidence > 0.1 + association_rules = arule(frequent_itemsets, metric="lift", min_threshold=1.2) + association_rules = association_rules[association_rules["confidence"] > 0.1] + + # extract rules with single antecedent and single consequent + single_rules = association_rules[ + (association_rules["antecedents"].apply(len) == 1) + & (association_rules["consequents"].apply(len) == 1) + ] + single_rules.insert( + 0, "antecedent", [list(tup)[0] for tup in single_rules["antecedents"]], True + ) + single_rules.insert( + 1, "consequent", [list(tup)[0] for tup in single_rules["consequents"]], True + ) + + # delete all previous associations + Association.delete_all() + + # store all new associations + for index, rule in single_rules.iterrows(): + a = Association( + antecedent_id=rule["antecedent"], + consequent_id=rule["consequent"], + support=rule["support"], + confidence=rule["confidence"], + lift=rule["lift"], + ) + db.session.add(a) + app.logger.info("associations rules of size 2 were updated") diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py new file mode 100644 index 00000000..73bf6dc6 --- /dev/null +++ b/backend/app/jobs/jobs.py @@ -0,0 +1,77 @@ +from datetime import timedelta +from app.jobs.recipe_suggestions import computeRecipeSuggestions +from app.config import app, scheduler, celery_app, MESSAGE_BROKER +from celery.schedules import crontab +from app.models import ( + Token, + Household, + Shoppinglist, + Recipe, + ChallengePasswordReset, + OIDCRequest, +) +from .item_ordering import findItemOrdering +from .item_suggestions import findItemSuggestions +from .cluster_shoppings import clusterShoppings + + +if not MESSAGE_BROKER: + + @scheduler.task("cron", id="everyDay", day_of_week="*", hour="3", minute="0") + def setup_daily(): + with app.app_context(): + daily() + + @scheduler.task("interval", id="every30min", minutes=30) + def setup_halfHourly(): + with app.app_context(): + halfHourly() + +else: + @celery_app.task + def dailyTask(): + daily() + + @celery_app.task + def halfHourlyTask(): + halfHourly() + + @celery_app.on_after_configure.connect + def setup_periodic_tasks(sender, **kwargs): + sender.add_periodic_task( + timedelta(minutes=30), halfHourlyTask, name="every30min" + ) + + sender.add_periodic_task( + crontab(day_of_week="*", hour=3, minute=0), + dailyTask, + name="everyDay", + ) + + +def daily(): + app.logger.info("--- daily analysis is starting ---") + # task for all households + for household in Household.all(): + # shopping tasks + shopping_instances = clusterShoppings( + Shoppinglist.query.filter(Shoppinglist.household_id == household.id) + .first() + .id + ) + if shopping_instances: + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) + # recipe planner tasks + computeRecipeSuggestions(household.id) + Recipe.compute_suggestion_ranking(household.id) + + app.logger.info("--- daily analysis is completed ---") + + +def halfHourly(): + # Remove expired Tokens + Token.delete_expired_access() + Token.delete_expired_refresh() + ChallengePasswordReset.delete_expired() + OIDCRequest.delete_expired() diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py new file mode 100644 index 00000000..f0cabfbf --- /dev/null +++ b/backend/app/jobs/recipe_suggestions.py @@ -0,0 +1,40 @@ +from sqlalchemy import func +from app.models import Recipe, RecipeHistory +from app import app, db +import datetime + +from app.models.recipe_history import Status + + +def computeRecipeSuggestions(household_id: int): + historyCount = ( + RecipeHistory.query.with_entities( + RecipeHistory.recipe_id, func.count().label("count") + ) + .filter( + RecipeHistory.status == Status.ADDED, + RecipeHistory.household_id == household_id, + RecipeHistory.created_at + >= datetime.datetime.utcnow() - datetime.timedelta(days=182), + RecipeHistory.created_at + <= datetime.datetime.utcnow() - datetime.timedelta(days=7), + ) + .group_by(RecipeHistory.recipe_id) + .all() + ) + # 0) reset all suggestion scores + for r in Recipe.all_from_household(household_id): + r.suggestion_score = 0 + db.session.add(r) + + # 1) count cooked instances in last six months + for e in historyCount: + r = Recipe.find_by_id(e.recipe_id) + if not r: + continue + r.suggestion_score = e.count + db.session.add(r) + + # commit changes to db + db.session.commit() + app.logger.info("computed and stored new suggestion scores") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..cf89bf33 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +from .user import User +from .item import Item +from .association import Association +from .expense import Expense, ExpensePaidFor +from .settings import Settings +from .history import History, Status +from .recipe import RecipeTags, RecipeItems, Recipe +from .planner import Planner +from .tag import Tag +from .shoppinglist import ShoppinglistItems, Shoppinglist +from .recipe_history import RecipeHistory +from .expense_category import ExpenseCategory +from .category import Category +from .token import Token +from .household import Household, HouseholdMember +from .file import File +from .challenge_mail_verify import ChallengeMailVerify +from .challenge_password_reset import ChallengePasswordReset +from .oidc import OIDCLink, OIDCRequest diff --git a/backend/app/models/association.py b/backend/app/models/association.py new file mode 100644 index 00000000..6d9b3600 --- /dev/null +++ b/backend/app/models/association.py @@ -0,0 +1,53 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Association(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "association" + + id = db.Column(db.Integer, primary_key=True) + + antecedent_id = db.Column(db.Integer, db.ForeignKey("item.id")) + consequent_id = db.Column(db.Integer, db.ForeignKey("item.id")) + support = db.Column(db.Float) + confidence = db.Column(db.Float) + lift = db.Column(db.Float) + + antecedent = db.relationship( + "Item", + uselist=False, + foreign_keys=[antecedent_id], + back_populates="antecedents", + ) + consequent = db.relationship( + "Item", + uselist=False, + foreign_keys=[consequent_id], + back_populates="consequents", + ) + + @classmethod + def create(cls, antecedent_id, consequent_id, support, confidence, lift): + return cls( + antecedent_id=antecedent_id, + consequent_id=consequent_id, + support=support, + confidence=confidence, + lift=lift, + ).save() + + @classmethod + def find_by_antecedent(cls, antecedent_id): + return cls.query.filter(cls.antecedent_id == antecedent_id).order_by( + cls.lift.desc() + ) + + @classmethod + def find_all(cls) -> list[Self]: + return cls.query.all() + + @classmethod + def delete_all(cls): + cls.query.delete() + db.session.commit() diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 00000000..72ba7ba1 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,111 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class Category(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "category" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + default = db.Column(db.Boolean, default=False) + default_key = db.Column(db.String(128)) + ordering = db.Column(db.Integer, default=0) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household = db.relationship("Household", uselist=False) + items = db.relationship("Item", back_populates="category") + + def obj_to_full_dict(self) -> dict: + res = super().obj_to_dict() + return res + + @classmethod + def all_by_ordering(cls, household_id: int): + return ( + cls.query.filter(cls.household_id == household_id) + .order_by(cls.ordering, cls.name) + .all() + ) + + @classmethod + def create_by_name( + cls, household_id: int, name, default=False, default_key=None + ) -> Self: + return cls( + name=name, + default=default, + default_key=default_key, + household_id=household_id, + ).save() + + @classmethod + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter( + cls.name == name, cls.household_id == household_id + ).first() + + @classmethod + def find_by_default_key(cls, household_id: int, default_key: str) -> Self: + return cls.query.filter( + cls.default_key == default_key, cls.household_id == household_id + ).first() + + @classmethod + def find_by_id(cls, id: int) -> Self: + return cls.query.filter(cls.id == id).first() + + def reorder(self, newIndex: int): + cls = self.__class__ + + l: list[cls] = ( + cls.query.filter(cls.household_id == self.household_id) + .order_by(cls.ordering, cls.name) + .all() + ) + + self.ordering = min(newIndex, len(l) - 1) + + oldIndex = list(map(lambda x: x.id, l)).index(self.id) + if oldIndex < 0: + raise Exception() # Something went wrong + e = l.pop(oldIndex) + + l.insert(self.ordering, e) + + for i, category in enumerate(l): + category.ordering = i + + try: + db.session.add_all(l) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import Item + + if not self.default_key and other.default_key: + self.default_key = other.default_key + self.default = other.default + + for item in Item.query.filter(Item.category_id == other.id).all(): + item.category_id = self.id + db.session.add(item) + + try: + db.session.add(self) + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + self.reorder(self.ordering) diff --git a/backend/app/models/challenge_mail_verify.py b/backend/app/models/challenge_mail_verify.py new file mode 100644 index 00000000..64a99365 --- /dev/null +++ b/backend/app/models/challenge_mail_verify.py @@ -0,0 +1,34 @@ +from __future__ import annotations +import hashlib +from typing import Self +import uuid +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.models.user import User + + +class ChallengeMailVerify(db.Model, DbModelMixin, TimestampMixin): + challenge_hash = db.Column(db.String(256), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + user = db.relationship("User") + + @classmethod + def find_by_challenge(cls, challenge: str) -> Self: + return cls.query.filter( + cls.challenge_hash == hashlib.sha256(bytes(challenge, "utf-8")).hexdigest() + ).first() + + @classmethod + def create_challenge(cls, user: User) -> str: + challenge = uuid.uuid4().hex + cls( + challenge_hash=hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + user_id=user.id, + ).save() + return challenge + + @classmethod + def delete_by_user(cls, user: User): + cls.query.filter(cls.user_id == user.id).delete() + db.session.commit() diff --git a/backend/app/models/challenge_password_reset.py b/backend/app/models/challenge_password_reset.py new file mode 100644 index 00000000..c03436c1 --- /dev/null +++ b/backend/app/models/challenge_password_reset.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from datetime import datetime, timedelta +import hashlib +from typing import Self +import uuid +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.models.user import User + + +class ChallengePasswordReset(db.Model, DbModelMixin, TimestampMixin): + challenge_hash = db.Column(db.String(256), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + user = db.relationship("User") + + @classmethod + def find_by_challenge(cls, challenge: str) -> Self: + filter_before = datetime.utcnow() - timedelta(hours=3) + return cls.query.filter( + cls.challenge_hash == hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + cls.created_at >= filter_before, + ).first() + + @classmethod + def create_challenge(cls, user: User) -> str: + challenge = uuid.uuid4().hex + cls( + challenge_hash=hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + user_id=user.id, + ).save() + return challenge + + @classmethod + def delete_by_user(cls, user: User): + cls.query.filter(cls.user_id == user.id).delete() + db.session.commit() + + @classmethod + def delete_expired(cls): + filter_before = datetime.utcnow() - timedelta(hours=3) + db.session.query(cls).filter(cls.created_at <= filter_before).delete() + db.session.commit() diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py new file mode 100644 index 00000000..9132f7f4 --- /dev/null +++ b/backend/app/models/expense.py @@ -0,0 +1,96 @@ +from datetime import datetime +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "expense" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + amount = db.Column(db.Float()) + date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + category_id = db.Column(db.Integer, db.ForeignKey("expense_category.id")) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) + paid_by_id = db.Column(db.Integer, db.ForeignKey("user.id")) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + exclude_from_statistics = db.Column(db.Boolean, default=False, nullable=False) + + household = db.relationship("Household", uselist=False) + category = db.relationship("ExpenseCategory") + paid_by = db.relationship("User") + paid_for = db.relationship( + "ExpensePaidFor", back_populates="expense", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="expense", uselist=False) + + def obj_to_dict(self) -> dict: + res = super().obj_to_dict() + if self.photo_file: + res["photo_hash"] = self.photo_file.blur_hash + return res + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() + paidFor = ( + ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id) + .join(ExpensePaidFor.user) + .order_by(ExpensePaidFor.expense_id) + .all() + ) + res["paid_for"] = [e.obj_to_dict() for e in paidFor] + if self.category: + res["category"] = self.category.obj_to_full_dict() + return res + + def obj_to_export_dict(self) -> dict: + res = { + "name": self.name, + "amount": self.amount, + "date": self.date, + "photo": self.photo, + "paid_for": [ + {"factor": e.factor, "username": e.user.username} for e in self.paid_for + ], + "paid_by": self.paid_by.username, + } + if self.category: + res["category"] = self.category.obj_to_export_dict() + return res + + @classmethod + def find_by_name(cls, name) -> Self: + return cls.query.filter(cls.name == name).first() + + @classmethod + def find_by_id(cls, id) -> Self: + return ( + cls.query.filter(cls.id == id).join(Expense.category, isouter=True).first() + ) + + +class ExpensePaidFor(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "expense_paid_for" + + expense_id = db.Column(db.Integer, db.ForeignKey("expense.id"), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) + factor = db.Column(db.Integer()) + + expense = db.relationship("Expense", back_populates="paid_for") + user = db.relationship("User", back_populates="expenses_paid_for") + + def obj_to_user_dict(self): + res = self.user.obj_to_dict() + res["factor"] = getattr(self, "factor") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") + return res + + @classmethod + def find_by_ids(cls, expense_id, user_id) -> list[Self]: + return cls.query.filter( + cls.expense_id == expense_id, cls.user_id == user_id + ).first() diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py new file mode 100644 index 00000000..7810897e --- /dev/null +++ b/backend/app/models/expense_category.py @@ -0,0 +1,59 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "expense_category" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + color = db.Column(db.BigInteger) + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) + + household = db.relationship("Household", uselist=False) + expenses = db.relationship("Expense", back_populates="category") + + def obj_to_full_dict(self) -> dict: + res = super().obj_to_dict() + return res + + def obj_to_export_dict(self) -> dict: + return { + "name": self.name, + "color": self.color, + } + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import Expense + + for expense in Expense.query.filter(Expense.category_id == other.id).all(): + expense.category_id = self.id + db.session.add(expense) + + try: + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def find_by_name(cls, houshold_id: int, name: str) -> Self: + return cls.query.filter( + cls.name == name, cls.household_id == houshold_id + ).first() + + @classmethod + def find_by_id(cls, id: int) -> Self: + return cls.query.filter(cls.id == id).first() + + @classmethod + def delete_by_name(cls, household_id: int, name: str): + mc = cls.find_by_name(household_id, name) + if mc: + mc.delete() diff --git a/backend/app/models/file.py b/backend/app/models/file.py new file mode 100644 index 00000000..c4d420cb --- /dev/null +++ b/backend/app/models/file.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.config import UPLOAD_FOLDER +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin +from app.models.user import User +import os + + +class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "file" + + filename = db.Column(db.String(), primary_key=True) + blur_hash = db.Column(db.String(length=40), nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) + + household = db.relationship("Household", uselist=False) + recipe = db.relationship("Recipe", uselist=False) + expense = db.relationship("Expense", uselist=False) + profile_picture = db.relationship("User", foreign_keys=[User.photo], uselist=False) + + def delete(self): + """ + Delete this instance of model from db + """ + os.remove(os.path.join(UPLOAD_FOLDER, self.filename)) + db.session.delete(self) + db.session.commit() + + def isUnused(self) -> bool: + return ( + not self.household + and not self.recipe + and not self.expense + and not self.profile_picture + ) + + @classmethod + def find(cls, filename: str) -> Self: + """ + Find the row with specified id + """ + return cls.query.filter(cls.filename == filename).first() diff --git a/backend/app/models/history.py b/backend/app/models/history.py new file mode 100644 index 00000000..3ad2065e --- /dev/null +++ b/backend/app/models/history.py @@ -0,0 +1,99 @@ +from datetime import datetime +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from .shoppinglist import ShoppinglistItems +from sqlalchemy import func + +import enum + + +class Status(enum.Enum): + ADDED = 1 + DROPPED = -1 + + +class History(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "history" + + id = db.Column(db.Integer, primary_key=True) + + shoppinglist_id = db.Column(db.Integer, db.ForeignKey("shoppinglist.id")) + item_id = db.Column(db.Integer, db.ForeignKey("item.id")) + + item = db.relationship("Item", uselist=False, back_populates="history") + shoppinglist = db.relationship( + "Shoppinglist", uselist=False, back_populates="history" + ) + + status = db.Column(db.Enum(Status)) + description = db.Column("description", db.String()) + + @classmethod + def create_added_without_save(cls, shoppinglist, item, description="") -> Self: + return cls( + shoppinglist_id=shoppinglist.id, + item_id=item.id, + status=Status.ADDED, + description=description, + ) + + @classmethod + def create_added(cls, shoppinglist, item, description="") -> Self: + return cls.create_added_without_save(shoppinglist, item, description).save() + + @classmethod + def create_dropped( + cls, shoppinglist, item, description="", created_at=None + ) -> Self: + return cls( + shoppinglist_id=shoppinglist.id, + item_id=item.id, + status=Status.DROPPED, + description=description, + created_at=created_at, + ).save() + + def obj_to_item_dict(self) -> dict: + res = self.item.obj_to_dict() + res["timestamp"] = getattr(self, "created_at") + return res + + @classmethod + def find_added_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.status == Status.ADDED + ).all() + + @classmethod + def find_dropped_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.status == Status.DROPPED + ).all() + + @classmethod + def find_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id).all() + + @classmethod + def find_all(cls) -> list[Self]: + return cls.query.all() + + @classmethod + def get_recent(cls, shoppinglist_id: int, limit: int = 9) -> list[Self]: + sq = db.session.query(ShoppinglistItems.item_id).subquery().select() + sq2 = ( + db.session.query(func.max(cls.id)) + .filter(cls.status == Status.DROPPED) + .filter(cls.item_id.notin_(sq)) + .group_by(cls.item_id) + .join(cls.item) + .subquery() + .select() + ) + return ( + cls.query.filter(cls.shoppinglist_id == shoppinglist_id) + .filter(cls.id.in_(sq2)) + .order_by(cls.id.desc()) + .limit(limit) + ) diff --git a/backend/app/models/household.py b/backend/app/models/household.py new file mode 100644 index 00000000..177dd1a9 --- /dev/null +++ b/backend/app/models/household.py @@ -0,0 +1,118 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.helpers.db_list_type import DbListType + + +class Household(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "household" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) + language = db.Column(db.String()) + planner_feature = db.Column(db.Boolean(), nullable=False, default=True) + expenses_feature = db.Column(db.Boolean(), nullable=False, default=True) + + view_ordering = db.Column(DbListType(), default=list()) + + items = db.relationship( + "Item", back_populates="household", cascade="all, delete-orphan" + ) + shoppinglists = db.relationship( + "Shoppinglist", back_populates="household", cascade="all, delete-orphan" + ) + categories = db.relationship( + "Category", back_populates="household", cascade="all, delete-orphan" + ) + recipes = db.relationship( + "Recipe", back_populates="household", cascade="all, delete-orphan" + ) + tags = db.relationship( + "Tag", back_populates="household", cascade="all, delete-orphan" + ) + expenses = db.relationship( + "Expense", back_populates="household", cascade="all, delete-orphan" + ) + expenseCategories = db.relationship( + "ExpenseCategory", back_populates="household", cascade="all, delete-orphan" + ) + member = db.relationship( + "HouseholdMember", back_populates="household", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="household", uselist=False) + + def obj_to_dict(self) -> dict: + res = super().obj_to_dict() + res["member"] = [m.obj_to_user_dict() for m in getattr(self, "member")] + res["default_shopping_list"] = self.shoppinglists[0].obj_to_dict() + if self.photo_file: + res["photo_hash"] = self.photo_file.blur_hash + return res + + def obj_to_export_dict(self) -> dict: + return { + "name": self.name, + "language": self.language, + "view_ordering": self.view_ordering, + "planner_feature": self.planner_feature, + "expenses_feature": self.expenses_feature, + "member": [m.user.username for m in getattr(self, "member")], + "shoppinglists": [s.name for s in self.shoppinglists], + "recipes": [s.obj_to_export_dict() for s in self.recipes], + "items": [s.obj_to_export_dict() for s in self.items], + "expenses": [s.obj_to_export_dict() for s in self.expenses], + } + + +class HouseholdMember(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "household_member" + + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), primary_key=True + ) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) + + owner = db.Column(db.Boolean(), default=False, nullable=False) + admin = db.Column(db.Boolean(), default=False, nullable=False) + + expense_balance = db.Column(db.Float(), default=0, nullable=False) + + household = db.relationship("Household", back_populates="member") + user = db.relationship("User", back_populates="households") + + def obj_to_user_dict(self) -> dict: + res = self.user.obj_to_dict() + res["owner"] = getattr(self, "owner") + res["admin"] = getattr(self, "admin") + res["expense_balance"] = getattr(self, "expense_balance") + return res + + def delete(self): + if len(self.household.member) <= 1: + self.household.delete() + elif self.owner: + newOwner = next( + (m for m in self.household.member if m.admin and m != self), + next((m for m in self.household.member if m != self)), + ) + newOwner.admin = True + newOwner.owner = True + newOwner.save() + super().delete() + else: + super().delete() + + @classmethod + def find_by_ids(cls, household_id: int, user_id: int) -> Self: + return cls.query.filter( + cls.household_id == household_id, cls.user_id == user_id + ).first() + + @classmethod + def find_by_household(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id).all() + + @classmethod + def find_by_user(cls, user_id: int) -> list[Self]: + return cls.query.filter(cls.user_id == user_id).all() diff --git a/backend/app/models/item.py b/backend/app/models/item.py new file mode 100644 index 00000000..963bbaa7 --- /dev/null +++ b/backend/app/models/item.py @@ -0,0 +1,219 @@ +from __future__ import annotations +from typing import Self + +from sqlalchemy import func +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin +from app.models.category import Category +from app.util import description_merger + + +class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "item" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + icon = db.Column(db.String(128), nullable=True) + category_id = db.Column(db.Integer, db.ForeignKey("category.id")) + default = db.Column(db.Boolean, default=False) + default_key = db.Column(db.String(128)) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household = db.relationship("Household", uselist=False) + category = db.relationship("Category") + + recipes = db.relationship( + "RecipeItems", back_populates="item", cascade="all, delete-orphan" + ) + shoppinglists = db.relationship( + "ShoppinglistItems", back_populates="item", cascade="all, delete-orphan" + ) + + # determines order of items in the shoppinglist + ordering = db.Column(db.Integer, server_default="0") + # frequency of item, used for item suggestions + support = db.Column(db.Float, server_default="0.0") + + history = db.relationship( + "History", back_populates="item", cascade="all, delete-orphan" + ) + antecedents = db.relationship( + "Association", + back_populates="antecedent", + foreign_keys="Association.antecedent_id", + cascade="all, delete-orphan", + ) + consequents = db.relationship( + "Association", + back_populates="consequent", + foreign_keys="Association.consequent_id", + cascade="all, delete-orphan", + ) + + def obj_to_dict(self) -> dict: + res = super().obj_to_dict() + if self.category_id: + category = Category.find_by_id(self.category_id) + res["category"] = category.obj_to_dict() + return res + + def obj_to_export_dict(self) -> dict: + res = { + "name": self.name, + } + if self.icon: + res["icon"] = self.icon + if self.category: + res["category"] = self.category.name + return res + + def save(self, keepDefault=False) -> Self: + if not keepDefault: + self.default = False + return super().save() + + def merge(self, other: Self) -> None: + if other.household_id != self.household_id: + return + + from app.models import RecipeItems + from app.models import History + from app.models import ShoppinglistItems + + if not self.default_key and other.default_key: + self.default_key = other.default_key + + if not self.category_id and other.category_id: + self.category_id = other.category_id + + if not self.icon and other.icon: + self.icon = other.icon + + for ri in RecipeItems.query.filter(RecipeItems.item_id == other.id).all(): + ri: RecipeItems + existingRi = RecipeItems.find_by_ids(ri.recipe_id, self.id) + if not existingRi: + ri.item_id = self.id + db.session.add(ri) + else: + existingRi.description = description_merger.merge( + existingRi.description, ri.description + ) + db.session.delete(ri) + db.session.add(existingRi) + + for si in ShoppinglistItems.query.filter( + ShoppinglistItems.item_id == other.id + ).all(): + si: ShoppinglistItems + existingSi = ShoppinglistItems.find_by_ids(si.shoppinglist_id, self.id) + if not existingSi: + si.item_id = self.id + db.session.add(si) + else: + existingSi.description = description_merger.merge( + existingSi.description, si.description + ) + db.session.delete(si) + db.session.add(existingSi) + + for history in History.query.filter(History.item_id == other.id).all(): + history.item_id = self.id + db.session.add(history) + + try: + db.session.add(self) + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def create_by_name( + cls, household_id: int, name: str, default: bool = False + ) -> Self: + return cls( + name=name.strip(), + default=default, + household_id=household_id, + ).save() + + @classmethod + def find_by_name(cls, household_id: int, name: str) -> Self: + name = name.strip() + return cls.query.filter( + cls.household_id == household_id, func.lower(cls.name) == func.lower(name) + ).first() + + @classmethod + def find_by_default_key(cls, household_id: int, default_key: str) -> Self: + return cls.query.filter( + cls.household_id == household_id, cls.default_key == default_key + ).first() + + @classmethod + def find_by_id(cls, id) -> Self: + return cls.query.filter(cls.id == id).first() + + @classmethod + def search_name(cls, name: str, household_id: int) -> list[Self]: + item_count = 11 + if "postgresql" in db.engine.name: + return ( + cls.query.filter( + cls.household_id == household_id, + func.levenshtein( + func.lower(func.substring(cls.name, 1, len(name))), name.lower() + ) + < 4, + ) + .order_by( + func.levenshtein( + func.lower(func.substring(cls.name, 1, len(name))), name.lower() + ), + cls.support.desc(), + ) + .limit(item_count) + ) + + found = [] + + # name is a regex + if "*" in name or "?" in name or "%" in name or "_" in name: + looking_for = name.replace("*", "%").replace("?", "_") + found = ( + cls.query.filter( + cls.name.ilike(looking_for), cls.household_id == household_id + ) + .order_by(cls.support.desc()) + .limit(item_count) + .all() + ) + return found + + # name is no regex + starts_with = "{0}%".format(name) + contains = "%{0}%".format(name) + one_error = [] + for index in range(len(name)): + name_one_error = name[:index] + "_" + name[index + 1 :] + one_error.append("%{0}%".format(name_one_error)) + + for looking_for in [starts_with, contains] + one_error: + res = ( + cls.query.filter( + cls.name.ilike(looking_for), cls.household_id == household_id + ) + .order_by(cls.support.desc(), cls.name) + .all() + ) + for r in res: + if r not in found: + found.append(r) + item_count -= 1 + if item_count <= 0: + return found + return found diff --git a/backend/app/models/oidc.py b/backend/app/models/oidc.py new file mode 100644 index 00000000..2bbab0e9 --- /dev/null +++ b/backend/app/models/oidc.py @@ -0,0 +1,47 @@ +from datetime import datetime, timedelta +from typing import Self + +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class OIDCLink(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "oidc_link" + + sub = db.Column(db.String(256), primary_key=True) + provider = db.Column(db.String(24), primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("user.id"), nullable=False, index=True + ) + + user = db.relationship("User", back_populates="oidc_links") + + @classmethod + def find_by_ids(cls, sub: str, provider: str) -> Self: + return cls.query.filter(cls.sub == sub, cls.provider == provider).first() + + +class OIDCRequest(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "oidc_request" + + state = db.Column(db.String(256), primary_key=True) + provider = db.Column(db.String(24), primary_key=True) + nonce = db.Column(db.String(256), nullable=False) + redirect_uri = db.Column(db.String(256), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + user = db.relationship("User", back_populates="oidc_link_requests") + + @classmethod + def find_by_state(cls, state: str) -> Self: + filter_before = datetime.utcnow() - timedelta(minutes=7) + return cls.query.filter( + cls.state == state, + cls.created_at >= filter_before, + ).first() + + @classmethod + def delete_expired(cls): + filter_before = datetime.utcnow() - timedelta(minutes=7) + db.session.query(cls).filter(cls.created_at <= filter_before).delete() + db.session.commit() diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py new file mode 100644 index 00000000..0b9c1019 --- /dev/null +++ b/backend/app/models/planner.py @@ -0,0 +1,39 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class Planner(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "planner" + + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) + day = db.Column(db.Integer, primary_key=True) + yields = db.Column(db.Integer) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household = db.relationship("Household", uselist=False) + recipe = db.relationship("Recipe", back_populates="plans") + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() + res["recipe"] = self.recipe.obj_to_full_dict() + return res + + @classmethod + def all_from_household(cls, household_id: int) -> list[Self]: + """ + Return all instances of model + IMPORTANT: requires household_id column + """ + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.day).all() + ) + + @classmethod + def find_by_day(cls, household_id: int, recipe_id: int, day: int) -> Self: + return cls.query.filter( + cls.household_id == household_id, cls.recipe_id == recipe_id, cls.day == day + ).first() diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py new file mode 100644 index 00000000..b602a561 --- /dev/null +++ b/backend/app/models/recipe.py @@ -0,0 +1,250 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin +from .item import Item +from .tag import Tag +from .planner import Planner +from random import randint + + +class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "recipe" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + description = db.Column(db.String()) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) + time = db.Column(db.Integer) + cook_time = db.Column(db.Integer) + prep_time = db.Column(db.Integer) + yields = db.Column(db.Integer) + source = db.Column(db.String()) + suggestion_score = db.Column(db.Integer, server_default="0") + suggestion_rank = db.Column(db.Integer, server_default="0") + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household = db.relationship("Household", uselist=False) + recipe_history = db.relationship( + "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan" + ) + items = db.relationship( + "RecipeItems", back_populates="recipe", cascade="all, delete-orphan" + ) + tags = db.relationship( + "RecipeTags", back_populates="recipe", cascade="all, delete-orphan" + ) + plans = db.relationship( + "Planner", back_populates="recipe", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="recipe", uselist=False) + + def obj_to_dict(self) -> dict: + res = super().obj_to_dict() + res["planned"] = len(self.plans) > 0 + res["planned_days"] = [plan.day for plan in self.plans if plan.day >= 0] + if self.photo_file: + res["photo_hash"] = self.photo_file.blur_hash + return res + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() + items = ( + RecipeItems.query.filter(RecipeItems.recipe_id == self.id) + .join(RecipeItems.item) + .order_by(Item.name) + .all() + ) + res["items"] = [e.obj_to_item_dict() for e in items] + tags = ( + RecipeTags.query.filter(RecipeTags.recipe_id == self.id) + .join(RecipeTags.tag) + .order_by(Tag.name) + .all() + ) + res["tags"] = [e.obj_to_item_dict() for e in tags] + return res + + def obj_to_export_dict(self) -> dict: + items = ( + RecipeItems.query.filter(RecipeItems.recipe_id == self.id) + .join(RecipeItems.item) + .order_by(Item.name) + .all() + ) + tags = ( + RecipeTags.query.filter(RecipeTags.recipe_id == self.id) + .join(RecipeTags.tag) + .order_by(Tag.name) + .all() + ) + res = { + "name": self.name, + "description": self.description, + "time": self.time, + "photo": self.photo, + "cook_time": self.cook_time, + "prep_time": self.prep_time, + "yields": self.yields, + "source": self.source, + "items": [ + { + "name": e.item.name, + "description": e.description, + "optional": e.optional, + } + for e in items + ], + "tags": [e.tag.name for e in tags], + } + return res + + @classmethod + def compute_suggestion_ranking(cls, household_id: int): + # reset all suggestion ranks + for r in cls.query.filter(cls.household_id == household_id).all(): + r.suggestion_rank = 0 + db.session.add(r) + # get all recipes with positive suggestion_score + recipes = cls.query.filter( + cls.household_id == household_id, cls.suggestion_score != 0 + ).all() + # compute the initial sum of all suggestion_scores + suggestion_sum = 0 + for r in recipes: + suggestion_sum += r.suggestion_score + # iteratively assign increasing suggestion rank to random recipes weighted by their score + current_rank = 1 + while len(recipes) > 0: + choose = randint(1, suggestion_sum) + to_be_removed = -1 + for i, r in enumerate(recipes): + choose -= r.suggestion_score + if choose <= 0: + r.suggestion_rank = current_rank + current_rank += 1 + suggestion_sum -= r.suggestion_score + to_be_removed = i + db.session.add(r) + break + recipes.pop(to_be_removed) + db.session.commit() + + @classmethod + def find_suggestions( + cls, + household_id: int, + ) -> list[Self]: + sq = ( + db.session.query(Planner.recipe_id) + .group_by(Planner.recipe_id) + .scalar_subquery() + ) + return ( + cls.query.filter(cls.household_id == household_id, cls.id.notin_(sq)) + .filter(cls.suggestion_rank > 0) # noqa + .order_by(cls.suggestion_rank) + .all() + ) + + @classmethod + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() + + @classmethod + def find_by_id(cls, id: int) -> Self: + return cls.query.filter(cls.id == id).first() + + @classmethod + def search_name(cls, household_id: int, name: str) -> list[Self]: + if "*" in name or "_" in name: + looking_for = name.replace("_", "__").replace("*", "%").replace("?", "_") + else: + looking_for = "%{0}%".format(name) + return ( + cls.query.filter( + cls.household_id == household_id, cls.name.ilike(looking_for) + ) + .order_by(cls.name) + .all() + ) + + @classmethod + def all_by_name_with_filter( + cls, household_id: int, filter: list[str] + ) -> list[Self]: + sq = ( + db.session.query(RecipeTags.recipe_id) + .join(RecipeTags.tag) + .filter(Tag.name.in_(filter)) + .subquery() + ) + return ( + db.session.query(cls) + .filter(cls.household_id == household_id, cls.id.in_(sq)) + .order_by(cls.name) + .all() + ) + + +class RecipeItems(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "recipe_items" + + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("item.id"), primary_key=True) + description = db.Column("description", db.String()) + optional = db.Column("optional", db.Boolean) + + item = db.relationship("Item", back_populates="recipes") + recipe = db.relationship("Recipe", back_populates="items") + + def obj_to_item_dict(self) -> dict: + res = self.item.obj_to_dict() + res["description"] = getattr(self, "description") + res["optional"] = getattr(self, "optional") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") + return res + + def obj_to_recipe_dict(self) -> dict: + res = self.recipe.obj_to_dict() + res["items"] = [ + { + "id": getattr(self, "item_id"), + "description": getattr(self, "description"), + "optional": getattr(self, "optional"), + } + ] + return res + + @classmethod + def find_by_ids(cls, recipe_id: int, item_id: int) -> Self: + return cls.query.filter( + cls.recipe_id == recipe_id, cls.item_id == item_id + ).first() + + +class RecipeTags(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "recipe_tags" + + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) + tag_id = db.Column(db.Integer, db.ForeignKey("tag.id"), primary_key=True) + + tag = db.relationship("Tag", back_populates="recipes") + recipe = db.relationship("Recipe", back_populates="tags") + + def obj_to_item_dict(self) -> dict: + res = self.tag.obj_to_dict() + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") + return res + + @classmethod + def find_by_ids(cls, recipe_id: int, tag_id: int) -> Self: + return cls.query.filter( + cls.recipe_id == recipe_id, cls.tag_id == tag_id + ).first() diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py new file mode 100644 index 00000000..222313c9 --- /dev/null +++ b/backend/app/models/recipe_history.py @@ -0,0 +1,84 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from .recipe import Recipe +from .planner import Planner +from sqlalchemy import func + +import enum + + +class Status(enum.Enum): + ADDED = 1 + DROPPED = -1 + + +class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "recipe_history" + + id = db.Column(db.Integer, primary_key=True) + + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id")) + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) + + household = db.relationship("Household", uselist=False) + recipe = db.relationship("Recipe", uselist=False, back_populates="recipe_history") + + status = db.Column(db.Enum(Status)) + + @classmethod + def create_added(cls, recipe: Recipe, household_id: int) -> Self: + return cls( + recipe_id=recipe.id, + status=Status.ADDED, + household_id=household_id, + ).save() + + @classmethod + def create_dropped(cls, recipe: Recipe, household_id: int) -> Self: + return cls( + recipe_id=recipe.id, + status=Status.DROPPED, + household_id=household_id, + ).save() + + def obj_to_item_dict(self) -> dict: + res = self.item.obj_to_dict() + res["timestamp"] = getattr(self, "created_at") + return res + + @classmethod + def find_added(cls, household_id: int) -> list[Self]: + return cls.query.filter( + cls.household_id == household_id, cls.status == Status.ADDED + ).all() + + @classmethod + def find_dropped(cls, household_id: int) -> list[Self]: + return cls.query.filter( + cls.household_id == household_id, cls.status == Status.DROPPED + ).all() + + @classmethod + def find_all(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id).all() + + @classmethod + def get_recent(cls, household_id: int) -> list[Self]: + sq = ( + db.session.query(Planner.recipe_id) + .group_by(Planner.recipe_id) + .filter(Planner.household_id == household_id) + .subquery() + .select() + ) + sq2 = ( + db.session.query(func.max(cls.id)) + .filter(cls.status == Status.DROPPED, cls.household_id == household_id) + .filter(cls.recipe_id.notin_(sq)) + .group_by(cls.recipe_id) + .join(cls.recipe) + .subquery() + .select() + ) + return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 00000000..f025e46a --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,17 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Settings(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "settings" + + id = db.Column(db.Integer, primary_key=True, nullable=False) + + @classmethod + def get(cls) -> Self: + settings = cls.query.first() + if not settings: + settings = cls() + settings.save() + return settings diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py new file mode 100644 index 00000000..ad0b710a --- /dev/null +++ b/backend/app/models/shoppinglist.py @@ -0,0 +1,59 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class Shoppinglist(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "shoppinglist" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household = db.relationship("Household", uselist=False) + items = db.relationship("ShoppinglistItems", cascade="all, delete-orphan") + + history = db.relationship( + "History", back_populates="shoppinglist", cascade="all, delete-orphan" + ) + + @classmethod + def getDefault(cls, household_id: int) -> Self: + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.id).first() + ) + + def isDefault(self) -> bool: + return self.id == self.getDefault(self.household_id).id + + +class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "shoppinglist_items" + + shoppinglist_id = db.Column( + db.Integer, db.ForeignKey("shoppinglist.id"), primary_key=True + ) + item_id = db.Column(db.Integer, db.ForeignKey("item.id"), primary_key=True) + description = db.Column("description", db.String()) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + item = db.relationship("Item", back_populates="shoppinglists") + shoppinglist = db.relationship("Shoppinglist", back_populates="items") + created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) + + def obj_to_item_dict(self) -> dict: + res = self.item.obj_to_dict() + res["description"] = getattr(self, "description") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") + res["created_by"] = getattr(self, "created_by") + return res + + @classmethod + def find_by_ids(cls, shoppinglist_id: int, item_id: int) -> Self: + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id + ).first() diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 00000000..1601cdd5 --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,63 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin + + +class Tag(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = "tag" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) + + household = db.relationship("Household", uselist=False) + recipes = db.relationship( + "RecipeTags", back_populates="tag", cascade="all, delete-orphan" + ) + + def obj_to_full_dict(self) -> dict: + res = super().obj_to_dict() + return res + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import RecipeTags + + for rectag in RecipeTags.query.filter( + RecipeTags.tag_id == other.id, + RecipeTags.recipe_id.notin_( + db.session.query(RecipeTags.recipe_id) + .filter(RecipeTags.tag_id == self.id) + .subquery() + .select() + ), + ).all(): + rectag.tag_id = self.id + db.session.add(rectag) + + try: + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def create_by_name(cls, household_id: int, name: str) -> Self: + return cls( + name=name, + household_id=household_id, + ).save() + + @classmethod + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() + + @classmethod + def find_by_id(cls, id: int) -> Self: + return cls.query.filter(cls.id == id).first() diff --git a/backend/app/models/token.py b/backend/app/models/token.py new file mode 100644 index 00000000..0283c3ee --- /dev/null +++ b/backend/app/models/token.py @@ -0,0 +1,149 @@ +from __future__ import annotations +from datetime import datetime +from typing import Self, Tuple + +from flask import request +from app import db +from app.config import JWT_REFRESH_TOKEN_EXPIRES, JWT_ACCESS_TOKEN_EXPIRES +from app.errors import UnauthorizedRequest +from app.helpers import DbModelMixin, TimestampMixin +from flask_jwt_extended import create_access_token, create_refresh_token, get_jti +from app.models.user import User + + +class Token(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "token" + + id = db.Column(db.Integer, primary_key=True) + jti = db.Column(db.String(36), nullable=False, index=True) + type = db.Column(db.String(16), nullable=False) + name = db.Column(db.String(), nullable=False) + last_used_at = db.Column(db.DateTime) + refresh_token_id = db.Column(db.Integer, db.ForeignKey("token.id"), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + created_tokens = db.relationship( + "Token", back_populates="refresh_token", cascade="all, delete-orphan" + ) + refresh_token = db.relationship("Token", remote_side=[id]) + user = db.relationship("User") + + def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: + if skip_columns: + skip_columns = skip_columns + ["jti"] + else: + skip_columns = ["jti"] + return super().obj_to_dict( + skip_columns=skip_columns, include_columns=include_columns + ) + + @classmethod + def find_by_jti(cls, jti: str) -> Self: + return cls.query.filter(cls.jti == jti).first() + + @classmethod + def delete_expired_refresh(cls): + filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + for token in ( + db.session.query(cls) + .filter( + cls.created_at <= filter_before, + cls.type == "refresh", + ~cls.created_tokens.any(), + ) + .all() + ): + token.delete_token_familiy(commit=False) + db.session.commit() + + @classmethod + def delete_expired_access(cls): + filter_before = datetime.utcnow() - JWT_ACCESS_TOKEN_EXPIRES + db.session.query(cls).filter( + cls.created_at <= filter_before, cls.type == "access" + ).delete() + db.session.commit() + + # Delete oldest refresh token -> log out device + # Used e.g. when a refresh token is used twice + def delete_token_familiy(self, commit=True): + if self.type != "refresh": + return + token = self + while token: + if token.refresh_token: + token = token.refresh_token + else: + db.session.delete(token) + token = None + if commit: + db.session.commit() + + def has_created_refresh_token(self) -> bool: + return ( + db.session.query(Token) + .filter(Token.refresh_token_id == self.id, Token.type == "refresh") + .count() + > 0 + ) + + def delete_created_access_tokens(self): + if self.type != "refresh": + return + db.session.query(Token).filter( + Token.refresh_token_id == self.id, Token.type == "access" + ).delete() + db.session.commit() + + @classmethod + def create_access_token( + cls, user: User, refreshTokenModel: Self + ) -> Tuple[any, Self]: + accesssToken = create_access_token(identity=user) + model = cls() + model.jti = get_jti(accesssToken) + model.type = "access" + model.name = refreshTokenModel.name + model.user = user + model.refresh_token = refreshTokenModel + model.save() + return accesssToken, model + + @classmethod + def create_refresh_token( + cls, user: User, device: str = None, oldRefreshToken: Self = None + ) -> Tuple[any, Self]: + assert device or oldRefreshToken + if oldRefreshToken and ( + oldRefreshToken.type != "refresh" + or oldRefreshToken.has_created_refresh_token() + ): + oldRefreshToken.delete_token_familiy() + raise UnauthorizedRequest( + message="Unauthorized: IP {} reused the same refresh token, loging out user".format( + request.remote_addr + ) + ) + + refreshToken = create_refresh_token(identity=user) + model = cls() + model.jti = get_jti(refreshToken) + model.type = "refresh" + model.name = device or oldRefreshToken.name + model.user = user + if oldRefreshToken: + oldRefreshToken.delete_created_access_tokens() + model.refresh_token = oldRefreshToken + model.save() + return refreshToken, model + + @classmethod + def create_longlived_token(cls, user: User, device: str) -> Tuple[any, Self]: + accesssToken = create_access_token(identity=user, expires_delta=False) + model = cls() + model.jti = get_jti(accesssToken) + model.type = "llt" + model.name = device + model.user = user + model.save() + return accesssToken, model diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 00000000..94cd2cf0 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,152 @@ +from typing import Self + +from flask_jwt_extended import current_user +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.config import bcrypt + + +class User(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "user" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + username = db.Column(db.String(256), unique=True, nullable=False) + email = db.Column(db.String(256), unique=True, nullable=True) + password = db.Column(db.String(256), nullable=True) + photo = db.Column(db.String(), db.ForeignKey("file.filename", use_alter=True)) + admin = db.Column(db.Boolean(), default=False) + email_verified = db.Column(db.Boolean(), default=False) + + tokens = db.relationship( + "Token", back_populates="user", cascade="all, delete-orphan" + ) + + password_reset_challenge = db.relationship( + "ChallengePasswordReset", back_populates="user", cascade="all, delete-orphan" + ) + verify_mail_challenge = db.relationship( + "ChallengeMailVerify", back_populates="user", cascade="all, delete-orphan" + ) + + households = db.relationship( + "HouseholdMember", back_populates="user", cascade="all, delete-orphan" + ) + + expenses_paid = db.relationship( + "Expense", back_populates="paid_by", cascade="all, delete-orphan" + ) + expenses_paid_for = db.relationship( + "ExpensePaidFor", back_populates="user", cascade="all, delete-orphan" + ) + photo_file = db.relationship( + "File", back_populates="profile_picture", foreign_keys=[photo], uselist=False + ) + + oidc_links = db.relationship( + "OIDCLink", back_populates="user", cascade="all, delete-orphan" + ) + oidc_link_requests = db.relationship( + "OIDCRequest", back_populates="user", cascade="all, delete-orphan" + ) + + + def check_password(self, password: str) -> bool: + return self.password and bcrypt.check_password_hash(self.password, password) + + def set_password(self, password: str): + self.password = bcrypt.generate_password_hash(password).decode("utf-8") + + def obj_to_dict( + self, + include_email: bool = False, + skip_columns: list[str] | None = None, + include_columns: list[str] | None = None, + ) -> dict: + if skip_columns: + skip_columns = skip_columns + ["password"] + else: + skip_columns = ["password"] + if not include_email: + skip_columns += ["email", "email_verified"] + + if not current_user or not current_user.admin: + # Filter out admin status if current user is not an admin + skip_columns = skip_columns + ["admin"] + + return super().obj_to_dict( + skip_columns=skip_columns, include_columns=include_columns + ) + + def obj_to_full_dict(self) -> dict: + from .token import Token + + res = self.obj_to_dict(include_email=True) + res["admin"] = self.admin + tokens = Token.query.filter( + Token.user_id == self.id, + Token.type != "access", + ~Token.created_tokens.any(Token.type == "refresh"), + ).all() + res["tokens"] = [e.obj_to_dict(skip_columns=["user_id"]) for e in tokens] + res["oidc_links"] = [e.provider for e in self.oidc_links] + return res + + def delete(self): + """ + Delete this instance of model from db + """ + from app.models import File + + for f in File.query.filter(File.created_by == self.id).all(): + f.created_by = None + f.save() + from app.models import ShoppinglistItems + + for s in ShoppinglistItems.query.filter( + ShoppinglistItems.created_by == self.id + ).all(): + s.created_by = None + s.save() + super().delete() + + @classmethod + def find_by_username(cls, username: str) -> Self: + return cls.query.filter(cls.username == username).first() + + @classmethod + def find_by_email(cls, email: str) -> Self: + return cls.query.filter(cls.email == email.strip()).first() + + @classmethod + def create( + cls, + username: str, + password: str, + name: str, + email: str | None = None, + admin: bool = False, + ) -> Self: + return cls( + username=username.lower().replace(" ", ""), + password=bcrypt.generate_password_hash(password).decode("utf-8") + if password + else None, + name=name.strip(), + email=email.strip() if email else None, + admin=admin, + ).save() + + @classmethod + def search_name(cls, name: str) -> list[Self]: + if "*" in name or "_" in name: + looking_for = name.replace("_", "__").replace("*", "%").replace("?", "_") + else: + looking_for = "%{0}%".format(name) + return ( + cls.query.filter( + cls.name.ilike(looking_for) | cls.username.ilike(looking_for) + ) + .order_by(cls.name) + .limit(15) + ) diff --git a/backend/app/service/__init__.py b/backend/app/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/service/delete_unused.py b/backend/app/service/delete_unused.py new file mode 100644 index 00000000..44613e76 --- /dev/null +++ b/backend/app/service/delete_unused.py @@ -0,0 +1,18 @@ +from app.models import Household, File +from app import app + + +def deleteUnusedFiles() -> int: + filesToDelete = [f for f in File.query.all() if f.isUnused()] + for f in filesToDelete: + f.delete() + app.logger.info(f"Deleted {len(filesToDelete)} unused files") + return len(filesToDelete) + + +def deleteEmptyHouseholds() -> int: + householdsToDelete = [h for h in Household.all() if len(h.member) == 0] + for h in householdsToDelete: + h.delete() + app.logger.info(f"Deleted {len(householdsToDelete)} empty households") + return len(householdsToDelete) diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py new file mode 100644 index 00000000..9af22925 --- /dev/null +++ b/backend/app/service/file_has_access_or_download.py @@ -0,0 +1,44 @@ +import os +import uuid +import requests +import blurhash +from PIL import Image +from app.util.filename_validator import allowed_file +from app.config import UPLOAD_FOLDER +from app.models import File +from flask_jwt_extended import current_user +from werkzeug.utils import secure_filename + + +def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: + """ + Downloads the file if the url is an external URL or checks if the user has access to the file on this server + If the user has no access oldPhoto is returned + """ + if newPhoto is not None and "/" in newPhoto: + from mimetypes import guess_extension + + resp = requests.get(newPhoto) + ext = guess_extension(resp.headers["content-type"]) + if ext and allowed_file("file" + ext): + filename = secure_filename(str(uuid.uuid4()) + ext) + with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: + o.write(resp.content) + blur = None + try: + with Image.open(os.path.join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode(image, x_components=4, y_components=3) + except FileNotFoundError: + return None + except Exception: + pass + File(filename=filename, blur_hash=blur, created_by=current_user.id).save() + return filename + elif newPhoto is not None: + if not newPhoto: + return None + f = File.find(newPhoto) + if f and (f.created_by == current_user.id or current_user.admin): + return f.filename + return oldPhoto diff --git a/backend/app/service/importServices/__init__.py b/backend/app/service/importServices/__init__.py new file mode 100644 index 00000000..d18c39f0 --- /dev/null +++ b/backend/app/service/importServices/__init__.py @@ -0,0 +1,4 @@ +from .import_recipe import importRecipe +from .import_expense import importExpense +from .import_shoppinglist import importShoppinglist +from .import_item import importItem diff --git a/backend/app/service/importServices/import_expense.py b/backend/app/service/importServices/import_expense.py new file mode 100644 index 00000000..525eca0c --- /dev/null +++ b/backend/app/service/importServices/import_expense.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from app.models import Household, Expense, ExpensePaidFor, ExpenseCategory +from app.service.file_has_access_or_download import file_has_access_or_download + + +def importExpense(household: Household, args: dict): + expense = Expense() + expense.household = household + expense.name = args["name"] + expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) + expense.amount = args["amount"] + if "photo" in args: + expense.photo = file_has_access_or_download(args["photo"]) + if "category" in args: + category = ExpenseCategory.find_by_name(household.id, args["category"]["name"]) + if not category: + category = ExpenseCategory() + category.name = args["category"]["name"] + category.color = args["category"]["color"] + category.household_id = household.id + category = category.save() + expense.category = category + + paid_by = next( + (x for x in household.member if x.user.username == args["paid_by"]), None + ) + if paid_by: + expense.paid_by_id = paid_by.user_id + + expense.save() + + for paid_for in args["paid_for"]: + paid_for_member = next( + (x for x in household.member if x.user.username == paid_for["username"]), + None, + ) + if not paid_for_member: + continue + con = ExpensePaidFor() + con.expense = expense + con.user_id = paid_for_member.user_id + con.factor = paid_for["factor"] + con.save() diff --git a/backend/app/service/importServices/import_item.py b/backend/app/service/importServices/import_item.py new file mode 100644 index 00000000..dbc49142 --- /dev/null +++ b/backend/app/service/importServices/import_item.py @@ -0,0 +1,17 @@ +from app.models import Household, Item, Category + + +def importItem(household: Household, args: dict): + item = Item.find_by_name(household.id, args["name"]) + if not item: + item = Item() + item.name = args["name"] + item.household = household + if "icon" in args: + item.icon = args["icon"] + if "category" in args and not item.category_id: + category = Category.find_by_name(household.id, args["category"]) + if not category: + category = Category.create_by_name(household.id, args["category"]) + item.category = category + item.save() diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py new file mode 100644 index 00000000..69826aba --- /dev/null +++ b/backend/app/service/importServices/import_recipe.py @@ -0,0 +1,58 @@ +from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag +from app.service.file_has_access_or_download import file_has_access_or_download + + +def importRecipe(household_id: int, args: dict, overwrite: bool = False): + recipeNameCount = 0 + recipe = Recipe.find_by_name(household_id, args["name"]) + if recipe and not overwrite: + recipeNameCount = ( + 1 + + Recipe.query.filter( + Recipe.household_id == household_id, + Recipe.name.ilike(args["name"] + " (_%)"), + ).count() + ) + recipe = None + if not recipe: + recipe = Recipe() + recipe.household_id = household_id + recipe.name = args["name"] + ( + f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "" + ) + recipe.description = args["description"] + if "time" in args: + recipe.time = args["time"] + if "cook_time" in args: + recipe.cook_time = args["cook_time"] + if "prep_time" in args: + recipe.prep_time = args["prep_time"] + if "yields" in args: + recipe.yields = args["yields"] + if "source" in args: + recipe.source = args["source"] + if "photo" in args: + recipe.photo = file_has_access_or_download(args["photo"]) + + recipe.save() + + if "items" in args: + for recipeItem in args["items"]: + item = Item.find_by_name(household_id, recipeItem["name"]) + if not item: + item = Item.create_by_name(household_id, recipeItem["name"]) + con = RecipeItems( + description=recipeItem["description"], optional=recipeItem["optional"] + ) + con.item = item + con.recipe = recipe + con.save() + if "tags" in args: + for tagName in args["tags"]: + tag = Tag.find_by_name(household_id, tagName) + if not tag: + tag = Tag.create_by_name(household_id, tagName) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() diff --git a/backend/app/service/importServices/import_shoppinglist.py b/backend/app/service/importServices/import_shoppinglist.py new file mode 100644 index 00000000..561bf5d6 --- /dev/null +++ b/backend/app/service/importServices/import_shoppinglist.py @@ -0,0 +1,5 @@ +from app.models import Household, Shoppinglist + + +def importShoppinglist(household: Household, args: dict): + pass diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py new file mode 100644 index 00000000..45e8a395 --- /dev/null +++ b/backend/app/service/import_language.py @@ -0,0 +1,81 @@ +import time +from app.config import app, APP_DIR, SUPPORTED_LANGUAGES, db +from os.path import exists +import json + +from app.errors import NotFoundRequest +from app.models import Item, Category + + +def importLanguage(household_id, lang, bulkSave=False): + with app.app_context(): + file_path = f"{APP_DIR}/../templates/l10n/{lang}.json" + if lang not in SUPPORTED_LANGUAGES or not exists(file_path): + raise NotFoundRequest("Language code not supported") + with open(file_path, "r") as f: + data = json.load(f) + with open(f"{APP_DIR}/../templates/attributes.json", "r") as f: + attributes = json.load(f) + + t0 = time.time() + models: list[Item] = [] + for key, name in data["items"].items(): + item = Item.find_by_default_key(household_id, key) or Item.find_by_name( + household_id, name + ) + if not item: + # needed to filter out duplicate names + if bulkSave and any(i.name == name for i in models): + continue + item = Item() + item.name = name.strip() + item.household_id = household_id + item.default = True + item.default_key = key + + if not item.default_key: # migrate to new system + item.default_key = key + + if item.default: + if ( + item.name != name.strip() + and not Item.find_by_name(household_id, name) + and not any(i.name == name for i in models) + ): + item.name = name.strip() + + if key in attributes["items"] and "icon" in attributes["items"][key]: + item.icon = attributes["items"][key]["icon"] + + # Category not already set for existing item and category set for template and category translation exist for language + if ( + key in attributes["items"] + and "category" in attributes["items"][key] + and attributes["items"][key]["category"] in data["categories"] + ): + category_key = attributes["items"][key]["category"] + category_name = data["categories"][category_key] + category = Category.find_by_default_key( + household_id, category_key + ) or Category.find_by_name(household_id, category_name) + if not category: + category = Category.create_by_name( + household_id, category_name, True, category_key + ) + if not category.default_key: # migrate to new system + category.default_key = category_key + category.save() + item.category = category + if not bulkSave: + item.save(keepDefault=True) + else: + models.append(item) + + if bulkSave: + try: + db.session.add_all(models) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py new file mode 100644 index 00000000..819ff2f7 --- /dev/null +++ b/backend/app/service/mail.py @@ -0,0 +1,130 @@ +import smtplib, ssl, os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from app.config import app, FRONT_URL +from app.models import User + +SMTP_HOST = os.getenv("SMTP_HOST") +SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) +SMTP_USER = os.getenv("SMTP_USER") +SMTP_PASS = os.getenv("SMTP_PASS") +SMTP_FROM = os.getenv("SMTP_FROM") +SMTP_REPLY_TO = os.getenv("SMTP_REPLY_TO") + +context = ssl.create_default_context() + +mail_configured: bool = None + + +def mailConfigured(): + global mail_configured + if mail_configured != None: + return mail_configured + if ( + not SMTP_HOST + or not SMTP_PORT + or not SMTP_USER + or not SMTP_PASS + or not SMTP_FROM + ): + mail_configured = False + return mail_configured + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server: + server.login(SMTP_USER, SMTP_PASS) + mail_configured = True + except Exception: + mail_configured = False + return mail_configured + + +def sendMail(to: str, message: MIMEMultipart): + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server: + server.login(SMTP_USER, SMTP_PASS) + message["From"] = SMTP_FROM + message["To"] = to + if SMTP_REPLY_TO: + message["Reply-To"] = SMTP_REPLY_TO + server.sendmail(SMTP_FROM, to, message.as_string()) + + +def sendVerificationMail(userId: int, token: str): + with app.app_context(): + user = User.find_by_id(userId) + if not user.email or not token: + return + + verifyLink = FRONT_URL + "/confirm-email?t=" + token + + message = MIMEMultipart("alternative") + message["Subject"] = "Verify Email" + text = """\ +Hi {name} (@{username}), + +Verify your email so we know it's really you, and you don't lose access to your account. +Verify email address: {link} + +Have any questions? Check out https://kitchenowl.org/privacy/""".format( + name=user.name, username=user.username, link=verifyLink + ) + html = """\ + + +

Hi {name} (@{username}),

+ + Verify your email so we know it's really you, and you don't lose access to your account.
+ Verify email address

+ + Have any questions? Check out our Privacy Policy +

+ + + """.format( + name=user.name, username=user.username, link=verifyLink + ) + # The email client will try to render the last part first + message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(html, "html")) + sendMail(user.email, message) + + +def sendPasswordResetMail(user: User, token: str): + if not user.email or not token: + return + + resetLink = FRONT_URL + "/reset-password?t=" + token + + message = MIMEMultipart("alternative") + message["Subject"] = "Reset password" + text = """\ +Hi {name} (@{username}), + +We received a request to change your password. This link is valid for three hours. +Reset password: {link} + +If you didn't request a password reset, you can ignore this message and continue to use your current password. + +Have any questions? Check out https://kitchenowl.org/privacy/""".format( + name=user.name, username=user.username, link=resetLink + ) + html = """\ + + +

Hi {name} (@{username}),

+ + We received a request to change your password. This link is valid for three hours:
+ Reset password

+ + If you didn't request a password reset, you can ignore this message and continue to use your current password.

+ + Have any questions? Check out our Privacy Policy +

+ + + """.format( + name=user.name, username=user.username, link=resetLink + ) + # The email client will try to render the last part first + message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(html, "html")) + sendMail(user.email, message) diff --git a/backend/app/service/recalculate_balances.py b/backend/app/service/recalculate_balances.py new file mode 100644 index 00000000..06884c3a --- /dev/null +++ b/backend/app/service/recalculate_balances.py @@ -0,0 +1,38 @@ +from sqlalchemy import func +from app.models import Expense, ExpensePaidFor, HouseholdMember +from app import db + + +def recalculateBalances(household_id): + for member in HouseholdMember.find_by_household(household_id): + member.expense_balance = float( + Expense.query.with_entities(func.sum(Expense.amount).label("balance")) + .filter( + Expense.paid_by_id == member.user_id, + Expense.household_id == household_id, + ) + .first() + .balance + or 0 + ) + for paid_for in ExpensePaidFor.query.filter( + ExpensePaidFor.user_id == member.user_id, + ExpensePaidFor.expense_id.in_( + db.session.query(Expense.id) + .filter(Expense.household_id == household_id) + .scalar_subquery() + ), + ).all(): + factor_sum = ( + Expense.query.with_entities( + func.sum(ExpensePaidFor.factor).label("factor_sum") + ) + .filter(ExpensePaidFor.expense_id == paid_for.expense_id) + .first() + .factor_sum + ) + member.expense_balance = ( + member.expense_balance + - (paid_for.factor / factor_sum) * paid_for.expense.amount + ) + member.save() diff --git a/backend/app/service/recalculate_blurhash.py b/backend/app/service/recalculate_blurhash.py new file mode 100644 index 00000000..a9a5248c --- /dev/null +++ b/backend/app/service/recalculate_blurhash.py @@ -0,0 +1,27 @@ +import os +from app.config import UPLOAD_FOLDER, db, app +from app.models import File +import blurhash +from PIL import Image + + +def recalculateBlurhashes(updateAll: bool = False) -> int: + files = File.all() if updateAll else File.query.filter(File.blur_hash == None).all() + for file in files: + try: + with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: + image.thumbnail((100, 100)) + file.blur_hash = blurhash.encode(image, x_components=4, y_components=3) + db.session.add(file) + except FileNotFoundError: + db.session.delete(file) + except Exception: + pass + try: + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + app.logger.info(f"Updated {len(files)} files") + return len(files) diff --git a/backend/app/sockets/__init__.py b/backend/app/sockets/__init__.py new file mode 100644 index 00000000..45d18859 --- /dev/null +++ b/backend/app/sockets/__init__.py @@ -0,0 +1,2 @@ +from .shoppinglist_socket import * +from .connection_socket import * diff --git a/backend/app/sockets/connection_socket.py b/backend/app/sockets/connection_socket.py new file mode 100644 index 00000000..6cc5e435 --- /dev/null +++ b/backend/app/sockets/connection_socket.py @@ -0,0 +1,18 @@ +from flask_jwt_extended import current_user +from flask_socketio import join_room + +from app.helpers import socket_jwt_required +from app import socketio + + +@socketio.on("connect") +@socket_jwt_required() +def on_connect(): + for household in current_user.households: + join_room(household.household_id) + + +@socketio.on("reconnect") +@socket_jwt_required() +def on_reconnect(): + pass diff --git a/backend/app/sockets/schemas.py b/backend/app/sockets/schemas.py new file mode 100644 index 00000000..8fde11ec --- /dev/null +++ b/backend/app/sockets/schemas.py @@ -0,0 +1,12 @@ +from marshmallow import Schema, fields + + +class shoppinglist_item_add(Schema): + shoppinglist_id = fields.Integer(required=True) + name = fields.String(required=True) + description = fields.String() + + +class shoppinglist_item_remove(Schema): + shoppinglist_id = fields.Integer(required=True) + item_id = fields.Integer(required=True) diff --git a/backend/app/sockets/shoppinglist_socket.py b/backend/app/sockets/shoppinglist_socket.py new file mode 100644 index 00000000..d949ab8f --- /dev/null +++ b/backend/app/sockets/shoppinglist_socket.py @@ -0,0 +1,64 @@ +from flask_jwt_extended import current_user +from flask_socketio import emit +from app.controller.shoppinglist.shoppinglist_controller import removeShoppinglistItem +from app.errors import NotFoundRequest + +from app.helpers import socket_jwt_required, validate_socket_args +from app.models import Shoppinglist, Item, ShoppinglistItems, History +from app import socketio +from .schemas import shoppinglist_item_add, shoppinglist_item_remove + + +@socketio.on("shoppinglist_item:add") +@socket_jwt_required() +@validate_socket_args(shoppinglist_item_add) +def on_add(args): + shoppinglist = Shoppinglist.find_by_id(args["shoppinglist_id"]) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + item = Item.find_by_name(shoppinglist.household_id, args["name"]) + if not item: + item = Item.create_by_name(shoppinglist.household_id, args["name"]) + + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if not con: + description = args["description"] if "description" in args else "" + con = ShoppinglistItems(description=description) + con.created_by = current_user.id + con.item = item + con.shoppinglist = shoppinglist + con.save() + + History.create_added(shoppinglist, item, description) + + emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) + + +@socketio.on("shoppinglist_item:remove") +@socket_jwt_required() +@validate_socket_args(shoppinglist_item_remove) +def on_remove(args): + shoppinglist = Shoppinglist.find_by_id(args["shoppinglist_id"]) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + con = removeShoppinglistItem(shoppinglist, args["item_id"]) + if con: + emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py new file mode 100644 index 00000000..4bba60df --- /dev/null +++ b/backend/app/util/__init__.py @@ -0,0 +1,2 @@ +from .kitchenowl_json_provider import KitchenOwlJSONProvider +from .multi_dict_list import MultiDictList diff --git a/backend/app/util/description_merger.py b/backend/app/util/description_merger.py new file mode 100644 index 00000000..80e14885 --- /dev/null +++ b/backend/app/util/description_merger.py @@ -0,0 +1,191 @@ +from typing import Self +from lark import Lark, Transformer, Tree, Token +from lark.visitors import Interpreter +import re + +grammar = r""" +start: ","* item (","+ item)* + +item: NUMBER? unit? +unit: COUNT | SI_WEIGHT | SI_VOLUME | DESCRIPTION +COUNT.5: "x"i +SI_WEIGHT.5: "mg"i | "g"i | "kg"i +SI_VOLUME.5: "ml"i | "l"i +DESCRIPTION: /[^0-9, ][^,]*/ + +DECIMAL: INT "." INT? | "." INT | INT "," INT +FLOAT: INT _EXP | DECIMAL _EXP? +NUMBER.10: FLOAT | INT + +%ignore WS +%import common (_EXP, INT, WS) +""" + + +class TreeItem(Tree): + # Quick and dirty class to not build an AST + def __init__(self, data: str, children) -> None: + self.data = data + self.children = children + self.number: Token = None + self.unit: Tree = None + for c in children: + if isinstance(c, Token) and c.type == "NUMBER": + self.number = c + else: + self.unit = c + + def unitIsCount(self) -> bool: + return not self.unit or self.unit.children[0].type == "COUNT" + + def sameUnit(self, other: Self) -> bool: + return (self.unitIsCount() and other.unitIsCount()) or ( + self.unit + and other.unit + and ( + self.unit.children[0].type == other.unit.children[0].type + and not other.unit.children[0].type == "DESCRIPTION" + or self.unit.children[0].lower().strip() + == other.unit.children[0].lower().strip() + ) + ) + + +class T(Transformer): + def NUMBER(self, number: Token): + return number.update(value=float(number.replace(",", "."))) + + def item(self, children): + return TreeItem("item", children) + + +class Printer(Interpreter): + def item(self, item: Tree): + res = "" + for child in item.children: + if isinstance(child, Tree): + if res and child.children[0].type == "DESCRIPTION": + res += " " + res += self.visit(child) + elif child.type == "NUMBER": + value = round(child.value, 5) + res += str(int(value)) if value.is_integer() else f"{value}" + return res + + def unit(self, unit: Tree): + return unit.children[0] + + def start(self, start: Tree): + return ", ".join([s for s in self.visit_children(start) if s]) + + +# Objects +parser = Lark(grammar) +transformer = T() + + +def merge(description: str, added: str) -> str: + if not description: + description = "1x" + if not added: + added = "1x" + description = clean(description) + added = clean(added) + desTree = transformer.transform(parser.parse(description)) + addTree = transformer.transform(parser.parse(added)) + + for item in addTree.children: + targetItem: TreeItem = next( + desTree.find_pred(lambda t: t.data == "item" and item.sameUnit(t)), None + ) + + if not targetItem: # No item with same unit + desTree.children.append(item) + else: # Found item with same unit + if ( + not targetItem.number + ): # Add number if not present and space behind it if description + targetItem.number = Token("NUMBER", 1) + targetItem.children.insert(0, targetItem.number) + + # Add up numbers + unit: Tree = item.unit + if unit and unit.children[0].type == "SI_WEIGHT": + merge_SI_Weight(targetItem, item) + elif unit and unit.children[0].type == "SI_VOLUME": + merge_SI_Volume(targetItem, item) + else: + targetItem.number.value = targetItem.number.value + ( + item.number.value if item.number else 1.0 + ) + + return Printer().visit(desTree) + + +def clean(input: str) -> str: + input = re.sub( + "¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞", + lambda match: { + "¼": "0.25", + "½": "0.5", + "¾": "0.75", + "⅐": "0.142857142857", + "⅑": "0.111111111111", + "⅒": "0.1", + "⅓": "0.333333333333", + "⅔": "0.666666666667", + "⅕": "0.2", + "⅖": "0.4", + "⅗": "0.6", + "⅘": "0.8", + "⅙": "0.166666666667", + "⅚": "0.833333333333", + "⅛": "0.125", + "⅜": "0.375", + "⅝": "0.625", + "⅞": "0.875", + }.get(match.group(), match.group), + input, + ) + + # replace 1/2 with .5 + input = re.sub( + r"(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)", + lambda match: str(float(match.group(1)) / float(match.group(4))), + input, + ) + + return input + + +def merge_SI_Volume(base: TreeItem, add: TreeItem) -> None: + def toMl(x: float, unit: str): + return {"ml": x, "l": 1000 * x}.get(unit.lower()) + + base.number.value = toMl(base.number.value, base.unit.children[0]) + toMl( + add.number.value if add.number else 1.0, add.unit.children[0] + ) + base.unit.children[0] = base.unit.children[0].update(value="ml") + + # Simplify if possible + if (base.number.value / 1000).is_integer(): + base.number.value = base.number.value / 1000 + base.unit.children[0] = base.unit.children[0].update(value="L") + + +def merge_SI_Weight(base: TreeItem, add: TreeItem) -> None: + def toG(x: float, unit: str): + return {"mg": x / 1000, "g": x, "kg": 1000 * x}.get(unit.lower()) + + base.number.value = toG(base.number.value, base.unit.children[0]) + toG( + add.number.value if add.number else 1.0, add.unit.children[0] + ) + base.unit.children[0] = base.unit.children[0].update(value="g") + + # Simplify when possible + if base.number.value < 1: + base.number.value = base.number.value * 1000 + base.unit.children[0] = base.unit.children[0].update(value="mg") + elif (base.number.value / 1000).is_integer(): + base.number.value = base.number.value / 1000 + base.unit.children[0] = base.unit.children[0].update(value="kg") diff --git a/backend/app/util/description_splitter.py b/backend/app/util/description_splitter.py new file mode 100644 index 00000000..fc1b6ed2 --- /dev/null +++ b/backend/app/util/description_splitter.py @@ -0,0 +1,112 @@ +from typing import Tuple +from lark import Lark, Transformer, Tree, Token +from lark.visitors import Interpreter +import re + +grammar = r""" +start: (NUMBER unit?)? NAME? (NUMBER unit?)? + +unit: COUNT | SI_WEIGHT | SI_VOLUME +COUNT.5: "x"i +SI_WEIGHT.5: "mg"i | "g"i | "kg"i +SI_VOLUME.5: "ml"i | "l"i +NAME: /[^ ][^0-9]*/ + +DECIMAL: INT "." INT? | "." INT | INT "," INT +FLOAT: INT _EXP | DECIMAL _EXP? +NUMBER.10: FLOAT | INT + +%ignore WS +%import common (_EXP, INT, WS) +""" + + +class TreeItem(Tree): + # Quick and dirty class to not build an AST + def __init__(self, data: str, children) -> None: + self.data = data + self.children = children + self.number: Token = None + self.unit: Tree = None + self.name: Token = None + for c in children: + if isinstance(c, Token) and c.type == "NUMBER": + self.number = c + elif isinstance(c, Token) and (c.type == "NAME" or c.type == "NAME_WO_NUM"): + self.name = c + else: + self.unit = c + + +class T(Transformer): + def NUMBER(self, number: Token): + return number.update(value=float(number.replace(",", "."))) + + def start(self, children): + return TreeItem("start", children) + + +class Printer(Interpreter): + def start(self, start: Tree): + res = "" + for child in start.children: + if isinstance(child, Tree): + res += self.visit(child) + elif child.type == "NUMBER": + value = round(child.value, 5) + res += str(int(value)) if value.is_integer() else f"{value}" + return res + + def unit(self, unit: Tree): + return unit.children[0] + + +# Objects +parser = Lark(grammar) +transformer = T() + + +def split(query: str) -> Tuple[str, str]: + try: + query = clean(query) + itemTree = transformer.transform(parser.parse(query)) + except: + return query, "" + + return (itemTree.name or "").strip(), Printer().visit(itemTree) + + +def clean(input: str) -> str: + input = re.sub( + "¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞", + lambda match: { + "¼": "0.25", + "½": "0.5", + "¾": "0.75", + "⅐": "0.142857142857", + "⅑": "0.111111111111", + "⅒": "0.1", + "⅓": "0.333333333333", + "⅔": "0.666666666667", + "⅕": "0.2", + "⅖": "0.4", + "⅗": "0.6", + "⅘": "0.8", + "⅙": "0.166666666667", + "⅚": "0.833333333333", + "⅛": "0.125", + "⅜": "0.375", + "⅝": "0.625", + "⅞": "0.875", + }.get(match.group(), match.group), + input, + ) + + # replace 1/2 with .5 + input = re.sub( + r"(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)", + lambda match: str(float(match.group(1)) / float(match.group(4))), + input, + ) + + return input diff --git a/backend/app/util/filename_validator.py b/backend/app/util/filename_validator.py new file mode 100644 index 00000000..595c46bd --- /dev/null +++ b/backend/app/util/filename_validator.py @@ -0,0 +1,8 @@ +from app.config import ALLOWED_FILE_EXTENSIONS + + +def allowed_file(filename): + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() in ALLOWED_FILE_EXTENSIONS + ) diff --git a/backend/app/util/kitchenowl_json_provider.py b/backend/app/util/kitchenowl_json_provider.py new file mode 100644 index 00000000..9add79b3 --- /dev/null +++ b/backend/app/util/kitchenowl_json_provider.py @@ -0,0 +1,10 @@ +from flask.json.provider import DefaultJSONProvider +from datetime import date, timezone + + +class KitchenOwlJSONProvider(DefaultJSONProvider): + def default(self, o): + if isinstance(o, date): + return int(round(o.replace(tzinfo=timezone.utc).timestamp() * 1000)) + + return super().default(o) diff --git a/backend/app/util/multi_dict_list.py b/backend/app/util/multi_dict_list.py new file mode 100644 index 00000000..4c455fd9 --- /dev/null +++ b/backend/app/util/multi_dict_list.py @@ -0,0 +1,8 @@ +import marshmallow + + +class MultiDictList(marshmallow.fields.List): + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(data, dict) and hasattr(data, "getlist"): + value = data.getlist(attr) + return super()._deserialize(value, attr, data, **kwargs) diff --git a/backend/docker-compose-postgres.yml b/backend/docker-compose-postgres.yml new file mode 100644 index 00000000..fd1d2734 --- /dev/null +++ b/backend/docker-compose-postgres.yml @@ -0,0 +1,54 @@ +version: "3" +services: + db: + image: postgres:15 + restart: unless-stopped + environment: + POSTGRES_DB: kitchenowl + POSTGRES_USER: kitchenowl + POSTGRES_PASSWORD: example + volumes: + - kitchenowl_db:/var/lib/postgresql/data + networks: + - default + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 80s + front: + image: tombursch/kitchenowl-web:latest + restart: unless-stopped + ports: + - "80:80" + depends_on: + - back + networks: + - default + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + #command: wsgi.ini --gevent 2000 #default: 100 + networks: + - default + environment: + JWT_SECRET_KEY: PLEASE_CHANGE_ME + DB_DRIVER: postgresql + DB_HOST: db + DB_NAME: kitchenowl + DB_USER: kitchenowl + DB_PASSWORD: example + depends_on: + - db + volumes: + - kitchenowl_files:/data + +volumes: + kitchenowl_files: + driver: local + kitchenowl_db: + driver: local + +networks: + default: diff --git a/backend/docker-compose-rabbitmq.yml b/backend/docker-compose-rabbitmq.yml new file mode 100644 index 00000000..d679fb80 --- /dev/null +++ b/backend/docker-compose-rabbitmq.yml @@ -0,0 +1,27 @@ +version: "3" +services: + front: + image: tombursch/kitchenowl-web:latest + restart: unless-stopped + # environment: + # - BACK_URL=back:5000 # Change this if you rename the containers + ports: + - "80:80" + depends_on: + - back + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + command: --ini wsgi.ini:celery --gevent 100 + environment: + - JWT_SECRET_KEY=PLEASE_CHANGE_ME + - MESSAGE_BROKER="amqp://rabbitmq" + volumes: + - kitchenowl_data:/data + rabbitmq: + image: rabbitmq:3 + volumes: + - ~/.docker-conf/rabbitmq/data/:/var/lib/rabbitmq/ + +volumes: + kitchenowl_data: diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 00000000..ac11ce26 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3" +services: + front: + image: tombursch/kitchenowl-web:latest + restart: unless-stopped + # environment: + # - BACK_URL=back:5000 # Change this if you rename the containers + ports: + - "80:80" + depends_on: + - back + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + # ports: # Should only be needed if you're not using docker-compose + # - "5000:5000" # uwsgi protocol + environment: + - JWT_SECRET_KEY=PLEASE_CHANGE_ME + # - FRONT_URL=http://localhost # Optional should not be changed unless you know what youre doing + volumes: + - kitchenowl_data:/data + +volumes: + kitchenowl_data: diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 00000000..09ac9600 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# if ipv6 is unavailable, remove it from the wsgi config +ME=$(basename $0) +if [ ! -f "/proc/net/if_inet6" ]; then + echo "$ME: info: ipv6 not available" + sed -i 's/\[::\]//g' /usr/src/kitchenowl/wsgi.ini +fi + +mkdir -p $STORAGE_PATH/upload +flask db upgrade +if [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "true" ] && [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "True" ]; then + python upgrade_default_items.py +fi +uwsgi "$@" \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 00000000..b190c8d4 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,182 @@ +from os import listdir +from os.path import isfile, join +import time +import blurhash +from PIL import Image +from tqdm import tqdm +from app import app, db +from app.config import UPLOAD_FOLDER +from app.jobs import jobs +from app.models import User, File, Household, HouseholdMember, ChallengeMailVerify +from app.service import mail +from app.service.delete_unused import deleteEmptyHouseholds, deleteUnusedFiles +from app.service.recalculate_blurhash import recalculateBlurhashes + + +def importFiles(): + try: + filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] + def createFile(filename: str) -> File: + blur = None + try: + with Image.open(join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode( + image, x_components=4, y_components=3) + except FileNotFoundError: + pass + except Exception: + pass + return File(filename=filename, blur_hash=blur) + files = [createFile(f) for f in filesInUploadFolder if not File.find(f)] + + db.session.bulk_save_objects(files) + db.session.commit() + print(f"-> Found {len(files)} new files in {UPLOAD_FOLDER}") + except Exception as e: + db.session.rollback() + raise e + +def manageHouseholds(): + while True: + print(""" +What next? + 1. List all households + 2. Delete empty + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + for h in Household.all(): + print(f"Id {h.id}: {h.name} ({len(h.member)} members)") + if selection == "2": + print(f"Deleted {deleteEmptyHouseholds()} unused households") + else: + return + +def manageUsers(): + while True: + print(""" +What next? + 1. List all users + 2. Create user + 3. Update user + 4. Delete user + 5. Send verification mail to unverified users + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + for u in User.all(): + print(f"@{u.username} ({u.email}): {u.name} (server admin: {u.admin})") + elif selection == "2": + username = input("Enter the username:") + password = input("Enter the password:") + User.create(username, password, username) + elif selection == "3": + username = input("Enter the username:") + user = User.find_by_username(username) + if not user: + print("No user found with that username") + else: + updateUser(user) + elif selection == "4": + username = input("Enter the username:") + user = User.find_by_username(username) + if not user: + print("No user found with that username") + else: + user.delete() + elif selection == "5": + if not mail.mailConfigured(): + print("Mail service not configured") + continue + delay = float(input("Delay between mails in seconds (0):") or "0") + for user in tqdm(User.query.filter((User.email_verified == False) | (User.email_verified == None)).all(), desc="Sending mails"): + if len(user.verify_mail_challenge) == 0: + mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) + if delay > 0: + time.sleep(delay) + else: + return + +def updateUser(user: User): + print(f""" +Settings for {user.name} (@{user.username}) (server admin: {user.admin}) + 1. Update password + 2. Add to household + 3. Set server admin + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + newPW = input("Enter new password:") + if not newPW.strip(): + print("Password cannot be empty") + newPWRepeat = input("Repeat new password:") + if newPW.strip() == newPWRepeat.strip(): + user.set_password(newPW.strip()) + user.save() + else: + print("Passwords do not match") + elif selection == "2": + id = input("Enter the household id:") + household = Household.find_by_id(id) + if not household: + print("No household found with that id") + elif not HouseholdMember.find_by_ids(household.id, user.id): + hm = HouseholdMember() + hm.user_id = user.id + hm.household_id = household.id + hm.save() + else: + print("User is already part of that household") + elif selection == "3": + selection = input("Set admin (y/N):") + user.admin = selection == "y" + user.save() + else: + return + +def manageFiles(): + while True: + print(""" +What next? + 1. Import files + 2. Delete unused files + 3. Generate missing blur-hashes + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + importFiles() + elif selection == "2": + print(f"Deleted {deleteUnusedFiles()} unused files") + elif selection == "3": + print(f"Updated {recalculateBlurhashes()} files") + else: + return + +# docker exec -it [backend container name] python manage.py +if __name__ == "__main__": + while True: + print(""" +Manage KitchenOwl\n---\nWhat do you want to do? +1. Manage users +2. Manage households +3. Manage images/files +4. Run all jobs +(q) Exit""") + selection = input("Your selection (q):") + if selection == "1": + with app.app_context(): + manageUsers() + elif selection == "2": + with app.app_context(): + manageHouseholds() + elif selection == "3": + with app.app_context(): + manageFiles() + elif selection == "4": + print("Starting jobs (might take a while)...") + jobs.daily() + jobs.halfHourly() + print("Done!") + else: + exit() diff --git a/backend/manage_default_items.py b/backend/manage_default_items.py new file mode 100644 index 00000000..5dbebe97 --- /dev/null +++ b/backend/manage_default_items.py @@ -0,0 +1,119 @@ +import argparse +import json +import os +import requests + +from sqlalchemy import desc, func +from app import app +from app.config import STORAGE_PATH +from app.models import Item, Category, Household + + +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) +EXPORT_FOLDER = STORAGE_PATH + "/export" +DEEPL_AUTH_KEY = os.getenv('DEEPL_AUTH_KEY', "") + + +def update_names(saveToTemplate: bool = False, consensus_count: int = 2): + default_items = {} + def nameToKey(name: str) -> str: + return name.lower().strip().replace(" ", "_") + def loadLang(lang: str): + default_items[lang] = {"items": {} } + if os.path.exists(BASE_PATH + "/templates/l10n/" + lang + ".json"): + with open(BASE_PATH + "/templates/l10n/" + lang + ".json", 'r', encoding="utf8") as f: + default_items[lang] = json.loads(f.read()) + supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] if DEEPL_AUTH_KEY else ['en'] + loadLang('en') + + items = Item.query.with_entities(Item.name, func.count().label('count'), Household.language).filter(Item.default_key == None, Household.language.in_(supported_lang)).join(Household, isouter=True).group_by(Item.name, Household.language).having(func.count().label('count') >= consensus_count).order_by(desc("count")).all() + for item in items: + if item.language == "en": + if not nameToKey(item.name) in default_items["en"]['items']: + default_items["en"]['items'][nameToKey(item.name)] = item.name + else: + if not item.language in default_items: + loadLang(item.language) + engl_name = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": item.language.upper(), "text": item.name}, + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] + if not nameToKey(engl_name) in default_items[item.language]['items']: + default_items[item.language]['items'][nameToKey(engl_name)] = item.name + if not nameToKey(engl_name) in default_items["en"]['items']: + default_items["en"]['items'][nameToKey(engl_name)] = engl_name + + + folder = BASE_PATH + "/templates/l10n/" if saveToTemplate else (EXPORT_FOLDER + "/") + for key, content in default_items.items(): + with open(folder + key + ".json", "w", encoding="utf8") as f: + f.write(json.dumps(content, ensure_ascii=False, indent=2, sort_keys=True)) + + +def update_attributes(saveToTemplate: bool = False): + # read files + with open(BASE_PATH + "/templates/l10n/en.json", encoding="utf8") as f: + en: dict = json.load(f) + with open(BASE_PATH + "/templates/attributes.json", encoding="utf8") as f: + attr: dict = json.load(f) + + unkownKeys = [] + # Remove unkown keys from attributes file + for key in attr["items"].keys(): + if key not in en["items"]: + unkownKeys.append(key) + for key in unkownKeys: + attr["items"].pop(key) + + # Find item icons + for key in en["items"].keys(): + # Add key to map + if key not in attr["items"]: + attr["items"][key] = {} + # Find icon consesus + iconItem = Item.query.with_entities(Item.icon, func.count().label('count')).filter( + Item.default_key == key, Item.icon != None).group_by(Item.icon).order_by(desc("count")).first() + if iconItem: + attr["items"][key]['icon'] = iconItem.icon + + # Find item categories + for key in en["items"].keys(): + filterQuery = Item.query.with_entities(Item.category_id).filter( + Item.default_key == key, Item.category_id != None).scalar_subquery() + itemCategory = Category.query.with_entities(Category.default_key, func.count( + ).label('count')).filter(Category.id.in_(filterQuery), Category.default_key != None).group_by(Category.default_key).order_by(desc("count")).first() + if itemCategory: + attr["items"][key]['category'] = itemCategory.default_key + + jsonContent = json.dumps(attr, ensure_ascii=False, + indent=2, sort_keys=True) + if saveToTemplate: + with open(BASE_PATH + "/templates/attributes.json", "w", encoding="utf8") as f: + f.write(jsonContent) + else: + with open(EXPORT_FOLDER + "/attributes.json", "w", encoding="utf8") as f: + f.write(jsonContent) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog='python manage_default_items.py', + description='This programms queries the current kitchenowl installation for updated template items (names & icon & category)', + ) + parser.add_argument('-s', '--save', action='store_true', + help="saves the output directly to the templates folder") + parser.add_argument('-n', '--names', action='store_true', + help="collects item names") + parser.add_argument('-a', '--attributes', action='store_true', + help="collects attributes") + parser.add_argument('-c' '--consensus', type=int, default=2, help="Minimum number of households to have this item for it to be considered default") + args = parser.parse_args() + if not args.names and not args.attributes: + parser.print_help() + else: + if args.save and not os.path.exists(EXPORT_FOLDER): + os.makedirs(EXPORT_FOLDER) + with app.app_context(): + if (args.names): + update_names(args.save, args.c__consensus) + if (args.attributes): + update_attributes(args.save) diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/backend/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 00000000..89f80b21 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,110 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/11c15698c8bf_.py b/backend/migrations/versions/11c15698c8bf_.py new file mode 100644 index 00000000..821c4a01 --- /dev/null +++ b/backend/migrations/versions/11c15698c8bf_.py @@ -0,0 +1,64 @@ +"""empty message + +Revision ID: 11c15698c8bf +Revises: e209fcb83993 +Create Date: 2022-04-18 15:12:24.971186 + +""" +from alembic import op +import sqlalchemy as sa + +from app.config import DB_URL + + +# revision identifiers, used by Alembic. +revision = '11c15698c8bf' +down_revision = 'e209fcb83993' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('expense_category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_expense_category')) + ) + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_category_id_expense_category'), 'expense_category', ['category_id'], ['id']) + + if DB_URL.drivername == 'sqlite': + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_user_username'), ['username']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + if DB_URL.drivername == 'sqlite': + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_user_username'), type_='unique') + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_shoppinglist_name'), type_='unique') + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_item_name'), type_='unique') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_category_id_expense_category'), type_='foreignkey') + batch_op.drop_column('category_id') + + op.drop_table('expense_category') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/144524c5cf79_.py b/backend/migrations/versions/144524c5cf79_.py new file mode 100644 index 00000000..b23a1d26 --- /dev/null +++ b/backend/migrations/versions/144524c5cf79_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 144524c5cf79 +Revises: ae608469ef8b +Create Date: 2022-09-13 13:41:30.316063 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '144524c5cf79' +down_revision = 'ae608469ef8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.drop_column('description') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/22d528c529ca_.py b/backend/migrations/versions/22d528c529ca_.py new file mode 100644 index 00000000..5a03c2e4 --- /dev/null +++ b/backend/migrations/versions/22d528c529ca_.py @@ -0,0 +1,89 @@ +"""empty message + +Revision ID: 22d528c529ca +Revises: +Create Date: 2021-02-02 12:16:23.535223 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '22d528c529ca' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('item', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('recipe', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('shoppinglist', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('username', sa.String(length=256), nullable=False), + sa.Column('password', sa.String(length=256), nullable=False), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('owner', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('recipe_items', + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ), + sa.PrimaryKeyConstraint('recipe_id', 'item_id') + ) + op.create_table('shoppinglist_items', + sa.Column('shoppinglist_id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['shoppinglist_id'], ['shoppinglist.id'], ), + sa.PrimaryKeyConstraint('shoppinglist_id', 'item_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('shoppinglist_items') + op.drop_table('recipe_items') + op.drop_table('user') + op.drop_table('shoppinglist') + op.drop_table('recipe') + op.drop_table('item') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/23445ea65b2b_.py b/backend/migrations/versions/23445ea65b2b_.py new file mode 100644 index 00000000..692c7f32 --- /dev/null +++ b/backend/migrations/versions/23445ea65b2b_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 23445ea65b2b +Revises: 6d6984e216ff +Create Date: 2021-04-09 16:40:19.462601 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '23445ea65b2b' +down_revision = '6d6984e216ff' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('planned', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'planned') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/29381d24ec31_.py b/backend/migrations/versions/29381d24ec31_.py new file mode 100644 index 00000000..1773f425 --- /dev/null +++ b/backend/migrations/versions/29381d24ec31_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 29381d24ec31 +Revises: 9be38fc16ce9 +Create Date: 2022-03-18 12:35:47.300705 + +""" +from alembic import op +import sqlalchemy as sa + +import app.helpers.db_set_type + + +# revision identifiers, used by Alembic. +revision = '29381d24ec31' +down_revision = '9be38fc16ce9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('planned_days', app.helpers.db_set_type.DbSetType(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'planned_days') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/3647c9eb1881_.py b/backend/migrations/versions/3647c9eb1881_.py new file mode 100644 index 00000000..c5af7b42 --- /dev/null +++ b/backend/migrations/versions/3647c9eb1881_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 3647c9eb1881 +Revises: 5140d8f9339b +Create Date: 2023-08-01 23:48:05.570802 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3647c9eb1881' +down_revision = '5140d8f9339b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_key', sa.String(length=128), nullable=True)) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_key', sa.String(length=128), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_column('default_key') + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_column('default_key') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/3d3333ffb91e_.py b/backend/migrations/versions/3d3333ffb91e_.py new file mode 100644 index 00000000..9894aedb --- /dev/null +++ b/backend/migrations/versions/3d3333ffb91e_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 3d3333ffb91e +Revises: 6c1be50bb858 +Create Date: 2021-08-18 10:13:11.745182 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3d3333ffb91e' +down_revision = '6c1be50bb858' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('suggestion_rank', sa.Integer(), server_default='0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'suggestion_rank') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/3d667fcc5581_.py b/backend/migrations/versions/3d667fcc5581_.py new file mode 100644 index 00000000..9ae19a40 --- /dev/null +++ b/backend/migrations/versions/3d667fcc5581_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 3d667fcc5581 +Revises: ed32086bf606 +Create Date: 2023-06-06 14:49:25.133125 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3d667fcc5581' +down_revision = 'ed32086bf606' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('color', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('color', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/4b4823a384e7_.py b/backend/migrations/versions/4b4823a384e7_.py new file mode 100644 index 00000000..51048e9f --- /dev/null +++ b/backend/migrations/versions/4b4823a384e7_.py @@ -0,0 +1,60 @@ +"""empty message + +Revision ID: 4b4823a384e7 +Revises: 55fe25bdf42b +Create Date: 2022-12-18 23:01:04.874862 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from app import db + +DeclarativeBase = orm.declarative_base() + + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + date = sa.Column(sa.DateTime) + created_at = sa.Column(sa.DateTime, nullable=False) + + +# revision identifiers, used by Alembic. +revision = '4b4823a384e7' +down_revision = '55fe25bdf42b' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('date', sa.DateTime())) + + # Data migration + bind = op.get_bind() + session = orm.Session(bind=bind) + + expenses = session.query(Expense).all() + for expense in expenses: + expense.date = expense.created_at + + try: + session.bulk_save_objects(expenses) + session.commit() + except Exception as e: + session.rollback() + raise e + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('date', nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_column('date') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/5140d8f9339b_.py b/backend/migrations/versions/5140d8f9339b_.py new file mode 100644 index 00000000..0930c0cb --- /dev/null +++ b/backend/migrations/versions/5140d8f9339b_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 5140d8f9339b +Revises: 3d667fcc5581 +Create Date: 2023-07-02 12:19:57.117736 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5140d8f9339b' +down_revision = '3d667fcc5581' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_shoppinglist_items_created_by_user'), 'user', ['created_by'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist_items', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_shoppinglist_items_created_by_user'), type_='foreignkey') + batch_op.drop_column('created_by') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/55fe25bdf42b_.py b/backend/migrations/versions/55fe25bdf42b_.py new file mode 100644 index 00000000..019ee3ed --- /dev/null +++ b/backend/migrations/versions/55fe25bdf42b_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: 55fe25bdf42b +Revises: a9824159e4e5 +Create Date: 2022-12-08 17:41:24.521923 + +""" +from alembic import op +import sqlalchemy as sa + +import app.helpers.db_set_type + +# revision identifiers, used by Alembic. +revision = '55fe25bdf42b' +down_revision = 'a9824159e4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('view_ordering') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/5a064f9c14d0_.py b/backend/migrations/versions/5a064f9c14d0_.py new file mode 100644 index 00000000..1ed37107 --- /dev/null +++ b/backend/migrations/versions/5a064f9c14d0_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 5a064f9c14d0 +Revises: 23445ea65b2b +Create Date: 2021-07-17 15:11:13.654652 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5a064f9c14d0' +down_revision = '23445ea65b2b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('recipe_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('recipe_id', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('ADDED', 'DROPPED', name='status'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('recipe_history') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/681d624f0d5f_.py b/backend/migrations/versions/681d624f0d5f_.py new file mode 100644 index 00000000..0e25c6de --- /dev/null +++ b/backend/migrations/versions/681d624f0d5f_.py @@ -0,0 +1,51 @@ +"""empty message + +Revision ID: 681d624f0d5f +Revises: 75e1eb3635c6 +Create Date: 2021-09-29 13:03:57.587862 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '681d624f0d5f' +down_revision = '75e1eb3635c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('expense', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('amount', sa.Float(), nullable=True), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('paid_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['paid_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('expense_paid_for', + sa.Column('expense_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('factor', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['expense_id'], ['expense.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('expense_id', 'user_id') + ) + op.add_column('user', sa.Column('expense_balance', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'expense_balance') + op.drop_table('expense_paid_for') + op.drop_table('expense') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6c1be50bb858_.py b/backend/migrations/versions/6c1be50bb858_.py new file mode 100644 index 00000000..171b018a --- /dev/null +++ b/backend/migrations/versions/6c1be50bb858_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 6c1be50bb858 +Revises: 5a064f9c14d0 +Create Date: 2021-08-14 16:15:45.794601 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6c1be50bb858' +down_revision = '5a064f9c14d0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('suggestion_score', sa.Integer(), server_default='0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'suggestion_score') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6c669d9ec3bd_.py b/backend/migrations/versions/6c669d9ec3bd_.py new file mode 100644 index 00000000..b3d09a9d --- /dev/null +++ b/backend/migrations/versions/6c669d9ec3bd_.py @@ -0,0 +1,318 @@ +"""empty message + +Revision ID: 6c669d9ec3bd +Revises: 4b4823a384e7 +Create Date: 2023-01-15 23:58:29.531456 + +""" +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from app import db +import app.helpers.db_set_type + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = '6c669d9ec3bd' +down_revision = '6d641b08aaa8' +branch_labels = None +depends_on = None + +class Category(DeclarativeBase): + __tablename__ = 'category' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class ExpenseCategory(DeclarativeBase): + __tablename__ = 'expense_category' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Item(DeclarativeBase): + __tablename__ = 'item' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Planner(DeclarativeBase): + __tablename__ = 'planner' + recipe_id = sa.Column(sa.Integer, primary_key=True) + day = db.Column(db.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class RecipeHistory(DeclarativeBase): + __tablename__ = 'recipe_history' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Shoppinglist(DeclarativeBase): + __tablename__ = 'shoppinglist' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Tag(DeclarativeBase): + __tablename__ = 'tag' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Settings(DeclarativeBase): + __tablename__ = 'settings' + id = sa.Column(sa.Integer) + planner_feature = sa.Column('planner_feature', sa.BOOLEAN(), nullable=False, primary_key=True) + expenses_feature = sa.Column('expenses_feature', sa.BOOLEAN(), nullable=False, primary_key=True) + view_ordering = sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True) + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + owner = sa.Column('owner', sa.Boolean(), nullable=False) + admin = sa.Column('admin', sa.Boolean(), nullable=False) + expense_balance = sa.Column(sa.Float(), default=0, nullable=False) + +class Household(DeclarativeBase): + __tablename__ = 'household' + + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String(128), unique=True) + planner_feature = sa.Column(sa.Boolean(), primary_key=True, default=True) + expenses_feature = sa.Column(sa.Boolean(), primary_key=True, default=True) + view_ordering = sa.Column(app.helpers.db_list_type.DbListType(), default=list()) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + +class HouseholdMember(DeclarativeBase): + __tablename__ = 'household_member' + + household_id = sa.Column(sa.Integer, sa.ForeignKey( + 'household.id'), primary_key=True) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), primary_key=True) + owner = sa.Column(sa.Boolean(), default=False, nullable=False) + admin = sa.Column(sa.Boolean(), default=False, nullable=False) + expense_balance = sa.Column(sa.Float(), default=0, nullable=False) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + + + +def upgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('household', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('language', sa.String(), nullable=True), + sa.Column('planner_feature', sa.Boolean(), nullable=False), + sa.Column('expenses_feature', sa.Boolean(), nullable=False), + sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_household')), + ) + op.create_table('household_member', + sa.Column('household_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('owner', sa.Boolean(), nullable=False), + sa.Column('admin', sa.Boolean(), nullable=False), + sa.Column('expense_balance', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['household_id'], ['household.id'], name=op.f('fk_household_member_household_id_household')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_household_member_user_id_user')), + sa.PrimaryKeyConstraint('household_id', 'user_id', name=op.f('pk_household_member')) + ) + # Initial + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_category_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_category_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_item_household_id_household'), 'household', ['household_id'], ['id']) + batch_op.drop_constraint('uq_item_name', type_='unique') + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_planner_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_recipe_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_recipe_history_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_shoppinglist_household_id_household'), 'household', ['household_id'], ['id']) + batch_op.drop_constraint('uq_shoppinglist_name', type_='unique') + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_tag_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('id', sa.Integer(), nullable=True)) + + # Data Migration + settings = session.query(Settings).first() + if settings: + models: list[db.Model] = session.query(Category).all()\ + + session.query(Expense).all()\ + + session.query(ExpenseCategory).all()\ + + session.query(Item).all()\ + + session.query(Recipe).all()\ + + session.query(RecipeHistory).all()\ + + session.query(Shoppinglist).all()\ + + session.query(Tag).all()\ + + session.query(Planner).all() + for model in models: + model.household_id = 1 + + household = Household() + household.id = 1 + household.name = "Home" + household.planner_feature = settings.planner_feature + household.expenses_feature = settings.expenses_feature + household.view_ordering = settings.view_ordering + household.created_at = datetime.utcnow() + household.updated_at = datetime.utcnow() + + users = session.query(User).all() + for user in users: + hm = HouseholdMember() + hm.created_at = datetime.utcnow() + hm.updated_at = datetime.utcnow() + hm.user_id = user.id + hm.household_id = 1 + hm.admin = user.admin + hm.owner = user.owner + hm.expense_balance = user.expense_balance + models.append(hm) + user.admin = user.admin or user.owner + + models.append(household) + models += users + + try: + session.delete(settings) + session.bulk_save_objects(models) + session.commit() + except Exception as e: + session.rollback() + raise e + + + # Final + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('owner') + batch_op.drop_column('expense_balance') + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.alter_column('id', nullable=False) + batch_op.drop_column('view_ordering') + batch_op.drop_column('expenses_feature') + batch_op.drop_column('planner_feature') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('expense_balance', sa.FLOAT(), nullable=True)) + batch_op.add_column(sa.Column('owner', sa.BOOLEAN(), nullable=True)) + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_tag_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_shoppinglist_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('planner_feature', sa.BOOLEAN(), nullable=False)) + batch_op.add_column(sa.Column('expenses_feature', sa.BOOLEAN(), nullable=False)) + batch_op.add_column(sa.Column('view_ordering', sa.VARCHAR(), nullable=True)) + batch_op.drop_column('id') + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_history_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_item_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_category_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_category_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + op.drop_table('household_member') + op.drop_table('household') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6d3027e07dc4_.py b/backend/migrations/versions/6d3027e07dc4_.py new file mode 100644 index 00000000..1cd03a4e --- /dev/null +++ b/backend/migrations/versions/6d3027e07dc4_.py @@ -0,0 +1,45 @@ +"""empty message + +Revision ID: 6d3027e07dc4 +Revises: 11c15698c8bf +Create Date: 2022-05-18 19:53:39.773740 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d3027e07dc4' +down_revision = '11c15698c8bf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('default', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_category')) + ) + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('default', sa.Boolean(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_item_category_id_category'), 'category', ['category_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_item_category_id_category'), type_='foreignkey') + batch_op.drop_column('default') + batch_op.drop_column('category_id') + + op.drop_table('category') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6d641b08aaa8_.py b/backend/migrations/versions/6d641b08aaa8_.py new file mode 100644 index 00000000..ec4caa81 --- /dev/null +++ b/backend/migrations/versions/6d641b08aaa8_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 6d641b08aaa8 +Revises: d611f88dafb2 +Create Date: 2023-03-06 16:45:59.256447 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d641b08aaa8' +down_revision = 'd611f88dafb2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('icon', sa.String(length=128), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_column('icon') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6d6984e216ff_.py b/backend/migrations/versions/6d6984e216ff_.py new file mode 100644 index 00000000..e0f72af3 --- /dev/null +++ b/backend/migrations/versions/6d6984e216ff_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: 6d6984e216ff +Revises: fffa4ab33d2a +Create Date: 2021-03-15 19:40:59.846065 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d6984e216ff' +down_revision = 'fffa4ab33d2a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('association', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('antecedent_id', sa.Integer(), nullable=True), + sa.Column('consequent_id', sa.Integer(), nullable=True), + sa.Column('support', sa.Float(), nullable=True), + sa.Column('confidence', sa.Float(), nullable=True), + sa.Column('lift', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['antecedent_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['consequent_id'], ['item.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('shoppinglist_id', sa.Integer(), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('ADDED', 'DROPPED', name='status'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['shoppinglist_id'], ['shoppinglist.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('item', sa.Column('ordering', sa.Integer(), server_default='0', nullable=True)) + op.add_column('item', sa.Column('support', sa.Float(), server_default='0.0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('item', 'support') + op.drop_column('item', 'ordering') + op.drop_table('history') + op.drop_table('association') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/718193c0581a_.py b/backend/migrations/versions/718193c0581a_.py new file mode 100644 index 00000000..07b6cfbf --- /dev/null +++ b/backend/migrations/versions/718193c0581a_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 718193c0581a +Revises: 681d624f0d5f +Create Date: 2021-12-04 14:32:12.860932 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '718193c0581a' +down_revision = '681d624f0d5f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('admin', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'admin') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/75e1eb3635c6_.py b/backend/migrations/versions/75e1eb3635c6_.py new file mode 100644 index 00000000..e2c36a43 --- /dev/null +++ b/backend/migrations/versions/75e1eb3635c6_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 75e1eb3635c6 +Revises: 3d3333ffb91e +Create Date: 2021-09-29 12:27:21.777936 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '75e1eb3635c6' +down_revision = '3d3333ffb91e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('settings', + sa.Column('planner_feature', sa.Boolean(), nullable=False), + sa.Column('expenses_feature', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('planner_feature', 'expenses_feature') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('settings') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/8897db89e7af_.py b/backend/migrations/versions/8897db89e7af_.py new file mode 100644 index 00000000..aa1d6c03 --- /dev/null +++ b/backend/migrations/versions/8897db89e7af_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 8897db89e7af +Revises: c058421705ec +Create Date: 2023-05-15 12:26:45.223242 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = '8897db89e7af' +down_revision = 'c058421705ec' +branch_labels = None +depends_on = None + + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + admin = sa.Column('admin', sa.Boolean(), nullable=False) + +def upgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + if session.query(User).count() > 0 and session.query(User).filter(User.admin == True).count() == 0: + admin = session.query(User).order_by(User.id).first() + admin.admin = True + try: + session.add(admin) + session.commit() + except Exception as e: + session.rollback() + raise e + + + +def downgrade(): + pass diff --git a/backend/migrations/versions/8f12363abaaf_.py b/backend/migrations/versions/8f12363abaaf_.py new file mode 100644 index 00000000..1fd98ae6 --- /dev/null +++ b/backend/migrations/versions/8f12363abaaf_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: 8f12363abaaf +Revises: c63508852dd1 +Create Date: 2023-11-06 14:46:20.697901 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8f12363abaaf' +down_revision = 'c63508852dd1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('challenge_mail_verify', + sa.Column('challenge_hash', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_challenge_mail_verify_user_id_user')), + sa.PrimaryKeyConstraint('challenge_hash', name=op.f('pk_challenge_mail_verify')) + ) + op.create_table('challenge_password_reset', + sa.Column('challenge_hash', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_challenge_password_reset_user_id_user')), + sa.PrimaryKeyConstraint('challenge_hash', name=op.f('pk_challenge_password_reset')) + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('email_verified') + + op.drop_table('challenge_password_reset') + op.drop_table('challenge_mail_verify') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/9b45d9dd5b8e_.py b/backend/migrations/versions/9b45d9dd5b8e_.py new file mode 100644 index 00000000..f059e5eb --- /dev/null +++ b/backend/migrations/versions/9b45d9dd5b8e_.py @@ -0,0 +1,64 @@ +"""empty message + +Revision ID: 9b45d9dd5b8e +Revises: ee2ba4d37d8b +Create Date: 2023-11-15 12:01:18.288028 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9b45d9dd5b8e' +down_revision = 'ee2ba4d37d8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('oidc_link', + sa.Column('sub', sa.String(length=256), nullable=False), + sa.Column('provider', sa.String(length=24), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oidc_link_user_id_user')), + sa.PrimaryKeyConstraint('sub', 'provider', name=op.f('pk_oidc_link')) + ) + with op.batch_alter_table('oidc_link', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_oidc_link_user_id'), ['user_id'], unique=False) + + op.create_table('oidc_request', + sa.Column('state', sa.String(length=256), nullable=False), + sa.Column('provider', sa.String(length=24), nullable=False), + sa.Column('nonce', sa.String(length=256), nullable=False), + sa.Column('redirect_uri', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oidc_request_user_id_user')), + sa.PrimaryKeyConstraint('state', 'provider', name=op.f('pk_oidc_request')) + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=256), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=256), + nullable=False) + + op.drop_table('oidc_request') + with op.batch_alter_table('oidc_link', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_oidc_link_user_id')) + + op.drop_table('oidc_link') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/9be38fc16ce9_.py b/backend/migrations/versions/9be38fc16ce9_.py new file mode 100644 index 00000000..52e4d108 --- /dev/null +++ b/backend/migrations/versions/9be38fc16ce9_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 9be38fc16ce9 +Revises: 718193c0581a +Create Date: 2021-12-27 16:13:02.262090 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9be38fc16ce9' +down_revision = '718193c0581a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('recipe_tags', + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ), + sa.PrimaryKeyConstraint('recipe_id', 'tag_id') + ) + op.add_column('recipe', sa.Column('time', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'time') + op.drop_table('recipe_tags') + op.drop_table('tag') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/a9824159e4e5_.py b/backend/migrations/versions/a9824159e4e5_.py new file mode 100644 index 00000000..36df80e4 --- /dev/null +++ b/backend/migrations/versions/a9824159e4e5_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: a9824159e4e5 +Revises: fe3a5c9ac84c +Create Date: 2022-11-29 13:24:03.377245 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9824159e4e5' +down_revision = 'fe3a5c9ac84c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.add_column(sa.Column('color', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.drop_column('color') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ade6487fe28a_.py b/backend/migrations/versions/ade6487fe28a_.py new file mode 100644 index 00000000..621484d8 --- /dev/null +++ b/backend/migrations/versions/ade6487fe28a_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: ade6487fe28a +Revises: 144524c5cf79 +Create Date: 2022-09-17 16:57:53.855716 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ade6487fe28a' +down_revision = '144524c5cf79' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('ordering', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_column('ordering') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ade9ad0be1a5_.py b/backend/migrations/versions/ade9ad0be1a5_.py new file mode 100644 index 00000000..6705f2ee --- /dev/null +++ b/backend/migrations/versions/ade9ad0be1a5_.py @@ -0,0 +1,67 @@ +"""empty message + +Revision ID: ade9ad0be1a5 +Revises: 3647c9eb1881 +Create Date: 2023-08-31 13:57:34.979533 + +""" +import os +from alembic import op +import blurhash +from PIL import Image +import sqlalchemy as sa +from sqlalchemy import inspect, orm + +from app.config import UPLOAD_FOLDER, db + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = 'ade9ad0be1a5' +down_revision = '3647c9eb1881' +branch_labels = None +depends_on = None + + +class File(DeclarativeBase): + __tablename__ = 'file' + filename = sa.Column(sa.String(), primary_key=True) + blur_hash = sa.Column(sa.String(length=40), nullable=True) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + inspector = inspect(db.engine) + # workaround since the inspector can only return existing tables which they don't if upgrade is run on an empty DB + # Only add the row if it does not exists (e.g. if the migration/hash calculation failed and is restarted) + if not 'file' in inspector.get_table_names() or not any(c['name'] == 'blur_hash' for c in inspector.get_columns('file')): + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.add_column(sa.Column('blur_hash', sa.String(length=40), nullable=True)) + + bind = op.get_bind() + session = orm.Session(bind=bind) + for file in session.query(File).filter(File.blur_hash == None).all(): + try: + with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: + image.thumbnail((100, 100)) + file.blur_hash = blurhash.encode(image, x_components=4, y_components=3) + session.add(file) + except FileNotFoundError: + session.delete(file) + except Exception: + pass + try: + session.commit() + except Exception as e: + session.rollback() + raise e + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.drop_column('blur_hash') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ae608469ef8b_.py b/backend/migrations/versions/ae608469ef8b_.py new file mode 100644 index 00000000..a3f98554 --- /dev/null +++ b/backend/migrations/versions/ae608469ef8b_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: ae608469ef8b +Revises: 6d3027e07dc4 +Create Date: 2022-06-08 23:27:18.639974 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae608469ef8b' +down_revision = '6d3027e07dc4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('jti', sa.String(length=36), nullable=False), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('refresh_token_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['refresh_token_id'], ['token.id'], name=op.f('fk_token_refresh_token_id_token')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_token_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_token')) + ) + with op.batch_alter_table('token', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_token_jti'), ['jti'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('token', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_token_jti')) + + op.drop_table('token') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/c058421705ec_.py b/backend/migrations/versions/c058421705ec_.py new file mode 100644 index 00000000..406efe30 --- /dev/null +++ b/backend/migrations/versions/c058421705ec_.py @@ -0,0 +1,105 @@ +"""empty message + +Revision ID: c058421705ec +Revises: 6c669d9ec3bd +Create Date: 2023-04-20 16:28:00.255353 + +""" +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm, inspect +from os import listdir +from os.path import isfile, join + +from app.config import UPLOAD_FOLDER, db + +DeclarativeBase = orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'c058421705ec' +down_revision = '6c669d9ec3bd' +branch_labels = None +depends_on = None + +class File(DeclarativeBase): + __tablename__ = 'file' + filename = sa.Column(sa.String, primary_key=True) + created_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) + updated_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) + created_by = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + photo = sa.Column(sa.String()) + +class Household(DeclarativeBase): + __tablename__ = 'household' + id = sa.Column(sa.Integer, primary_key=True) + photo = sa.Column(sa.String()) + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + admin = sa.Column(sa.Boolean(), default=False) + photo = sa.Column(sa.String()) + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + inspector = inspect(db.engine) + if not inspector.has_table("file"): + op.create_table('file', + sa.Column('filename', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], name=op.f('fk_file_created_by_user')), + sa.PrimaryKeyConstraint('filename', name=op.f('pk_file')), + ) + # ### end Alembic commands ### + bind = op.get_bind() + session = orm.Session(bind=bind) + + + try: + filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] + files = [File(filename=f) for f in filesInUploadFolder if not session.query(File.filename).filter(File.filename == f).first()] + + session.bulk_save_objects(files) + session.commit() + except FileNotFoundError as e: + session.rollback() + except BaseException as e: + session.rollback() + raise e + + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_household_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_recipe_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_expense_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_user_photo_file'), 'file', ['photo'], ['filename']) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_photo_file'), type_='foreignkey') + + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_household_photo_file'), type_='foreignkey') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_photo_file'), type_='foreignkey') + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_photo_file'), type_='foreignkey') + + op.drop_table('file') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/c63508852dd1_.py b/backend/migrations/versions/c63508852dd1_.py new file mode 100644 index 00000000..5f6988da --- /dev/null +++ b/backend/migrations/versions/c63508852dd1_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: c63508852dd1 +Revises: dedd014b6a59 +Create Date: 2023-10-04 12:36:55.881848 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c63508852dd1' +down_revision = 'dedd014b6a59' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_category_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_expense_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_planner_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_recipe_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_shoppinglist_household_id'), ['household_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_shoppinglist_household_id')) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_recipe_household_id')) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_planner_household_id')) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_expense_household_id')) + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_category_household_id')) + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/d611f88dafb2_.py b/backend/migrations/versions/d611f88dafb2_.py new file mode 100644 index 00000000..ba25c07c --- /dev/null +++ b/backend/migrations/versions/d611f88dafb2_.py @@ -0,0 +1,96 @@ +"""empty message + +Revision ID: d611f88dafb2 +Revises: 4b4823a384e7 +Create Date: 2023-03-03 15:05:29.932888 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime +from app.helpers.db_set_type import DbSetType + +DeclarativeBase = sa.orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'd611f88dafb2' +down_revision = '4b4823a384e7' +branch_labels = None +depends_on = None + + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + planned = sa.Column(sa.Boolean) + planned_days = sa.Column(DbSetType(), default=set()) + + +class Planner(DeclarativeBase): + __tablename__ = 'planner' + recipe_id = sa.Column(sa.Integer, sa.ForeignKey( + 'recipe.id'), primary_key=True) + day = sa.Column(sa.Integer, primary_key=True) + yields = sa.Column(sa.Integer) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('planner', + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('day', sa.Integer(), nullable=False), + sa.Column('yields', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], name=op.f('fk_planner_recipe_id_recipe')), + sa.PrimaryKeyConstraint('recipe_id', 'day', name=op.f('pk_planner')) + ) + + # Data migration + bind = op.get_bind() + session = sa.orm.Session(bind=bind) + plans = [] + for recipe in session.query(Recipe).all(): + if recipe.planned: + if len(recipe.planned_days) > 0: + for day in recipe.planned_days: + p = Planner() + p.recipe_id = recipe.id + p.day = day + p.created_at = datetime.utcnow() + p.updated_at = datetime.utcnow() + plans.append(p) + else: + p = Planner() + p.recipe_id = recipe.id + p.day = -1 + p.created_at = datetime.utcnow() + p.updated_at = datetime.utcnow() + plans.append(p) + + try: + session.bulk_save_objects(plans) + session.commit() + except Exception as e: + session.rollback() + raise e + + # Data migration end + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_column('planned') + batch_op.drop_column('planned_days') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('planned_days', sa.VARCHAR(), nullable=True)) + batch_op.add_column(sa.Column('planned', sa.BOOLEAN(), nullable=True)) + + op.drop_table('planner') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/dedd014b6a59_.py b/backend/migrations/versions/dedd014b6a59_.py new file mode 100644 index 00000000..de995268 --- /dev/null +++ b/backend/migrations/versions/dedd014b6a59_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: dedd014b6a59 +Revises: ade9ad0be1a5 +Create Date: 2023-10-03 17:33:03.605572 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dedd014b6a59' +down_revision = 'ade9ad0be1a5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_item_household_id'), ['household_id'], unique=False) + if "postgresql" in bind.engine.name: + op.execute("CREATE EXTENSION fuzzystrmatch") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_item_household_id')) + if "postgresql" in bind.engine.name: + op.execute("DROP EXTENSION fuzzystrmatch") + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e209fcb83993_.py b/backend/migrations/versions/e209fcb83993_.py new file mode 100644 index 00000000..0f10b590 --- /dev/null +++ b/backend/migrations/versions/e209fcb83993_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: e209fcb83993 +Revises: 29381d24ec31 +Create Date: 2022-03-19 13:39:54.518323 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e209fcb83993' +down_revision = '29381d24ec31' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('source', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'source') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ed32086bf606_.py b/backend/migrations/versions/ed32086bf606_.py new file mode 100644 index 00000000..89915825 --- /dev/null +++ b/backend/migrations/versions/ed32086bf606_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: ed32086bf606 +Revises: 8897db89e7af +Create Date: 2023-05-26 10:34:59.800754 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ed32086bf606' +down_revision = '8897db89e7af' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', sa.String(length=256), nullable=True)) + batch_op.create_unique_constraint(batch_op.f('uq_user_email'), ['email']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_user_email'), type_='unique') + batch_op.drop_column('email') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ee2ba4d37d8b_.py b/backend/migrations/versions/ee2ba4d37d8b_.py new file mode 100644 index 00000000..9fec0152 --- /dev/null +++ b/backend/migrations/versions/ee2ba4d37d8b_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: ee2ba4d37d8b +Revises: 8f12363abaaf +Create Date: 2023-11-09 16:20:23.973472 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm + +DeclarativeBase = orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'ee2ba4d37d8b' +down_revision = '8f12363abaaf' +branch_labels = None +depends_on = None + + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + exclude_from_statistics = sa.Column(sa.Boolean, default=False, nullable=True) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + session = orm.Session(bind=bind) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('exclude_from_statistics', sa.Boolean(), nullable=True)) + + expenses = session.query(Expense).all() + for expense in expenses: + expense.exclude_from_statistics = False + try: + session.bulk_save_objects(expenses) + session.commit() + except Exception as e: + session.rollback() + raise e + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('exclude_from_statistics', nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_column('exclude_from_statistics') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/fe3a5c9ac84c_.py b/backend/migrations/versions/fe3a5c9ac84c_.py new file mode 100644 index 00000000..ec2383bd --- /dev/null +++ b/backend/migrations/versions/fe3a5c9ac84c_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: fe3a5c9ac84c +Revises: ade6487fe28a +Create Date: 2022-10-18 17:26:44.087997 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fe3a5c9ac84c' +down_revision = 'ade6487fe28a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('cook_time', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('prep_time', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('yields', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_column('yields') + batch_op.drop_column('prep_time') + batch_op.drop_column('cook_time') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/fffa4ab33d2a_.py b/backend/migrations/versions/fffa4ab33d2a_.py new file mode 100644 index 00000000..ba0c3a2f --- /dev/null +++ b/backend/migrations/versions/fffa4ab33d2a_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: fffa4ab33d2a +Revises: 22d528c529ca +Create Date: 2021-03-04 17:42:36.395179 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fffa4ab33d2a' +down_revision = '22d528c529ca' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe_items', sa.Column('optional', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe_items', 'optional') + # ### end Alembic commands ### diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..6c1c8aab --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,135 @@ +alembic==1.13.1 +amqp==5.2.0 +annotated-types==0.6.0 +apispec==6.3.1 +appdirs==1.4.4 +APScheduler==3.10.4 +attrs==23.2.0 +autopep8==2.0.4 +bcrypt==4.1.2 +beautifulsoup4==4.12.2 +bidict==0.22.1 +billiard==4.2.0 +black==24.1a1 +blinker==1.7.0 +blurhash-python==1.2.1 +celery==5.3.6 +certifi==2023.11.17 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.3.0 +contourpy==1.2.0 +cryptography==41.0.7 +cycler==0.12.1 +dbscan1d==0.2.2 +defusedxml==0.7.1 +extruct==0.16.0 +flake8==7.0.0 +Flask==3.0.0 +Flask-APScheduler==1.13.1 +Flask-BasicAuth==0.2.0 +Flask-Bcrypt==1.0.1 +Flask-JWT-Extended==4.6.0 +Flask-Migrate==4.0.5 +Flask-SocketIO==5.3.6 +Flask-SQLAlchemy==3.1.1 +fonttools==4.47.0 +future==0.18.3 +gevent==23.9.1 +greenlet==3.0.0rc3 +h11==0.14.0 +html-text==0.5.2 +html5lib==1.1 +idna==3.6 +ingredient-parser-nlp==0.1.0b7 +iniconfig==2.0.0 +isodate==0.6.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +joblib==1.3.2 +jstyleson==0.0.2 +kiwisolver==1.4.5 +kombu==5.3.4 +lark==1.1.8 +lxml==5.0.1 +Mako==1.3.0 +MarkupSafe==2.1.3 +marshmallow==3.20.1 +matplotlib==3.8.2 +mccabe==0.7.0 +mf2py==2.0.1 +mlxtend==0.23.1 +mypy-extensions==1.0.0 +nltk==3.8.1 +numpy==1.26.3 +oic==1.6.1 +packaging==23.2 +pandas==2.1.4 +pathspec==0.12.1 +Pillow==10.2.0 +platformdirs==4.1.0 +pluggy==1.3.0 +prometheus-client==0.19.0 +prometheus-flask-exporter==0.23.0 +prompt-toolkit==3.0.43 +psycopg2-binary==2.9.9 +py==1.11.0 +pycodestyle==2.11.1 +pycparser==2.21 +pycryptodomex==3.19.1 +pydantic==2.5.3 +pydantic-settings==2.1.0 +pydantic_core==2.14.6 +pyflakes==3.2.0 +pyjwkest==1.4.2 +PyJWT==2.8.0 +pyparsing==3.1.1 +pyRdfa3==3.5.3 +pytest==7.4.4 +python-crfsuite==0.9.10 +python-dateutil==2.8.2 +python-dotenv==1.0.0 +python-editor==1.0.4 +python-engineio==4.8.1 +python-socketio==5.10.0 +pytz==2023.3 +pytz-deprecation-shim==0.1.0.post0 +rdflib==7.0.0 +rdflib-jsonld==0.6.2 +recipe-scrapers==14.53.0 +regex==2023.10.3 +requests==2.31.0 +scikit-learn==1.3.2 +scipy==1.11.4 +setuptools-scm==8.0.4 +simple-websocket==1.0.0 +six==1.16.0 +soupsieve==2.5 +SQLAlchemy==2.0.25 +sqlite-icu==1.0 +threadpoolctl==3.2.0 +toml==0.10.2 +tomli==2.0.1 +tqdm==4.66.1 +typed-ast==1.5.5 +types-beautifulsoup4==4.12.0.20240106 +types-html5lib==1.1.11.20240106 +types-requests==2.31.0.20240106 +types-urllib3==1.26.25.14 +typing_extensions==4.9.0 +tzdata==2023.4 +tzlocal==5.2 +urllib3==2.1.0 +uWSGI==2.0.23 +uwsgi-tools==1.1.1 +vine==5.1.0 +w3lib==2.1.2 +wcwidth==0.2.13 +webencodings==0.5.1 +Werkzeug==3.0.1 +wsproto==1.2.0 +zope.event==5.0 +zope.interface==6.1 diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json new file mode 100644 index 00000000..d78b2d31 --- /dev/null +++ b/backend/templates/attributes.json @@ -0,0 +1,1246 @@ +{ + "items": { + "agave_syrup": { + "icon": "honey" + }, + "aioli": { + "icon": "garlic" + }, + "amaretto": { + "category": "drinks" + }, + "apple": { + "category": "fruits_vegetables", + "icon": "apple" + }, + "apple_pulp": { + "icon": "apple" + }, + "applesauce": { + "icon": "apple" + }, + "apricots": { + "category": "fruits_vegetables", + "icon": "apricot" + }, + "apérol": { + "category": "drinks", + "icon": "wine-bottle" + }, + "arugula": { + "category": "fruits_vegetables" + }, + "asian_egg_noodles": { + "icon": "noodles" + }, + "asian_noodles": { + "icon": "noodles" + }, + "asparagus": { + "category": "fruits_vegetables", + "icon": "asparagus" + }, + "aspirin": { + "icon": "pills" + }, + "avocado": { + "category": "fruits_vegetables", + "icon": "avocado" + }, + "baby_potatoes": { + "category": "fruits_vegetables", + "icon": "potato" + }, + "baby_spinach": { + "category": "fruits_vegetables", + "icon": "spinach" + }, + "bacon": { + "icon": "bacon" + }, + "baguette": { + "category": "bread", + "icon": "bread" + }, + "bakefish": { + "icon": "fish_food" + }, + "baking_cocoa": { + "category": "dairy", + "icon": "chocolate_bar" + }, + "baking_mix": {}, + "baking_paper": { + "category": "hygiene" + }, + "baking_powder": { + "category": "dairy" + }, + "baking_soda": {}, + "baking_yeast": {}, + "balsamic_vinegar": {}, + "bananas": { + "category": "fruits_vegetables", + "icon": "banana" + }, + "basil": { + "icon": "basil" + }, + "basmati_rice": { + "icon": "grains-of-rice" + }, + "bathroom_cleaner": { + "icon": "spray" + }, + "batteries": {}, + "bay_leaf": {}, + "beans": { + "icon": "peas" + }, + "beef": { + "icon": "beef" + }, + "beef_broth": { + "icon": "mayonnaise" + }, + "beer": { + "category": "drinks", + "icon": "beer-bottle" + }, + "beet": { + "category": "fruits_vegetables", + "icon": "beet" + }, + "beetroot": { + "category": "fruits_vegetables", + "icon": "beet" + }, + "birthday_card": {}, + "black_beans": {}, + "blister_plaster": {}, + "bockwurst": { + "icon": "sausage" + }, + "bodywash": { + "category": "hygiene" + }, + "bread": { + "category": "bread", + "icon": "bread" + }, + "breadcrumbs": {}, + "broccoli": { + "category": "fruits_vegetables", + "icon": "broccoli" + }, + "brown_sugar": { + "category": "dairy", + "icon": "sugar" + }, + "brussels_sprouts": { + "category": "fruits_vegetables" + }, + "buffalo_mozzarella": { + "category": "dairy" + }, + "buns": { + "category": "bread" + }, + "burger_buns": { + "category": "bread" + }, + "burger_patties": {}, + "burger_sauces": {}, + "butter": { + "category": "dairy", + "icon": "butter" + }, + "butter_cookies": { + "icon": "cookies" + }, + "butternut_squash": { + "icon": "pumpkin" + }, + "button_cells": {}, + "börek_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "cake": {}, + "cake_icing": { + "category": "dairy" + }, + "cane_sugar": {}, + "cannelloni": {}, + "canola_oil": { + "icon": "plastic_bottle" + }, + "cardamom": {}, + "carrots": { + "category": "fruits_vegetables", + "icon": "carrot" + }, + "cashews": { + "icon": "nut" + }, + "cat_treats": { + "category": "hygiene" + }, + "cauliflower": { + "category": "fruits_vegetables" + }, + "celeriac": { + "category": "fruits_vegetables", + "icon": "kohlrabi" + }, + "celery": { + "category": "fruits_vegetables", + "icon": "celery" + }, + "cereal_bar": {}, + "cheddar": { + "category": "dairy", + "icon": "cheese" + }, + "cheese": { + "category": "dairy", + "icon": "cheese" + }, + "cherry_tomatoes": { + "category": "fruits_vegetables", + "icon": "tomato" + }, + "chickpeas": { + "icon": "peas" + }, + "chicory": {}, + "chili_oil": {}, + "chili_pepper": {}, + "chips": { + "category": "snacks" + }, + "chives": { + "category": "fruits_vegetables" + }, + "chocolate": { + "category": "snacks", + "icon": "chocolate-bar" + }, + "chocolate_chips": {}, + "chopped_tomatoes": { + "icon": "tomato" + }, + "chunky_tomatoes": {}, + "ciabatta": { + "category": "bread", + "icon": "bread" + }, + "cider_vinegar": {}, + "cilantro": {}, + "cinnamon": { + "icon": "cinnamon_sticks" + }, + "cinnamon_stick": { + "icon": "cinnamon_sticks" + }, + "cocktail_sauce": {}, + "cocktail_tomatoes": { + "category": "fruits_vegetables", + "icon": "tomato" + }, + "coconut_flakes": { + "icon": "coconut" + }, + "coconut_milk": { + "category": "canned", + "icon": "coconut" + }, + "coconut_oil": { + "icon": "coconut" + }, + "coffee_powder": { + "icon": "coffee_beans" + }, + "colorful_sprinkles": { + "category": "dairy", + "icon": "candy_cane" + }, + "concealer": {}, + "cookies": { + "category": "snacks", + "icon": "cookies" + }, + "coriander": {}, + "corn": { + "category": "fruits_vegetables", + "icon": "corn" + }, + "cornflakes": { + "icon": "cereal" + }, + "cornstarch": { + "category": "dairy", + "icon": "flour" + }, + "cornys": {}, + "corriander": { + "category": "fruits_vegetables", + "icon": "natural_food" + }, + "cotton_rounds": {}, + "cough_drops": {}, + "couscous": { + "icon": "lentil" + }, + "covid_rapid_test": {}, + "cow's_milk": { + "category": "dairy", + "icon": "milk-carton" + }, + "cream": { + "category": "dairy", + "icon": "whipped_cream" + }, + "cream_cheese": { + "category": "dairy" + }, + "creamed_spinach": { + "icon": "spinach" + }, + "creme_fraiche": { + "category": "dairy" + }, + "crepe_tape": {}, + "crispbread": {}, + "cucumber": { + "category": "fruits_vegetables", + "icon": "cucumber" + }, + "cumin": {}, + "curd": { + "category": "dairy" + }, + "curry_paste": {}, + "curry_powder": {}, + "curry_sauce": {}, + "dates": { + "category": "fruits_vegetables" + }, + "dental_floss": { + "category": "hygiene" + }, + "deo": { + "category": "hygiene", + "icon": "spray" + }, + "deodorant": { + "category": "hygiene" + }, + "detergent": { + "category": "hygiene", + "icon": "soap_bubble" + }, + "detergent_sheets": {}, + "diarrhea_remedy": {}, + "dill": {}, + "dishwasher_salt": { + "category": "hygiene" + }, + "dishwasher_tabs": { + "category": "hygiene" + }, + "disinfection_spray": { + "category": "hygiene" + }, + "dried_tomatoes": { + "icon": "tomato" + }, + "dry_yeast": {}, + "edamame": { + "icon": "peas" + }, + "egg_salad": {}, + "egg_yolk": {}, + "eggplant": { + "category": "fruits_vegetables", + "icon": "eggplant" + }, + "eggs": { + "category": "dairy", + "icon": "eggs" + }, + "enoki_mushrooms": { + "category": "fruits_vegetables", + "icon": "mushroom" + }, + "eyebrow_gel": {}, + "falafel": {}, + "falafel_powder": {}, + "fanta": { + "category": "drinks" + }, + "feta": { + "category": "dairy" + }, + "ffp2": { + "category": "hygiene" + }, + "fish_sticks": { + "icon": "fish_food" + }, + "flour": { + "category": "dairy", + "icon": "flour" + }, + "flushing": {}, + "fresh_chili_pepper": { + "category": "fruits_vegetables" + }, + "frozen_berries": { + "category": "freezer", + "icon": "strawberry" + }, + "frozen_broccoli": { + "icon": "broccoli" + }, + "frozen_fruit": { + "category": "freezer" + }, + "frozen_pizza": { + "category": "freezer", + "icon": "salami-pizza" + }, + "frozen_spinach": { + "category": "freezer", + "icon": "spinach" + }, + "funeral_card": {}, + "garam_masala": {}, + "garbage_bag": { + "category": "hygiene" + }, + "garlic": { + "category": "fruits_vegetables", + "icon": "garlic" + }, + "garlic_dip": { + "icon": "garlic" + }, + "garlic_granules": { + "icon": "garlic" + }, + "gherkins": {}, + "ginger": { + "category": "fruits_vegetables", + "icon": "ginger" + }, + "ginger_ale": { + "icon": "cola" + }, + "glass_noodles": { + "icon": "noodles" + }, + "gluten": { + "category": "bread" + }, + "gnocchi": { + "icon": "potato" + }, + "gochujang": {}, + "gorgonzola": { + "category": "dairy" + }, + "gouda": { + "category": "dairy", + "icon": "cheese" + }, + "granola": {}, + "granola_bar": {}, + "grapes": { + "category": "fruits_vegetables", + "icon": "grapes" + }, + "greek_yogurt": { + "category": "dairy", + "icon": "yogurt" + }, + "green_asparagus": { + "category": "fruits_vegetables" + }, + "green_chili": {}, + "green_pesto": {}, + "hair_gel": {}, + "hair_ties": {}, + "hair_wax": {}, + "ham": { + "icon": "jamon" + }, + "ham_cubes": { + "icon": "jamon" + }, + "hand_soap": { + "category": "hygiene" + }, + "handkerchief_box": { + "category": "hygiene", + "icon": "wipes" + }, + "handkerchiefs": {}, + "hard_cheese": {}, + "haribo": { + "category": "snacks" + }, + "harissa": {}, + "hazelnuts": { + "icon": "nut" + }, + "head_of_lettuce": { + "category": "fruits_vegetables", + "icon": "lettuce" + }, + "herb_baguettes": {}, + "herb_butter": { + "icon": "butter" + }, + "herb_cream_cheese": { + "category": "dairy" + }, + "honey": { + "icon": "honey" + }, + "honey_wafers": {}, + "hot_dog_bun": { + "category": "bread" + }, + "ice_cream": { + "category": "freezer", + "icon": "whipped_cream" + }, + "ice_cube": {}, + "iceberg_lettuce": { + "category": "fruits_vegetables", + "icon": "lettuce" + }, + "iced_tea": { + "category": "drinks" + }, + "instant_soups": {}, + "jam": {}, + "jasmine_rice": { + "icon": "grains_of_rice" + }, + "katjes": {}, + "ketchup": { + "icon": "ketchup" + }, + "kidney_beans": { + "icon": "can_soup" + }, + "kitchen_roll": { + "category": "hygiene", + "icon": "wipes" + }, + "kitchen_towels": { + "category": "hygiene" + }, + "kiwi": { + "category": "fruits_vegetables", + "icon": "kiwi" + }, + "kohlrabi": { + "category": "fruits_vegetables", + "icon": "kohlrabi" + }, + "lasagna": {}, + "lasagna_noodles": {}, + "lasagna_plates": {}, + "leaf_spinach": { + "category": "fruits_vegetables", + "icon": "spinach" + }, + "leek": { + "category": "fruits_vegetables", + "icon": "leek" + }, + "lemon": { + "category": "fruits_vegetables", + "icon": "citrus" + }, + "lemon_curd": {}, + "lemon_juice": { + "icon": "citrus" + }, + "lemonade": { + "category": "drinks", + "icon": "cola" + }, + "lemongrass": { + "category": "fruits_vegetables" + }, + "lentil_stew": {}, + "lentils": { + "icon": "lentil" + }, + "lentils_red": { + "icon": "lentil" + }, + "lettuce": { + "category": "fruits_vegetables", + "icon": "lettuce" + }, + "lillet": { + "category": "drinks" + }, + "lime": { + "category": "fruits_vegetables", + "icon": "citrus" + }, + "linguine": {}, + "lip_care": { + "category": "hygiene" + }, + "liqueur": {}, + "low-fat_curd_cheese": { + "category": "dairy", + "icon": "yogurt" + }, + "maggi": {}, + "magnesium": {}, + "mango": { + "category": "fruits_vegetables", + "icon": "plum" + }, + "maple_syrup": {}, + "margarine": { + "category": "dairy", + "icon": "butter" + }, + "marjoram": {}, + "marshmallows": { + "category": "snacks" + }, + "mascara": {}, + "mascarpone": {}, + "mask": { + "category": "hygiene" + }, + "mayonnaise": { + "icon": "mustard" + }, + "meat_substitute_product": {}, + "microfiber_cloth": {}, + "milk": { + "category": "dairy", + "icon": "milk-carton" + }, + "mint": { + "icon": "basil" + }, + "mint_candy": {}, + "miso_paste": {}, + "mixed_vegetables": { + "category": "fruits_vegetables" + }, + "mochis": {}, + "mold_remover": {}, + "mountain_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "mouth_wash": { + "category": "hygiene" + }, + "mozzarella": { + "icon": "mozzarella" + }, + "muesli": { + "icon": "cereal" + }, + "muesli_bar": {}, + "mulled_wine": { + "category": "drinks" + }, + "mushrooms": { + "category": "fruits_vegetables", + "icon": "mushroom" + }, + "mustard": {}, + "nail_file": {}, + "nail_polish_remover": {}, + "neutral_oil": {}, + "nori_sheets": {}, + "nutmeg": {}, + "oat_milk": { + "category": "dairy", + "icon": "milk-carton" + }, + "oatmeal": { + "icon": "wheat" + }, + "oatmeal_cookies": { + "icon": "cookies" + }, + "oatsome": {}, + "obatzda": { + "category": "refrigerated" + }, + "oil": {}, + "olive_oil": { + "category": "bread", + "icon": "olive" + }, + "olives": { + "category": "fruits_vegetables", + "icon": "olive" + }, + "onion": { + "category": "fruits_vegetables", + "icon": "onion" + }, + "onion_powder": {}, + "orange_juice": { + "category": "drinks", + "icon": "orange" + }, + "oranges": { + "category": "fruits_vegetables", + "icon": "orange" + }, + "oregano": { + "icon": "basil" + }, + "organic_lemon": { + "category": "fruits_vegetables", + "icon": "citrus" + }, + "organic_waste_bags": { + "category": "hygiene" + }, + "pak_choi": { + "category": "fruits_vegetables", + "icon": "lettuce" + }, + "pantyhose": {}, + "papaya": { + "category": "fruits_vegetables", + "icon": "papaya" + }, + "paprika": { + "category": "fruits_vegetables", + "icon": "paprika" + }, + "paprika_seasoning": {}, + "pardina_lentils_dried": { + "icon": "lentil" + }, + "parmesan": { + "category": "dairy", + "icon": "cheese" + }, + "parsley": { + "category": "fruits_vegetables", + "icon": "basil" + }, + "pasta": { + "category": "grain", + "icon": "penne" + }, + "peach": { + "category": "fruits_vegetables" + }, + "peanut_butter": { + "category": "dairy", + "icon": "peanuts" + }, + "peanut_flips": { + "icon": "peanuts" + }, + "peanut_oil": { + "icon": "peanuts" + }, + "peanuts": { + "category": "snacks", + "icon": "peanuts" + }, + "pears": { + "category": "fruits_vegetables", + "icon": "pear" + }, + "peas": { + "icon": "peas" + }, + "penne": { + "icon": "penne" + }, + "pepper": {}, + "pepper_mill": { + "category": "fruits_vegetables" + }, + "peppers": { + "category": "fruits_vegetables" + }, + "persian_rice": { + "icon": "grains-of-rice" + }, + "pesto": {}, + "pilsner": { + "category": "drinks" + }, + "pine_nuts": { + "icon": "nut" + }, + "pineapple": { + "category": "fruits_vegetables", + "icon": "pineapple" + }, + "pita_bag": {}, + "pita_bread": { + "category": "bread", + "icon": "bread_loaf" + }, + "pizza": { + "icon": "salami-pizza" + }, + "pizza_dough": { + "icon": "salami-pizza" + }, + "plant_magarine": { + "category": "dairy" + }, + "plant_oil": { + "category": "fruits_vegetables" + }, + "plaster": {}, + "pointed_peppers": { + "category": "fruits_vegetables" + }, + "porcini_mushrooms": { + "category": "fruits_vegetables", + "icon": "mushroom" + }, + "potato_dumpling_dough": { + "icon": "potato" + }, + "potato_wedges": {}, + "potatoes": { + "category": "fruits_vegetables", + "icon": "potato" + }, + "potting_soil": {}, + "powder": {}, + "powdered_sugar": {}, + "processed_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "prosecco": { + "category": "drinks" + }, + "puff_pastry": {}, + "pumpkin": { + "category": "fruits_vegetables", + "icon": "pumpkin" + }, + "pumpkin_seeds": {}, + "quark": { + "category": "dairy" + }, + "quinoa": {}, + "radicchio": { + "category": "fruits_vegetables", + "icon": "radish" + }, + "radish": { + "category": "fruits_vegetables", + "icon": "radish" + }, + "ramen": {}, + "rapeseed_oil": {}, + "raspberries": { + "category": "fruits_vegetables", + "icon": "raspberry" + }, + "raspberry_syrup": { + "category": "drinks", + "icon": "raspberry" + }, + "razor_blades": { + "icon": "razor" + }, + "red_bull": { + "category": "drinks" + }, + "red_chili": {}, + "red_curry_paste": {}, + "red_lentils": { + "icon": "grains_of_rice" + }, + "red_onions": { + "category": "fruits_vegetables", + "icon": "onion" + }, + "red_pesto": {}, + "red_wine": { + "category": "drinks", + "icon": "wine-bottle" + }, + "red_wine_vinegar": {}, + "rhubarb": { + "category": "fruits_vegetables" + }, + "ribbon_noodles": {}, + "rice": { + "icon": "grains-of-rice" + }, + "rice_cakes": {}, + "rice_paper": {}, + "rice_ribbon_noodles": {}, + "rice_vinegar": {}, + "ricotta": {}, + "rinse_tabs": { + "category": "hygiene", + "icon": "soap_bubble" + }, + "rinsing_agent": { + "category": "hygiene" + }, + "risotto_rice": { + "icon": "grains_of_rice" + }, + "rocket": { + "category": "fruits_vegetables" + }, + "roll": { + "category": "bread", + "icon": "bread" + }, + "rosemary": {}, + "saffron_threads": {}, + "sage": {}, + "saitan_powder": {}, + "salad_mix": { + "category": "fruits_vegetables", + "icon": "lettuce" + }, + "salad_seeds_mix": {}, + "salt": {}, + "salt_mill": { + "category": "fruits_vegetables" + }, + "sambal_oelek": {}, + "sauce": {}, + "sausage": { + "icon": "sausage" + }, + "sausages": { + "icon": "sausage" + }, + "savoy_cabbage": { + "category": "fruits_vegetables", + "icon": "cabbage" + }, + "scallion": {}, + "scattered_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "schlemmerfilet": {}, + "schupfnudeln": {}, + "semolina_porridge": { + "category": "dairy" + }, + "sesame": { + "icon": "lentil" + }, + "sesame_oil": {}, + "shallot": { + "category": "fruits_vegetables", + "icon": "onion" + }, + "shampoo": { + "category": "hygiene" + }, + "shawarma_spice": {}, + "shiitake_mushroom": { + "category": "fruits_vegetables", + "icon": "mushroom" + }, + "shoe_insoles": {}, + "shower_gel": { + "category": "hygiene" + }, + "shredded_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "sieved_tomatoes": { + "icon": "tomato" + }, + "skyr": { + "icon": "yogurt" + }, + "sliced_cheese": { + "category": "dairy", + "icon": "cheese" + }, + "smoked_paprika": { + "icon": "paprika" + }, + "smoked_tofu": { + "icon": "natural_food" + }, + "snacks": { + "category": "snacks", + "icon": "peanuts" + }, + "soap": { + "category": "hygiene" + }, + "soba_noodles": {}, + "soft_drinks": { + "category": "drinks", + "icon": "cola" + }, + "soup_vegetables": { + "category": "fruits_vegetables" + }, + "sour_cream": { + "category": "dairy" + }, + "sour_cucumbers": { + "icon": "cucumber" + }, + "soy_cream": { + "icon": "tetra_pak" + }, + "soy_hack": {}, + "soy_sauce": { + "icon": "soy_sauce" + }, + "soy_shred": { + "icon": "soy" + }, + "spaetzle": {}, + "spaghetti": {}, + "sparkling_water": { + "category": "drinks", + "icon": "plastic-bottle" + }, + "spelt": {}, + "spinach": { + "category": "fruits_vegetables", + "icon": "spinach" + }, + "sponge_cloth": { + "category": "hygiene" + }, + "sponge_fingers": {}, + "sponge_wipes": { + "category": "hygiene" + }, + "sponges": { + "category": "hygiene", + "icon": "soap_bubble" + }, + "spreading_cream": { + "category": "dairy" + }, + "spring_onions": { + "category": "fruits_vegetables", + "icon": "leek" + }, + "sprite": { + "category": "drinks" + }, + "sprouts": { + "category": "fruits_vegetables" + }, + "sriracha": {}, + "strained_tomatoes": { + "icon": "tomato" + }, + "strawberries": { + "category": "fruits_vegetables", + "icon": "strawberry" + }, + "sugar": { + "category": "dairy", + "icon": "sugar" + }, + "summer_roll_paper": {}, + "sunflower_oil": {}, + "sunflower_seeds": {}, + "sunscreen": {}, + "sushi_rice": { + "icon": "grains-of-rice" + }, + "swabian_ravioli": {}, + "sweet_chili_sauce": {}, + "sweet_potato": { + "category": "fruits_vegetables", + "icon": "sweet-potato" + }, + "sweet_potatoes": { + "category": "fruits_vegetables", + "icon": "sweet-potato" + }, + "sweets": { + "icon": "candy_cane" + }, + "table_salt": {}, + "tagliatelle": {}, + "tahini": {}, + "tangerines": { + "category": "fruits_vegetables" + }, + "tape": {}, + "tapioca_flour": {}, + "tea": { + "category": "drinks", + "icon": "tea" + }, + "teriyaki_sauce": { + "icon": "soy_sauce" + }, + "thyme": {}, + "toast": { + "category": "bread", + "icon": "bread_loaf" + }, + "tofu": { + "icon": "natural_food" + }, + "toilet_paper": { + "category": "hygiene", + "icon": "toilet_paper" + }, + "tomato_juice": { + "icon": "tomato" + }, + "tomato_paste": { + "icon": "tomato" + }, + "tomato_sauce": { + "icon": "tomato" + }, + "tomatoes": { + "category": "fruits_vegetables", + "icon": "tomato" + }, + "tonic_water": { + "category": "drinks" + }, + "toothpaste": { + "category": "hygiene", + "icon": "tooth_cleaning_kit" + }, + "tortellini": {}, + "tortilla_chips": {}, + "tuna": { + "icon": "fish-food" + }, + "turmeric": {}, + "tzatziki": {}, + "udon_noodles": {}, + "uht_milk": { + "category": "dairy", + "icon": "milk_carton" + }, + "vanilla_sugar": {}, + "vegetable_bouillon_cube": {}, + "vegetable_broth": { + "icon": "mayonnaise" + }, + "vegetable_oil": {}, + "vegetable_onion": { + "category": "fruits_vegetables" + }, + "vegetables": { + "category": "fruits_vegetables" + }, + "vegetarian_cold_cuts": {}, + "vinegar": {}, + "vitamin_tablets": { + "category": "bread", + "icon": "pills" + }, + "vodka": { + "category": "drinks" + }, + "walnuts": { + "category": "dairy" + }, + "washing_gel": { + "category": "hygiene", + "icon": "soap_bubble" + }, + "washing_powder": { + "category": "hygiene", + "icon": "soap_bubble" + }, + "water": { + "category": "drinks", + "icon": "water" + }, + "water_ice": {}, + "watermelon": { + "category": "fruits_vegetables", + "icon": "melon" + }, + "wc_cleaner": { + "category": "hygiene", + "icon": "spray" + }, + "wheat_flour": {}, + "whipped_cream": { + "category": "dairy" + }, + "white_wine": { + "category": "drinks", + "icon": "wine_bottle" + }, + "white_wine_vinegar": {}, + "whole_canned_tomatoes": { + "category": "canned", + "icon": "tomato" + }, + "wild_berries": { + "category": "fruits_vegetables", + "icon": "raspberry" + }, + "wild_rice": { + "icon": "grains_of_rice" + }, + "wildberry_lillet": {}, + "worcester_sauce": {}, + "wrapping_paper": {}, + "wraps": { + "category": "bread", + "icon": "nachos" + }, + "yeast": {}, + "yeast_flakes": { + "icon": "lentil" + }, + "yoghurt": { + "category": "dairy", + "icon": "yogurt" + }, + "yogurt": { + "category": "dairy" + }, + "yum_yum": { + "icon": "noodles" + }, + "zewa": { + "category": "hygiene" + }, + "zinc_cream": { + "category": "hygiene" + }, + "zucchini": { + "category": "fruits_vegetables", + "icon": "cucumber" + } + } +} \ No newline at end of file diff --git a/backend/templates/l10n/cs.json b/backend/templates/l10n/cs.json new file mode 100644 index 00000000..fc1eddbc --- /dev/null +++ b/backend/templates/l10n/cs.json @@ -0,0 +1,50 @@ +{ + "categories": { + "bread": "🍞 Chlebové zboží", + "canned": "🥫 Konzervované jídlo", + "dairy": "🥛 Mlékárna", + "drinks": "🍹 Nápoje", + "freezer": "❄️ Mrazák", + "fruits_vegetables": "🥬 Ovoce a zelenina", + "grain": "🥟 Obilné výrobky", + "hygiene": "🚽 Drogerie", + "refrigerated": "💧 Chlazené", + "snacks": "🥜 Občerstvení" + }, + "items": { + "apple": "Jablko", + "apricots": "Meruňky", + "arugula": "Rukola", + "asian_egg_noodles": "Asijské vaječné nudle", + "asian_noodles": "Asijské nudle", + "asparagus": "Chřest", + "aspirin": "Aspirin", + "avocado": "Avokádo", + "baby_spinach": "Špenát", + "bacon": "Slanina", + "baguette": "Bageta", + "baking_cocoa": "Kakao", + "baking_mix": "Směs na pečení", + "baking_paper": "Pečící papír", + "baking_powder": "Prášek do pečiva", + "baking_soda": "Jedlá soda", + "baking_yeast": "Kvasnice", + "balsamic_vinegar": "Balsamicový ocet", + "bananas": "Banány", + "basil": "Bazalka", + "basmati_rice": "Basmati rýže", + "batteries": "Baterie", + "bay_leaf": "Bobkový list", + "beans": "Fazole", + "beer": "Pivo", + "beetroot": "Červená řepa", + "birthday_card": "Narozeninové přání", + "black_beans": "Černé fazole", + "bread": "Chléb", + "breadcrumbs": "Chlebové drobky", + "broccoli": "Brokolice", + "brown_sugar": "Hnědý cukr", + "brussels_sprouts": "Růžičková kapusta", + "burger_buns": "Hamburgerové bulky" + } +} diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json new file mode 100644 index 00000000..7fb244a8 --- /dev/null +++ b/backend/templates/l10n/da.json @@ -0,0 +1,496 @@ +{ + "categories": { + "bread": "🍞 Brød", + "canned": "🥫 Konserves", + "dairy": "🥛 Mejeriprodukter", + "drinks": "🍹 Drikkevarer", + "freezer": "❄️ Frost", + "fruits_vegetables": "🥬 Frugt og grønt", + "grain": "🥟 Kornprodukter", + "hygiene": "🚽 Hygiejne", + "refrigerated": "💧 Køl", + "snacks": "🥜 Snacks" + }, + "items": { + "agave_syrup": "Agave Sirup", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Æble", + "apple_pulp": "Æblemos", + "applesauce": "Æblemos", + "apricots": "Abrikoser", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatiske ægnudler", + "asian_noodles": "Asiatiske nudler", + "asparagus": "Asparges", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_potatoes": "Trillinger", + "baby_spinach": "Babyspinat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bagefisk", + "baking_cocoa": "Bagekakao", + "baking_mix": "Bage-blanding", + "baking_paper": "Bagepapir", + "baking_powder": "Bagepulver", + "baking_soda": "Bagepulver", + "baking_yeast": "Bage gær", + "balsamic_vinegar": "Balsamicoeddike", + "bananas": "Bananer", + "basil": "Basil", + "basmati_rice": "Basmati-ris", + "bathroom_cleaner": "Rengøringsmiddel til badeværelset", + "batteries": "Batterier", + "bay_leaf": "Laurbærblad", + "beans": "Bønner", + "beer": "Øl", + "beet": "Rødbeder", + "beetroot": "Rødbeder", + "birthday_card": "Fødselsdagskort", + "black_beans": "Sorte bønner", + "blister_plaster": "Plaster", + "bockwurst": "Bockwurst", + "bodywash": "Bodywash", + "bread": "Brød", + "breadcrumbs": "Brødkrummer", + "broccoli": "Broccoli", + "brown_sugar": "Brunt sukker", + "brussels_sprouts": "rosenkål", + "buffalo_mozzarella": "Buffalo-mozzarella", + "buns": "Boller", + "burger_buns": "Burgerboller", + "burger_patties": "Burgerpatties", + "burger_sauces": "Burger saucer", + "butter": "Smør", + "butter_cookies": "Smørkager", + "button_cells": "Knapceller", + "börek_cheese": "Börek-ost", + "cake": "Kage", + "cake_icing": "Kage glasur", + "cane_sugar": "Rørsukker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsolie", + "cardamom": "Kardemomme", + "carrots": "Gulerødder", + "cashews": "Cashewnødder", + "cat_treats": "Kattegodbidder", + "cauliflower": "Blomkål", + "celeriac": "Knoldselleri", + "celery": "Selleri", + "cereal_bar": "Müslibar", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Cherrytomater", + "chickpeas": "Kikærter", + "chicory": "Cikorie", + "chili_oil": "Chiliolie", + "chili_pepper": "Chilipeber", + "chips": "Chips", + "chives": "Purløg", + "chocolate": "Chokolade", + "chocolate_chips": "Chokoladestykker", + "chopped_tomatoes": "Hakkede tomater", + "chunky_tomatoes": "Stærke tomater", + "ciabatta": "Ciabatta", + "cider_vinegar": "Æblecidereddike", + "cilantro": "Cilantro", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstang", + "cocktail_sauce": "Cocktailsauce", + "cocktail_tomatoes": "Cocktail-tomater", + "coconut_flakes": "Kokosnøddeflager", + "coconut_milk": "Kokosmælk", + "coconut_oil": "Kokosolie", + "coffee_powder": "Kaffe Pulver", + "colorful_sprinkles": "Farverige drys", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Koriander", + "corn": "Majs", + "cornflakes": "Cornflakes", + "cornstarch": "Majsstivelse", + "cornys": "Cornys", + "corriander": "Koriander", + "cotton_rounds": "Vatkugler", + "cough_drops": "Hostedråber", + "couscous": "Couscous", + "covid_rapid_test": "COVID-sneltest", + "cow's_milk": "Komælk", + "cream": "Creme", + "cream_cheese": "Flødeost", + "creamed_spinach": "Cremet spinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepe tape", + "crispbread": "Knækbrød", + "cucumber": "Agurk", + "cumin": "Spidskommen", + "curd": "Ostemasse", + "curry_paste": "Karrypasta", + "curry_powder": "Karrypulver", + "curry_sauce": "Karrysauce", + "dates": "Datoer", + "dental_floss": "Tandtråd", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Vaskemiddel", + "detergent_sheets": "Vaskemiddelark", + "diarrhea_remedy": "Middel mod diarré", + "dill": "Dild", + "dishwasher_salt": "Salt til opvaskemaskine", + "dishwasher_tabs": "Tabs til opvaskemaskine", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Tørrede tomater", + "dry_yeast": "Tør gær", + "edamame": "Edamame", + "egg_salad": "Æggesalat", + "egg_yolk": "Æggeblomme", + "eggplant": "Aubergine", + "eggs": "Æg", + "enoki_mushrooms": "Enoki-svampe", + "eyebrow_gel": "Gel til øjenbryn", + "falafel": "Falafel", + "falafel_powder": "Falafel-pulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskestænger", + "flour": "Mel", + "flushing": "Skylning", + "fresh_chili_pepper": "Frisk chilipeber", + "frozen_berries": "Frosne bær", + "frozen_broccoli": "Frossen broccoli", + "frozen_fruit": "Frossen frugt", + "frozen_pizza": "Frossen pizza", + "frozen_spinach": "Frossen spinat", + "funeral_card": "Begravelseskort", + "garam_masala": "Garam Masala", + "garbage_bag": "Affaldsposer", + "garlic": "Hvidløg", + "garlic_dip": "Hvidløgsdip", + "garlic_granules": "Hvidløg i granulatform", + "gherkins": "Agurker", + "ginger": "Ingefær", + "ginger_ale": "Ginger ale", + "glass_noodles": "Glasnudler", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola-bar", + "grapes": "Druer", + "greek_yogurt": "Græsk yoghurt", + "green_asparagus": "Grønne asparges", + "green_chili": "Grøn chili", + "green_pesto": "Grøn pesto", + "hair_gel": "Hårgel", + "hair_ties": "Hårbøjler", + "hair_wax": "Hårvoks", + "ham": "Skinke", + "ham_cubes": "Skinke terninger", + "hand_soap": "Håndsæbe", + "handkerchief_box": "Lommetørklæde boks", + "handkerchiefs": "Lommetørklæder", + "hard_cheese": "Hård ost", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnødder", + "head_of_lettuce": "Hoved af salat", + "herb_baguettes": "Baguettes med urter", + "herb_butter": "Krydder smør", + "herb_cream_cheese": "Krydderurter flødeost", + "honey": "Honning", + "honey_wafers": "Honningvafler", + "hot_dog_bun": "Hotdog-bolle", + "ice_cream": "Is", + "ice_cube": "Isterninger", + "iceberg_lettuce": "Iceberg-salat", + "iced_tea": "Iste", + "instant_soups": "Instant-supper", + "jam": "Syltetøj", + "jasmine_rice": "Jasminris", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybønner", + "kitchen_roll": "Køkkenrulle", + "kitchen_towels": "Køkkenhåndklæder", + "kohlrabi": "Kålrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagne-nudler", + "lasagna_plates": "Lasagneplader", + "leaf_spinach": "Bladspinat", + "leek": "Porre", + "lemon": "Citron", + "lemon_curd": "Citronfromage", + "lemon_juice": "Citronsaft", + "lemonade": "Lemonade", + "lemongrass": "Citrongræs", + "lentil_stew": "Linsestuvning", + "lentils": "Linser", + "lentils_red": "Røde linser", + "lettuce": "Salat", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Læbepleje", + "liqueur": "Likør", + "low-fat_curd_cheese": "Ostemasse med lavt fedtindhold", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Ahornsirup", + "margarine": "Margarine", + "marjoram": "Merian", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Maske", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Køderstatningsprodukt", + "microfiber_cloth": "Mikrofiberklud", + "milk": "Mælk", + "mint": "Mynte", + "mint_candy": "Mint slik", + "miso_paste": "Miso-pasta", + "mixed_vegetables": "Blandede grøntsager", + "mochis": "Mochis", + "mold_remover": "Fjernelse af skimmelsvamp", + "mountain_cheese": "Ost fra bjergene", + "mouth_wash": "Mundskylning", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli bar", + "mulled_wine": "Gløgg", + "mushrooms": "Svampe", + "mustard": "Sennep", + "nail_file": "Neglefil", + "nail_polish_remover": "Neglefjerner", + "neutral_oil": "Neutral olie", + "nori_sheets": "Nori-ark", + "nutmeg": "Muskatnød", + "oat_milk": "Havremælk", + "oatmeal": "Havregryn", + "oatmeal_cookies": "Havregrynskager", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Olie", + "olive_oil": "Olivenolie", + "olives": "Oliven", + "onion": "Løg", + "onion_powder": "Løgpulver", + "orange_juice": "Appelsinjuice", + "oranges": "Appelsiner", + "oregano": "Oregano", + "organic_lemon": "Økologisk citron", + "organic_waste_bags": "Poser til organisk affald", + "pak_choi": "Pak Choi", + "pantyhose": "Strømpebukser", + "papaya": "Papaya", + "paprika": "Paprika", + "paprika_seasoning": "Paprika-krydderi", + "pardina_lentils_dried": "Pardina-linser, tørrede", + "parmesan": "Parmesan", + "parsley": "Persille", + "pasta": "Pasta", + "peach": "Fersken", + "peanut_butter": "Jordnøddesmør", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Jordnøddeolie", + "peanuts": "Jordnødder", + "pears": "Pærer", + "peas": "Ærter", + "penne": "Penne", + "pepper": "Peber", + "pepper_mill": "Peberkværn", + "peppers": "Peberfrugter", + "persian_rice": "Persiske ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjekerner", + "pineapple": "Ananas", + "pita_bag": "Pita-pose", + "pita_bread": "Pitabrød", + "pizza": "Pizza", + "pizza_dough": "Pizzadej", + "plant_magarine": "Plante Magarine", + "plant_oil": "Planteolie", + "plaster": "Gips", + "pointed_peppers": "Spidse peberfrugter", + "porcini_mushrooms": "Porcini-svampe", + "potato_dumpling_dough": "Dej til kartoffelboller", + "potato_wedges": "Kartoffelkiler", + "potatoes": "Kartofler", + "potting_soil": "Pottemuld", + "powder": "Pulver", + "powdered_sugar": "Pulveriseret sukker", + "processed_cheese": "Smelteost", + "prosecco": "Prosecco", + "puff_pastry": "Butterdej", + "pumpkin": "Græskar", + "pumpkin_seeds": "Græskarkerner", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radise", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolie", + "raspberries": "Hindbær", + "raspberry_syrup": "Hindbærsirup", + "razor_blades": "Barberblade", + "red_bull": "Red Bull", + "red_chili": "Rød chili", + "red_curry_paste": "Rød karrypasta", + "red_lentils": "Røde linser", + "red_onions": "Røde løg", + "red_pesto": "Rød pesto", + "red_wine": "Rødvin", + "red_wine_vinegar": "Rødvinseddike", + "rhubarb": "Rabarber", + "ribbon_noodles": "Nudler med bånd", + "rice": "Ris", + "rice_cakes": "Ristkager", + "rice_paper": "Rispapir", + "rice_ribbon_noodles": "Risbåndsnudler", + "rice_vinegar": "Rice eddike", + "ricotta": "Ricotta", + "rinse_tabs": "Tabs til skylning", + "rinsing_agent": "Skyllemiddel", + "risotto_rice": "Risottoris", + "rocket": "Raket", + "roll": "Rulle", + "rosemary": "Rosemary", + "saffron_threads": "Safrantråde", + "sage": "Sage", + "saitan_powder": "Saitan-pulver", + "salad_mix": "Salatblanding", + "salad_seeds_mix": "Salatfrø mix", + "salt": "Salt", + "salt_mill": "Saltmølle", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Pølse", + "sausages": "Pølser", + "savoy_cabbage": "Savoy-kål", + "scallion": "Skalotteløg", + "scattered_cheese": "Spredt ost", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Gryngrød af semulje", + "sesame": "Sesam", + "sesame_oil": "Sesamolie", + "shallot": "Skalotteløg", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma krydderi", + "shiitake_mushroom": "Shiitake-svamp", + "shoe_insoles": "Indlægssåler til sko", + "shower_gel": "Brusegel", + "shredded_cheese": "Revet ost", + "sieved_tomatoes": "Tomater, sigtet", + "skyr": "Skyr", + "sliced_cheese": "Skiveskåret ost", + "smoked_paprika": "Røget paprika", + "smoked_tofu": "Røget tofu", + "snacks": "Snacks", + "soap": "Sæbe", + "soba_noodles": "Soba-nudler", + "soft_drinks": "Sodavand", + "soup_vegetables": "Suppe grøntsager", + "sour_cream": "Creme fraiche", + "sour_cucumbers": "Sure agurker", + "soy_cream": "Sojafløde", + "soy_hack": "Soya mince", + "soy_sauce": "Sojasovs", + "soy_shred": "Soja strimler", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Mousserende vand", + "spelt": "Spelt", + "spinach": "Spinat", + "sponge_cloth": "Svampeklud", + "sponge_fingers": "Svampefingre", + "sponge_wipes": "Svampeservietter", + "sponges": "Svampe", + "spreading_cream": "Smørcreme", + "spring_onions": "Forårsløg", + "sprite": "Sprite", + "sprouts": "Spirer", + "sriracha": "Sriracha", + "strained_tomatoes": "Sigtede tomater", + "strawberries": "Jordbær", + "sugar": "Sukker", + "summer_roll_paper": "Sommerrullepapir", + "sunflower_oil": "Solsikkeolie", + "sunflower_seeds": "Solsikkefrø", + "sunscreen": "Solcreme", + "sushi_rice": "Sushiris", + "swabian_ravioli": "Svabisk ravioli", + "sweet_chili_sauce": "Sød chilisauce", + "sweet_potato": "Sød kartoffel", + "sweet_potatoes": "Søde kartofler", + "sweets": "Slik", + "table_salt": "Bordsalt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariner", + "tape": "Bånd", + "tapioca_flour": "Tapiokamel", + "tea": "Te", + "teriyaki_sauce": "Teriyaki-sauce", + "thyme": "Timian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toiletpapir", + "tomato_juice": "Tomatsaft", + "tomato_paste": "Tomatpasta", + "tomato_sauce": "Tomatsauce", + "tomatoes": "Tomater", + "tonic_water": "Tonicvand", + "toothpaste": "Tandpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla-chips", + "tuna": "Tun", + "turmeric": "Gurkemeje", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nudler", + "uht_milk": "UHT-mælk", + "vanilla_sugar": "Vaniljesukker", + "vegetable_bouillon_cube": "Terning af grøntsagsbouillon", + "vegetable_broth": "Grøntsagsbouillon", + "vegetable_oil": "Vegetabilsk olie", + "vegetable_onion": "Vegetabilske løg", + "vegetables": "Grøntsager", + "vegetarian_cold_cuts": "vegetarisk pålæg", + "vinegar": "Eddike", + "vitamin_tablets": "Vitamintabletter", + "vodka": "Vodka", + "walnuts": "Valnødder", + "washing_gel": "Vaskegel", + "washing_powder": "Vaskepulver", + "water": "Vand", + "water_ice": "Vandis", + "watermelon": "Vandmelon", + "wc_cleaner": "WC-rengøringsmiddel", + "wheat_flour": "Hvedemel", + "whipped_cream": "Flødeskum", + "white_wine": "Hvidvin", + "white_wine_vinegar": "Hvidvinseddike", + "whole_canned_tomatoes": "Hele tomater på dåse", + "wild_berries": "Vilde bær", + "wild_rice": "Vilde ris", + "wildberry_lillet": "Vildbær Lillet", + "worcester_sauce": "Worcester sauce", + "wrapping_paper": "Indpakningspapir", + "wraps": "Indpakninger", + "yeast": "Gær", + "yeast_flakes": "Gærflager", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinkcreme", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json new file mode 100644 index 00000000..66cfb0d8 --- /dev/null +++ b/backend/templates/l10n/de.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Brotwaren", + "canned": "🥫 Konserven", + "dairy": "🥛 Milch", + "drinks": "🍹 Getränke", + "freezer": "❄️ Tiefgekühlt", + "fruits_vegetables": "🥬 Obst & Gemüse", + "grain": "🥟 Teigwaren", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Kühltheke", + "snacks": "🥜 Snacks" + }, + "items": { + "agave_syrup": "Agavendicksaft", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apfel", + "apple_pulp": "Apfelmark", + "applesauce": "Apfelmus", + "apricots": "Aprikosen", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatische Eiernudeln", + "asian_noodles": "Nudeln", + "asparagus": "Spargel", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_potatoes": "Drillinge", + "baby_spinach": "Babyspinat", + "bacon": "Speck", + "baguette": "Baguette", + "bakefish": "Backfisch", + "baking_cocoa": "Backkakao", + "baking_mix": "Backmischung", + "baking_paper": "Backpapier", + "baking_powder": "Backpulver", + "baking_soda": "Natron", + "baking_yeast": "Backhefe", + "balsamic_vinegar": "Balsamico Essig", + "bananas": "Bananen", + "basil": "Basilikum", + "basmati_rice": "Basmati Reis", + "bathroom_cleaner": "Badreiniger", + "batteries": "Batterien", + "bay_leaf": "Lorbeerblatt", + "beans": "Bohnen", + "beef": "Rinderfleisch", + "beef_broth": "Rinderbrühe", + "beer": "Bier", + "beet": "Rote Beete", + "beetroot": "Rote Bete", + "birthday_card": "Geburtstagskarte", + "black_beans": "Schwarze Bohnen", + "blister_plaster": "Blasenpflaster", + "bockwurst": "Bockwurst", + "bodywash": "Duschgel", + "bread": "Brot", + "breadcrumbs": "Paniermehl", + "broccoli": "Brokkoli", + "brown_sugar": "Brauner Zucker", + "brussels_sprouts": "Rosenkohl", + "buffalo_mozzarella": "Büffelmozzarella", + "buns": "Buns", + "burger_buns": "Burgerbrötchen", + "burger_patties": "Burgerpatties", + "burger_sauces": "Burgersauce", + "butter": "Butter", + "butter_cookies": "Butterkekse", + "butternut_squash": "Butternut-Kürbis", + "button_cells": "Knopfzellen", + "börek_cheese": "Börek Käse", + "cake": "Kuchen", + "cake_icing": "Kuchenglasur", + "cane_sugar": "Rohrzucker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsöl", + "cardamom": "Kardamom", + "carrots": "Möhren", + "cashews": "Cashewkerne", + "cat_treats": "Katzenleckerlis", + "cauliflower": "Blumenkohl", + "celeriac": "Knollensellerie", + "celery": "Sellerie", + "cereal_bar": "Müsliriegel", + "cheddar": "Cheddar", + "cheese": "Käse", + "cherry_tomatoes": "Kirschtomaten", + "chickpeas": "Kichererbsen", + "chicory": "Chicorée", + "chili_oil": "Chili-Öl", + "chili_pepper": "Chilischote", + "chips": "Chips", + "chives": "Schnittlauch", + "chocolate": "Schokolade", + "chocolate_chips": "Sckokoladenstückchen", + "chopped_tomatoes": "Gehackte Tomaten", + "chunky_tomatoes": "Stückige Tomaten", + "ciabatta": "Ciabatta", + "cider_vinegar": "Apfelessig", + "cilantro": "Koriander", + "cinnamon": "Zimt", + "cinnamon_stick": "Zimtstange", + "cocktail_sauce": "Cocktailsauce", + "cocktail_tomatoes": "Cocktailtomaten", + "coconut_flakes": "Kokosraspel", + "coconut_milk": "Kokosnuss-Milch", + "coconut_oil": "Kokosöl", + "coffee_powder": "Kaffeepulver", + "colorful_sprinkles": "Bunte Streusel", + "concealer": "Concealer", + "cookies": "Kekse", + "coriander": "Koriander", + "corn": "Mais", + "cornflakes": "Cornflakes", + "cornstarch": "Speisestärke", + "cornys": "Cornys", + "corriander": "Korriander", + "cotton_rounds": "Wattepads", + "cough_drops": "Hustenbonbons", + "couscous": "Couscous", + "covid_rapid_test": "COVID Schnelltest", + "cow's_milk": "Kuhmilch", + "cream": "Sahne", + "cream_cheese": "Frischkäse", + "creamed_spinach": "Rahmspinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepesband", + "crispbread": "Knäckebrot", + "cucumber": "Gurke", + "cumin": "Kreuzkümmel", + "curd": "Quark", + "curry_paste": "Currypaste", + "curry_powder": "Currypulver", + "curry_sauce": "Currysoße", + "dates": "Datteln", + "dental_floss": "Zahnseide", + "deo": "Deo", + "deodorant": "Deodorant", + "detergent": "Waschmittel", + "detergent_sheets": "Waschmittelblätter", + "diarrhea_remedy": "Durchfallmittel", + "dill": "Dill", + "dishwasher_salt": "Spülmaschinensalz", + "dishwasher_tabs": "Tabs für die Spülmaschine", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Getrocknete Tomaten", + "dry_yeast": "Trockenhefe", + "edamame": "Edamame", + "egg_salad": "Eiersalat", + "egg_yolk": "Eigelb", + "eggplant": "Aubergine", + "eggs": "Eier", + "enoki_mushrooms": "Enoki Pilze", + "eyebrow_gel": "Augenbrauengel", + "falafel": "Falafel", + "falafel_powder": "Falafelpulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fischstäbchen", + "flour": "Mehl", + "flushing": "Spülung", + "fresh_chili_pepper": "Frische Chilischote", + "frozen_berries": "TK Beeren", + "frozen_broccoli": "TK Brokkoli", + "frozen_fruit": "TK Obst", + "frozen_pizza": "Tiefkühlpizza", + "frozen_spinach": "TK Spinat", + "funeral_card": "Trauerkarte", + "garam_masala": "Garam Masala", + "garbage_bag": "Müllbeutel", + "garlic": "Knoblauch", + "garlic_dip": "Knoblauch Dip", + "garlic_granules": "Knoblauch Granulat", + "gherkins": "Gewürzgurken", + "ginger": "Ingwer", + "ginger_ale": "Ginger Ale", + "glass_noodles": "Glasnudeln", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Knuspermüsli", + "granola_bar": "Müsliriegel", + "grapes": "Trauben", + "greek_yogurt": "Griechischer Joghurt", + "green_asparagus": "Grüner Spargel", + "green_chili": "Grüne Chili", + "green_pesto": "grünes Pesto", + "hair_gel": "Haargel", + "hair_ties": "Haargummis", + "hair_wax": "Haar-Wachs", + "ham": "Schinken", + "ham_cubes": "Schinkenwürfel", + "hand_soap": "Handseife", + "handkerchief_box": "Taschentuchbox", + "handkerchiefs": "Taschentücher", + "hard_cheese": "Hartkäse", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Haselnüsse", + "head_of_lettuce": "Salatkopf", + "herb_baguettes": "Kräuterbaguettes", + "herb_butter": "Kräuterbutter", + "herb_cream_cheese": "Kräuterfrischkäse", + "honey": "Honig", + "honey_wafers": "Honigwaffeln", + "hot_dog_bun": "Hot Dog Brötchen", + "ice_cream": "Eis", + "ice_cube": "Eiswürfel", + "iceberg_lettuce": "Eisbergsalat", + "iced_tea": "Eistee", + "instant_soups": "Instant Suppen", + "jam": "Konfitüre", + "jasmine_rice": "Jasminreis", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybohnen", + "kitchen_roll": "Küchenrolle", + "kitchen_towels": "Küchenhandtücher", + "kiwi": "Kiwi", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagnenudeln", + "lasagna_plates": "Lasagne Platten", + "leaf_spinach": "Blattspinat", + "leek": "Lauch", + "lemon": "Zitrone", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Zitronensaft", + "lemonade": "Limonade", + "lemongrass": "Zitronengras", + "lentil_stew": "Linseneintopf", + "lentils": "Linsen", + "lentils_red": "Linsen rot", + "lettuce": "Kopfsalat", + "lillet": "Lillet", + "lime": "Limette", + "linguine": "Linguine", + "lip_care": "Lippenpflege", + "liqueur": "Likör", + "low-fat_curd_cheese": "Magerquark", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Ahornsirup", + "margarine": "Margarine", + "marjoram": "Majoran", + "marshmallows": "Marshmallows", + "mascara": "Wimperntusche", + "mascarpone": "Mascarpone", + "mask": "Maske", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Fleischersatzprodukt", + "microfiber_cloth": "Mikrofasertuch", + "milk": "Milch", + "mint": "Minze", + "mint_candy": "Minz-Bonbon", + "miso_paste": "Miso Paste", + "mixed_vegetables": "Gemischtes Gemüse", + "mochis": "Mochis", + "mold_remover": "Schimmelentferner", + "mountain_cheese": "Bergkäse", + "mouth_wash": "Mundspülung", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsliriegel", + "mulled_wine": "Glühwein", + "mushrooms": "Champignons", + "mustard": "Senf", + "nail_file": "Nagelpfeile", + "nail_polish_remover": "Nagellackentferner", + "neutral_oil": "Neutrales Öl", + "nori_sheets": "Nori Blätter", + "nutmeg": "Muskatnuss", + "oat_milk": "Hafermilch", + "oatmeal": "Haferflocken", + "oatmeal_cookies": "Haferkekse", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Öl", + "olive_oil": "Olivenöl", + "olives": "Oliven", + "onion": "Zwiebel", + "onion_powder": "Zwiebelpulver", + "orange_juice": "Orangensaft", + "oranges": "Orangen", + "oregano": "Oregano", + "organic_lemon": "Bio-Zitrone", + "organic_waste_bags": "Biomülltüten", + "pak_choi": "Pak Choi", + "pantyhose": "Strumpfhose", + "papaya": "Papaya", + "paprika": "Paprika", + "paprika_seasoning": "Paprikagewürz", + "pardina_lentils_dried": "Pardina Linsen getrocknet", + "parmesan": "Parmesan", + "parsley": "Petersilie", + "pasta": "Nudeln", + "peach": "Pfirsich", + "peanut_butter": "Erdnussbutter", + "peanut_flips": "Erdnussflips", + "peanut_oil": "Erdnussöl", + "peanuts": "Erdnüsse", + "pears": "Birnen", + "peas": "Erbsen", + "penne": "Penne", + "pepper": "Pfeffer", + "pepper_mill": "Pfeffermühle", + "peppers": "Paprika", + "persian_rice": "Persischer Reis", + "pesto": "Pesto", + "pilsner": "Pils", + "pine_nuts": "Pinienkerne", + "pineapple": "Ananas", + "pita_bag": "Pitatasche", + "pita_bread": "Fladenbrot", + "pizza": "Pizza", + "pizza_dough": "Pizzateig", + "plant_magarine": "Pflanzenmagarine", + "plant_oil": "Pflanzenöl", + "plaster": "Pflaster", + "pointed_peppers": "Spitzpaprika", + "porcini_mushrooms": "Steinpilze", + "potato_dumpling_dough": "Kartoffelkloßteig", + "potato_wedges": "Kartoffelecken", + "potatoes": "Kartoffeln", + "potting_soil": "Blumenerde", + "powder": "Puder", + "powdered_sugar": "Puderzucker", + "processed_cheese": "Schmelzkäse", + "prosecco": "Prosecco", + "puff_pastry": "Blätterteig", + "pumpkin": "Kürbis", + "pumpkin_seeds": "Kürbiskerne", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radieschen", + "ramen": "Ramen", + "rapeseed_oil": "Rapsöl", + "raspberries": "Himbeeren", + "raspberry_syrup": "Himbeersirup", + "razor_blades": "Rasierklingen", + "red_bull": "Red Bull", + "red_chili": "Rote Chili", + "red_curry_paste": "Rote Currypaste", + "red_lentils": "Rote Linsen", + "red_onions": "Rote Zwiebeln", + "red_pesto": "rotes Pesto", + "red_wine": "Rotwein", + "red_wine_vinegar": "Rotweinessig", + "rhubarb": "Rhabarber", + "ribbon_noodles": "Bandnudeln", + "rice": "Reis", + "rice_cakes": "Reiswaffeln", + "rice_paper": "Reispapier", + "rice_ribbon_noodles": "Reisbandnudeln", + "rice_vinegar": "Reis-Essig", + "ricotta": "Ricotta", + "rinse_tabs": "Spültabs", + "rinsing_agent": "Spüli", + "risotto_rice": "Risotto Reis", + "rocket": "Rakete", + "roll": "Brötchen", + "rosemary": "Rosmarin", + "saffron_threads": "Safranfäden", + "sage": "Salbei", + "saitan_powder": "Saitan-Pulver", + "salad_mix": "Salat Mix", + "salad_seeds_mix": "Salatkerne Mix", + "salt": "Salz", + "salt_mill": "Salzmühle", + "sambal_oelek": "Sambal oelek", + "sauce": "Soße", + "sausage": "Wurst", + "sausages": "Würstchen", + "savoy_cabbage": "Wirsing", + "scallion": "Frühlingszwiebel", + "scattered_cheese": "Streukäse", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Grießbrei", + "sesame": "Sesam", + "sesame_oil": "Sesamöl", + "shallot": "Schalotte", + "shampoo": "Shampoo", + "shawarma_spice": "Schawarma-Gewürz", + "shiitake_mushroom": "Shiitakepilz", + "shoe_insoles": "Schuheinlagen", + "shower_gel": "Duschgel", + "shredded_cheese": "Geriebener Käse", + "sieved_tomatoes": "Gesiebte Tomaten", + "skyr": "Skyr", + "sliced_cheese": "Scheibenkäse", + "smoked_paprika": "Smoked Paprika", + "smoked_tofu": "Räuchertofu", + "snacks": "Snacks", + "soap": "Seife", + "soba_noodles": "Soba-Nudeln", + "soft_drinks": "Erfrischungsgetränke", + "soup_vegetables": "Suppengemüse", + "sour_cream": "Schmand", + "sour_cucumbers": "Saure Gurken", + "soy_cream": "Sojasahne", + "soy_hack": "Sojahack", + "soy_sauce": "Sojasauce", + "soy_shred": "Soja Schnetzel", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sprudelwasser", + "spelt": "Dinkel", + "spinach": "Spinat", + "sponge_cloth": "Schwammlappen", + "sponge_fingers": "Löffelbiskuit", + "sponge_wipes": "Schwammtücher", + "sponges": "Schwämme", + "spreading_cream": "Streichcreme", + "spring_onions": "Frühlingszwiebeln", + "sprite": "Sprite", + "sprouts": "Sprossen", + "sriracha": "Sriracha", + "strained_tomatoes": "Passierte Tomaten", + "strawberries": "Erdbeeren", + "sugar": "Zucker", + "summer_roll_paper": "Sommerrollen-Papier", + "sunflower_oil": "Sonnenblumenöl", + "sunflower_seeds": "Sonnenblumenkerne", + "sunscreen": "Sonnencreme", + "sushi_rice": "Sushi Reis", + "swabian_ravioli": "Maultaschen", + "sweet_chili_sauce": "Sweet Chili Soße", + "sweet_potato": "Süßkartoffel", + "sweet_potatoes": "Süßkartoffeln", + "sweets": "Süßigkeiten", + "table_salt": "Tafelsalz", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarinen", + "tape": "Klebeband", + "tapioca_flour": "Tapiokamehl", + "tea": "Tee", + "teriyaki_sauce": "Teriyaki-Soße", + "thyme": "Thymian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Klopapier", + "tomato_juice": "Tomatensaft", + "tomato_paste": "Tomatenmark", + "tomato_sauce": "Tomatensoße", + "tomatoes": "Tomaten", + "tonic_water": "Tonic Water", + "toothpaste": "Zahnpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Thunfisch", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udonnudeln", + "uht_milk": "H-Milch", + "vanilla_sugar": "Vanillezucker", + "vegetable_bouillon_cube": "Gemüsebrühwürfel", + "vegetable_broth": "Gemüsebrühe", + "vegetable_oil": "Pflanzenöl", + "vegetable_onion": "Gemüsezwiebel", + "vegetables": "Gemüse", + "vegetarian_cold_cuts": "vegetarischer Aufschnitt", + "vinegar": "Essig", + "vitamin_tablets": "Vitamintabletten", + "vodka": "Vodka", + "walnuts": "Walnüsse", + "washing_gel": "Waschgel", + "washing_powder": "Waschpulver", + "water": "Wasser", + "water_ice": "Wassereis", + "watermelon": "Wassermelone", + "wc_cleaner": "WC-Reiniger", + "wheat_flour": "Weizenmehl", + "whipped_cream": "Schlagsahne", + "white_wine": "Weißwein", + "white_wine_vinegar": "Weißweinessig", + "whole_canned_tomatoes": "Ganze Dosentomaten", + "wild_berries": "Waldbeeren", + "wild_rice": "Wildreis", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcester Soße", + "wrapping_paper": "Geschenkpapier", + "wraps": "Wraps", + "yeast": "Hefe", + "yeast_flakes": "Hefeflocken", + "yoghurt": "Joghurt", + "yogurt": "Joghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinkcreme", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json new file mode 100644 index 00000000..a5ab07c0 --- /dev/null +++ b/backend/templates/l10n/el.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Είδη Άρτου", + "canned": "🥫 Είδη Κονσέρβας", + "dairy": "🥛 Γαλακτοκομικά", + "drinks": "🍹 Ποτά", + "freezer": "❄️ Κατεψυγμένα", + "fruits_vegetables": "🥬 Φρούτα και Λαχανικά", + "grain": "🥟 Πάστα και Νούντλς", + "hygiene": "🚽 Είδη Υγιεινής", + "refrigerated": "💧 Είδη Ψυγείου", + "snacks": "🥜 Σνάκ" + }, + "items": { + "agave_syrup": "Σιρόπι Αγαύης", + "aioli": "Αιόλι", + "amaretto": "Αμαρέττο", + "apple": "Μήλο", + "apple_pulp": "Πουρές μήλου", + "applesauce": "Σάλτσα μήλου", + "apricots": "Βερίκοκα", + "apérol": "Άπερολ", + "arugula": "Ρόκα", + "asian_egg_noodles": "Ασιάτικα νούντλς αυγού", + "asian_noodles": "Νούντλς", + "asparagus": "Σπαράγγια", + "aspirin": "Ασπιρίνη", + "avocado": "Αβοκάντο", + "baby_potatoes": "Μπέιμπι πατάτες", + "baby_spinach": "Baby σπανάκι", + "bacon": "Μπέικον", + "baguette": "Μπαγκέτα", + "bakefish": "Ψητό ψάρι", + "baking_cocoa": "Κακάο ζαχαροπλαστικής", + "baking_mix": "Μείγμα ψησίματος", + "baking_paper": "Χαρτί ψησίματος", + "baking_powder": "Μπέικιν πάουντερ", + "baking_soda": "Μαγειρική σόδα", + "baking_yeast": "Μαγιά ψησίματος", + "balsamic_vinegar": "Βαλσάμικο ξύδι", + "bananas": "Μπανάνες", + "basil": "Βασιλικός", + "basmati_rice": "Ρύζι μπασμάτι", + "bathroom_cleaner": "Καθαριστικό μπάνιου", + "batteries": "Μπαταρίες", + "bay_leaf": "Δάφνη", + "beans": "Φασόλια", + "beef": "Μοσχάρι", + "beef_broth": "Ζωμός μοσχαρίσιος", + "beer": "Μπίρα", + "beet": "Παντζάρι", + "beetroot": "Ρίζα παντζαριού", + "birthday_card": "Κάρτα γενεθλίων", + "black_beans": "Μαύρα φασόλια", + "blister_plaster": "Επίδεσμος φουσκάλας", + "bockwurst": "Λουκάνικο Bockwurst", + "bodywash": "Αφρόλουτρο", + "bread": "Ψωμί", + "breadcrumbs": "Τριμμένη φρυγανιά", + "broccoli": "Μπρόκολο", + "brown_sugar": "Καστανή ζάχαρη", + "brussels_sprouts": "Λαχανάκια Βρυξελλών", + "buffalo_mozzarella": "Μοτσαρέλα Buffalo", + "buns": "Ψωμάκια", + "burger_buns": "Ψωμάκια Μπέργκερ", + "burger_patties": "Μπιφτέκια Μπέργκερ", + "burger_sauces": "Σάλτσες Μπέργκερ", + "butter": "Βούτυρο", + "butter_cookies": "Μπισκότα βουτύρου", + "butternut_squash": "Σκουός Κολοκύθας", + "button_cells": "Μπαταρίες ρολογιού", + "börek_cheese": "Τυρί Börek", + "cake": "Κέικ", + "cake_icing": "Γλάσο τούρτας", + "cane_sugar": "Ζάχαρη από ζαχαροκάλαμο", + "cannelloni": "Κανελόνι", + "canola_oil": "Λάδι Κανόλα", + "cardamom": "Κάρδαμο", + "carrots": "Καρότα", + "cashews": "Κάσιους", + "cat_treats": "Λιχουδιές για γάτες", + "cauliflower": "Κουνουπίδι", + "celeriac": "Σελινόριζα", + "celery": "Σέλινο", + "cereal_bar": "Μπάρα Μουέσλι", + "cheddar": "Τσένταρ", + "cheese": "Τυρί", + "cherry_tomatoes": "Ντοματίνια", + "chickpeas": "Ρεβύθια", + "chicory": "Ραδίκι", + "chili_oil": "Λάδι Τσίλι", + "chili_pepper": "Πιπέρι τσίλι", + "chips": "Πατατάκια", + "chives": "Σχοινόπρασο", + "chocolate": "Σοκολάτα", + "chocolate_chips": "Κομματάκια σοκολάτας", + "chopped_tomatoes": "Κομμένες ντομάτες", + "chunky_tomatoes": "Ντομάτες με κομμάτια", + "ciabatta": "Τσιαμπάτα", + "cider_vinegar": "Ξίδι μηλίτη", + "cilantro": "Κόλιαντρο", + "cinnamon": "Κανέλλα", + "cinnamon_stick": "Στικ Κανέλλας", + "cocktail_sauce": "Σως κοκτέιλ", + "cocktail_tomatoes": "Ντομάτες κοκτέιλ", + "coconut_flakes": "Νιφάδες καρύδας", + "coconut_milk": "Γάλα καρύδας", + "coconut_oil": "Λάδι καρύδας", + "coffee_powder": "Σκόνη καφέ", + "colorful_sprinkles": "Πολύχρωμες τρούφες", + "concealer": "Κονσίλερ", + "cookies": "Μπισκότα", + "coriander": "Κολίανδρο", + "corn": "Καλαμπόκι", + "cornflakes": "Δημητριακά", + "cornstarch": "Άμυλο καλαμποκιού", + "cornys": "Κόρνις", + "corriander": "Κορύανδρος", + "cotton_rounds": "Δίσκοι ντεμακιγιάζ", + "cough_drops": "Παστίλιες για τον βήχα", + "couscous": "Κουσκους", + "covid_rapid_test": "COVID ράπιντ τεστ", + "cow's_milk": "Γάλα αγελαδίσιο", + "cream": "Κρέμα", + "cream_cheese": "Τυρί κρέμα", + "creamed_spinach": "Σπανάκι κρέμα", + "creme_fraiche": "Κρέμα γάλακτος", + "crepe_tape": "Χαρτοταινία", + "crispbread": "Τραγανόψωμο", + "cucumber": "Αγγούρι", + "cumin": "Κύμινο", + "curd": "Τυρί", + "curry_paste": "Πάστα κάρι", + "curry_powder": "Σκόνη κάρι", + "curry_sauce": "Σάλτσα κάρι", + "dates": "Χουρμάδες", + "dental_floss": "Οδοντικό νήμα", + "deo": "Αποσμητικό", + "deodorant": "Αποσμητικό", + "detergent": "Απορρυπαντικό", + "detergent_sheets": "Φύλλα απορρυπαντικού", + "diarrhea_remedy": "Θεραπεία διάρροιας", + "dill": "Άνηθος", + "dishwasher_salt": "Σκόνη πλυντηρίου πιάτων", + "dishwasher_tabs": "Ταμπλέτες πλυντηρίου πιάτων", + "disinfection_spray": "Απολυμαντικό σπρέι", + "dried_tomatoes": "Αποξηραμένες ντομάτες", + "dry_yeast": "Ξηρά μαγιά", + "edamame": "Εντάμαμε", + "egg_salad": "Σαλάτα με αυγά", + "egg_yolk": "Κρόκος αυγού", + "eggplant": "Μελιτζάνα", + "eggs": "Αυγά", + "enoki_mushrooms": "Μανιτάρια Enoki", + "eyebrow_gel": "Gel φρυδιών", + "falafel": "Φαλάφελ", + "falafel_powder": "Σκόνη φαλάφελ", + "fanta": "Φάντα", + "feta": "Φέτα", + "ffp2": "Μάσκα FFP2", + "fish_sticks": "Ψαροκροκέτες", + "flour": "Αλεύρι", + "flushing": "Καθαριστικά τουαλέτας", + "fresh_chili_pepper": "Φρέσκια πιπεριά τσίλι", + "frozen_berries": "Κατεψυγμένα μούρα", + "frozen_broccoli": "Κατεψυγμένο μπρόκολο", + "frozen_fruit": "Κατεψυγμένα φρούτα", + "frozen_pizza": "Κατεψυγμένη πίτσα", + "frozen_spinach": "Κατεψυγμένο σπανάκι", + "funeral_card": "Κάρτα κηδείας", + "garam_masala": "Γκαράμ Μασάλα", + "garbage_bag": "Σακούλες απορριμμάτων", + "garlic": "Σκόρδο", + "garlic_dip": "Σάλτσα σκόρδου", + "garlic_granules": "Κόκκοι σκόρδου", + "gherkins": "Αγγουράκια", + "ginger": "Τζίντζερ", + "ginger_ale": "Μπίρα Τζίντζερ", + "glass_noodles": "Νουντλς φασολιού", + "gluten": "Γλουτένη", + "gnocchi": "Νιόκι", + "gochujang": "Κοτσουτζάν", + "gorgonzola": "Γκοργκονζόλα", + "gouda": "Γκούντα", + "granola": "Γκρανόλα", + "granola_bar": "Μπάρα γκρανόλα", + "grapes": "Σταφύλια", + "greek_yogurt": "Γιαούρτι", + "green_asparagus": "Πράσινα σπαράγγια", + "green_chili": "Πράσινο τσίλι", + "green_pesto": "Πράσινο Πέστο", + "hair_gel": "Τζέλ μαλλιών", + "hair_ties": "Γραβάτες μαλλιών", + "hair_wax": "Κερί μαλλιών", + "ham": "Χοιρομέρι", + "ham_cubes": "Χοιρομέρι σε κύβους", + "hand_soap": "Σαπούνι χεριών", + "handkerchief_box": "Κουτί χαρτομάντηλα", + "handkerchiefs": "Χαρτομάντηλα", + "hard_cheese": "Σκληρό τυρί", + "haribo": "Haribo", + "harissa": "Harissa (καυτερή πάστα τσίλι)", + "hazelnuts": "Φουντούκια", + "head_of_lettuce": "Κεφάλι μαρουλιού", + "herb_baguettes": "Μπαγκέτες με μπαχαρικά", + "herb_butter": "Βούτυρο μπαχαρικών", + "herb_cream_cheese": "Κρέμα τυριού με μπαχαρικά", + "honey": "Μέλι", + "honey_wafers": "Γκοφρέτες μελιού", + "hot_dog_bun": "Ψωμί χοτ ντόγκ", + "ice_cream": "Παγωτό", + "ice_cube": "Παγάκια", + "iceberg_lettuce": "Μαρούλι Iceberg", + "iced_tea": "Κρύο Τσάι", + "instant_soups": "Σούπες στιγμιαίες", + "jam": "Μαρμελάδα", + "jasmine_rice": "Ρύζι γιασεμί", + "katjes": "Τσίχλες βίγκαν", + "ketchup": "Κέτσαπ", + "kidney_beans": "Κόκκινα φασόλια", + "kitchen_roll": "Χαρτί κουζίνας", + "kitchen_towels": "Πετσέτες κουζίνας", + "kiwi": "Kiwi", + "kohlrabi": "Γογγυλοκράμβη", + "lasagna": "Λαζάνια", + "lasagna_noodles": "Νούντλς λαζάνια", + "lasagna_plates": "Ζυμαρικά λαζάνια", + "leaf_spinach": "Φύλλο Σπανάκι", + "leek": "Πράσο", + "lemon": "Λεμόνι", + "lemon_curd": "Curd λεμονιού", + "lemon_juice": "Χυμός λεμονιού", + "lemonade": "Λεμονάδα", + "lemongrass": "Λεμονόχορτο", + "lentil_stew": "Φακές στιφάδο", + "lentils": "Φακές", + "lentils_red": "Κόκκινες φακές", + "lettuce": "Μαρούλι", + "lillet": "Απεριτίφ", + "lime": "Λάιμ", + "linguine": "Λιγκουίνι", + "lip_care": "Φροντίδα χειλιών", + "liqueur": "Λικέρ", + "low-fat_curd_cheese": "Τυρί με χαμηλά λιπαρά", + "maggi": "Κύβος Maggi", + "magnesium": "Μαγνήσιο", + "mango": "Μάνγκο", + "maple_syrup": "Σιρόπι σφενδάμου", + "margarine": "Μαργαρίνη", + "marjoram": "Μαντζουράνα", + "marshmallows": "Marshmallows", + "mascara": "Μάσκαρα", + "mascarpone": "Μασκαρπόνε", + "mask": "Μάσκα", + "mayonnaise": "Μαγιονέζα", + "meat_substitute_product": "Υποκατάστατο κρέατος", + "microfiber_cloth": "Πανί μικροϊνων", + "milk": "Γάλα", + "mint": "Μέντα", + "mint_candy": "Καραμέλα μέντας", + "miso_paste": "Πάστα Miso", + "mixed_vegetables": "Ανάμεικτα λαχανικά", + "mochis": "Mότσις", + "mold_remover": "Αφαίρεσης μούχλας", + "mountain_cheese": "Τυρί βουνού", + "mouth_wash": "Στοματικό διάλυμα", + "mozzarella": "Μοτσαρέλα", + "muesli": "Μουέσλι", + "muesli_bar": "Μπάρα μουέσλι", + "mulled_wine": "Ζεστό κρασί", + "mushrooms": "Μανιτάρια", + "mustard": "Μουστάρδα", + "nail_file": "Λίμα νυχιών", + "nail_polish_remover": "Ασετόν", + "neutral_oil": "Ουδέτερο λάδι", + "nori_sheets": "Φύλλα από φύκια", + "nutmeg": "Μοσχοκάρυδο", + "oat_milk": "Γάλα βρώμης", + "oatmeal": "Βρώμη", + "oatmeal_cookies": "Μπισκότα βρώμης", + "oatsome": "Γάλα βρώμης", + "obatzda": "Obatzda", + "oil": "Λάδι", + "olive_oil": "Ελαιόλαδο", + "olives": "Ελιές", + "onion": "Κρεμμύδι", + "onion_powder": "Κρεμμύδι σε σκόνη", + "orange_juice": "Πορτοκαλάδα", + "oranges": "Πορτοκάλια", + "oregano": "Ρίγανη", + "organic_lemon": "Βιολογικό λεμόνι", + "organic_waste_bags": "Οργανικές σακούλες σκουπιδιών", + "pak_choi": "Μποκ τσόι", + "pantyhose": "Καλσόν", + "papaya": "Παπάγια", + "paprika": "Πάπρικα", + "paprika_seasoning": "Καρύκευμα πάπρικας", + "pardina_lentils_dried": "Αποξηραμένες φακές", + "parmesan": "Παρμεζάνα", + "parsley": "Μαϊντανός", + "pasta": "Ζυμαρικά", + "peach": "Ροδάκινο", + "peanut_butter": "Φυστικοβούτυρο", + "peanut_flips": "Γαριδάκια φιστικιού", + "peanut_oil": "Λάδι φιστικιού", + "peanuts": "Φιστίκια", + "pears": "Αχλάδια", + "peas": "Αρακάς", + "penne": "Πένες", + "pepper": "Πιπέρι", + "pepper_mill": "Μύλος πιπεριού", + "peppers": "Πιπεριές", + "persian_rice": "Περσικό ρύζι", + "pesto": "Πέστο", + "pilsner": "Πίλσνερ", + "pine_nuts": "Κουκουνάρι", + "pineapple": "Ανανάς", + "pita_bag": "Σακούλα Πίτα", + "pita_bread": "Ψωμί πίτα", + "pizza": "Πίτσα", + "pizza_dough": "Ζύμη πίτσας", + "plant_magarine": "Φυτική μαργαρίνη", + "plant_oil": "Φυτικό λάδι", + "plaster": "Γύψος", + "pointed_peppers": "Πιπεριές με αιχμή", + "porcini_mushrooms": "Μανιτάρια πορτσίνι", + "potato_dumpling_dough": "Ζύμη για ντάμπλινγκ πατάτας", + "potato_wedges": "Κυδωνάτες πατάτες", + "potatoes": "Πατάτες", + "potting_soil": "Χώμα γλάστρας", + "powder": "Πούδρα", + "powdered_sugar": "Ζάχαρη άχνη", + "processed_cheese": "Επεξεργασμένο τυρί", + "prosecco": "Prosecco", + "puff_pastry": "Σφολιάτα", + "pumpkin": "Κολοκύθα", + "pumpkin_seeds": "Κολοκυθόσποροι", + "quark": "Τυρί Quark", + "quinoa": "Κινόα", + "radicchio": "Ραδίκιο", + "radish": "Ραπανάκι", + "ramen": "Ράμεν", + "rapeseed_oil": "Κραμβέλαιο", + "raspberries": "Βατόμουρα", + "raspberry_syrup": "Σιρόπι βατόμουρου", + "razor_blades": "Λεπίδες ξυραφιού", + "red_bull": "Red Bull", + "red_chili": "Κόκκινο τσίλι", + "red_curry_paste": "Κόκκινη πάστα κάρυ", + "red_lentils": "Κόκκινες φακές", + "red_onions": "Κόκκινα κρεμμύδια", + "red_pesto": "Κόκκινη πέστο", + "red_wine": "Κόκκινο κρασί", + "red_wine_vinegar": "Ξύδι από κόκκινο κρασί", + "rhubarb": "Ραβέντι", + "ribbon_noodles": "Νούντλς κορδέλα", + "rice": "Ρύζι", + "rice_cakes": "Ρυζογκοφρέτες", + "rice_paper": "Χαρτί ρυζιού", + "rice_ribbon_noodles": "Νούντλς κορδέλα από ρύζι", + "rice_vinegar": "Ξύδι ρυζιού", + "ricotta": "Ρικότα", + "rinse_tabs": "Ταμπλέτες λαμπρυντικού", + "rinsing_agent": "Απορρυπαντικό πιάτων", + "risotto_rice": "Ριζότο", + "rocket": "Ρόκα", + "roll": "Χαρτί", + "rosemary": "Δενδρολίβανο", + "saffron_threads": "Κλωστές σαφράν", + "sage": "Φασκόμηλο", + "saitan_powder": "Σκόνη Saitan", + "salad_mix": "Ανάμεικτη σαλάτα", + "salad_seeds_mix": "Ανάμεικτοι καρποί σαλατικών", + "salt": "Αλάτι", + "salt_mill": "Μύλος αλατιού", + "sambal_oelek": "Ινδονησιακή σάλτσα Sambal", + "sauce": "Σάλτσα", + "sausage": "Λουκάνικο", + "sausages": "Λουκάνικα", + "savoy_cabbage": "Λάχανο Σαβοΐας", + "scallion": "Πρασουλίδα", + "scattered_cheese": "Άλειμμα τυριού", + "schlemmerfilet": "Φιλέτο ψάρι", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Σιμιγδάλι", + "sesame": "Σουσάμι", + "sesame_oil": "Σησαμέλαιο", + "shallot": "Εσαλότ", + "shampoo": "Σαμπουάν", + "shawarma_spice": "Μπαχαρικά σουάρμα", + "shiitake_mushroom": "Μανιτάρι Σιτάκε", + "shoe_insoles": "Σόλες παπουτσιών", + "shower_gel": "Αφρόλουτρο τζελ", + "shredded_cheese": "Τριμμένο τυρί", + "sieved_tomatoes": "Κοσκινισμένες ντομάτες", + "skyr": "Ισλανδικό γιαούρτι", + "sliced_cheese": "Τυρί σε φέτες", + "smoked_paprika": "Καπνιστή πάπρικα", + "smoked_tofu": "Καπνιστό τόφου", + "snacks": "Σνάκς", + "soap": "Σαπούνι", + "soba_noodles": "Νουντλς σόμπα", + "soft_drinks": "Αναψυκτικά", + "soup_vegetables": "Λαχανικά σούπας", + "sour_cream": "Ξινή κρέμα", + "sour_cucumbers": "Ξινά αγγούρια", + "soy_cream": "Κρέμα σόγιας", + "soy_hack": "Κιμάς Σόγιας", + "soy_sauce": "Σάλτσα σόγιας", + "soy_shred": "Τριμμένη σόγια", + "spaetzle": "Spaetzle (Νούντλς)", + "spaghetti": "Σπαγγέτι", + "sparkling_water": "Ανθρακούχο νερό", + "spelt": "Όλυρα", + "spinach": "Σπανάκι", + "sponge_cloth": "Σφουγγαρόπανο", + "sponge_fingers": "Σφουγγαράκια", + "sponge_wipes": "Μαντιλάκια σφουγγαριού", + "sponges": "Σφουγγάρια", + "spreading_cream": "Κρέμα αλειφόμμενη", + "spring_onions": "Φρέσκα κρεμμυδάκια", + "sprite": "Sprite", + "sprouts": "Βλαστάρια", + "sriracha": "Σιράτσα", + "strained_tomatoes": "Ντομάτες στραγγισμένες", + "strawberries": "Φράουλες", + "sugar": "Ζάχαρη", + "summer_roll_paper": "Φύλλο Spring Rolls", + "sunflower_oil": "Ηλιέλαιο", + "sunflower_seeds": "Ηλιόσποροι", + "sunscreen": "Αντηλιακό", + "sushi_rice": "Ρύζι για σούσι", + "swabian_ravioli": "Σουηβικά ραβιόλια", + "sweet_chili_sauce": "Γλυκιά σάλτσα τσίλι", + "sweet_potato": "Γλυκοπατάτα", + "sweet_potatoes": "Γλυκοπατάτες", + "sweets": "Γλυκά", + "table_salt": "Χοντρό αλάτι", + "tagliatelle": "Ταλιατέλλες", + "tahini": "Ταχίνι", + "tangerines": "Μανταρίνια", + "tape": "Ταινία", + "tapioca_flour": "Αλεύρι ταπιόκας", + "tea": "Τσάι", + "teriyaki_sauce": "Σάλτσα Τεριγιάκι", + "thyme": "Θυμάρι", + "toast": "Τόστ", + "tofu": "Τόφου", + "toilet_paper": "Χαρτί υγείας", + "tomato_juice": "Ντοματοχυμός", + "tomato_paste": "Ντοματοπελτές", + "tomato_sauce": "Σάλτσα ντομάτας", + "tomatoes": "Ντομάτες", + "tonic_water": "Τόνικ", + "toothpaste": "Οδοντόκρεμα", + "tortellini": "Τορτελίνι", + "tortilla_chips": "Τσίπς τορτίγιας", + "tuna": "Τόνος", + "turmeric": "Κουρκουμάς", + "tzatziki": "Τζατζίκι", + "udon_noodles": "Νούντλς Udon", + "uht_milk": "Γάλα υψηλής παστερίωσης", + "vanilla_sugar": "Βανίλιες (Ζάχαρη)", + "vegetable_bouillon_cube": "Κύβος λαχανικών", + "vegetable_broth": "Ζωμός λαχανικών", + "vegetable_oil": "Φυτικό έλαιο", + "vegetable_onion": "Κρεμμυδάκι", + "vegetables": "Λαχανικά", + "vegetarian_cold_cuts": "Χορτοφαγικά αλλαντικά", + "vinegar": "Ξίδι", + "vitamin_tablets": "Ταμπλέτες βιταμινών", + "vodka": "Βότκα", + "walnuts": "Καρύδια", + "washing_gel": "Gel πλύσης", + "washing_powder": "Σκόνη πλυσίματος", + "water": "Νερό", + "water_ice": "Παγωμένο νερό", + "watermelon": "Καρπούζι", + "wc_cleaner": "Καθαριστικό τουαλέτας", + "wheat_flour": "Αλεύρι σίτου", + "whipped_cream": "Σαντιγύ", + "white_wine": "Λευκό κρασί", + "white_wine_vinegar": "Ξίδι λευκού κρασιού", + "whole_canned_tomatoes": "Ντομάτες κονσέρβα", + "wild_berries": "Άγρια μούρα", + "wild_rice": "Άγριο ρύζι", + "wildberry_lillet": "Άγριο μούρο Lillet", + "worcester_sauce": "Σάλτσα Worcester", + "wrapping_paper": "Χαρτί περιτυλίγματος", + "wraps": "Αραβικές πίτες", + "yeast": "Μαγιά", + "yeast_flakes": "Νιφάδες μαγιάς", + "yoghurt": "Γιαουρτάκι", + "yogurt": "Κατσικίσιο γιαούρτι", + "yum_yum": "Yum Yum Νούντλς", + "zewa": "Χαρτί Zewa", + "zinc_cream": "Κρέμα ψευδάργυρου", + "zucchini": "Κολοκύθι" + } +} diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json new file mode 100644 index 00000000..7cb07b38 --- /dev/null +++ b/backend/templates/l10n/en.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Baked goods", + "canned": "🥫 Preserved goods", + "dairy": "🥛 Dairy", + "drinks": "🍹 Drinks", + "freezer": "❄️ Freezer", + "fruits_vegetables": "🥬 Fruits and vegetables", + "grain": "🥟 Pasta and noodles", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Refrigerated", + "snacks": "🥜 Snacks" + }, + "items": { + "agave_syrup": "Agave syrup", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apple", + "apple_pulp": "Apple puree", + "applesauce": "Apple sauce", + "apricots": "Apricots", + "apérol": "Apérol", + "arugula": "Arugula", + "asian_egg_noodles": "Asian egg noodles", + "asian_noodles": "Noodles", + "asparagus": "Asparagus", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_potatoes": "Baby potatoes", + "baby_spinach": "Baby spinach", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Baked fish", + "baking_cocoa": "Baking cocoa", + "baking_mix": "Baking mix", + "baking_paper": "Baking paper", + "baking_powder": "Baking powder", + "baking_soda": "Baking soda", + "baking_yeast": "Baking yeast", + "balsamic_vinegar": "Balsamic vinegar", + "bananas": "Bananas", + "basil": "Basil", + "basmati_rice": "Basmati rice", + "bathroom_cleaner": "Bathroom cleaner", + "batteries": "Batteries", + "bay_leaf": "Bay leaf", + "beans": "Beans", + "beef": "Beef", + "beef_broth": "Beef broth", + "beer": "Beer", + "beet": "Beet", + "beetroot": "Beetroot", + "birthday_card": "Birthday card", + "black_beans": "Black beans", + "blister_plaster": "Blister plaster", + "bockwurst": "Bockwurst", + "bodywash": "Body wash", + "bread": "Bread", + "breadcrumbs": "Breadcrumbs", + "broccoli": "Broccoli", + "brown_sugar": "Brown sugar", + "brussels_sprouts": "Brussels sprouts", + "buffalo_mozzarella": "Buffalo mozzarella", + "buns": "Buns", + "burger_buns": "Burger buns", + "burger_patties": "Hamburger patties", + "burger_sauces": "Hamburger sauce", + "butter": "Butter", + "butter_cookies": "Butter cookies", + "butternut_squash": "Butternut squash", + "button_cells": "Button cells", + "börek_cheese": "Börek cheese", + "cake": "Cake", + "cake_icing": "Cake icing", + "cane_sugar": "Cane sugar", + "cannelloni": "Cannelloni", + "canola_oil": "Canola oil", + "cardamom": "Cardamom", + "carrots": "Carrots", + "cashews": "Cashews", + "cat_treats": "Cat treats", + "cauliflower": "Cauliflower", + "celeriac": "Celeriac", + "celery": "Celery", + "cereal_bar": "Muesli bar", + "cheddar": "Cheddar", + "cheese": "Cheese", + "cherry_tomatoes": "Cherry tomatoes", + "chickpeas": "Chickpeas", + "chicory": "Chicory", + "chili_oil": "Chili oil", + "chili_pepper": "Chili pepper", + "chips": "Chips", + "chives": "Chives", + "chocolate": "Chocolate", + "chocolate_chips": "Chocolate chips", + "chopped_tomatoes": "Chopped tomatoes", + "chunky_tomatoes": "Chunky tomatoes", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider vinegar", + "cilantro": "Cilantro", + "cinnamon": "Cinnamon", + "cinnamon_stick": "Cinnamon stick", + "cocktail_sauce": "Cocktail sauce", + "cocktail_tomatoes": "Cocktail tomatoes", + "coconut_flakes": "Coconut flakes", + "coconut_milk": "Coconut milk", + "coconut_oil": "Coconut oil", + "coffee_powder": "Coffee powder", + "colorful_sprinkles": "Colorful sprinkles", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Coriander", + "corn": "Corn", + "cornflakes": "Cornflakes", + "cornstarch": "Cornstarch", + "cornys": "Cornys", + "corriander": "Corriander", + "cotton_rounds": "Cotton rounds", + "cough_drops": "Cough drops", + "couscous": "Couscous", + "covid_rapid_test": "COVID rapid test", + "cow's_milk": "Cow's milk", + "cream": "Cream", + "cream_cheese": "Cream cheese", + "creamed_spinach": "Creamed spinach", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepe tape", + "crispbread": "Crispbread", + "cucumber": "Cucumber", + "cumin": "Cumin", + "curd": "Curd", + "curry_paste": "Curry paste", + "curry_powder": "Curry powder", + "curry_sauce": "Curry sauce", + "dates": "Dates", + "dental_floss": "Dental floss", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Detergent", + "detergent_sheets": "Detergent sheets", + "diarrhea_remedy": "Diarrhea remedy", + "dill": "Dill", + "dishwasher_salt": "Dishwasher salt", + "dishwasher_tabs": "Dishwasher tabs", + "disinfection_spray": "Disinfection spray", + "dried_tomatoes": "Dried tomatoes", + "dry_yeast": "Dry yeast", + "edamame": "Edamame", + "egg_salad": "Egg salad", + "egg_yolk": "Egg yolk", + "eggplant": "Eggplant", + "eggs": "Eggs", + "enoki_mushrooms": "Enoki mushrooms", + "eyebrow_gel": "Eyebrow gel", + "falafel": "Falafel", + "falafel_powder": "Falafel powder", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fish sticks", + "flour": "Flour", + "flushing": "Flushing", + "fresh_chili_pepper": "Fresh chili pepper", + "frozen_berries": "Frozen berries", + "frozen_broccoli": "Frozen broccoli", + "frozen_fruit": "Frozen fruit", + "frozen_pizza": "Frozen pizza", + "frozen_spinach": "Frozen spinach", + "funeral_card": "Funeral card", + "garam_masala": "Garam Masala", + "garbage_bag": "Garbage bags", + "garlic": "Garlic", + "garlic_dip": "Garlic dip", + "garlic_granules": "Garlic granules", + "gherkins": "Gherkins", + "ginger": "Ginger", + "ginger_ale": "Ginger ale", + "glass_noodles": "Glass noodles", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola bar", + "grapes": "Grapes", + "greek_yogurt": "Greek yogurt", + "green_asparagus": "Green asparagus", + "green_chili": "Green chili", + "green_pesto": "Green pesto", + "hair_gel": "Hair gel", + "hair_ties": "Hair ties", + "hair_wax": "Hair Wax", + "ham": "Ham", + "ham_cubes": "Ham cubes", + "hand_soap": "Hand soap", + "handkerchief_box": "Handkerchief box", + "handkerchiefs": "Handkerchiefs", + "hard_cheese": "Hard cheese", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnuts", + "head_of_lettuce": "Head of lettuce", + "herb_baguettes": "Herb baguettes", + "herb_butter": "Herb butter", + "herb_cream_cheese": "Herb cream cheese", + "honey": "Honey", + "honey_wafers": "Honey wafers", + "hot_dog_bun": "Hot dog bun", + "ice_cream": "Ice cream", + "ice_cube": "Ice cubes", + "iceberg_lettuce": "Iceberg lettuce", + "iced_tea": "Iced tea", + "instant_soups": "Instant soups", + "jam": "Jam", + "jasmine_rice": "Jasmine rice", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidney beans", + "kitchen_roll": "Kitchen roll", + "kitchen_towels": "Kitchen towels", + "kiwi": "Kiwi", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna", + "lasagna_noodles": "Lasagna noodles", + "lasagna_plates": "Lasagna plates", + "leaf_spinach": "Leaf spinach", + "leek": "Leek", + "lemon": "Lemon", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Lemon juice", + "lemonade": "Lemonade", + "lemongrass": "Lemongrass", + "lentil_stew": "Lentil stew", + "lentils": "Lentils", + "lentils_red": "Red lentils", + "lettuce": "Lettuce", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Lip Care", + "liqueur": "Liqueur", + "low-fat_curd_cheese": "Low-fat curd cheese", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Maple syrup", + "margarine": "Margarine", + "marjoram": "Marjoram", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Mask", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Meat substitute product", + "microfiber_cloth": "Microfiber cloth", + "milk": "Milk", + "mint": "Mint", + "mint_candy": "Mint candy", + "miso_paste": "Miso paste", + "mixed_vegetables": "Mixed vegetables", + "mochis": "Mochis", + "mold_remover": "Mold Remover", + "mountain_cheese": "Mountain cheese", + "mouth_wash": "Mouth wash", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Muesli bar", + "mulled_wine": "Mulled wine", + "mushrooms": "Mushrooms", + "mustard": "Mustard", + "nail_file": "Nail file", + "nail_polish_remover": "Nail polish remover", + "neutral_oil": "Neutral oil", + "nori_sheets": "Nori sheets", + "nutmeg": "Nutmeg", + "oat_milk": "Oat milk", + "oatmeal": "Oatmeal", + "oatmeal_cookies": "Oatmeal cookies", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Oil", + "olive_oil": "Olive oil", + "olives": "Olives", + "onion": "Onion", + "onion_powder": "Onion powder", + "orange_juice": "Orange juice", + "oranges": "Oranges", + "oregano": "Oregano", + "organic_lemon": "Organic lemon", + "organic_waste_bags": "Organic waste bags", + "pak_choi": "Pak Choi", + "pantyhose": "Pantyhose", + "papaya": "Papaya", + "paprika": "Paprika", + "paprika_seasoning": "Paprika seasoning", + "pardina_lentils_dried": "Pardina lentils dried", + "parmesan": "Parmesan", + "parsley": "Parsley", + "pasta": "Pasta", + "peach": "Peach", + "peanut_butter": "Peanut butter", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Peanut oil", + "peanuts": "Peanuts", + "pears": "Pears", + "peas": "Peas", + "penne": "Penne", + "pepper": "Pepper", + "pepper_mill": "Pepper mill", + "peppers": "Peppers", + "persian_rice": "Persian rice", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pine nuts", + "pineapple": "Pineapple", + "pita_bag": "Pita bag", + "pita_bread": "Pita bread", + "pizza": "Pizza", + "pizza_dough": "Pizza dough", + "plant_magarine": "Plant Magarine", + "plant_oil": "Plant oil", + "plaster": "Plaster", + "pointed_peppers": "Pointed peppers", + "porcini_mushrooms": "Porcini mushrooms", + "potato_dumpling_dough": "Potato dumpling dough", + "potato_wedges": "Potato wedges", + "potatoes": "Potatoes", + "potting_soil": "Potting soil", + "powder": "Powder", + "powdered_sugar": "Powdered sugar", + "processed_cheese": "Processed cheese", + "prosecco": "Prosecco", + "puff_pastry": "Puff pastry", + "pumpkin": "Pumpkin", + "pumpkin_seeds": "Pumpkin seeds", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radish", + "ramen": "Ramen", + "rapeseed_oil": "Rapeseed oil", + "raspberries": "Raspberries", + "raspberry_syrup": "Raspberry syrup", + "razor_blades": "Razor blades", + "red_bull": "Red Bull", + "red_chili": "Red chili", + "red_curry_paste": "Red curry paste", + "red_lentils": "Red lentils", + "red_onions": "Red onions", + "red_pesto": "Red pesto", + "red_wine": "Red wine", + "red_wine_vinegar": "Red wine vinegar", + "rhubarb": "Rhubarb", + "ribbon_noodles": "Ribbon noodles", + "rice": "Rice", + "rice_cakes": "Rice cakes", + "rice_paper": "Rice paper", + "rice_ribbon_noodles": "Rice ribbon noodles", + "rice_vinegar": "Rice vinegar", + "ricotta": "Ricotta", + "rinse_tabs": "Rinse tabs", + "rinsing_agent": "Rinsing agent", + "risotto_rice": "Risotto rice", + "rocket": "Rocket", + "roll": "Roll", + "rosemary": "Rosemary", + "saffron_threads": "Saffron threads", + "sage": "Sage", + "saitan_powder": "Saitan powder", + "salad_mix": "Salad Mix", + "salad_seeds_mix": "Salad seeds mix", + "salt": "Salt", + "salt_mill": "Salt mill", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Sausage", + "sausages": "Sausages", + "savoy_cabbage": "Savoy cabbage", + "scallion": "Scallion", + "scattered_cheese": "Scattered cheese", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Semolina porridge", + "sesame": "Sesame", + "sesame_oil": "Sesame oil", + "shallot": "Shallot", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma spice", + "shiitake_mushroom": "Shiitake mushroom", + "shoe_insoles": "Shoe insoles", + "shower_gel": "Shower gel", + "shredded_cheese": "Shredded cheese", + "sieved_tomatoes": "Sieved tomatoes", + "skyr": "Skyr", + "sliced_cheese": "Sliced cheese", + "smoked_paprika": "Smoked paprika", + "smoked_tofu": "Smoked tofu", + "snacks": "Snacks", + "soap": "Soap", + "soba_noodles": "Soba noodles", + "soft_drinks": "Soft drinks", + "soup_vegetables": "Soup vegetables", + "sour_cream": "Sour cream", + "sour_cucumbers": "Sour cucumbers", + "soy_cream": "Soy cream", + "soy_hack": "Soy mince", + "soy_sauce": "Soy sauce", + "soy_shred": "Soy shred", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sparkling water", + "spelt": "Spelt", + "spinach": "Spinach", + "sponge_cloth": "Sponge cloth", + "sponge_fingers": "Sponge fingers", + "sponge_wipes": "Sponge wipes", + "sponges": "Sponges", + "spreading_cream": "Spreading cream", + "spring_onions": "Spring onions", + "sprite": "Sprite", + "sprouts": "Sprouts", + "sriracha": "Sriracha", + "strained_tomatoes": "Strained tomatoes", + "strawberries": "Strawberries", + "sugar": "Sugar", + "summer_roll_paper": "Summer roll paper", + "sunflower_oil": "Sunflower oil", + "sunflower_seeds": "Sunflower seeds", + "sunscreen": "Sunscreen", + "sushi_rice": "Sushi rice", + "swabian_ravioli": "Swabian ravioli", + "sweet_chili_sauce": "Sweet Chili Sauce", + "sweet_potato": "Sweet potato", + "sweet_potatoes": "Sweet potatoes", + "sweets": "Sweets", + "table_salt": "Table salt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerines", + "tape": "Tape", + "tapioca_flour": "Tapioca flour", + "tea": "Tea", + "teriyaki_sauce": "Teriyaki sauce", + "thyme": "Thyme", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toilet paper", + "tomato_juice": "Tomato juice", + "tomato_paste": "Tomato paste", + "tomato_sauce": "Tomato sauce", + "tomatoes": "Tomatoes", + "tonic_water": "Tonic water", + "toothpaste": "Toothpaste", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Tuna", + "turmeric": "Turmeric", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon noodles", + "uht_milk": "UHT milk", + "vanilla_sugar": "Vanilla sugar", + "vegetable_bouillon_cube": "Vegetable bouillon cube", + "vegetable_broth": "Vegetable broth", + "vegetable_oil": "Vegetable oil", + "vegetable_onion": "Vegetable onion", + "vegetables": "Vegetables", + "vegetarian_cold_cuts": "vegetarian cold cuts", + "vinegar": "Vinegar", + "vitamin_tablets": "Vitamin tablets", + "vodka": "Vodka", + "walnuts": "Walnuts", + "washing_gel": "Washing gel", + "washing_powder": "Washing powder", + "water": "Water", + "water_ice": "Water ice", + "watermelon": "Watermelon", + "wc_cleaner": "WC cleaner", + "wheat_flour": "Wheat flour", + "whipped_cream": "Whipped cream", + "white_wine": "White wine", + "white_wine_vinegar": "White wine vinegar", + "whole_canned_tomatoes": "Whole canned tomatoes", + "wild_berries": "Wild berries", + "wild_rice": "Wild rice", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcester sauce", + "wrapping_paper": "Wrapping paper", + "wraps": "Wraps", + "yeast": "Yeast", + "yeast_flakes": "Yeast flakes", + "yoghurt": "Yoghurt", + "yogurt": "Yogurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinc cream", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/en_AU.json b/backend/templates/l10n/en_AU.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/backend/templates/l10n/en_AU.json @@ -0,0 +1 @@ +{} diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json new file mode 100644 index 00000000..ae4710d3 --- /dev/null +++ b/backend/templates/l10n/es.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Productos horneados", + "canned": "🥫 Conservas", + "dairy": "🥛 Lácteos", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas y verduras", + "grain": "🥟 Pasta y fideos", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerados", + "snacks": "🥜 Aperitivos" + }, + "items": { + "agave_syrup": "Aguamiel", + "aioli": "Alioli", + "amaretto": "Amaretto", + "apple": "Manzana", + "apple_pulp": "Puré de manzana", + "applesauce": "Puré de manzana", + "apricots": "Albaricoques", + "apérol": "Aperol", + "arugula": "Rúcula", + "asian_egg_noodles": "Fideos asiáticos al huevo", + "asian_noodles": "Tallarines", + "asparagus": "Espárragos", + "aspirin": "Aspirina", + "avocado": "Aguacate", + "baby_potatoes": "Patatas pequeñas", + "baby_spinach": "Espinacas tiernas", + "bacon": "Beicon", + "baguette": "Baguette", + "bakefish": "Pescado al horno", + "baking_cocoa": "Cacao de repostería", + "baking_mix": "Preparado para hornear", + "baking_paper": "Papel de horno", + "baking_powder": "Levadura en polvo", + "baking_soda": "Bicarbonato", + "baking_yeast": "Levadura", + "balsamic_vinegar": "Vinagre balsámico", + "bananas": "Plátanos", + "basil": "Albahaca", + "basmati_rice": "Arroz basmati", + "bathroom_cleaner": "Limpiador de baños", + "batteries": "Pilas", + "bay_leaf": "Hoja de laurel", + "beans": "Judías", + "beef": "Ternera", + "beef_broth": "Caldo de carne", + "beer": "Cerveza", + "beet": "Remolacha", + "beetroot": "Remolacha", + "birthday_card": "Tarjeta de cumpleaños", + "black_beans": "Frijol negro", + "blister_plaster": "Protector de ampollas", + "bockwurst": "Bockwurst", + "bodywash": "Gel de baño", + "bread": "Pan", + "breadcrumbs": "Migas de pan", + "broccoli": "Brécol", + "brown_sugar": "Azúcar moreno", + "brussels_sprouts": "Coles de Bruselas", + "buffalo_mozzarella": "Queso Mozzarella de búfala campana", + "buns": "Bollos", + "burger_buns": "Pan de hamburguesa", + "burger_patties": "Hamburguesas", + "burger_sauces": "Salsas para hamburguesas", + "butter": "Mantequilla", + "butter_cookies": "Galletas de mantequilla", + "butternut_squash": "Calabaza moscada", + "button_cells": "Pilas de botón", + "börek_cheese": "Queso Börek", + "cake": "Pastel", + "cake_icing": "Glaseado", + "cane_sugar": "Azúcar de caña", + "cannelloni": "Canelones", + "canola_oil": "Aceite de colza", + "cardamom": "Cardamomo", + "carrots": "Zanahorias", + "cashews": "Anacardos", + "cat_treats": "Golosinas para gatos", + "cauliflower": "Coliflor", + "celeriac": "Apionabo", + "celery": "Apio", + "cereal_bar": "Barra de muesli", + "cheddar": "Queso cheddar", + "cheese": "Queso", + "cherry_tomatoes": "Tomates cherry", + "chickpeas": "Garbanzos", + "chicory": "Achicoria", + "chili_oil": "Aceite de chile", + "chili_pepper": "Guindilla", + "chips": "Patatas fritas", + "chives": "Cebollino", + "chocolate": "Chocolate", + "chocolate_chips": "Chispas de chocolate", + "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates en trozos", + "ciabatta": "Chapata", + "cider_vinegar": "Vinagre de sidra", + "cilantro": "Cilantro", + "cinnamon": "Canela", + "cinnamon_stick": "Canela en rama", + "cocktail_sauce": "Salsa cóctel", + "cocktail_tomatoes": "Tomates de cóctel", + "coconut_flakes": "Copos de coco", + "coconut_milk": "Leche de coco", + "coconut_oil": "Aceite de coco", + "coffee_powder": "Café en polvo", + "colorful_sprinkles": "Virutas de colores", + "concealer": "Corrector", + "cookies": "Cookies", + "coriander": "Cilantro", + "corn": "Maíz", + "cornflakes": "Copos de maíz", + "cornstarch": "Maicena", + "cornys": "Cornys", + "corriander": "Cilantro", + "cotton_rounds": "Círculos de algodón", + "cough_drops": "Pastillas para la tos", + "couscous": "Cuscús", + "covid_rapid_test": "Test rápido del COVID", + "cow's_milk": "Leche de vaca", + "cream": "Crema", + "cream_cheese": "Queso cremoso", + "creamed_spinach": "Crema de espinacas", + "creme_fraiche": "Nata agria (Crème fraîche)", + "crepe_tape": "Cinta adhesiva", + "crispbread": "Pan crujiente", + "cucumber": "Pepino", + "cumin": "Comino", + "curd": "Cuajada", + "curry_paste": "Pasta de curry", + "curry_powder": "Curry en polvo", + "curry_sauce": "Salsa de curry", + "dates": "Fechas", + "dental_floss": "Hilo dental", + "deo": "Desodorante", + "deodorant": "Desodorante", + "detergent": "Detergente", + "detergent_sheets": "Hojas de detergente", + "diarrhea_remedy": "Remedio para la diarrea", + "dill": "Eneldo", + "dishwasher_salt": "Sal para lavavajillas", + "dishwasher_tabs": "Pastillas para el lavavajillas", + "disinfection_spray": "Desinfectante en spray", + "dried_tomatoes": "Tomates secos", + "dry_yeast": "Levadura seca", + "edamame": "Vainas de soja tiernas (Edamame)", + "egg_salad": "Ensalada de huevo", + "egg_yolk": "Yema de huevo", + "eggplant": "Berenjena", + "eggs": "Huevos", + "enoki_mushrooms": "Setas Enoki", + "eyebrow_gel": "Gel para cejas", + "falafel": "Faláfel", + "falafel_powder": "Falafel en polvo", + "fanta": "Fanta", + "feta": "Queso Feta", + "ffp2": "FFP2", + "fish_sticks": "Palitos de pescado", + "flour": "Harina", + "flushing": "Enjuague", + "fresh_chili_pepper": "Guindilla fresca", + "frozen_berries": "Bayas congeladas", + "frozen_broccoli": "Brócoli congelado", + "frozen_fruit": "Fruta congelada", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinacas congeladas", + "funeral_card": "Tarjeta funeraria", + "garam_masala": "Garam Masala", + "garbage_bag": "Bolsas de basura", + "garlic": "Ajo", + "garlic_dip": "Salsa de ajo", + "garlic_granules": "Ajo granulado", + "gherkins": "Pepinillos", + "ginger": "Jengibre", + "ginger_ale": "Ginger ale", + "glass_noodles": "Fideos de vidrio", + "gluten": "Gluten", + "gnocchi": "Ñoqui", + "gochujang": "Gochujang", + "gorgonzola": "Queso Gorgonzola", + "gouda": "Queso Gouda", + "granola": "Granola", + "granola_bar": "Barrita de cereales", + "grapes": "Uvas", + "greek_yogurt": "Yogur griego", + "green_asparagus": "Espárragos verdes", + "green_chili": "Guindilla verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gomina", + "hair_ties": "Lazos para el pelo", + "hair_wax": "Cera para el pelo", + "ham": "Jamón", + "ham_cubes": "Taquitos de jamón", + "hand_soap": "Jabón de manos", + "handkerchief_box": "Caja de pañuelos", + "handkerchiefs": "Pañuelos", + "hard_cheese": "Queso duro", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Avellanas", + "head_of_lettuce": "Cogollo de lechuga", + "herb_baguettes": "Baguettes de hierbas", + "herb_butter": "Mantequilla de hierbas", + "herb_cream_cheese": "Crema de queso a las hierbas", + "honey": "Miel", + "honey_wafers": "Barquillos de miel", + "hot_dog_bun": "Panecillo para perritos calientes", + "ice_cream": "Helados", + "ice_cube": "Cubitos de hielo", + "iceberg_lettuce": "Lechuga iceberg", + "iced_tea": "Té helado", + "instant_soups": "Sopas instantáneas", + "jam": "Mermelada", + "jasmine_rice": "Arroz jazmín", + "katjes": "Katjes", + "ketchup": "Kétchup", + "kidney_beans": "Judías rojas (Frijoles)", + "kitchen_roll": "Papel de cocina", + "kitchen_towels": "Paños de cocina", + "kiwi": "Kiwi", + "kohlrabi": "Colinabo", + "lasagna": "Lasaña", + "lasagna_noodles": "Fideos para lasaña", + "lasagna_plates": "Platos de lasaña", + "leaf_spinach": "Espinacas de hoja", + "leek": "Puerro", + "lemon": "Limón", + "lemon_curd": "Cuajada de limón", + "lemon_juice": "Zumo de limón", + "lemonade": "Limonada", + "lemongrass": "Hierba limón", + "lentil_stew": "Guiso de lentejas", + "lentils": "Lentejas", + "lentils_red": "Lentejas rojas", + "lettuce": "Lechuga", + "lillet": "Lillet", + "lime": "Lima", + "linguine": "Linguine", + "lip_care": "Cuidado de los labios", + "liqueur": "Licor", + "low-fat_curd_cheese": "Requesón bajo en grasa", + "maggi": "Maggi", + "magnesium": "Magnesio", + "mango": "Mango", + "maple_syrup": "Sirope de arce", + "margarine": "Margarina", + "marjoram": "Mejorana (Origanum majorana)", + "marshmallows": "Malvaviscos", + "mascara": "Máscara", + "mascarpone": "Mascarpone", + "mask": "Mascarilla", + "mayonnaise": "Mayonesa", + "meat_substitute_product": "Carne de origen vegetal (Tofu)", + "microfiber_cloth": "Paño de microfibras", + "milk": "Leche", + "mint": "Menta", + "mint_candy": "Caramelos de menta", + "miso_paste": "Pasta de miso", + "mixed_vegetables": "Mezcla de verduras", + "mochis": "Mochis", + "mold_remover": "Eliminador de moho", + "mountain_cheese": "Queso de montaña", + "mouth_wash": "Enjuague bucal", + "mozzarella": "Queso Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de muesli", + "mulled_wine": "Vino caliente", + "mushrooms": "Setas", + "mustard": "Mostaza", + "nail_file": "Lima de uñas", + "nail_polish_remover": "Quitaesmalte", + "neutral_oil": "Aceite neutro", + "nori_sheets": "Hojas de nori", + "nutmeg": "Nuez moscada", + "oat_milk": "Leche de avena", + "oatmeal": "Harina de avena", + "oatmeal_cookies": "Galletas de avena", + "oatsome": "Avena", + "obatzda": "Obatzda", + "oil": "Aceite", + "olive_oil": "Aceite de oliva", + "olives": "Aceitunas", + "onion": "Cebolla", + "onion_powder": "Cebolla en polvo", + "orange_juice": "Zumo de naranja", + "oranges": "Naranjas", + "oregano": "Orégano", + "organic_lemon": "Limón ecológico", + "organic_waste_bags": "Bolsas para residuos orgánicos", + "pak_choi": "Col china o repollo chino", + "pantyhose": "Pantimedias", + "papaya": "Papaya", + "paprika": "Pimentón", + "paprika_seasoning": "Condimento de pimentón", + "pardina_lentils_dried": "Lentejas pardinas secas", + "parmesan": "Parmesano", + "parsley": "Perejil", + "pasta": "Pasta", + "peach": "Melocotón", + "peanut_butter": "Mantequilla de cacahuete", + "peanut_flips": "Maíz extruido crudo", + "peanut_oil": "Aceite de cacahuete", + "peanuts": "Cacahuetes", + "pears": "Peras", + "peas": "Guisantes", + "penne": "Penne", + "pepper": "Pimienta", + "pepper_mill": "Molinillo de pimienta", + "peppers": "Pimientos", + "persian_rice": "Arroz persa", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Piñones", + "pineapple": "Piña", + "pita_bag": "Bolsa de pita", + "pita_bread": "Pan de pita", + "pizza": "Pizza", + "pizza_dough": "Masa de pizza", + "plant_magarine": "Margarina vegetal", + "plant_oil": "Aceite vegetal", + "plaster": "Yeso", + "pointed_peppers": "Pimientos puntiagudos", + "porcini_mushrooms": "Champiñón al ajillo", + "potato_dumpling_dough": "Masa de albóndigas de patata", + "potato_wedges": "Cuñas de patata", + "potatoes": "Patatas", + "potting_soil": "Tierra para macetas", + "powder": "Polvo", + "powdered_sugar": "Azúcar en polvo", + "processed_cheese": "Queso fundido", + "prosecco": "Prosecco", + "puff_pastry": "Hojaldre", + "pumpkin": "Calabaza", + "pumpkin_seeds": "Semillas de calabaza", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rábano", + "ramen": "Ramen", + "rapeseed_oil": "Aceite de colza", + "raspberries": "Frambuesas", + "raspberry_syrup": "Sirope de frambuesa", + "razor_blades": "Hojas de afeitar", + "red_bull": "Red Bull", + "red_chili": "Chili rojo", + "red_curry_paste": "Pasta de curry rojo", + "red_lentils": "Lentejas rojas", + "red_onions": "Cebollas rojas", + "red_pesto": "Pesto rojo", + "red_wine": "Vino tinto", + "red_wine_vinegar": "Vinagre de Módena", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Cinta de fideos", + "rice": "Arroz", + "rice_cakes": "Pasteles de arroz", + "rice_paper": "Papel de arroz", + "rice_ribbon_noodles": "Fideos con cinta de arroz", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Requesón", + "rinse_tabs": "Pastillas Abrillantadoras", + "rinsing_agent": "Agente de enjuague", + "risotto_rice": "Arroz para risotto", + "rocket": "Cohete", + "roll": "Rollo", + "rosemary": "Romero", + "saffron_threads": "Hilos de azafrán", + "sage": "Salvia", + "saitan_powder": "Saitán en polvo", + "salad_mix": "Mezcla para ensalada", + "salad_seeds_mix": "Mezcla de semillas para ensalada", + "salt": "Sal", + "salt_mill": "Molino de sal", + "sambal_oelek": "Sambal", + "sauce": "Salsa", + "sausage": "Embutido", + "sausages": "Salchichas", + "savoy_cabbage": "Col rizada", + "scallion": "Cebolleta", + "scattered_cheese": "Queso de untar", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln (ñoquis alemanes)", + "semolina_porridge": "Papilla de sémola", + "sesame": "Sésamo", + "sesame_oil": "Aceite de sésamo", + "shallot": "Chalota", + "shampoo": "Champú", + "shawarma_spice": "Shawarma picante", + "shiitake_mushroom": "Champiñón shiitake", + "shoe_insoles": "Plantillas para zapatos", + "shower_gel": "Gel de ducha", + "shredded_cheese": "Queso rallado", + "sieved_tomatoes": "Tomates tamizados", + "skyr": "Skyr", + "sliced_cheese": "Queso en lonchas", + "smoked_paprika": "Pimentón ahumado", + "smoked_tofu": "Tofu ahumado", + "snacks": "Aperitivos", + "soap": "Jabón", + "soba_noodles": "Fideos soba", + "soft_drinks": "Refrescos", + "soup_vegetables": "Sopa de verduras", + "sour_cream": "Crema agria", + "sour_cucumbers": "Pepinos agrios", + "soy_cream": "Crema de soja", + "soy_hack": "Carne picada de soja", + "soy_sauce": "Salsa de soja", + "soy_shred": "Triturado de soja", + "spaetzle": "Späeztle", + "spaghetti": "Espaguetis", + "sparkling_water": "Agua con gas", + "spelt": "Espelta", + "spinach": "Espinacas", + "sponge_cloth": "Bayetas", + "sponge_fingers": "Dedos de esponja", + "sponge_wipes": "Esponjas limpiadoras (de poliuretano)", + "sponges": "Esponjas", + "spreading_cream": "Crema para untar", + "spring_onions": "Cebolletas", + "sprite": "Sprite", + "sprouts": "Brotes", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates escurridos", + "strawberries": "Fresas", + "sugar": "Azúcar", + "summer_roll_paper": "Rollo de papel de verano", + "sunflower_oil": "Aceite de girasol", + "sunflower_seeds": "Semillas de girasol", + "sunscreen": "Protector solar", + "sushi_rice": "Arroz para sushi", + "swabian_ravioli": "Raviolis suabos", + "sweet_chili_sauce": "Salsa de chile dulce", + "sweet_potato": "Boniato", + "sweet_potatoes": "Boniatos", + "sweets": "Dulces", + "table_salt": "Sal de mesa", + "tagliatelle": "Tallarín", + "tahini": "Tahini", + "tangerines": "Mandarinas", + "tape": "Cinta", + "tapioca_flour": "Harina de tapioca", + "tea": "Té", + "teriyaki_sauce": "Salsa Teriyaki", + "thyme": "Tomillo", + "toast": "Tostadas", + "tofu": "Tofu", + "toilet_paper": "Papel higiénico", + "tomato_juice": "Zumo de tomate", + "tomato_paste": "Pasta de tomate", + "tomato_sauce": "Salsa de tomate", + "tomatoes": "Tomates", + "tonic_water": "Tónica", + "toothpaste": "Pasta de dientes", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla chip", + "tuna": "Atún", + "turmeric": "Cúrcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Fideos Udon", + "uht_milk": "Leche UHT", + "vanilla_sugar": "Azúcar vainillado", + "vegetable_bouillon_cube": "Pastillas de caldo vegetal", + "vegetable_broth": "Caldo de verduras", + "vegetable_oil": "Aceite vegetal", + "vegetable_onion": "Cebolla vegetal", + "vegetables": "Verduras", + "vegetarian_cold_cuts": "fiambres vegetarianos", + "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos vitamínicos", + "vodka": "Vodka", + "walnuts": "Nuez", + "washing_gel": "Gel de lavado", + "washing_powder": "Detergente en polvo", + "water": "Agua", + "water_ice": "Hielo", + "watermelon": "Sandía", + "wc_cleaner": "Limpiador de WC", + "wheat_flour": "Harina de trigo", + "whipped_cream": "Nata montada", + "white_wine": "Vino blanco", + "white_wine_vinegar": "Vinagre de vino blanco", + "whole_canned_tomatoes": "Tomates enteros en conserva", + "wild_berries": "Bayas silvestres", + "wild_rice": "Arroz salvaje", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Salsa Worcester", + "wrapping_paper": "Papel de envolver", + "wraps": "Wraps", + "yeast": "Levadura", + "yeast_flakes": "Copos de levadura", + "yoghurt": "Yogur", + "yogurt": "Yogur", + "yum_yum": "Ñam Ñam", + "zewa": "Zewa", + "zinc_cream": "Crema de zinc", + "zucchini": "Calabacín" + } +} diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json new file mode 100644 index 00000000..7a8b01a6 --- /dev/null +++ b/backend/templates/l10n/fi.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Paistotuotteet", + "canned": "🥫 Säilykkeet", + "dairy": "🥛 Maitotuotteet", + "drinks": "🍹 Juomat", + "freezer": "❄️ Pakasteet", + "fruits_vegetables": "🥬 Hedelmät ja vihannekset", + "grain": "Pastat ja nuudelit", + "hygiene": "🚽 Hygienia", + "refrigerated": "💧 Jääkaappi", + "snacks": "🥜 Herkut" + }, + "items": { + "agave_syrup": "Agavesiirappi", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Omena", + "apple_pulp": "Omenasose", + "applesauce": "Omenasose", + "apricots": "Aprikoosit", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Munanuudelit", + "asian_noodles": "Nuudelit", + "asparagus": "Parsa", + "aspirin": "Aspiriini", + "avocado": "Avocado", + "baby_potatoes": "Varhaisperunat", + "baby_spinach": "Babypinaatti", + "bacon": "Pekoni", + "baguette": "Patonki", + "bakefish": "Paistettu kala", + "baking_cocoa": "Leivontakaakao", + "baking_mix": "Jauhoseos", + "baking_paper": "Leivinpaperi", + "baking_powder": "Leivinjauhe", + "baking_soda": "Ruokasooda", + "baking_yeast": "Hiiva", + "balsamic_vinegar": "Balsamiviinietikka", + "bananas": "Banaanit", + "basil": "Basilika", + "basmati_rice": "Basmatiriisi", + "bathroom_cleaner": "Kylpyhuoneen pesuaine", + "batteries": "Paristot", + "bay_leaf": "Laakerinlehti", + "beans": "Pavut", + "beef": "Naudanliha", + "beef_broth": "Lihaliemi", + "beer": "Olut", + "beet": "Juurikas", + "beetroot": "Punajuuri", + "birthday_card": "Syntymäpäiväkortti", + "black_beans": "Mustapavut", + "blister_plaster": "Rakkolaastari", + "bockwurst": "Bockwurst", + "bodywash": "Suihkusaippua", + "bread": "Leipä", + "breadcrumbs": "Korppujauhot", + "broccoli": "Parsakaali", + "brown_sugar": "Fariinisokeri", + "brussels_sprouts": "Ruusukaali", + "buffalo_mozzarella": "Buffalomozzarella", + "buns": "Sämpylät", + "burger_buns": "Hampurilaissämpylät", + "burger_patties": "Burgerpihvit", + "burger_sauces": "Hampurilaiskastikke", + "butter": "Voi", + "butter_cookies": "Voikeksit", + "butternut_squash": "Pähkinäkurpitsa", + "button_cells": "Nappiparistot", + "börek_cheese": "Börek-juusto", + "cake": "Kakku", + "cake_icing": "Kakun kuorrute", + "cane_sugar": "Ruokosokeri", + "cannelloni": "Cannelloni", + "canola_oil": "Rypsiöljy", + "cardamom": "Kardemumma", + "carrots": "Porkkanat", + "cashews": "Cashewpähkinät", + "cat_treats": "Kissan herkut", + "cauliflower": "Kukkakaali", + "celeriac": "Mukulaselleri", + "celery": "Selleri", + "cereal_bar": "Myslipatukka", + "cheddar": "Cheddar-juusto", + "cheese": "Juusto", + "cherry_tomatoes": "Kirsikkatomaatit", + "chickpeas": "Kikherneet", + "chicory": "Sikuri", + "chili_oil": "Chiliöljy", + "chili_pepper": "Chilipippuri", + "chips": "Sipsit", + "chives": "Ruohosipuli", + "chocolate": "Suklaa", + "chocolate_chips": "Suklaalastut", + "chopped_tomatoes": "Pilkotut tomaatit", + "chunky_tomatoes": "Tomaattimurska", + "ciabatta": "Ciabatta", + "cider_vinegar": "Siideriviinietikka", + "cilantro": "Korianteri", + "cinnamon": "Kaneli", + "cinnamon_stick": "Kanelitanko", + "cocktail_sauce": "Cocktail-kastike", + "cocktail_tomatoes": "Cocktail-tomaatit", + "coconut_flakes": "Kookoshiutaleet", + "coconut_milk": "Kookosmaito", + "coconut_oil": "Kookosöljy", + "coffee_powder": "Jauhettu kahvi", + "colorful_sprinkles": "Koristerakeet", + "concealer": "Peitevoide", + "cookies": "Keksit", + "coriander": "Korianteri", + "corn": "Maissi", + "cornflakes": "Maissihiutaleet", + "cornstarch": "Maissitärkkelys", + "cornys": "Cornys", + "corriander": "Korianteri", + "cotton_rounds": "Pyöreät vanulaput", + "cough_drops": "Yskänpastillit", + "couscous": "Kuskus", + "covid_rapid_test": "COVID-pikatesti", + "cow's_milk": "Lehmänmaito", + "cream": "Kerma", + "cream_cheese": "Kermajuusto", + "creamed_spinach": "Kermapinaatti", + "creme_fraiche": "Ranskankerma", + "crepe_tape": "Maalarinteippi", + "crispbread": "Näkkileipä", + "cucumber": "Kurkku", + "cumin": "Kumina", + "curd": "Juustomassa", + "curry_paste": "Currytahna", + "curry_powder": "Curryjauhe", + "curry_sauce": "Currykastike", + "dates": "Taatelit", + "dental_floss": "Hammaslanka", + "deo": "Deodorantti", + "deodorant": "Deodorantti", + "detergent": "Pesuaine", + "detergent_sheets": "Huuhteluliina", + "diarrhea_remedy": "Ripulilääke", + "dill": "Tilli", + "dishwasher_salt": "Astianpesukoneen suola", + "dishwasher_tabs": "Astianpesutabletit", + "disinfection_spray": "Desinfektiosuihke", + "dried_tomatoes": "Kuivatut tomaatit", + "dry_yeast": "Kuivahiiva", + "edamame": "Edamame-pavut", + "egg_salad": "Munasalaatti", + "egg_yolk": "Munankeltuainen", + "eggplant": "Munakoiso", + "eggs": "Kananmunat", + "enoki_mushrooms": "Enoki-sienet", + "eyebrow_gel": "Kulmageeli", + "falafel": "Falafel", + "falafel_powder": "Falafel-jauhe", + "fanta": "Fanta", + "feta": "Feta-juusto", + "ffp2": "FFP2-maskit", + "fish_sticks": "Kalapuikot", + "flour": "Jauhot", + "flushing": "Huuhtelu", + "fresh_chili_pepper": "Tuore chilipippuri", + "frozen_berries": "Pakastemarjat", + "frozen_broccoli": "Pakasteparsakaali", + "frozen_fruit": "Pakastehedelmät", + "frozen_pizza": "Pakastepizza", + "frozen_spinach": "Pakastepinaatti", + "funeral_card": "Hautajaiskortti", + "garam_masala": "Garam Masala", + "garbage_bag": "Jätesäkit", + "garlic": "Valkosipuli", + "garlic_dip": "Valkosipulidippi", + "garlic_granules": "Valkosipulirakeet", + "gherkins": "Maustekurkut", + "ginger": "Inkivääri", + "ginger_ale": "Inkivääriolut", + "glass_noodles": "Lasinuudelit", + "gluten": "Gluteenijauho", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang-chilitahna", + "gorgonzola": "Gorgonzola-juusto", + "gouda": "Goudajuusto", + "granola": "Granola", + "granola_bar": "Granola-patukka", + "grapes": "Viinirypäleet", + "greek_yogurt": "Kreikkalainen jogurtti", + "green_asparagus": "Vihreä parsa", + "green_chili": "Vihreä chili", + "green_pesto": "Vihreä pesto", + "hair_gel": "Hiusgeeli", + "hair_ties": "Hiuslenkit", + "hair_wax": "Hiusvaha", + "ham": "Kinkku", + "ham_cubes": "Kinkkukuutiot", + "hand_soap": "Käsisaippua", + "handkerchief_box": "Nenäliinalaatikko", + "handkerchiefs": "Nenäliinat", + "hard_cheese": "Kova juusto", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselpähkinät", + "head_of_lettuce": "Salaatinlehdet", + "herb_baguettes": "Yrttipatongit", + "herb_butter": "Yrttivoi", + "herb_cream_cheese": "Yrttituorejuusto", + "honey": "Hunaja", + "honey_wafers": "Hunajavohvelit", + "hot_dog_bun": "Hot Dog-sämpylä", + "ice_cream": "Jäätelö", + "ice_cube": "Jääkuutiot", + "iceberg_lettuce": "Jäävuorisalaatti", + "iced_tea": "Jäätee", + "instant_soups": "Pikakeitot", + "jam": "Hillo", + "jasmine_rice": "Jasmiiniriisi", + "katjes": "Katjes", + "ketchup": "Ketsuppi", + "kidney_beans": "Kidneypavut", + "kitchen_roll": "Talouspaperi", + "kitchen_towels": "Keittiöpyyhkeet", + "kiwi": "Kiivi", + "kohlrabi": "Kyssäkaali", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagnepasta", + "lasagna_plates": "Lasagnelevyt", + "leaf_spinach": "Lehtipinaatti", + "leek": "Purjo", + "lemon": "Sitruuna", + "lemon_curd": "Sitruunatahna", + "lemon_juice": "Sitruunamehu", + "lemonade": "Limonadi", + "lemongrass": "Sitruunaruoho", + "lentil_stew": "Linssimuhennos", + "lentils": "Linssit", + "lentils_red": "Punaiset linssit", + "lettuce": "Salaatti", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Huulirasva", + "liqueur": "Likööri", + "low-fat_curd_cheese": "Vähärasvainen juustomassa", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Vaahterasiirappi", + "margarine": "Margariini", + "marjoram": "Meiram", + "marshmallows": "Vaahtokarkit", + "mascara": "Ripsiväri", + "mascarpone": "Mascarpone", + "mask": "Maski", + "mayonnaise": "Majoneesi", + "meat_substitute_product": "Lihankorviketuote", + "microfiber_cloth": "Mikrokuitupyyhe", + "milk": "Maito", + "mint": "Minttu", + "mint_candy": "Minttukarkki", + "miso_paste": "Misotahna", + "mixed_vegetables": "Vihannessekoitus", + "mochis": "Mochit", + "mold_remover": "Homeenpoistoaine", + "mountain_cheese": "Vuoristojuusto", + "mouth_wash": "Suuvesi", + "mozzarella": "Mozzarella", + "muesli": "Mysli", + "muesli_bar": "Myslipatukka", + "mulled_wine": "Glögi", + "mushrooms": "Sienet", + "mustard": "Sinappi", + "nail_file": "Kynsiviila", + "nail_polish_remover": "Kynsilakanpoistoaine", + "neutral_oil": "Öljy", + "nori_sheets": "Noriarkit", + "nutmeg": "Muskottipähkinä", + "oat_milk": "Kaurajuoma", + "oatmeal": "Kaurahiutaleet", + "oatmeal_cookies": "Kaurakeksit", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Öljy", + "olive_oil": "Oliiviöljy", + "olives": "Oliivit", + "onion": "Sipuli", + "onion_powder": "Sipulijauhe", + "orange_juice": "Appelsiinimehu", + "oranges": "Appelsiinit", + "oregano": "Oregano", + "organic_lemon": "Luomusitruuna", + "organic_waste_bags": "Biojätepussit", + "pak_choi": "Pak Choi", + "pantyhose": "Sukkahousut", + "papaya": "Papaija", + "paprika": "Paprika", + "paprika_seasoning": "Paprikamauste", + "pardina_lentils_dried": "Kuivatut Pardina-linssit", + "parmesan": "Parmesan", + "parsley": "Persilja", + "pasta": "Pasta", + "peach": "Persikka", + "peanut_butter": "Maapähkinävoi", + "peanut_flips": "Maapähkinänaksut", + "peanut_oil": "Maapähkinäöljy", + "peanuts": "Maapähkinät", + "pears": "Päärynät", + "peas": "Herneet", + "penne": "Penne-pasta", + "pepper": "Pippuri", + "pepper_mill": "Pippurimylly", + "peppers": "Pippurit", + "persian_rice": "Persialainen riisi", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjansiemenet", + "pineapple": "Ananas", + "pita_bag": "Pita-pussi", + "pita_bread": "Pita-leipä", + "pizza": "Pizza", + "pizza_dough": "Pizzataikina", + "plant_magarine": "Kasvipohjainen margariini", + "plant_oil": "Kasviöljy", + "plaster": "Laastari", + "pointed_peppers": "Suippopaprikat", + "porcini_mushrooms": "Porcini-sienet", + "potato_dumpling_dough": "Perunanyyttitaikina", + "potato_wedges": "Lohkoperunat", + "potatoes": "Perunat", + "potting_soil": "Ruukkumulta", + "powder": "Jauhe", + "powdered_sugar": "Tomusokeri", + "processed_cheese": "Sulatejuusto", + "prosecco": "Prosecco", + "puff_pastry": "Lehtitaikinaleivonnaiset", + "pumpkin": "Kurpitsa", + "pumpkin_seeds": "Kurpitsan siemenet", + "quark": "Rahka", + "quinoa": "Kvinoa", + "radicchio": "Radicchio", + "radish": "Retiisi", + "ramen": "Ramen", + "rapeseed_oil": "Rypsiöljy", + "raspberries": "Vadelmat", + "raspberry_syrup": "Vadelmasiirappi", + "razor_blades": "Partaterät", + "red_bull": "Red Bull", + "red_chili": "Punainen chili", + "red_curry_paste": "Punainen currytahna", + "red_lentils": "Punaiset linssit", + "red_onions": "Punasipulit", + "red_pesto": "Punainen pesto", + "red_wine": "Punaviini", + "red_wine_vinegar": "Punaviinietikka", + "rhubarb": "Raparperi", + "ribbon_noodles": "Nauhanuudelit", + "rice": "Riisi", + "rice_cakes": "Riisikakut", + "rice_paper": "Riisipaperi", + "rice_ribbon_noodles": "Riisinauhanuudelit", + "rice_vinegar": "Riisiviinietikka", + "ricotta": "Ricotta", + "rinse_tabs": "Puhdistustabletti", + "rinsing_agent": "Huuhteluaine", + "risotto_rice": "Risottoriisi", + "rocket": "Raketti", + "roll": "Rulla", + "rosemary": "Rosmariini", + "saffron_threads": "Sahramilangat", + "sage": "Salvia", + "saitan_powder": "Seitan-jauhe", + "salad_mix": "Salaattisekoitus", + "salad_seeds_mix": "Salaattisiemensekoitus", + "salt": "Suola", + "salt_mill": "Suolamylly", + "sambal_oelek": "Sambal oelek", + "sauce": "Kastike", + "sausage": "Makkara", + "sausages": "Makkarat", + "savoy_cabbage": "Savoijinkaali", + "scallion": "Kevätsipuli", + "scattered_cheese": "Juustolevite", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Mannapuuro", + "sesame": "Seesam", + "sesame_oil": "Seesamöljy", + "shallot": "Salottisipuli", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma-mauste", + "shiitake_mushroom": "Shiitake-sienet", + "shoe_insoles": "Kengänpohjalliset", + "shower_gel": "Suihkugeeli", + "shredded_cheese": "Juustoraaste", + "sieved_tomatoes": "Paseraattu tomaatti", + "skyr": "Skyr", + "sliced_cheese": "Juustoviipaleet", + "smoked_paprika": "Savustettu paprika", + "smoked_tofu": "Savutofu", + "snacks": "Herkut", + "soap": "Saippua", + "soba_noodles": "Soba-nuudelit", + "soft_drinks": "Alkoholittomat juomat", + "soup_vegetables": "Keittovihannekset", + "sour_cream": "Hapankerma", + "sour_cucumbers": "Hapakurkut", + "soy_cream": "Soijakerma", + "soy_hack": "Soijarouhe", + "soy_sauce": "Soijakastike", + "soy_shred": "Soijasuikale", + "spaetzle": "Spätzle", + "spaghetti": "Spagetti", + "sparkling_water": "Hiilihapotettu vesi", + "spelt": "Speltti", + "spinach": "Pinaatti", + "sponge_cloth": "Sieniliina", + "sponge_fingers": "Savoiardi", + "sponge_wipes": "Hankaussieni", + "sponges": "Pesusienet", + "spreading_cream": "Levitysvoide", + "spring_onions": "Kevätsipulit", + "sprite": "Sprite", + "sprouts": "Idut", + "sriracha": "Sriracha", + "strained_tomatoes": "Paseerattu tomaatti", + "strawberries": "Mansikat", + "sugar": "Sokeri", + "summer_roll_paper": "Kesärullapaperi", + "sunflower_oil": "Auringonkukkaöljy", + "sunflower_seeds": "Auringonkukan siemenet", + "sunscreen": "Aurinkovoide", + "sushi_rice": "Sushiriisi", + "swabian_ravioli": "Swabian ravioli", + "sweet_chili_sauce": "Makea chilikastike", + "sweet_potato": "Bataatti", + "sweet_potatoes": "Bataatit", + "sweets": "Makeiset", + "table_salt": "Pöytäsuola", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariinit", + "tape": "Teippi", + "tapioca_flour": "Tapiokajauhot", + "tea": "Tee", + "teriyaki_sauce": "Teriyaki-kastike", + "thyme": "Timjami", + "toast": "Paahtoleipä", + "tofu": "Tofu", + "toilet_paper": "Vessapaperi", + "tomato_juice": "Tomaattimehu", + "tomato_paste": "Tomaattipyree", + "tomato_sauce": "Tomaattikastike", + "tomatoes": "Tomaatit", + "tonic_water": "Tonic-vesi", + "toothpaste": "Hammastahna", + "tortellini": "Tortellini", + "tortilla_chips": "Tortillasipsit", + "tuna": "Tonnikala", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nuudelit", + "uht_milk": "UHT-maito", + "vanilla_sugar": "Vaniljasokeri", + "vegetable_bouillon_cube": "Kasvisliemikuutio", + "vegetable_broth": "Kasvisliemi", + "vegetable_oil": "Kasvisöljy", + "vegetable_onion": "Sipuli", + "vegetables": "Kasvikset", + "vegetarian_cold_cuts": "Kasvipohjaiset leikkeleet", + "vinegar": "Etikka", + "vitamin_tablets": "Vitamiinitabletit", + "vodka": "Vodka", + "walnuts": "Saksanpähkinä", + "washing_gel": "Pesugeeli", + "washing_powder": "Pesujauhe", + "water": "Vesi", + "water_ice": "Jäävesi", + "watermelon": "Vesimeloni", + "wc_cleaner": "WC:n puhdistusaine", + "wheat_flour": "Vehnäjauho", + "whipped_cream": "Kermavaahto", + "white_wine": "Valkoviini", + "white_wine_vinegar": "Valkoviinietikka", + "whole_canned_tomatoes": "Kokonaiset säilyketomaatit", + "wild_berries": "Metsämarjoja", + "wild_rice": "Villiriisi", + "wildberry_lillet": "Villimarja Lillet", + "worcester_sauce": "Worcester-kastike", + "wrapping_paper": "Käärepaperi", + "wraps": "Wrapit", + "yeast": "Hiiva", + "yeast_flakes": "Hiivahiutaleet", + "yoghurt": "Jogurtti", + "yogurt": "Jogurtti", + "yum_yum": "Nami nami", + "zewa": "Zewa", + "zinc_cream": "Sinkkivoide", + "zucchini": "Kesäkurpitsa" + } +} diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json new file mode 100644 index 00000000..8f2edc2e --- /dev/null +++ b/backend/templates/l10n/fr.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Produits de boulangerie", + "canned": "🥫 Conserves", + "dairy": "🥛 Laitage", + "drinks": "🍹 Boissons", + "freezer": "❄️ Surgelé", + "fruits_vegetables": "🥬 Fruits et légumes", + "grain": "🥟 Pâtes et nouilles", + "hygiene": "🚽 Hygiène", + "refrigerated": "💧 Réfrigéré", + "snacks": "🥜 Collations" + }, + "items": { + "agave_syrup": "Sirop d'agave", + "aioli": "Aïoli", + "amaretto": "Amaretto", + "apple": "Pomme", + "apple_pulp": "Pulpe de pomme", + "applesauce": "Compote de pommes", + "apricots": "Abricots", + "apérol": "Apérol", + "arugula": "Roquette", + "asian_egg_noodles": "Nouilles asiatiques aux œufs", + "asian_noodles": "Nouilles", + "asparagus": "Asperges", + "aspirin": "Aspirine", + "avocado": "Avocat", + "baby_potatoes": "Petites pommes de terre", + "baby_spinach": "Jeunes épinards", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Poisson cuit", + "baking_cocoa": "Cacao à cuire", + "baking_mix": "Mélange à pâtisserie", + "baking_paper": "Papier sulfurisé", + "baking_powder": "Poudre à lever", + "baking_soda": "Bicarbonate de soude", + "baking_yeast": "Levure de boulangerie", + "balsamic_vinegar": "Vinaigre balsamique", + "bananas": "Bananes", + "basil": "Basilic", + "basmati_rice": "Riz basmati", + "bathroom_cleaner": "Nettoyant pour salle de bains", + "batteries": "Piles", + "bay_leaf": "Feuilles de laurier", + "beans": "Haricots", + "beef": "Bœuf", + "beef_broth": "Bouillon de bœuf", + "beer": "Bière", + "beet": "Betterave", + "beetroot": "Betterave rouge", + "birthday_card": "Carte d'anniversaire", + "black_beans": "Haricots noirs", + "blister_plaster": "Pansement pour ampoules", + "bockwurst": "Bockwurst", + "bodywash": "Soin du corps", + "bread": "Pain", + "breadcrumbs": "Chapelure", + "broccoli": "Brocoli", + "brown_sugar": "Sucre roux", + "brussels_sprouts": "Choux de Bruxelles", + "buffalo_mozzarella": "Mozzarella de buffle", + "buns": "Brioches", + "burger_buns": "Pains à burger", + "burger_patties": "Galettes pour hamburgers", + "burger_sauces": "Sauce hamburger", + "butter": "Beurre", + "butter_cookies": "Biscuits au beurre", + "butternut_squash": "Purée de butternuts", + "button_cells": "Piles boutons", + "börek_cheese": "Fromage Börek", + "cake": "Gâteau", + "cake_icing": "Glaçage de gâteau", + "cane_sugar": "Sucre de canne", + "cannelloni": "Cannelloni", + "canola_oil": "Huile de canola", + "cardamom": "Cardamome", + "carrots": "Carottes", + "cashews": "Noix de cajou", + "cat_treats": "Friandises pour chats", + "cauliflower": "Chou-fleur", + "celeriac": "Cèleri-rave", + "celery": "Cèleri", + "cereal_bar": "Barre de céréales", + "cheddar": "Cheddar", + "cheese": "Fromage", + "cherry_tomatoes": "Tomates cerises", + "chickpeas": "Pois chiches", + "chicory": "Chicorée", + "chili_oil": "Huile de piment", + "chili_pepper": "Piment", + "chips": "Chips", + "chives": "Ciboulette", + "chocolate": "Chocolat", + "chocolate_chips": "Pépites de chocolat", + "chopped_tomatoes": "Tomates coupées en morceaux", + "chunky_tomatoes": "Tomates en morceaux", + "ciabatta": "Ciabatta", + "cider_vinegar": "Vinaigre de cidre", + "cilantro": "Coriandre", + "cinnamon": "Cannelle", + "cinnamon_stick": "Bâton de cannelle", + "cocktail_sauce": "Sauce cocktail", + "cocktail_tomatoes": "Tomates cocktail", + "coconut_flakes": "Flocons de noix de coco", + "coconut_milk": "Lait de coco", + "coconut_oil": "Huile de noix de coco", + "coffee_powder": "Café en poudre", + "colorful_sprinkles": "Saupoudrage coloré", + "concealer": "Correcteur de teint", + "cookies": "Cookies", + "coriander": "Coriandre", + "corn": "Maïs", + "cornflakes": "Cornflakes", + "cornstarch": "Amidon de maïs", + "cornys": "Cornys", + "corriander": "Corriandre", + "cotton_rounds": "Cotons démaquillants", + "cough_drops": "Gouttes contre la toux", + "couscous": "Couscous", + "covid_rapid_test": "Test rapide COVID", + "cow's_milk": "Lait de vache", + "cream": "Crème", + "cream_cheese": "Fromage à la crème", + "creamed_spinach": "Crème d'épinards", + "creme_fraiche": "Crème fraiche", + "crepe_tape": "Bande crêpe", + "crispbread": "Pain croustillant", + "cucumber": "Concombre", + "cumin": "Cumin", + "curd": "Caillé", + "curry_paste": "Pâte de curry", + "curry_powder": "Poudre de curry", + "curry_sauce": "Sauce au curry", + "dates": "Dates", + "dental_floss": "Fil dentaire", + "deo": "Déodorant", + "deodorant": "Déodorant", + "detergent": "Détergent", + "detergent_sheets": "Feuilles de détergent", + "diarrhea_remedy": "Remède contre la diarrhée", + "dill": "Aneth", + "dishwasher_salt": "Sel pour lave-vaisselle", + "dishwasher_tabs": "Languettes pour lave-vaisselle", + "disinfection_spray": "Spray désinfectant", + "dried_tomatoes": "Tomates séchées", + "dry_yeast": "Levure sèche", + "edamame": "Edamame", + "egg_salad": "Salade d'œufs", + "egg_yolk": "Jaune d'œuf", + "eggplant": "Aubergine", + "eggs": "Œufs", + "enoki_mushrooms": "Champignons Enoki", + "eyebrow_gel": "Gel pour sourcils", + "falafel": "Falafel", + "falafel_powder": "Poudre de falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Bâtonnets de poisson", + "flour": "Farine", + "flushing": "Chasse d'eau", + "fresh_chili_pepper": "Piment cili frais", + "frozen_berries": "Baies congelées", + "frozen_broccoli": "Brocoli surgelé", + "frozen_fruit": "Fruits congelés", + "frozen_pizza": "Pizza surgelée", + "frozen_spinach": "Epinards surgelés", + "funeral_card": "Carte funéraire", + "garam_masala": "Garam Masala", + "garbage_bag": "Sacs à ordures", + "garlic": "Ail", + "garlic_dip": "Trempette à l'ail", + "garlic_granules": "Ail en granulés", + "gherkins": "Cornichons", + "ginger": "Gingembre", + "ginger_ale": "Bière au gingembre", + "glass_noodles": "Nouilles en verre", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barre de céréales", + "grapes": "Raisins", + "greek_yogurt": "Yogourt grec", + "green_asparagus": "Asperges vertes", + "green_chili": "Piment vert", + "green_pesto": "Pesto vert", + "hair_gel": "Gel pour cheveux", + "hair_ties": "Attaches pour cheveux", + "hair_wax": "Cire pour cheveux", + "ham": "Jambon", + "ham_cubes": "Lardons", + "hand_soap": "Savon à main", + "handkerchief_box": "Boîte à mouchoirs", + "handkerchiefs": "Mouchoirs en papier", + "hard_cheese": "Fromage à pâte dure", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Noisettes", + "head_of_lettuce": "Tête de laitue", + "herb_baguettes": "Baguettes aux herbes", + "herb_butter": "Beurre aux herbes", + "herb_cream_cheese": "Fromage frais aux herbes", + "honey": "Miel", + "honey_wafers": "Gaufres au miel", + "hot_dog_bun": "Pain à hot-dog", + "ice_cream": "Crème glacée", + "ice_cube": "Glaçons", + "iceberg_lettuce": "Laitue iceberg", + "iced_tea": "Thé glacé", + "instant_soups": "Soupes instantanées", + "jam": "Confiture", + "jasmine_rice": "Riz au jasmin", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Haricots rouges", + "kitchen_roll": "Rouleau de cuisine", + "kitchen_towels": "Torchons de cuisine", + "kiwi": "Kiwi", + "kohlrabi": "Chou-rave", + "lasagna": "Lasagnes", + "lasagna_noodles": "Nouilles à lasagnes", + "lasagna_plates": "Assiettes à lasagnes", + "leaf_spinach": "Epinards à feuilles", + "leek": "Poireau", + "lemon": "Citron", + "lemon_curd": "Caillé de citron", + "lemon_juice": "Jus de citron", + "lemonade": "Limonade", + "lemongrass": "Lemongrass", + "lentil_stew": "Ragoût de lentilles", + "lentils": "Lentilles", + "lentils_red": "Lentilles rouges", + "lettuce": "Laitue", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Soins des lèvres", + "liqueur": "Liqueur", + "low-fat_curd_cheese": "Fromage blanc à faible teneur en matières grasses", + "maggi": "Maggi", + "magnesium": "Magnésium", + "mango": "Mangue", + "maple_syrup": "Sirop d'érable", + "margarine": "Margarine", + "marjoram": "Marjolaine", + "marshmallows": "Guimauves", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Masque", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Produit de substitution de la viande", + "microfiber_cloth": "Chiffon en microfibre", + "milk": "Lait", + "mint": "Menthe", + "mint_candy": "Bonbons à la menthe", + "miso_paste": "Pâte de miso", + "mixed_vegetables": "Légumes mélangés", + "mochis": "Mochis", + "mold_remover": "Démolisseur de moisissures", + "mountain_cheese": "Fromage de montagne", + "mouth_wash": "Bain de bouche", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Bar à muesli", + "mulled_wine": "Vin chaud", + "mushrooms": "Champignons", + "mustard": "Moutarde", + "nail_file": "Lime à ongles", + "nail_polish_remover": "Dissolvant", + "neutral_oil": "Huile neutre", + "nori_sheets": "Feuilles de nori", + "nutmeg": "Noix de muscade", + "oat_milk": "Lait d'avoine", + "oatmeal": "Flocons d'avoine", + "oatmeal_cookies": "Biscuits à la farine d'avoine", + "oatsome": "Avoine", + "obatzda": "Obatzda", + "oil": "Huile", + "olive_oil": "Huile d'olive", + "olives": "Olives", + "onion": "Oignon", + "onion_powder": "Oignon en poudre", + "orange_juice": "Jus d'orange", + "oranges": "Oranges", + "oregano": "Origan", + "organic_lemon": "Citron biologique", + "organic_waste_bags": "Sacs à déchets organiques", + "pak_choi": "Pak Choi", + "pantyhose": "Collants", + "papaya": "Papaye", + "paprika": "Paprika", + "paprika_seasoning": "Assaisonnement au paprika", + "pardina_lentils_dried": "Lentilles Pardina séchées", + "parmesan": "Parmesan", + "parsley": "Persil", + "pasta": "Pâtes", + "peach": "Pêche", + "peanut_butter": "Beurre de cacahuète", + "peanut_flips": "Flips aux cacahuètes", + "peanut_oil": "Huile d'arachide", + "peanuts": "Cacahuètes", + "pears": "Poires", + "peas": "Pois", + "penne": "Penne", + "pepper": "Poivre", + "pepper_mill": "Moulin à poivre", + "peppers": "Poivrons", + "persian_rice": "Riz persan", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pignons de pin", + "pineapple": "Ananas", + "pita_bag": "Sac à pita", + "pita_bread": "Pain pita", + "pizza": "Pizza", + "pizza_dough": "Pâte à pizza", + "plant_magarine": "Magarine végétale", + "plant_oil": "Huile végétale", + "plaster": "Plâtre", + "pointed_peppers": "Poivrons pointus", + "porcini_mushrooms": "Champignons Porcini", + "potato_dumpling_dough": "Pâte à boulettes de pommes de terre", + "potato_wedges": "Quartiers de pommes de terre", + "potatoes": "Pommes de terre", + "potting_soil": "Terreau", + "powder": "Poudre", + "powdered_sugar": "Sucre en poudre", + "processed_cheese": "Fromage fondu", + "prosecco": "Prosecco", + "puff_pastry": "Pâte feuilletée", + "pumpkin": "Citrouille", + "pumpkin_seeds": "Graines de citrouille", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radis", + "ramen": "Ramen", + "rapeseed_oil": "Huile de colza", + "raspberries": "Framboises", + "raspberry_syrup": "Sirop de framboise", + "razor_blades": "Lames de rasoir", + "red_bull": "Red Bull", + "red_chili": "Piment rouge", + "red_curry_paste": "Pâte de curry rouge", + "red_lentils": "Lentilles rouges", + "red_onions": "Oignons rouges", + "red_pesto": "Pesto rouge", + "red_wine": "Vin rouge", + "red_wine_vinegar": "Vinaigre de vin rouge", + "rhubarb": "Rhubarbe", + "ribbon_noodles": "Nouilles en ruban", + "rice": "Riz", + "rice_cakes": "Gâteaux de riz", + "rice_paper": "Papier de riz", + "rice_ribbon_noodles": "Nouilles en ruban de riz", + "rice_vinegar": "Vinaigre de riz", + "ricotta": "Ricotta", + "rinse_tabs": "Onglets de rinçage", + "rinsing_agent": "Agent de rinçage", + "risotto_rice": "Riz pour risotto", + "rocket": "Fusée", + "roll": "Rouleau", + "rosemary": "Rosemary", + "saffron_threads": "Fils de safran", + "sage": "Sage", + "saitan_powder": "Poudre de saitan", + "salad_mix": "Mélange de salades", + "salad_seeds_mix": "Mélange de graines pour salade", + "salt": "Sel", + "salt_mill": "Moulin à sel", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Saucisse", + "sausages": "Saucisses", + "savoy_cabbage": "Chou de Savoie", + "scallion": "Echalote", + "scattered_cheese": "Fromage éparpillé", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Porridge de semoule", + "sesame": "Sésame", + "sesame_oil": "Huile de sésame", + "shallot": "Échalote", + "shampoo": "Shampooing", + "shawarma_spice": "Épices pour shawarma", + "shiitake_mushroom": "Champignon Shiitake", + "shoe_insoles": "Semelles de chaussures", + "shower_gel": "Gel douche", + "shredded_cheese": "Fromage râpé", + "sieved_tomatoes": "Tomates tamisées", + "skyr": "Skyr", + "sliced_cheese": "Fromage en tranches", + "smoked_paprika": "Paprika fumé", + "smoked_tofu": "Tofu fumé", + "snacks": "Snacks", + "soap": "Savon", + "soba_noodles": "Nouilles Soba", + "soft_drinks": "Boissons gazeuses", + "soup_vegetables": "Soupe de légumes", + "sour_cream": "Crème aigre", + "sour_cucumbers": "Concombres aigres", + "soy_cream": "Crème de soja", + "soy_hack": "Soja haché", + "soy_sauce": "Sauce soja", + "soy_shred": "Effilochage de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Eau pétillante", + "spelt": "Épeautre", + "spinach": "Epinards", + "sponge_cloth": "Tissu éponge", + "sponge_fingers": "Doigts en éponge", + "sponge_wipes": "Lingettes éponge", + "sponges": "Éponges", + "spreading_cream": "Crème à tartiner", + "spring_onions": "Oignons de printemps", + "sprite": "Sprite", + "sprouts": "Sprouts", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates égouttées", + "strawberries": "Fraises", + "sugar": "Sucre", + "summer_roll_paper": "Rouleau de papier d'été", + "sunflower_oil": "Huile de tournesol", + "sunflower_seeds": "Graines de tournesol", + "sunscreen": "Crème solaire", + "sushi_rice": "Riz à sushi", + "swabian_ravioli": "Raviolis souabes", + "sweet_chili_sauce": "Sauce chili douce", + "sweet_potato": "Patate douce", + "sweet_potatoes": "Patates douces", + "sweets": "Bonbons", + "table_salt": "Sel de table", + "tagliatelle": "Tagliatelles", + "tahini": "Tahini", + "tangerines": "Mandarines", + "tape": "Ruban adhésif", + "tapioca_flour": "Farine de tapioca", + "tea": "Thé", + "teriyaki_sauce": "Sauce teriyaki", + "thyme": "Thym", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Papier hygiénique", + "tomato_juice": "Jus de tomate", + "tomato_paste": "Pâte de tomates", + "tomato_sauce": "Sauce tomate", + "tomatoes": "Tomates", + "tonic_water": "Eau tonique", + "toothpaste": "Dentifrice", + "tortellini": "Tortellini", + "tortilla_chips": "Chips de tortilla", + "tuna": "Thon", + "turmeric": "Curcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Nouilles Udon", + "uht_milk": "Lait UHT", + "vanilla_sugar": "Sucre vanillé", + "vegetable_bouillon_cube": "Cube de bouillon de légumes", + "vegetable_broth": "Bouillon de légumes", + "vegetable_oil": "Huile végétale", + "vegetable_onion": "Oignon végétal", + "vegetables": "Légumes", + "vegetarian_cold_cuts": "charcuterie végétarienne", + "vinegar": "Vinaigre", + "vitamin_tablets": "Comprimés de vitamines", + "vodka": "Vodka", + "walnuts": "Noix", + "washing_gel": "Gel de lavage", + "washing_powder": "Poudre à laver", + "water": "Eau", + "water_ice": "Glace d'eau", + "watermelon": "Pastèque", + "wc_cleaner": "Nettoyant pour WC", + "wheat_flour": "Farine de blé", + "whipped_cream": "Crème fouettée", + "white_wine": "Vin blanc", + "white_wine_vinegar": "Vinaigre de vin blanc", + "whole_canned_tomatoes": "Tomates entières en conserve", + "wild_berries": "Baies sauvages", + "wild_rice": "Riz sauvage", + "wildberry_lillet": "Lillet aux baies sauvages", + "worcester_sauce": "Sauce Worcester", + "wrapping_paper": "Papier d'emballage", + "wraps": "Wraps", + "yeast": "Levure", + "yeast_flakes": "Flocons de levure", + "yoghurt": "Yaourt", + "yogurt": "Yogourt", + "yum_yum": "Miam miam", + "zewa": "Zewa", + "zinc_cream": "Crème de zinc", + "zucchini": "Courgettes" + } +} diff --git a/backend/templates/l10n/hu.json b/backend/templates/l10n/hu.json new file mode 100644 index 00000000..62643ea3 --- /dev/null +++ b/backend/templates/l10n/hu.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Kenyér félék", + "canned": "🥫Konzervek", + "dairy": "🥛 Tejtermékek", + "drinks": "🍹 Italok", + "freezer": "❄️ Fagyasztott termékek", + "fruits_vegetables": "🥬 Zöldségek és gyümölcsök", + "grain": "🥟 Szemes termékek", + "hygiene": "🚽 Higéniás termékek", + "refrigerated": "💧 Hűtött termékek", + "snacks": "🥜 Nasi" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Alma", + "apple_pulp": "Alma szósz", + "applesauce": "Almaszósz", + "apricots": "Sárgabarack", + "apérol": "Aperol", + "arugula": "Rukkola", + "asian_egg_noodles": "Ázsiai tojásos tészta", + "asian_noodles": "Ázsiai tészta", + "asparagus": "Spárga", + "aspirin": "Aszpirin", + "avocado": "Avokádó", + "baby_potatoes": "Újkrumpli", + "baby_spinach": "Bébi spenót", + "bacon": "Szalonna", + "baguette": "Bagett", + "bakefish": "Hal", + "baking_cocoa": "Kakaópor", + "baking_mix": "Sütőpor", + "baking_paper": "Sütőpapír", + "baking_powder": "Sütő por", + "baking_soda": "Szódabikarbon", + "baking_yeast": "Élesztő", + "balsamic_vinegar": "Balzsam ecet", + "bananas": "Banán", + "basil": "Bazsalikom", + "basmati_rice": "Basmati rizs", + "bathroom_cleaner": "Fürdőszoba tisztító", + "batteries": "Elem", + "bay_leaf": "Babérlevél", + "beans": "Bab", + "beer": "Sör", + "beet": "Cékla", + "beetroot": "Cékla", + "birthday_card": "Szülinapi kártya", + "black_beans": "Fekete bab", + "bockwurst": "Virsli", + "bodywash": "Tusfürdő", + "bread": "Kenyér", + "breadcrumbs": "Kenyérmorzsa", + "broccoli": "Brokkoli", + "brown_sugar": "Barna cukor", + "brussels_sprouts": "Kelbimbó", + "buffalo_mozzarella": "Bivaly mozzarella", + "buns": "Zsemle", + "burger_buns": "Hamburger zsemle", + "burger_patties": "Hamburger hús", + "burger_sauces": "Hamburger szósz", + "butter": "Vaj", + "butter_cookies": "Vajas süti", + "button_cells": "Gombok", + "börek_cheese": "Börek sajt", + "cake": "Torta", + "cake_icing": "Torta krém", + "cane_sugar": "Nádcukor", + "cannelloni": "Cannelloni tészta", + "canola_oil": "Canola olaj", + "cardamom": "Kardamom", + "carrots": "Répa", + "cashews": "Kesudió", + "cat_treats": "Macska nasi", + "cauliflower": "Karfiol", + "celeriac": "Zellerszár", + "celery": "Zeller", + "cereal_bar": "Müzli szelet", + "cheddar": "Cheddar sajt", + "cheese": "Sajt", + "cherry_tomatoes": "Koktél paradicsom", + "chickpeas": "Csicseri borsó", + "chicory": "Cikória", + "chili_oil": "Csili olaj", + "chili_pepper": "Csili paprika", + "chips": "Csipsz", + "chives": "Metélőhagyma", + "chocolate": "Csokoládé", + "chocolate_chips": "Csokis süti", + "chopped_tomatoes": "Apritott paradicsom", + "chunky_tomatoes": "Darabos paradicsom", + "ciabatta": "Ciabatta", + "cider_vinegar": "Almaecet", + "cilantro": "Koriander", + "cinnamon": "Fahéj", + "cinnamon_stick": "Fahéj rúd", + "cocktail_sauce": "Koktél szósz", + "cocktail_tomatoes": "Koktélparadicsom", + "coconut_flakes": "Kokusz reszelék", + "coconut_milk": "Kókusz tej", + "coconut_oil": "Kókusz olaj", + "colorful_sprinkles": "Szines cukorszórás", + "concealer": "Alapozó", + "cookies": "Sütik", + "coriander": "Koriander mag", + "corn": "Kukorica", + "cornflakes": "Kukorica pehely", + "cornstarch": "Kukorica keményítő", + "cornys": "Cornys", + "corriander": "Korriander", + "cough_drops": "Köhögés elleni cukor", + "couscous": "Kuszkusz", + "covid_rapid_test": "COVID gyorsteszt", + "cow's_milk": "Tehéntej", + "cream": "Tejszín", + "cream_cheese": "Krémsajt", + "creamed_spinach": "Spenót főzelék", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Palacsinta lap", + "crispbread": "Kétszersült", + "cucumber": "Uborka", + "cumin": "Kömény", + "curd": "Vaniliasodó", + "curry_paste": "Curry krém", + "curry_powder": "Curry por", + "curry_sauce": "Curry szósz", + "dates": "Datolya", + "dental_floss": "Fogselyem", + "deo": "Dezodor", + "deodorant": "Dezodor", + "detergent": "Mosószer", + "detergent_sheets": "Tisztító lapok", + "diarrhea_remedy": "Hasmenés elleni gyógyszer", + "dill": "Petrezselyem", + "dishwasher_salt": "Mosógatógép só", + "dishwasher_tabs": "Mosógatógép tabbleta", + "disinfection_spray": "Fertötlenítő", + "dried_tomatoes": "Szárított paradicsom", + "edamame": "Edamame bab", + "egg_salad": "Tojás saláta", + "egg_yolk": "Tojás sárgája", + "eggplant": "Padlizsán", + "eggs": "Tojások", + "enoki_mushrooms": "Enoki gomba", + "eyebrow_gel": "Szemhélyfesték", + "falafel": "Falafel", + "falafel_powder": "Falafel por", + "fanta": "Fanta", + "feta": "Feta sajt", + "ffp2": "FFP2 maszk", + "fish_sticks": "Halrudacskák", + "flour": "Liszt", + "flushing": "Lefolyótisztitó", + "fresh_chili_pepper": "Friss chili paprika", + "frozen_berries": "Fagyasztott bogyók", + "frozen_fruit": "Fagyasztott gyümölcsök", + "frozen_pizza": "Fagyasztott pizza", + "frozen_spinach": "Fagyasztott spenót", + "funeral_card": "Temetésre kártya", + "garam_masala": "GaramMAsala", + "garbage_bag": "Szemetes zsák", + "garlic": "Fokhagyma", + "garlic_dip": "Fokhagyma szósz", + "garlic_granules": "Fokhagyma granulátum", + "gherkins": "Gherkin", + "ginger": "Gyömbér", + "glass_noodles": "Üveg tészta", + "gluten": "Glutén", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola saláta", + "gouda": "Gouda sajt", + "granola": "Granola müzli", + "granola_bar": "Granola szelet", + "grapes": "Szöllő", + "greek_yogurt": "Görög joghurt", + "green_asparagus": "Zöld spárga", + "green_chili": "Zöld csilli", + "green_pesto": "Zöld pesto", + "hair_gel": "Hajzselé", + "hair_ties": "Hajgumi", + "hair_wax": "Wax", + "hand_soap": "Szappan", + "handkerchief_box": "Zsebkendő", + "handkerchiefs": "Kéztörlő", + "hard_cheese": "Kemény sajt", + "haribo": "Gumicukor", + "harissa": "Harissa paszta", + "hazelnuts": "Törökmogyoró", + "head_of_lettuce": "Saláta fej", + "herb_baguettes": "Fűszeres bagett", + "herb_cream_cheese": "Fűszeres krémsajt", + "honey": "Méz", + "honey_wafers": "Mézes holland szelet", + "hot_dog_bun": "Hotdog kifli", + "ice_cream": "Jégkrém", + "ice_cube": "Jég", + "iceberg_lettuce": "Jégsaláta", + "iced_tea": "Jeges tea", + "instant_soups": "Instant leves", + "jam": "Lekvár", + "jasmine_rice": "Jázmin rizs", + "katjes": "Katjes", + "ketchup": "Kecsap", + "kidney_beans": "Vese Bab", + "kitchen_roll": "Konyhai kéztörlő", + "kitchen_towels": "Konyha ruha", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna tészta", + "lasagna_noodles": "Lasagna tésztaalap", + "lasagna_plates": "Lasagna tészta", + "leaf_spinach": "Spenót levél", + "leek": "Póréhagyma", + "lemon": "Citrom", + "lemon_curd": "Cirtom héj", + "lemon_juice": "Citromlé", + "lemonade": "Limonádé", + "lemongrass": "Citromfű", + "lentil_stew": "Lencsefőzelék", + "lentils": "Lencse", + "lentils_red": "Vöröslencse", + "lettuce": "Saláta", + "lillet": "Szamóca", + "lime": "Lime", + "linguine": "Hosszúmetélt", + "lip_care": "Ajakbalzsam", + "low-fat_curd_cheese": "Zsírsazegény túró", + "maggi": "Maggi kocka", + "magnesium": "Magnézium", + "mango": "Mangó", + "maple_syrup": "Juharszirup", + "margarine": "Margarin", + "marjoram": "Majoranna", + "marshmallows": "Pillecukor", + "mascara": "Smink", + "mascarpone": "Mascarpone", + "mask": "Éjjeli maszk", + "mayonnaise": "Majonéz", + "meat_substitute_product": "Húshelyetesítő", + "microfiber_cloth": "Mikroszálas törlőkendő", + "milk": "Tej", + "mint": "Menta", + "mint_candy": "Mentás cukor", + "miso_paste": "Miso paszta", + "mixed_vegetables": "Vegyes zöldség", + "mochis": "Mocsi", + "mold_remover": "Penészeltávolító", + "mountain_cheese": "Hegyi sajt", + "mouth_wash": "Szájvíz", + "mozzarella": "Mozzarella sajt", + "muesli": "Müzli", + "muesli_bar": "Müzli szelet", + "mulled_wine": "Kannás bor", + "mushrooms": "Gomba", + "mustard": "Mustár", + "nail_file": "Köröm reszelő", + "neutral_oil": "Napfraforgó olaj", + "nori_sheets": "Nori lapok", + "nutmeg": "Szerecsendió", + "oat_milk": "Zabtej", + "oatmeal": "Zabkása", + "oatmeal_cookies": "Zabos süti", + "oatsome": "Zabpehely", + "obatzda": "Obatzda", + "oil": "Olaj", + "olive_oil": "Oliva olaj", + "olives": "Oliva bogyó", + "onion": "Hagyma", + "onion_powder": "Hagyma por", + "orange_juice": "Narancslé", + "oranges": "Narancs", + "oregano": "Oregano", + "organic_lemon": "Bio citrom", + "organic_waste_bags": "Lebomló szemeteszsák", + "pak_choi": "Pak Choi", + "pantyhose": "harisnya", + "paprika": "Paprika", + "paprika_seasoning": "Piros paprika", + "pardina_lentils_dried": "Szárított lencse", + "parmesan": "Parmezán sajt", + "parsley": "Petrezselyem", + "pasta": "Tészta", + "peach": "Barack", + "peanut_butter": "Földimogyoró krém", + "peanut_flips": "Mogyorós Flip", + "peanut_oil": "Mogyoró olaj", + "peanuts": "Földimogyoró", + "pears": "Körte", + "peas": "Borsó", + "penne": "Penne tészta", + "pepper": "Bors", + "pepper_mill": "Bors szóró", + "peppers": "Paprikák", + "persian_rice": "Perzsa rizs", + "pesto": "Pesto", + "pilsner": "Világos sör", + "pine_nuts": "Fenyőmag", + "pineapple": "Ananász", + "pita_bag": "Pita", + "pita_bread": "Pita kenyér", + "pizza": "Pizza", + "pizza_dough": "Pizza tészta", + "plant_magarine": "Növényi margarin", + "plant_oil": "növényi olaj", + "plaster": "Sebtapasz", + "pointed_peppers": "Hegyes erős paprika", + "porcini_mushrooms": "Porcini gomba", + "potato_dumpling_dough": "Krumpligombóc tészta", + "potato_wedges": "Sültkrumpli", + "potatoes": "Krumpli", + "potting_soil": "Ültető föld", + "powder": "Por", + "powdered_sugar": "Porcukor", + "processed_cheese": "Trapista sajt", + "prosecco": "Pezsgő bor", + "puff_pastry": "Leveles tészta", + "pumpkin": "Tök", + "pumpkin_seeds": "Tökmag", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Cikória saláta", + "radish": "Retek", + "ramen": "Ramen", + "rapeseed_oil": "Szölömag olaj", + "raspberries": "Málna", + "raspberry_syrup": "Málna szirup", + "razor_blades": "Borotva penge", + "red_bull": "REdBull", + "red_chili": "Vörös Csili", + "red_curry_paste": "Vörös curry paszta", + "red_lentils": "Vörös lencse", + "red_onions": "Vöröshagyma", + "red_pesto": "Vörös pesto", + "red_wine": "Vörös bor", + "red_wine_vinegar": "Vörösbor ecet", + "rhubarb": "Rebarbara", + "ribbon_noodles": "Szalag tészta", + "rice": "Rizs", + "rice_cakes": "Rizs süti", + "rice_paper": "Rizspapir", + "rice_ribbon_noodles": "Rizs szalag tészta", + "rice_vinegar": "Rizs ecet", + "ricotta": "Ricotta sajt", + "rinse_tabs": "Tisaztitó tabletta", + "rinsing_agent": "Tisztitószer", + "risotto_rice": "Rizottó rizs", + "rocket": "Rakéta", + "roll": "Tekercs", + "rosemary": "Rozmaring", + "saffron_threads": "Sáfrányszál", + "sage": "Zsálya", + "saitan_powder": "Saitan por", + "salad_mix": "Saláta keverék", + "salad_seeds_mix": "Saláta mag keverék", + "salt": "Só", + "salt_mill": "Só malom", + "sambal_oelek": "Sambal oelek fűszerpástétom", + "sauce": "Szósz", + "sausage": "Kolbász", + "sausages": "Kolbászok", + "savoy_cabbage": "Savanyú káposzta", + "scallion": "Metélő hagyma", + "scattered_cheese": "Sajtkrém", + "schlemmerfilet": "Halfilé", + "schupfnudeln": "Nudli", + "semolina_porridge": "Semolina kása", + "sesame": "Szezámmag", + "sesame_oil": "Szezámmag olaj", + "shallot": "Sonka hagyma", + "shampoo": "Sampon", + "shawarma_spice": "Shawarma fűszer", + "shiitake_mushroom": "Shiitake gomba", + "shoe_insoles": "Cipőfűző", + "shower_gel": "Tusfürdő", + "shredded_cheese": "Reszelt sajt", + "sieved_tomatoes": "Szárított paradicsom", + "sliced_cheese": "Szeletelt sajt", + "smoked_paprika": "Füstölt pirospaprika", + "smoked_tofu": "Füstölt tofu", + "snacks": "Nasik", + "soap": "Szappan", + "soba_noodles": "Soba tészta", + "soft_drinks": "Üditő", + "soup_vegetables": "Leves zöldség", + "sour_cream": "Tejföl", + "sour_cucumbers": "Savanyú uborka", + "soy_cream": "Szója krém", + "soy_hack": "Szója hack", + "soy_sauce": "Szója szósz", + "soy_shred": "Szója darab", + "spaetzle": "Nokedli", + "spaghetti": "Spagetti", + "sparkling_water": "Ásvány víz", + "spelt": "Köles", + "spinach": "Spenót", + "sponge_cloth": "Mosogató rongy", + "sponge_fingers": "Babapiskóta", + "sponge_wipes": "Törlőkendő", + "sponges": "Szivacsok", + "spreading_cream": "Vajkrém", + "spring_onions": "Újhagyma", + "sprite": "sprite", + "sprouts": "Csírák", + "sriracha": "Sriracha szósz", + "strained_tomatoes": "Passzírozott paradicsom", + "strawberries": "Eper", + "sugar": "Cukor", + "summer_roll_paper": "Tavaszi tekercs", + "sunflower_oil": "Napraforgó olaj", + "sunflower_seeds": "Napraforgó mag", + "sunscreen": "Napvédő krém", + "sushi_rice": "Szusi rizs", + "swabian_ravioli": "Töltött tészta", + "sweet_chili_sauce": "Édes Csili szósz", + "sweet_potato": "Édesburgonya", + "sweet_potatoes": "Édesburgonyák", + "sweets": "Édességek", + "table_salt": "Asztali só", + "tagliatelle": "Tagliatelle tészta", + "tahini": "Tahini krém", + "tangerines": "Mandarin", + "tape": "Szalag", + "tapioca_flour": "Tápióka liszt", + "tea": "TEa", + "teriyaki_sauce": "Teriyaki szósz", + "thyme": "Kakukkfű", + "toast": "Piritós", + "tofu": "Tofu", + "toilet_paper": "WC papír", + "tomato_juice": "Paradicsomlé", + "tomato_paste": "Paradicsomkrém", + "tomato_sauce": "Paradicsom szósz", + "tomatoes": "Paradicsomok", + "tonic_water": "Tonic", + "toothpaste": "Fogkrém", + "tortellini": "Tortellini tészta", + "tortilla_chips": "Tortilla csipsz", + "tuna": "Tonhal", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki öntet", + "udon_noodles": "Udon tészta", + "uht_milk": "UHT tej", + "vanilla_sugar": "Vaniliás cukor", + "vegetable_bouillon_cube": "Zöldséges leveskocka", + "vegetable_broth": "Zöldség alaplé", + "vegetable_oil": "Zöldség olaj", + "vegetable_onion": "Zöld hagyma", + "vegetables": "Zöldségek", + "vegetarian_cold_cuts": "Vegetáriánus hideg vágás", + "vinegar": "Ecet", + "vitamin_tablets": "Vitaminok", + "vodka": "Vodka", + "washing_gel": "Mosógél", + "washing_powder": "Mosószer", + "water": "Víz", + "water_ice": "Vízjég", + "watermelon": "Dinnye", + "wc_cleaner": "WC tisztító", + "wheat_flour": "Fehér liszt", + "whipped_cream": "Habtejszín", + "white_wine": "Fehérbor", + "white_wine_vinegar": "Fehérbor ecet", + "whole_canned_tomatoes": "Egész koncerv paradicsom", + "wild_berries": "Erdei gyümölcsök", + "wild_rice": "Vad rizs", + "wildberry_lillet": "Ribizli", + "worcester_sauce": "Worchester szósz", + "wrapping_paper": "Csomagoló papír", + "wraps": "Tortilla lapok", + "yeast": "Élesztő", + "yeast_flakes": "Szárított élesztő", + "yoghurt": "Joghurt", + "yogurt": "Joghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Cink krém", + "zucchini": "Cukkini" + } +} diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json new file mode 100644 index 00000000..325f3be3 --- /dev/null +++ b/backend/templates/l10n/id.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Barang Roti", + "canned": "🥫 Makanan Kaleng", + "dairy": "🥛 Susu", + "drinks": "🍹 Minuman", + "freezer": "❄️ Freezer", + "fruits_vegetables": "🥬 Buah dan sayur", + "grain": "🥟 Produk Biji-bijian", + "hygiene": "🚽 Kebersihan", + "refrigerated": "💧 Didinginkan", + "snacks": "🥜 Camilan" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apel", + "apple_pulp": "Bubur apel", + "applesauce": "Saus apel", + "apricots": "Aprikot", + "apérol": "Apérol", + "arugula": "Arugula", + "asian_egg_noodles": "Mie telur Asia", + "asian_noodles": "Mie Asia", + "asparagus": "Asparagus", + "aspirin": "Aspirin", + "avocado": "Alpukat", + "baby_potatoes": "Kembar tiga", + "baby_spinach": "Bayam bayi", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bakefish", + "baking_cocoa": "Kakao panggang", + "baking_mix": "Campuran kue", + "baking_paper": "Kertas roti", + "baking_powder": "Bubuk pengembang", + "baking_soda": "Soda kue", + "baking_yeast": "Ragi kue", + "balsamic_vinegar": "Cuka balsamic", + "bananas": "Pisang", + "basil": "Basil", + "basmati_rice": "Beras Basmati", + "bathroom_cleaner": "Pembersih kamar mandi", + "batteries": "Baterai", + "bay_leaf": "Daun salam", + "beans": "Kacang", + "beer": "Bir", + "beet": "Bit", + "beetroot": "Bit", + "birthday_card": "Kartu ulang tahun", + "black_beans": "Kacang hitam", + "bockwurst": "Bockwurst", + "bodywash": "Sabun mandi", + "bread": "Roti", + "breadcrumbs": "Remah roti", + "broccoli": "Brokoli", + "brown_sugar": "Gula merah", + "brussels_sprouts": "Kubis Brussel", + "buffalo_mozzarella": "Mozzarella kerbau", + "buns": "Roti", + "burger_buns": "Roti Burger", + "burger_patties": "Roti Burger", + "burger_sauces": "Saus burger", + "butter": "Mentega", + "butter_cookies": "Kue mentega", + "button_cells": "Sel tombol", + "börek_cheese": "Keju Börek", + "cake": "Kue", + "cake_icing": "Lapisan gula kue", + "cane_sugar": "Gula tebu", + "cannelloni": "Cannelloni", + "canola_oil": "Minyak kanola", + "cardamom": "Kapulaga", + "carrots": "Wortel", + "cashews": "Kacang mete", + "cat_treats": "Camilan kucing", + "cauliflower": "Kembang kol", + "celeriac": "Celeriac", + "celery": "Seledri", + "cereal_bar": "Bar sereal", + "cheddar": "Cheddar", + "cheese": "Keju", + "cherry_tomatoes": "Tomat ceri", + "chickpeas": "Buncis", + "chicory": "Sawi putih", + "chili_oil": "Minyak cabai", + "chili_pepper": "Cabai", + "chips": "Keripik", + "chives": "Daun bawang", + "chocolate": "Cokelat", + "chocolate_chips": "Keripik cokelat", + "chopped_tomatoes": "Tomat cincang", + "chunky_tomatoes": "Tomat tebal", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cuka sari apel", + "cilantro": "Ketumbar", + "cinnamon": "Kayu manis", + "cinnamon_stick": "Batang kayu manis", + "cocktail_sauce": "Saus koktail", + "cocktail_tomatoes": "Tomat koktail", + "coconut_flakes": "Serpihan kelapa", + "coconut_milk": "Santan", + "coconut_oil": "Minyak kelapa", + "colorful_sprinkles": "Taburan warna-warni", + "concealer": "Concealer", + "cookies": "Cookie", + "coriander": "Ketumbar", + "corn": "Jagung", + "cornflakes": "Serpihan jagung", + "cornstarch": "Tepung maizena", + "cornys": "Cornys", + "corriander": "Corriander", + "cough_drops": "Obat tetes batuk", + "couscous": "Couscous", + "covid_rapid_test": "Tes cepat COVID", + "cow's_milk": "Susu sapi", + "cream": "Krim", + "cream_cheese": "Keju krim", + "creamed_spinach": "Bayam krim", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Pita krep", + "crispbread": "Roti Garing", + "cucumber": "Mentimun", + "cumin": "Jinten", + "curd": "Dadih", + "curry_paste": "Pasta kari", + "curry_powder": "Bubuk kari", + "curry_sauce": "Saus kari", + "dates": "Kurma", + "dental_floss": "Benang gigi", + "deo": "Deodoran", + "deodorant": "Deodoran", + "detergent": "Deterjen", + "detergent_sheets": "Lembaran deterjen", + "diarrhea_remedy": "Obat diare", + "dill": "Dill", + "dishwasher_salt": "Garam pencuci piring", + "dishwasher_tabs": "Tab pencuci piring", + "disinfection_spray": "Semprotan desinfeksi", + "dried_tomatoes": "Tomat kering", + "edamame": "Edamame", + "egg_salad": "Salad telur", + "egg_yolk": "Kuning telur", + "eggplant": "Terong", + "eggs": "Telur", + "enoki_mushrooms": "Jamur Enoki", + "eyebrow_gel": "Gel alis", + "falafel": "Falafel", + "falafel_powder": "Bubuk falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Tongkat ikan", + "flour": "Tepung", + "flushing": "Pembilasan", + "fresh_chili_pepper": "Cabai rawit segar", + "frozen_berries": "Buah beri beku", + "frozen_fruit": "Buah beku", + "frozen_pizza": "Pizza beku", + "frozen_spinach": "Bayam beku", + "funeral_card": "Kartu pemakaman", + "garam_masala": "Garam Masala", + "garbage_bag": "Kantong sampah", + "garlic": "Bawang putih", + "garlic_dip": "Saus bawang putih", + "garlic_granules": "Butiran bawang putih", + "gherkins": "Gherkins", + "ginger": "Jahe", + "glass_noodles": "Mie gelas", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola bar", + "grapes": "Anggur", + "greek_yogurt": "Yoghurt Yunani", + "green_asparagus": "Asparagus hijau", + "green_chili": "Cabai hijau", + "green_pesto": "Pesto hijau", + "hair_gel": "Gel rambut", + "hair_ties": "Ikatan rambut", + "hair_wax": "Lilin Rambut", + "hand_soap": "Sabun tangan", + "handkerchief_box": "Kotak saputangan", + "handkerchiefs": "Saputangan", + "hard_cheese": "Keju keras", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnut", + "head_of_lettuce": "Kepala selada", + "herb_baguettes": "Baguette herbal", + "herb_cream_cheese": "Keju krim herbal", + "honey": "Sayang.", + "honey_wafers": "Wafer madu", + "hot_dog_bun": "Roti hot dog", + "ice_cream": "Es krim", + "ice_cube": "Es batu", + "iceberg_lettuce": "Selada gunung es", + "iced_tea": "Es teh", + "instant_soups": "Sup instan", + "jam": "Selai", + "jasmine_rice": "Nasi melati", + "katjes": "Katjes", + "ketchup": "Kecap", + "kidney_beans": "Kacang merah", + "kitchen_roll": "Gulungan dapur", + "kitchen_towels": "Handuk dapur", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna", + "lasagna_noodles": "Mie Lasagna", + "lasagna_plates": "Piring Lasagna", + "leaf_spinach": "Daun bayam", + "leek": "Leek", + "lemon": "Lemon", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Jus lemon", + "lemonade": "Limun", + "lemongrass": "Serai", + "lentil_stew": "Rebusan miju-miju", + "lentils": "Lentil", + "lentils_red": "Lentil merah", + "lettuce": "Selada", + "lillet": "Lillet", + "lime": "Kapur", + "linguine": "Linguine", + "lip_care": "Perawatan Bibir", + "low-fat_curd_cheese": "Keju dadih rendah lemak", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mangga", + "maple_syrup": "Sirup maple", + "margarine": "Margarin", + "marjoram": "Marjoram", + "marshmallows": "Marshmallow", + "mascara": "Maskara", + "mascarpone": "Mascarpone", + "mask": "Topeng", + "mayonnaise": "Mayones", + "meat_substitute_product": "Produk pengganti daging", + "microfiber_cloth": "Kain mikrofiber", + "milk": "Susu", + "mint": "Mint", + "mint_candy": "Permen mint", + "miso_paste": "Pasta miso", + "mixed_vegetables": "Sayuran campuran", + "mochis": "Mochis", + "mold_remover": "Penghilang Jamur", + "mountain_cheese": "Keju gunung", + "mouth_wash": "Cuci mulut", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Muesli bar", + "mulled_wine": "Mulled wine", + "mushrooms": "Jamur", + "mustard": "Mustard", + "nail_file": "Kikir kuku", + "neutral_oil": "Minyak netral", + "nori_sheets": "Lembaran nori", + "nutmeg": "Pala", + "oat_milk": "Minuman gandum", + "oatmeal": "Oatmeal", + "oatmeal_cookies": "Kue gandum", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Minyak", + "olive_oil": "Minyak zaitun", + "olives": "Zaitun", + "onion": "Bawang", + "onion_powder": "Bubuk bawang", + "orange_juice": "Jus jeruk", + "oranges": "Jeruk", + "oregano": "Oregano", + "organic_lemon": "Lemon organik", + "organic_waste_bags": "Kantong sampah organik", + "pak_choi": "Pak Choi", + "pantyhose": "Pantyhose", + "paprika": "Paprika", + "paprika_seasoning": "Bumbu paprika", + "pardina_lentils_dried": "Lentil pardina dikeringkan", + "parmesan": "Parmesan", + "parsley": "Peterseli", + "pasta": "Pasta", + "peach": "Peach", + "peanut_butter": "Selai kacang", + "peanut_flips": "Membalik Kacang", + "peanut_oil": "Minyak kacang", + "peanuts": "Kacang", + "pears": "Pir", + "peas": "Kacang polong", + "penne": "Penne", + "pepper": "Lada", + "pepper_mill": "Penggilingan lada", + "peppers": "Paprika", + "persian_rice": "Beras Persia", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Kacang pinus", + "pineapple": "Nanas", + "pita_bag": "Tas pita", + "pita_bread": "Roti pita", + "pizza": "Pizza", + "pizza_dough": "Adonan pizza", + "plant_magarine": "Tanaman Magarine", + "plant_oil": "Minyak nabati", + "plaster": "Plester", + "pointed_peppers": "Paprika runcing", + "porcini_mushrooms": "Jamur porcini", + "potato_dumpling_dough": "Adonan pangsit kentang", + "potato_wedges": "Irisan kentang", + "potatoes": "Kentang", + "potting_soil": "Tanah pot", + "powder": "Bedak", + "powdered_sugar": "Gula bubuk", + "processed_cheese": "Keju olahan", + "prosecco": "Prosecco", + "puff_pastry": "Kue puff", + "pumpkin": "Labu", + "pumpkin_seeds": "Biji labu", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Lobak", + "ramen": "Ramen", + "rapeseed_oil": "Minyak lobak", + "raspberries": "Raspberry", + "raspberry_syrup": "Sirup raspberry", + "razor_blades": "Pisau cukur", + "red_bull": "Banteng Merah", + "red_chili": "Cabai merah", + "red_curry_paste": "Pasta kari merah", + "red_lentils": "Lentil merah", + "red_onions": "Bawang merah", + "red_pesto": "Pesto merah", + "red_wine": "Anggur merah", + "red_wine_vinegar": "Cuka anggur merah", + "rhubarb": "Rhubarb", + "ribbon_noodles": "Mie pita", + "rice": "Beras", + "rice_cakes": "Kue beras", + "rice_paper": "Kertas beras", + "rice_ribbon_noodles": "Mie pita nasi", + "rice_vinegar": "Cuka beras", + "ricotta": "Ricotta", + "rinse_tabs": "Bilas tab", + "rinsing_agent": "Agen pembilas", + "risotto_rice": "Nasi risotto", + "rocket": "Roket", + "roll": "Gulung", + "rosemary": "Rosemary", + "saffron_threads": "Benang kunyit", + "sage": "Sage", + "saitan_powder": "Bubuk saitan", + "salad_mix": "Campuran Salad", + "salad_seeds_mix": "Campuran biji salad", + "salt": "Garam", + "salt_mill": "Pabrik garam", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Sosis", + "sausages": "Sosis", + "savoy_cabbage": "Kubis savoy", + "scallion": "Daun bawang", + "scattered_cheese": "Keju yang tersebar", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Bubur semolina", + "sesame": "Wijen", + "sesame_oil": "Minyak wijen", + "shallot": "Bawang merah", + "shampoo": "Sampo", + "shawarma_spice": "Bumbu Shawarma", + "shiitake_mushroom": "Jamur Shiitake", + "shoe_insoles": "Sol sepatu", + "shower_gel": "Gel mandi", + "shredded_cheese": "Keju parut", + "sieved_tomatoes": "Tomat yang diayak", + "sliced_cheese": "Keju iris", + "smoked_paprika": "Paprika asap", + "smoked_tofu": "Tahu asap", + "snacks": "Makanan ringan", + "soap": "Sabun", + "soba_noodles": "Mie soba", + "soft_drinks": "Minuman ringan", + "soup_vegetables": "Sup sayuran", + "sour_cream": "Krim asam", + "sour_cucumbers": "Mentimun asam", + "soy_cream": "Krim kedelai", + "soy_hack": "Peretasan kedelai", + "soy_sauce": "Kecap", + "soy_shred": "Rusaknya kedelai", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Air soda", + "spelt": "Eja", + "spinach": "Bayam", + "sponge_cloth": "Kain spons", + "sponge_fingers": "Jari-jari spons", + "sponge_wipes": "Tisu spons", + "sponges": "Spons", + "spreading_cream": "Menyebarkan krim", + "spring_onions": "Daun bawang", + "sprite": "Sprite", + "sprouts": "Kecambah", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomat yang disaring", + "strawberries": "Stroberi", + "sugar": "Gula", + "summer_roll_paper": "Kertas gulung musim panas", + "sunflower_oil": "Minyak bunga matahari", + "sunflower_seeds": "Biji bunga matahari", + "sunscreen": "Tabir surya", + "sushi_rice": "Nasi sushi", + "swabian_ravioli": "Ravioli Swabia", + "sweet_chili_sauce": "Saus Cabai Manis", + "sweet_potato": "Ubi jalar", + "sweet_potatoes": "Ubi jalar", + "sweets": "Permen", + "table_salt": "Garam meja", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Jeruk keprok", + "tape": "Pita", + "tapioca_flour": "Tepung tapioka", + "tea": "Teh", + "teriyaki_sauce": "Saus teriyaki", + "thyme": "Thyme", + "toast": "Bersulang", + "tofu": "Tahu", + "toilet_paper": "Kertas toilet", + "tomato_juice": "Jus tomat", + "tomato_paste": "Pasta tomat", + "tomato_sauce": "Saus tomat", + "tomatoes": "Tomat", + "tonic_water": "Air tonik", + "toothpaste": "Pasta gigi", + "tortellini": "Tortellini", + "tortilla_chips": "Keripik Tortilla", + "tuna": "Tuna", + "turmeric": "Kunyit", + "tzatziki": "Tzatziki", + "udon_noodles": "Mie Udon", + "uht_milk": "Susu UHT", + "vanilla_sugar": "Gula vanila", + "vegetable_bouillon_cube": "Kubus kaldu sayuran", + "vegetable_broth": "Kaldu sayuran", + "vegetable_oil": "Minyak sayur", + "vegetable_onion": "Bawang sayur", + "vegetables": "Sayuran", + "vegetarian_cold_cuts": "potongan daging dingin vegetarian", + "vinegar": "Cuka", + "vitamin_tablets": "Tablet vitamin", + "vodka": "Vodka", + "washing_gel": "Gel pencuci", + "washing_powder": "Bubuk pencuci", + "water": "Air", + "water_ice": "Es air", + "watermelon": "Semangka", + "wc_cleaner": "Pembersih WC", + "wheat_flour": "Tepung terigu", + "whipped_cream": "Krim kocok", + "white_wine": "Anggur putih", + "white_wine_vinegar": "Cuka anggur putih", + "whole_canned_tomatoes": "Tomat kalengan utuh", + "wild_berries": "Buah beri liar", + "wild_rice": "Beras liar", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Saus Worcester", + "wrapping_paper": "Kertas pembungkus", + "wraps": "Membungkus", + "yeast": "Ragi", + "yeast_flakes": "Serpihan ragi", + "yoghurt": "Yoghurt", + "yogurt": "Yogurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Krim seng", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json new file mode 100644 index 00000000..fd9fdf0e --- /dev/null +++ b/backend/templates/l10n/it.json @@ -0,0 +1,497 @@ +{ + "categories": { + "bread": "🍞 Panetteria", + "canned": "🥫 Cibi in scatola", + "dairy": "🥛 Latticini", + "drinks": "🍹 Bevande", + "freezer": "❄️ Surgelati", + "fruits_vegetables": "🥬 Frutta e verdura", + "grain": "🥟 Prodotti a base di cereali", + "hygiene": "🚽 Igene", + "refrigerated": "💧 Refrigerati", + "snacks": "🥜 Spuntini" + }, + "items": { + "agave_syrup": "Sciroppo d'agave", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Mela", + "apple_pulp": "Popla di mela", + "applesauce": "Salsa di mela", + "apricots": "Albicocche", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Noodles asiatici all'uovo", + "asian_noodles": "Tagliatelle asiatiche", + "asparagus": "Asparagi", + "aspirin": "Aspirina", + "avocado": "Avocado", + "baby_potatoes": "Tripletta", + "baby_spinach": "Spinaci baby", + "bacon": "Pancetta", + "baguette": "Baguette", + "bakefish": "Pesce al forno", + "baking_cocoa": "Cacao da forno", + "baking_mix": "Preparato per dolci", + "baking_paper": "Carta da forno", + "baking_powder": "Lievito in polvere", + "baking_soda": "Bicarbonato di sodio", + "baking_yeast": "Lievito da forno", + "balsamic_vinegar": "Aceto balsamico", + "bananas": "Banane", + "basil": "Basilico", + "basmati_rice": "Riso basmati", + "bathroom_cleaner": "Detergente bagno", + "batteries": "Batterie", + "bay_leaf": "Alloro", + "beans": "Fagioli", + "beer": "Birra", + "beet": "Barbabietola", + "beetroot": "Rape", + "birthday_card": "Biglietto da compleanno", + "black_beans": "Fagioli neri", + "blister_plaster": "Cerotto per vesciche", + "bockwurst": "Wurstel", + "bodywash": "Detergente corpo", + "bread": "Pane", + "breadcrumbs": "Pangrattato", + "broccoli": "Broccoli", + "brown_sugar": "Zucchero di canna", + "brussels_sprouts": "Cavoletti di Bruxelles", + "buffalo_mozzarella": "Mozzarella di bufala", + "buns": "Pagnotte", + "burger_buns": "Pagnotte per hamburger", + "burger_patties": "Hamburger", + "burger_sauces": "Salse per hamburger", + "butter": "Burro", + "butter_cookies": "Biscotti al burro", + "butternut_squash": "Zucca trombetta", + "button_cells": "Pile a bottone", + "börek_cheese": "Formaggio Börek", + "cake": "Torta", + "cake_icing": "Glassa per torta", + "cane_sugar": "Zucchero di canna", + "cannelloni": "Cannelloni", + "canola_oil": "Olio di semi di colza", + "cardamom": "Cardamomo", + "carrots": "Carote", + "cashews": "Anacardi", + "cat_treats": "Snacks per gatti", + "cauliflower": "Cavolfiore", + "celeriac": "Sedano rapa", + "celery": "Sedano", + "cereal_bar": "Barretta ai cereali", + "cheddar": "Formaggio cheddar", + "cheese": "Formaggio", + "cherry_tomatoes": "Pomodori ciglieggina", + "chickpeas": "Ceci", + "chicory": "Cicoria", + "chili_oil": "Olio al peperoncino", + "chili_pepper": "Peperoncino", + "chips": "Patatine", + "chives": "Erba cipollina", + "chocolate": "Cioccolata", + "chocolate_chips": "Gocce di cioccolato", + "chopped_tomatoes": "Polpa di pomodoro", + "chunky_tomatoes": "Pomodori a pezzi", + "ciabatta": "Ciabatta", + "cider_vinegar": "Aceto di mele", + "cilantro": "Coriandolo", + "cinnamon": "Cannella", + "cinnamon_stick": "Bastoncini di cannella", + "cocktail_sauce": "Salsa cocktail", + "cocktail_tomatoes": "Pomodorini cocktail", + "coconut_flakes": "Fiocchi di cocco", + "coconut_milk": "Latte di cocco", + "coconut_oil": "Olio di cocco", + "coffee_powder": "Caffè in polvere", + "colorful_sprinkles": "Confettini colorati", + "concealer": "Cancellina", + "cookies": "Biscotti", + "coriander": "Coriandolo", + "corn": "Mais", + "cornflakes": "Cornflakes", + "cornstarch": "Amido di mais", + "cornys": "Cereali Cornys", + "corriander": "Corriandolo", + "cotton_rounds": "Batuffoli di cotone", + "cough_drops": "Sciroppo per la tosse", + "couscous": "Couscous", + "covid_rapid_test": "Test rapido COVID", + "cow's_milk": "Latte di mucca", + "cream": "Crema", + "cream_cheese": "Crema di formaggio", + "creamed_spinach": "Spinaci alla crema", + "creme_fraiche": "Crème fraîche", + "crepe_tape": "Scotch crespo", + "crispbread": "Pane croccante", + "cucumber": "Cetriolo", + "cumin": "Cumino", + "curd": "Cagliata", + "curry_paste": "Pasta di curry", + "curry_powder": "Curry in polvere", + "curry_sauce": "Salsa al curry", + "dates": "Date", + "dental_floss": "Filo interdentale", + "deo": "Deodorante", + "deodorant": "Deodorante", + "detergent": "Detergente", + "detergent_sheets": "Fogli di detersivo", + "diarrhea_remedy": "Rimedio contro la diarrea", + "dill": "Aneto", + "dishwasher_salt": "Sale per lavastoviglie", + "dishwasher_tabs": "Pastiglie per lavastoviglie", + "disinfection_spray": "Disinfestante spray", + "dried_tomatoes": "Pomodori secchi", + "dry_yeast": "Lievito secco", + "edamame": "Edamame", + "egg_salad": "Insalata di uova", + "egg_yolk": "Tuorlo d'uovo", + "eggplant": "Melanzana", + "eggs": "Uova", + "enoki_mushrooms": "Funghi Enoki", + "eyebrow_gel": "Gel per sopracciglia", + "falafel": "Falafel", + "falafel_powder": "Preparato per Falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Bastoncini di pesce", + "flour": "Farina", + "flushing": "Risciacquo", + "fresh_chili_pepper": "Peperoncino fresco", + "frozen_berries": "Bacche surgelate", + "frozen_broccoli": "Broccoli surgelati", + "frozen_fruit": "Frutta surgelata", + "frozen_pizza": "Pizza surgelata", + "frozen_spinach": "Spinaci surgelati", + "funeral_card": "Biglietto funebre", + "garam_masala": "Garam Masala", + "garbage_bag": "Sacchetti per la spazzatura", + "garlic": "Aglio", + "garlic_dip": "Salsa all'aglio", + "garlic_granules": "Aglio in granuli", + "gherkins": "Cetriolini", + "ginger": "Zenzero", + "ginger_ale": "Ginger ale", + "glass_noodles": "Glass noodles", + "gluten": "Glutine", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barretta di granola", + "grapes": "Uva", + "greek_yogurt": "Yogurt greco", + "green_asparagus": "Asparagi verdi", + "green_chili": "Peperoncino verde", + "green_pesto": "Pesto alla genovese", + "hair_gel": "Gel per capelli", + "hair_ties": "Fascette per capelli", + "hair_wax": "Cera per capelli", + "ham": "Prosciutto cotto", + "ham_cubes": "Prosciutto cotto a dadini", + "hand_soap": "Sapone per le mani", + "handkerchief_box": "Scatola per fazzoletti", + "handkerchiefs": "Fazzoletti", + "hard_cheese": "Formaggio a pasta dura", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Nocciole", + "head_of_lettuce": "Testa di lattuga", + "herb_baguettes": "Baguette alle erbe", + "herb_butter": "Burro alle erbe", + "herb_cream_cheese": "Crema di formaggio alle erbe", + "honey": "Il miele", + "honey_wafers": "Cialde di miele", + "hot_dog_bun": "Panino per hot dog", + "ice_cream": "Gelato", + "ice_cube": "Cubetti di ghiaccio", + "iceberg_lettuce": "Lattuga Iceberg", + "iced_tea": "Tè freddo", + "instant_soups": "Zuppe istantanee", + "jam": "Marmellata", + "jasmine_rice": "Riso al gelsomino", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Fagioli renali", + "kitchen_roll": "Rotolo da cucina", + "kitchen_towels": "Asciugamani da cucina", + "kohlrabi": "Cavolo rapa", + "lasagna": "Lasagne", + "lasagna_noodles": "Tagliatelle per lasagne", + "lasagna_plates": "Piatti di lasagna", + "leaf_spinach": "Spinaci in foglia", + "leek": "Porro", + "lemon": "Limone", + "lemon_curd": "Curd al limone", + "lemon_juice": "Succo di limone", + "lemonade": "Limonata", + "lemongrass": "Citronella", + "lentil_stew": "Stufato di lenticchie", + "lentils": "Lenticchie", + "lentils_red": "Lenticchie rosse", + "lettuce": "Lattuga", + "lillet": "Lillet", + "lime": "Calce", + "linguine": "Linguine", + "lip_care": "Cura delle labbra", + "liqueur": "Liquore", + "low-fat_curd_cheese": "Formaggio cagliato a basso contenuto di grassi", + "maggi": "Maggi", + "magnesium": "Magnesio", + "mango": "Mango", + "maple_syrup": "Sciroppo d'acero", + "margarine": "Margarina", + "marjoram": "Maggiorana", + "marshmallows": "Marshmallow", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Maschera", + "mayonnaise": "Maionese", + "meat_substitute_product": "Prodotto sostitutivo della carne", + "microfiber_cloth": "Panno in microfibra", + "milk": "Latte", + "mint": "Menta", + "mint_candy": "Caramelle alla menta", + "miso_paste": "Pasta di miso", + "mixed_vegetables": "Verdure miste", + "mochis": "Mochis", + "mold_remover": "Rimuovi muffa", + "mountain_cheese": "Formaggio di montagna", + "mouth_wash": "Lavaggio della bocca", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barretta di muesli", + "mulled_wine": "Vin brulè", + "mushrooms": "Funghi", + "mustard": "Senape", + "nail_file": "Lima per unghie", + "nail_polish_remover": "Acetone per smalto unghie", + "neutral_oil": "Olio neutro", + "nori_sheets": "Fogli di nori", + "nutmeg": "Noce moscata", + "oat_milk": "Latte d'avena", + "oatmeal": "Farina d'avena", + "oatmeal_cookies": "Biscotti d'avena", + "oatsome": "Avena", + "obatzda": "Obatzda", + "oil": "Olio", + "olive_oil": "Olio d'oliva", + "olives": "Olive", + "onion": "Cipolla", + "onion_powder": "Cipolla in polvere", + "orange_juice": "Succo d'arancia", + "oranges": "Arance", + "oregano": "Origano", + "organic_lemon": "Limone biologico", + "organic_waste_bags": "Sacchetti per rifiuti organici", + "pak_choi": "Pak Choi", + "pantyhose": "Collant", + "papaya": "Papaya", + "paprika": "Paprika", + "paprika_seasoning": "Condimento alla paprika", + "pardina_lentils_dried": "Lenticchie Pardina secche", + "parmesan": "Parmigiano", + "parsley": "Prezzemolo", + "pasta": "Pasta", + "peach": "Pesca", + "peanut_butter": "Burro di arachidi", + "peanut_flips": "Pinzette di arachidi", + "peanut_oil": "Olio di arachidi", + "peanuts": "Arachidi", + "pears": "Pere", + "peas": "Piselli", + "penne": "Penne", + "pepper": "Pepe", + "pepper_mill": "Macinapepe", + "peppers": "Peperoni", + "persian_rice": "Riso persiano", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinoli", + "pineapple": "Ananas", + "pita_bag": "Sacchetto di pita", + "pita_bread": "Pane pita", + "pizza": "Pizza", + "pizza_dough": "Impasto per pizza", + "plant_magarine": "Impianto Magarine", + "plant_oil": "Olio vegetale", + "plaster": "Gesso", + "pointed_peppers": "Peperoni a punta", + "porcini_mushrooms": "Funghi porcini", + "potato_dumpling_dough": "Impasto per gnocchi di patate", + "potato_wedges": "Cunei di patate", + "potatoes": "Patate", + "potting_soil": "Terriccio", + "powder": "Polvere", + "powdered_sugar": "Zucchero a velo", + "processed_cheese": "Formaggio trasformato", + "prosecco": "Prosecco", + "puff_pastry": "Pasta sfoglia", + "pumpkin": "Zucca", + "pumpkin_seeds": "Semi di zucca", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Ravanello", + "ramen": "Ramen", + "rapeseed_oil": "Olio di colza", + "raspberries": "Lamponi", + "raspberry_syrup": "Sciroppo di lamponi", + "razor_blades": "Lame di rasoio", + "red_bull": "Red Bull", + "red_chili": "Peperoncino rosso", + "red_curry_paste": "Pasta di curry rosso", + "red_lentils": "Lenticchie rosse", + "red_onions": "Cipolle rosse", + "red_pesto": "Pesto rosso", + "red_wine": "Vino rosso", + "red_wine_vinegar": "Aceto di vino rosso", + "rhubarb": "Rabarbaro", + "ribbon_noodles": "Tagliatelle a nastro", + "rice": "Il riso", + "rice_cakes": "Torte di riso", + "rice_paper": "Carta di riso", + "rice_ribbon_noodles": "Tagliatelle a nastro di riso", + "rice_vinegar": "Aceto di riso", + "ricotta": "Ricotta", + "rinse_tabs": "Schede di risciacquo", + "rinsing_agent": "Agente di risciacquo", + "risotto_rice": "Riso per risotti", + "rocket": "Razzo", + "roll": "Rotolo", + "rosemary": "Rosmarino", + "saffron_threads": "Fili di zafferano", + "sage": "Salvia", + "saitan_powder": "Polvere di saitan", + "salad_mix": "Miscela di insalate", + "salad_seeds_mix": "Miscela di semi per insalata", + "salt": "Il sale", + "salt_mill": "Mulino per il sale", + "sambal_oelek": "Sambal oelek", + "sauce": "Salsa", + "sausage": "Salsiccia", + "sausages": "Salsicce", + "savoy_cabbage": "Cavolo verza", + "scallion": "Scalogna", + "scattered_cheese": "Formaggio sparso", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Porridge di semola", + "sesame": "Sesamo", + "sesame_oil": "Olio di sesamo", + "shallot": "Scalogno", + "shampoo": "Shampoo", + "shawarma_spice": "Spezie Shawarma", + "shiitake_mushroom": "Fungo shiitake", + "shoe_insoles": "Solette per scarpe", + "shower_gel": "Gel doccia", + "shredded_cheese": "Formaggio a pezzetti", + "sieved_tomatoes": "Pomodori setacciati", + "skyr": "Skyr", + "sliced_cheese": "Formaggio a fette", + "smoked_paprika": "Paprika affumicata", + "smoked_tofu": "Tofu affumicato", + "snacks": "Spuntini", + "soap": "Sapone", + "soba_noodles": "Tagliatelle di soba", + "soft_drinks": "Bevande analcoliche", + "soup_vegetables": "Zuppa di verdure", + "sour_cream": "Panna acida", + "sour_cucumbers": "Cetrioli acidi", + "soy_cream": "Crema di soia", + "soy_hack": "Macinato di soia", + "soy_sauce": "Salsa di soia", + "soy_shred": "Tritatutto di soia", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Acqua frizzante", + "spelt": "Farro", + "spinach": "Spinaci", + "sponge_cloth": "Panno di spugna", + "sponge_fingers": "Dita di spugna", + "sponge_wipes": "Salviette di spugna", + "sponges": "Spugne", + "spreading_cream": "Crema da spalmare", + "spring_onions": "Cipolline", + "sprite": "Sprite", + "sprouts": "Germogli", + "sriracha": "Sriracha", + "strained_tomatoes": "Pomodori filtrati", + "strawberries": "Fragole", + "sugar": "Zucchero", + "summer_roll_paper": "Carta in rotoli per l'estate", + "sunflower_oil": "Olio di girasole", + "sunflower_seeds": "Semi di girasole", + "sunscreen": "Protezione solare", + "sushi_rice": "Riso per sushi", + "swabian_ravioli": "Ravioli svevi", + "sweet_chili_sauce": "Salsa al peperoncino dolce", + "sweet_potato": "Patata dolce", + "sweet_potatoes": "Patate dolci", + "sweets": "Dolci", + "table_salt": "Sale da cucina", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarini", + "tape": "Nastro", + "tapioca_flour": "Farina di tapioca", + "tea": "Tè", + "teriyaki_sauce": "Salsa teriyaki", + "thyme": "Timo", + "toast": "Brindisi", + "tofu": "Tofu", + "toilet_paper": "Carta igienica", + "tomato_juice": "Succo di pomodoro", + "tomato_paste": "Pasta di pomodoro", + "tomato_sauce": "Salsa di pomodoro", + "tomatoes": "Pomodori", + "tonic_water": "Acqua tonica", + "toothpaste": "Dentifricio", + "tortellini": "Tortellini", + "tortilla_chips": "Patatine fritte", + "tuna": "Tonno", + "turmeric": "Curcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Tagliatelle Udon", + "uht_milk": "Latte UHT", + "vanilla_sugar": "Zucchero vanigliato", + "vegetable_bouillon_cube": "Dado per brodo vegetale", + "vegetable_broth": "Brodo vegetale", + "vegetable_oil": "Olio vegetale", + "vegetable_onion": "Cipolla vegetale", + "vegetables": "Verdure", + "vegetarian_cold_cuts": "salumi vegetariani", + "vinegar": "Aceto", + "vitamin_tablets": "Compresse di vitamine", + "vodka": "Vodka", + "walnuts": "Noci", + "washing_gel": "Gel di lavaggio", + "washing_powder": "Detersivo in polvere", + "water": "Acqua", + "water_ice": "Ghiaccio d'acqua", + "watermelon": "Anguria", + "wc_cleaner": "Detergente per WC", + "wheat_flour": "Farina di frumento", + "whipped_cream": "Panna montata", + "white_wine": "Vino bianco", + "white_wine_vinegar": "Aceto di vino bianco", + "whole_canned_tomatoes": "Pomodori interi in scatola", + "wild_berries": "Frutti di bosco", + "wild_rice": "Riso selvatico", + "wildberry_lillet": "Lillet alle bacche selvatiche", + "worcester_sauce": "Salsa Worcester", + "wrapping_paper": "Carta da regalo", + "wraps": "Avvolgimenti", + "yeast": "Lievito", + "yeast_flakes": "Fiocchi di lievito", + "yoghurt": "Yogurt", + "yogurt": "Yogurt", + "yum_yum": "Gnam gnam", + "zewa": "Zewa", + "zinc_cream": "Crema allo zinco", + "zucchini": "Zucchine" + } +} diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json new file mode 100644 index 00000000..eb3c6650 --- /dev/null +++ b/backend/templates/l10n/nb_NO.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Brødvarer", + "canned": "🥫 Hermetikk", + "dairy": "🥛 Meieri", + "drinks": "🍹 Drinker", + "freezer": "❄️ Fryser", + "fruits_vegetables": "🥬 Frukt og grønnsaker", + "grain": "🥟 Kornprodukter", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Nedkjølt", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Eple", + "apple_pulp": "Eplemasse", + "applesauce": "Eplemos", + "apricots": "Aprikoser", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatiske eggnudler", + "asian_noodles": "Asiatiske nudler", + "asparagus": "Asparges", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_potatoes": "Trillinger", + "baby_spinach": "Baby spinat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bakefisk", + "baking_cocoa": "Baking av kakao", + "baking_mix": "Bakemiks", + "baking_paper": "Bakepapir", + "baking_powder": "Bakepulver", + "baking_soda": "Bakepulver", + "baking_yeast": "Bakegjær", + "balsamic_vinegar": "Balsamicoeddik", + "bananas": "Bananer", + "basil": "Basilikum", + "basmati_rice": "Basmati ris", + "bathroom_cleaner": "Baderomsrengjøringsmiddel", + "batteries": "Batterier", + "bay_leaf": "Løvblad", + "beans": "Bønner", + "beer": "Øl", + "beet": "Rødbeter", + "beetroot": "Rødbeter", + "birthday_card": "Bursdagskort", + "black_beans": "Svarte bønner", + "bockwurst": "Bockwurst", + "bodywash": "Kroppsvask", + "bread": "Brød", + "breadcrumbs": "Brødsmuler", + "broccoli": "Brokkoli", + "brown_sugar": "Brunt sukker", + "brussels_sprouts": "Rosenkål", + "buffalo_mozzarella": "Bøffelmozzarella", + "buns": "Boller", + "burger_buns": "Burgerboller", + "burger_patties": "Burger Patties", + "burger_sauces": "Burgersauser", + "butter": "Smør", + "butter_cookies": "Smørkaker", + "button_cells": "Knappeceller", + "börek_cheese": "Börek-ost", + "cake": "Kake", + "cake_icing": "Kakeglasur", + "cane_sugar": "Rørsukker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsolje", + "cardamom": "Kardemomme", + "carrots": "Gulrøtter", + "cashews": "Cashewnøtter", + "cat_treats": "Kattegodbiter", + "cauliflower": "Blomkål", + "celeriac": "Knollselleri", + "celery": "Selleri", + "cereal_bar": "Frokostblanding", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Cherrytomater", + "chickpeas": "Kikerter", + "chicory": "Sikori", + "chili_oil": "Chiliolje", + "chili_pepper": "Chilipepper", + "chips": "Chips", + "chives": "Gressløk", + "chocolate": "Sjokolade", + "chocolate_chips": "Sjokoladebiter", + "chopped_tomatoes": "Hakkede tomater", + "chunky_tomatoes": "Grove tomater", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider eddik", + "cilantro": "Koriander", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstang", + "cocktail_sauce": "Cocktailsaus", + "cocktail_tomatoes": "Cocktailtomater", + "coconut_flakes": "Kokosflak", + "coconut_milk": "Kokosmelk", + "coconut_oil": "Kokosnøttolje", + "colorful_sprinkles": "Fargerikt strøssel", + "concealer": "Concealer", + "cookies": "Informasjonskapsler", + "coriander": "Koriander", + "corn": "Mais", + "cornflakes": "Cornflakes", + "cornstarch": "Maisstivelse", + "cornys": "Cornys", + "corriander": "Koriander", + "cough_drops": "Hostedråper", + "couscous": "Couscous", + "covid_rapid_test": "COVID-hurtigtest", + "cow's_milk": "Kumelk", + "cream": "Krem", + "cream_cheese": "Fløteost", + "creamed_spinach": "Kremet spinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Kreppbånd", + "crispbread": "Knekkebrød", + "cucumber": "Agurk", + "cumin": "Kummin", + "curd": "Ostemasse", + "curry_paste": "Karrypasta", + "curry_powder": "Karrypulver", + "curry_sauce": "Karrisaus", + "dates": "Datoer", + "dental_floss": "Tanntråd", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Vaskemiddel", + "detergent_sheets": "Vaskemiddelark", + "diarrhea_remedy": "Middel mot diaré", + "dill": "Dill", + "dishwasher_salt": "Oppvaskmaskinsalt", + "dishwasher_tabs": "Tabs til oppvaskmaskin", + "disinfection_spray": "Desinfeksjonsspray", + "dried_tomatoes": "Tørkede tomater", + "edamame": "Edamame", + "egg_salad": "Eggesalat", + "egg_yolk": "Eggeplomme", + "eggplant": "Aubergine", + "eggs": "Egg", + "enoki_mushrooms": "Enoki-sopp", + "eyebrow_gel": "Øyenbrynsgelé", + "falafel": "Falafel", + "falafel_powder": "Falafel pulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskepinner", + "flour": "Mel", + "flushing": "Spyling", + "fresh_chili_pepper": "Fersk chilipepper", + "frozen_berries": "Frosne bær", + "frozen_fruit": "Frossen frukt", + "frozen_pizza": "Frossen pizza", + "frozen_spinach": "Frossen spinat", + "funeral_card": "Begravelseskort", + "garam_masala": "Garam Masala", + "garbage_bag": "Søppelposer", + "garlic": "Hvitløk", + "garlic_dip": "Hvitløksdipp", + "garlic_granules": "Hvitløksgranulat", + "gherkins": "Agurker", + "ginger": "Ingefær", + "glass_noodles": "Glassnudler", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola-bar", + "grapes": "Druer", + "greek_yogurt": "Gresk yoghurt", + "green_asparagus": "Grønn asparges", + "green_chili": "Grønn chili", + "green_pesto": "Grønn pesto", + "hair_gel": "Hårgelé", + "hair_ties": "Hårbånd", + "hair_wax": "Hårvoks", + "hand_soap": "Håndsåpe", + "handkerchief_box": "Lommetørkleboks", + "handkerchiefs": "Lommetørklær", + "hard_cheese": "Hard ost", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnøtter", + "head_of_lettuce": "Salathode", + "herb_baguettes": "Urtebaguetter", + "herb_cream_cheese": "Kremost med urter", + "honey": "Honning", + "honey_wafers": "Honning wafers", + "hot_dog_bun": "Pølsebrød", + "ice_cream": "Iskrem", + "ice_cube": "Isbiter", + "iceberg_lettuce": "Isbergsalat", + "iced_tea": "Iste", + "instant_soups": "Instant supper", + "jam": "Syltetøy", + "jasmine_rice": "Sjasminris", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybønner", + "kitchen_roll": "Kjøkkenrull", + "kitchen_towels": "Kjøkkenhåndklær", + "kohlrabi": "Kålrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagne nudler", + "lasagna_plates": "Lasagneplater", + "leaf_spinach": "Bladspinat", + "leek": "Purre", + "lemon": "Sitron", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Sitronsaft", + "lemonade": "Limonade", + "lemongrass": "Sitrongress", + "lentil_stew": "Linsestuing", + "lentils": "Linser", + "lentils_red": "Røde linser", + "lettuce": "Salat", + "lillet": "Lillet", + "lime": "Kalk", + "linguine": "Linguine", + "lip_care": "Leppepleie", + "low-fat_curd_cheese": "Ost med lavt fettinnhold", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Lønnesirup", + "margarine": "Margarin", + "marjoram": "Merian", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Maske", + "mayonnaise": "Majones", + "meat_substitute_product": "Kjøtterstatningsprodukt", + "microfiber_cloth": "Mikrofiberklut", + "milk": "Melk", + "mint": "Mint", + "mint_candy": "Mint godteri", + "miso_paste": "Misopasta", + "mixed_vegetables": "Blandede grønnsaker", + "mochis": "Mochis", + "mold_remover": "Muggfjerner", + "mountain_cheese": "Fjellost", + "mouth_wash": "Munnskyllemiddel", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müslibar", + "mulled_wine": "Gløgg", + "mushrooms": "Sopp", + "mustard": "Sennep", + "nail_file": "Neglefil", + "neutral_oil": "Nøytral olje", + "nori_sheets": "Nori-ark", + "nutmeg": "Muskatnøtt", + "oat_milk": "Havredrikk", + "oatmeal": "Havregryn", + "oatmeal_cookies": "Havregrynkaker", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Olje", + "olive_oil": "Olivenolje", + "olives": "Oliven", + "onion": "Løk", + "onion_powder": "Løkpulver", + "orange_juice": "Appelsinjuice", + "oranges": "Appelsiner", + "oregano": "Oregano", + "organic_lemon": "Økologisk sitron", + "organic_waste_bags": "Poser for organisk avfall", + "pak_choi": "Pak Choi", + "pantyhose": "Strømpebukse", + "paprika": "Paprika", + "paprika_seasoning": "Paprikakrydder", + "pardina_lentils_dried": "Pardina linser tørket", + "parmesan": "Parmesan", + "parsley": "Persille", + "pasta": "Pasta", + "peach": "Fersken", + "peanut_butter": "Peanøttsmør", + "peanut_flips": "Peanøtt Flips", + "peanut_oil": "Jordnøttolje", + "peanuts": "Peanøtter", + "pears": "Pærer", + "peas": "Erter", + "penne": "Penne", + "pepper": "Pepper", + "pepper_mill": "Pepperkvern", + "peppers": "Paprika", + "persian_rice": "Persisk ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjekjerner", + "pineapple": "Ananas", + "pita_bag": "Pitapose", + "pita_bread": "Pitabrød", + "pizza": "Pizza", + "pizza_dough": "Pizzadeig", + "plant_magarine": "Plant Magarine", + "plant_oil": "Planteolje", + "plaster": "Gips", + "pointed_peppers": "Spiss paprika", + "porcini_mushrooms": "Porcini sopp", + "potato_dumpling_dough": "Deig til potetboller", + "potato_wedges": "Potetkiler", + "potatoes": "Poteter", + "potting_soil": "Pottejord", + "powder": "Pulver", + "powdered_sugar": "Pulverisert sukker", + "processed_cheese": "Bearbeidet ost", + "prosecco": "Prosecco", + "puff_pastry": "Butterdeig", + "pumpkin": "Gresskar", + "pumpkin_seeds": "Gresskarfrø", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Reddik", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolje", + "raspberries": "Bringebær", + "raspberry_syrup": "Bringebærsirup", + "razor_blades": "Barberblader", + "red_bull": "Red Bull", + "red_chili": "Rød chili", + "red_curry_paste": "Rød karripasta", + "red_lentils": "Røde linser", + "red_onions": "Rødløk", + "red_pesto": "Rød pesto", + "red_wine": "Rødvin", + "red_wine_vinegar": "Rødvinseddik", + "rhubarb": "Rabarbra", + "ribbon_noodles": "Båndnudler", + "rice": "Ris", + "rice_cakes": "Riskaker", + "rice_paper": "Rispapir", + "rice_ribbon_noodles": "Risbåndnudler", + "rice_vinegar": "Riseddik", + "ricotta": "Ricotta", + "rinse_tabs": "Skylletabletter", + "rinsing_agent": "Skyllemiddel", + "risotto_rice": "Risottoris", + "rocket": "Rakett", + "roll": "Rulle", + "rosemary": "Rosmarin", + "saffron_threads": "Safrantråder", + "sage": "Sage", + "saitan_powder": "Saitan pulver", + "salad_mix": "Salatblanding", + "salad_seeds_mix": "Salatfrøblanding", + "salt": "Salt", + "salt_mill": "Saltmølle", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Pølse", + "sausages": "Pølser", + "savoy_cabbage": "Savoykål", + "scallion": "Scallion", + "scattered_cheese": "Spredt ost", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Grøt av semulegryn", + "sesame": "Sesam", + "sesame_oil": "Sesamolje", + "shallot": "Sjalottløk", + "shampoo": "Sjampo", + "shawarma_spice": "Shawarma krydder", + "shiitake_mushroom": "Shiitake-sopp", + "shoe_insoles": "Skosåler", + "shower_gel": "Dusjgelé", + "shredded_cheese": "Strimlet ost", + "sieved_tomatoes": "Siktede tomater", + "sliced_cheese": "Skivet ost", + "smoked_paprika": "Røkt paprika", + "smoked_tofu": "Røkt tofu", + "snacks": "Snacks", + "soap": "Såpe", + "soba_noodles": "Soba-nudler", + "soft_drinks": "Brus", + "soup_vegetables": "Suppe med grønnsaker", + "sour_cream": "Rømme", + "sour_cucumbers": "Sure agurker", + "soy_cream": "Soyakrem", + "soy_hack": "Soya-hack", + "soy_sauce": "Soyasaus", + "soy_shred": "Soyastrimler", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Kullsyreholdig vann", + "spelt": "Spelt", + "spinach": "Spinat", + "sponge_cloth": "Svampeklut", + "sponge_fingers": "Svampefingre", + "sponge_wipes": "Svampeservietter", + "sponges": "Svamper", + "spreading_cream": "Påsmøring av krem", + "spring_onions": "Vårløk", + "sprite": "Sprite", + "sprouts": "Spirer", + "sriracha": "Sriracha", + "strained_tomatoes": "Silte tomater", + "strawberries": "Jordbær", + "sugar": "Sukker", + "summer_roll_paper": "Papir for sommerruller", + "sunflower_oil": "Solsikkeolje", + "sunflower_seeds": "Solsikkefrø", + "sunscreen": "Solkrem", + "sushi_rice": "Sushi ris", + "swabian_ravioli": "Schwabisk ravioli", + "sweet_chili_sauce": "Søt chilisaus", + "sweet_potato": "Søtpotet", + "sweet_potatoes": "Søtpoteter", + "sweets": "Søtsaker", + "table_salt": "Bordsalt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariner", + "tape": "Tape", + "tapioca_flour": "Tapiokamel", + "tea": "Te", + "teriyaki_sauce": "Teriyakisaus", + "thyme": "Timian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toalettpapir", + "tomato_juice": "Tomatjuice", + "tomato_paste": "Tomatpuré", + "tomato_sauce": "Tomatsaus", + "tomatoes": "Tomater", + "tonic_water": "Tonic vann", + "toothpaste": "Tannkrem", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Tunfisk", + "turmeric": "Gurkemeie", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nudler", + "uht_milk": "UHT-melk", + "vanilla_sugar": "Vaniljesukker", + "vegetable_bouillon_cube": "Grønnsaksbuljongterning", + "vegetable_broth": "Grønnsaksbuljong", + "vegetable_oil": "Vegetabilsk olje", + "vegetable_onion": "Grønnsaksløk", + "vegetables": "Grønnsaker", + "vegetarian_cold_cuts": "vegetarisk pålegg", + "vinegar": "Eddik", + "vitamin_tablets": "Vitamintabletter", + "vodka": "Vodka", + "washing_gel": "Vaskegel", + "washing_powder": "Vaskepulver", + "water": "Vann", + "water_ice": "Vannis", + "watermelon": "Vannmelon", + "wc_cleaner": "WC-rengjøringsmiddel", + "wheat_flour": "Hvetemel", + "whipped_cream": "Pisket krem", + "white_wine": "Hvitvin", + "white_wine_vinegar": "Hvitvinseddik", + "whole_canned_tomatoes": "Hele hermetiske tomater", + "wild_berries": "Ville bær", + "wild_rice": "Vill ris", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcestersaus", + "wrapping_paper": "Innpakningspapir", + "wraps": "Innpakning", + "yeast": "Gjær", + "yeast_flakes": "Gjærflak", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Sink krem", + "zucchini": "Courgetter" + } +} diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json new file mode 100644 index 00000000..e1736ee9 --- /dev/null +++ b/backend/templates/l10n/nl.json @@ -0,0 +1,483 @@ +{ + "categories": { + "bread": "🍞 Gebakken artikelen", + "canned": "🥫 Ingeblikt eten", + "dairy": "🥛 Zuivel", + "drinks": "🍹 Drinken", + "freezer": "❄️ Vriezer", + "fruits_vegetables": "🥬 Fruit en Groenten", + "grain": "🥟 Pasta en noedels", + "hygiene": "🚽 Hygiëne", + "refrigerated": "💧 Gekoeld", + "snacks": "🥜 Snacks" + }, + "items": { + "agave_syrup": "Agave siroop", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Appel", + "apple_pulp": "Appelpulp", + "applesauce": "Appelmoes", + "apricots": "Abrikozen", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Aziatische eiernoedels", + "asian_noodles": "Noedels", + "asparagus": "Asperges", + "aspirin": "Aspirine", + "avocado": "Avocado", + "baby_potatoes": "Krielaardappel", + "baby_spinach": "Babyspinazie", + "bacon": "Spek", + "baguette": "Stokbrood", + "bakefish": "Gebakken vis", + "baking_cocoa": "Bak cacao", + "baking_mix": "Bak mix", + "baking_paper": "Bakpapier", + "baking_powder": "Bakpoeder", + "baking_soda": "Bak soda", + "baking_yeast": "Gist", + "balsamic_vinegar": "Balsamico azijn", + "bananas": "Bananen", + "basil": "Basilicum", + "basmati_rice": "Basmati rijst", + "bathroom_cleaner": "Badkamer reiniger", + "batteries": "Batterijen", + "bay_leaf": "Laurierblad", + "beans": "Bonen", + "beef": "Biefstuk", + "beer": "Bier", + "beet": "Biet", + "beetroot": "Biet", + "birthday_card": "Verjaardagskaart", + "black_beans": "Zwarte bonen", + "bockwurst": "Bockworst", + "bodywash": "Body wash", + "bread": "Brood", + "breadcrumbs": "Paneermeel", + "broccoli": "Broccoli", + "brown_sugar": "Bruine suiker", + "brussels_sprouts": "Spruiten", + "buffalo_mozzarella": "Buffel mozzarella", + "buns": "Broodjes", + "burger_buns": "Hamburgerbroodjes", + "burger_patties": "Hamburgers", + "burger_sauces": "Burger sauzen", + "butter": "Boter", + "butter_cookies": "Boterkoekjes", + "button_cells": "Knoopbatterij", + "börek_cheese": "Börek kaas", + "cake": "Taart", + "cake_icing": "Taart glazuur", + "cane_sugar": "Rietsuiker", + "cannelloni": "Cannelloni", + "canola_oil": "Canola olie", + "cardamom": "Kardemom", + "carrots": "Wortelen", + "cashews": "Cashewnoten", + "cat_treats": "Kattensnoepjes", + "cauliflower": "Bloemkool", + "celeriac": "Knolselderij", + "celery": "Selderij", + "cereal_bar": "Graanreep", + "cheddar": "Cheddar", + "cheese": "Kaas", + "cherry_tomatoes": "Cherry tomaten", + "chickpeas": "Kikkererwten", + "chicory": "Cichorei", + "chili_oil": "Chili olie", + "chili_pepper": "Chilipeper", + "chips": "Chips", + "chives": "Bieslook", + "chocolate": "Chocolade", + "chocolate_chips": "Chocolade chips", + "chopped_tomatoes": "Gehakte tomaten", + "chunky_tomatoes": "Grove tomaten", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider azijn", + "cilantro": "Koriander", + "cinnamon": "Kaneel", + "cinnamon_stick": "Kaneelstokje", + "cocktail_sauce": "Cocktailsaus", + "cocktail_tomatoes": "Cocktail tomaten", + "coconut_flakes": "Kokosnootschilfers", + "coconut_milk": "Kokosmelk", + "coconut_oil": "Kokosolie", + "colorful_sprinkles": "Kleurrijke hagelslag", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Koriander", + "corn": "Maïs", + "cornflakes": "Cornflakes", + "cornstarch": "Maïszetmeel", + "cornys": "Cornys", + "corriander": "Koriander", + "cough_drops": "Hoestdruppels", + "couscous": "Couscous", + "covid_rapid_test": "COVID-sneltest", + "cow's_milk": "Koemelk", + "cream": "Crème", + "cream_cheese": "Roomkaas", + "creamed_spinach": "Spinazie a la crème", + "creme_fraiche": "Crème fraiche", + "crepe_tape": "Afplaktape", + "crispbread": "Knäckebröd", + "cucumber": "Komkommer", + "cumin": "Komijn", + "curd": "Wrongel", + "curry_paste": "Kerriepasta", + "curry_powder": "Kerriepoeder", + "curry_sauce": "Kerrie saus", + "dates": "Data", + "dental_floss": "Flosdraad", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Wasmiddel", + "detergent_sheets": "Vellen wasmiddel", + "diarrhea_remedy": "Middelen tegen diarree", + "dill": "Dille", + "dishwasher_salt": "Vaatwasser zout", + "dishwasher_tabs": "Vaatwasser tabs", + "disinfection_spray": "Ontsmettingsspray", + "dried_tomatoes": "Gedroogde tomaten", + "edamame": "Edamame", + "egg_salad": "Eiersalade", + "egg_yolk": "Eigeel", + "eggplant": "Aubergine", + "eggs": "Eieren", + "enoki_mushrooms": "Enoki paddenstoelen", + "eyebrow_gel": "Wenkbrauw gel", + "falafel": "Falafel", + "falafel_powder": "Falafel poeder", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Vissticks", + "flour": "Meel", + "flushing": "Spoelen", + "fresh_chili_pepper": "Verse chili peper", + "frozen_berries": "Bevroren bessen", + "frozen_fruit": "Bevroren fruit", + "frozen_pizza": "Bevroren pizza", + "frozen_spinach": "Bevroren spinazie", + "funeral_card": "Begrafeniskaart", + "garam_masala": "Garam Masala", + "garbage_bag": "Vuilniszakken", + "garlic": "Knoflook", + "garlic_dip": "Knoflook dip", + "garlic_granules": "Knoflookgranulaat", + "gherkins": "Augurken", + "ginger": "Gember", + "glass_noodles": "Glazen noedels", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Goudse kaas", + "granola": "Granola", + "granola_bar": "Granolareep", + "grapes": "Druiven", + "greek_yogurt": "Griekse yoghurt", + "green_asparagus": "Groene asperges", + "green_chili": "Groene chili", + "green_pesto": "Groene pesto", + "hair_gel": "Haargel", + "hair_ties": "Haarbanden", + "hair_wax": "Haarwas", + "hand_soap": "Handzeep", + "handkerchief_box": "Zakdoek doos", + "handkerchiefs": "Zakdoeken", + "hard_cheese": "Harde kaas", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnoten", + "head_of_lettuce": "Krop sla", + "herb_baguettes": "Baguettes met kruiden", + "herb_cream_cheese": "Kruidenroomkaas", + "honey": "Honing", + "honey_wafers": "Honingwafels", + "hot_dog_bun": "Hot dog broodje", + "ice_cream": "IJs", + "ice_cube": "IJsblokjes", + "iceberg_lettuce": "IJsbergsla", + "iced_tea": "Ijsthee", + "instant_soups": "Instant soepen", + "jam": "Jam", + "jasmine_rice": "Jasmijnrijst", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybonen", + "kitchen_roll": "Keukenrol", + "kitchen_towels": "Keukenhanddoeken", + "kohlrabi": "Koolrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagna noedels", + "lasagna_plates": "Lasagne borden", + "leaf_spinach": "Bladspinazie", + "leek": "Prei", + "lemon": "Citroen", + "lemon_curd": "Citroen kwark", + "lemon_juice": "Citroensap", + "lemonade": "Limonade", + "lemongrass": "Citroengras", + "lentil_stew": "Stoofpotje van linzen", + "lentils": "Linzen", + "lentils_red": "Rode linzen", + "lettuce": "Sla", + "lillet": "Lillet", + "lime": "Kalk", + "linguine": "Linguine", + "lip_care": "Lipverzorging", + "low-fat_curd_cheese": "Magere kwark", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Ahornsiroop", + "margarine": "Margarine", + "marjoram": "Marjolein", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Masker", + "mayonnaise": "Mayonaise", + "meat_substitute_product": "Vleesvervangend product", + "microfiber_cloth": "Microfiber doek", + "milk": "Melk", + "mint": "Munt", + "mint_candy": "Mint snoep", + "miso_paste": "Misopasta", + "mixed_vegetables": "Gemengde groenten", + "mochis": "Mochi", + "mold_remover": "Schimmelverwijderaar", + "mountain_cheese": "Bergkaas", + "mouth_wash": "Mondspoeling", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Mueslireep", + "mulled_wine": "Glühwein", + "mushrooms": "Champignons", + "mustard": "Mosterd", + "nail_file": "Nagelvijl", + "neutral_oil": "Neutrale olie", + "nori_sheets": "Nori vellen", + "nutmeg": "Nootmuskaat", + "oat_milk": "Haverdrank", + "oatmeal": "Havermout", + "oatmeal_cookies": "Havermoutkoekjes", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Olie", + "olive_oil": "Olijfolie", + "olives": "Olijven", + "onion": "Ui", + "onion_powder": "Uipoeder", + "orange_juice": "Sinaasappelsap", + "oranges": "Sinaasappels", + "oregano": "Oregano", + "organic_lemon": "Biologische citroen", + "organic_waste_bags": "Zakken voor organisch afval", + "pak_choi": "Paksoi", + "pantyhose": "Kousenband", + "paprika": "Paprika", + "paprika_seasoning": "Paprikakruiden", + "pardina_lentils_dried": "Pardina linzen gedroogd", + "parmesan": "Parmezaan", + "parsley": "Peterselie", + "pasta": "Pasta", + "peach": "Perzik", + "peanut_butter": "Pindakaas", + "peanut_flips": "Pinda flips", + "peanut_oil": "Pindaolie", + "peanuts": "Pinda's", + "pears": "Peren", + "peas": "Erwten", + "penne": "Penne", + "pepper": "Peper", + "pepper_mill": "Pepermolen", + "peppers": "Paprika's", + "persian_rice": "Perzische rijst", + "pesto": "Pesto", + "pilsner": "Pils", + "pine_nuts": "Pijnboompitten", + "pineapple": "Ananas", + "pita_bag": "Pita zak", + "pita_bread": "Pitabrood", + "pizza": "Pizza", + "pizza_dough": "Pizzadeeg", + "plant_magarine": "Plantaardige margarine", + "plant_oil": "Plantaardige olie", + "plaster": "Gips", + "pointed_peppers": "Puntpaprika's", + "porcini_mushrooms": "Porcini paddestoelen", + "potato_dumpling_dough": "Aardappel knoedel deeg", + "potato_wedges": "Aardappelpartjes", + "potatoes": "Aardappelen", + "potting_soil": "Potgrond", + "powder": "Poeder", + "powdered_sugar": "Poedersuiker", + "processed_cheese": "Verwerkte kaas", + "prosecco": "Prosecco", + "puff_pastry": "Bladerdeeg", + "pumpkin": "Pompoen", + "pumpkin_seeds": "Pompoenpitten", + "quark": "Kwark", + "quinoa": "Quinoa", + "radicchio": "Roodlof", + "radish": "Radijs", + "ramen": "Ramen", + "rapeseed_oil": "Koolzaadolie", + "raspberries": "Frambozen", + "raspberry_syrup": "Frambozensiroop", + "razor_blades": "Scheermesjes", + "red_bull": "Red Bull", + "red_chili": "Rode chili", + "red_curry_paste": "Rode currypasta", + "red_lentils": "Rode linzen", + "red_onions": "Rode uien", + "red_pesto": "Rode pesto", + "red_wine": "Rode wijn", + "red_wine_vinegar": "Rode wijnazijn", + "rhubarb": "Rabarber", + "ribbon_noodles": "Lintnoedels", + "rice": "Rijst", + "rice_cakes": "Rijstwafels", + "rice_paper": "Rijstpapier", + "rice_ribbon_noodles": "Rijstlint noedels", + "rice_vinegar": "Rijstazijn", + "ricotta": "Ricotta", + "rinse_tabs": "Spoel tabs", + "rinsing_agent": "Spoelmiddel", + "risotto_rice": "Risotto rijst", + "rocket": "Raket", + "roll": "Rol", + "rosemary": "Rozemarijn", + "saffron_threads": "Saffraandraden", + "sage": "Salie", + "saitan_powder": "Saitan poeder", + "salad_mix": "Salade Mix", + "salad_seeds_mix": "Salade zaden mix", + "salt": "Zout", + "salt_mill": "Zoutmolen", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Worst", + "sausages": "Worstjes", + "savoy_cabbage": "Savooiekool", + "scallion": "Bosui", + "scattered_cheese": "Verspreide kaas", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Griesmeelpap", + "sesame": "Sesam", + "sesame_oil": "Sesamolie", + "shallot": "Sjalot", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma kruiden", + "shiitake_mushroom": "Shiitake paddenstoel", + "shoe_insoles": "Schoenzolen", + "shower_gel": "Douchegel", + "shredded_cheese": "Versnipperde kaas", + "sieved_tomatoes": "Gezeefde tomaten", + "sliced_cheese": "Gesneden kaas", + "smoked_paprika": "Gerookte paprika", + "smoked_tofu": "Gerookte tofu", + "snacks": "Snacks", + "soap": "Zeep", + "soba_noodles": "Soba noedels", + "soft_drinks": "Frisdranken", + "soup_vegetables": "Soepgroenten", + "sour_cream": "Zure room", + "sour_cucumbers": "Zure komkommers", + "soy_cream": "Sojaroom", + "soy_hack": "Soja hack", + "soy_sauce": "Sojasaus", + "soy_shred": "Sojasnippers", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sprankelend water", + "spelt": "Spelt", + "spinach": "Spinazie", + "sponge_cloth": "Sponsdoek", + "sponge_fingers": "Sponsvingers", + "sponge_wipes": "Sponsdoekjes", + "sponges": "Sponzen", + "spreading_cream": "Smeercrème", + "spring_onions": "Lente-uitjes", + "sprite": "Sprite", + "sprouts": "Spruiten", + "sriracha": "Sriracha", + "strained_tomatoes": "Gezeefde tomaten", + "strawberries": "Aardbeien", + "sugar": "Suiker", + "summer_roll_paper": "Rijstpapier", + "sunflower_oil": "Zonnebloemolie", + "sunflower_seeds": "Zonnebloempitten", + "sunscreen": "Zonnebrandcrème", + "sushi_rice": "Sushi rijst", + "swabian_ravioli": "Zwabische ravioli", + "sweet_chili_sauce": "Zoete Chilisaus", + "sweet_potato": "Zoete aardappel", + "sweet_potatoes": "Zoete aardappelen", + "sweets": "Zoetigheden", + "table_salt": "Tafelzout", + "tagliatelle": "Tagliatelle", + "tahini": "Tahin", + "tangerines": "Mandarijnen", + "tape": "Tape", + "tapioca_flour": "Tapiocameel", + "tea": "Thee", + "teriyaki_sauce": "Teriyaki saus", + "thyme": "Tijm", + "toast": "Geroosterd brood", + "tofu": "Tofu", + "toilet_paper": "Toiletpapier", + "tomato_juice": "Tomatensap", + "tomato_paste": "Tomatenpasta", + "tomato_sauce": "Tomatensaus", + "tomatoes": "Tomaten", + "tonic_water": "Tonic", + "toothpaste": "Tandpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortillachips", + "tuna": "Tonijn", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon noedels", + "uht_milk": "UHT-melk", + "vanilla_sugar": "Vanille suiker", + "vegetable_bouillon_cube": "Groentebouillonblokje", + "vegetable_broth": "Groentenbouillon", + "vegetable_oil": "Plantaardige olie", + "vegetable_onion": "Plantaardige ui", + "vegetables": "Groenten", + "vegetarian_cold_cuts": "vegetarische vleeswaren", + "vinegar": "Azijn", + "vitamin_tablets": "Vitamine tabletten", + "vodka": "Wodka", + "washing_gel": "Wasgel", + "washing_powder": "Waspoeder", + "water": "Water", + "water_ice": "Waterijs", + "watermelon": "Watermeloen", + "wc_cleaner": "WC-reiniger", + "wheat_flour": "Tarwebloem", + "whipped_cream": "Slagroom", + "white_wine": "Witte wijn", + "white_wine_vinegar": "Witte wijnazijn", + "whole_canned_tomatoes": "Hele tomaten in blik", + "wild_berries": "Wilde bessen", + "wild_rice": "Wilde rijst", + "wildberry_lillet": "Wilde bosbes Lillet", + "worcester_sauce": "Worcestersaus", + "wrapping_paper": "Inpakpapier", + "wraps": "Wraps", + "yeast": "Gist", + "yeast_flakes": "Gistvlokken", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zink crème", + "zucchini": "Courgette" + } +} diff --git a/backend/templates/l10n/pl.json b/backend/templates/l10n/pl.json new file mode 100644 index 00000000..b632ebc6 --- /dev/null +++ b/backend/templates/l10n/pl.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Pieczywo", + "canned": "🥫 Konserwy", + "dairy": "🥛 Nabiał", + "drinks": "🍹 Napoje", + "freezer": "❄️ Mrożone", + "fruits_vegetables": "🥬 Warzywa i owoce", + "grain": "🥟 Wyroby mączne", + "hygiene": "🚽 Higiena", + "refrigerated": "💧 Schłodzone", + "snacks": "🥜 Przekąski" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Jabłko", + "apple_pulp": "Przecier jabłkowy", + "applesauce": "Mus jabłkowy", + "apricots": "Morele", + "apérol": "Apérol", + "arugula": "Rukola", + "asian_egg_noodles": "Azjatycki makaron jajeczny", + "asian_noodles": "Makaron azjatycki", + "asparagus": "Szparagi", + "aspirin": "Aspiryna", + "avocado": "Awokado", + "baby_potatoes": "Trojaczki", + "baby_spinach": "Szpinak", + "bacon": "Boczek", + "baguette": "Bagietka", + "bakefish": "Smażona ryba", + "baking_cocoa": "Kakao do pieczenia", + "baking_mix": "Gotowa mieszanka", + "baking_paper": "Papier do pieczenia", + "baking_powder": "Proszek do pieczenia", + "baking_soda": "Soda oczyszczona", + "baking_yeast": "Drożdże", + "balsamic_vinegar": "Ocet balsamiczny", + "bananas": "Banany", + "basil": "Bazylia", + "basmati_rice": "Ryż basmati", + "bathroom_cleaner": "Płyn do czyszczenia toalet", + "batteries": "Baterie", + "bay_leaf": "Liść laurowy", + "beans": "Fasolka", + "beer": "Piwo", + "beet": "Buraki", + "beetroot": "Buraki", + "birthday_card": "Kartka urodzinowa", + "black_beans": "Czarna fasolka", + "bockwurst": "Parówka", + "bodywash": "Żel do mycia", + "bread": "Chleb", + "breadcrumbs": "Panierka", + "broccoli": "Brokuły", + "brown_sugar": "Cukier brązowy", + "brussels_sprouts": "Brukselka", + "buffalo_mozzarella": "Mozzarella wołowa", + "buns": "Bułeczki", + "burger_buns": "Bułki do hamburgerów", + "burger_patties": "Kotlety do hamburgerów", + "burger_sauces": "Sosy do burgerów", + "butter": "Masło", + "butter_cookies": "Ciastka maślane", + "button_cells": "Baterie guzikowe", + "börek_cheese": "Ser Börek", + "cake": "Ciasto", + "cake_icing": "Polewa do ciasta", + "cane_sugar": "Cukier trzcinowy", + "cannelloni": "Cannelloni", + "canola_oil": "Olej rzepakowy", + "cardamom": "Kardamon", + "carrots": "Marchewki", + "cashews": "Nanercz zachodni", + "cat_treats": "Smaczki dla kota", + "cauliflower": "Kalafior", + "celeriac": "Seler", + "celery": "Seler naciowy", + "cereal_bar": "Baton musli", + "cheddar": "Ser cheddar", + "cheese": "Ser", + "cherry_tomatoes": "Pomidorki koktajlowe", + "chickpeas": "Ciecierzyca", + "chicory": "Cykoria", + "chili_oil": "Olej chili", + "chili_pepper": "Papryczka chili", + "chips": "Frytki", + "chives": "Szczypiorek", + "chocolate": "Czekolada", + "chocolate_chips": "Cząstki czekolady", + "chopped_tomatoes": "Pokrojone pomidory", + "chunky_tomatoes": "Grube pomidory", + "ciabatta": "Ciabatta", + "cider_vinegar": "Ocet jabłkowy", + "cilantro": "Kolendra", + "cinnamon": "Cynamon", + "cinnamon_stick": "Laska cynamonowa", + "cocktail_sauce": "Sos koktajlowy", + "cocktail_tomatoes": "Pomidorki koktajlowe", + "coconut_flakes": "Wiórki kokosowe", + "coconut_milk": "Mleko kokosowe", + "coconut_oil": "Olej kokosowy", + "colorful_sprinkles": "Kolorowa posypka", + "concealer": "Korektor", + "cookies": "Ciastka", + "coriander": "Kolendra", + "corn": "Kukurydza", + "cornflakes": "Płatki kukurydziane", + "cornstarch": "Skrobia kukurydziana", + "cornys": "Cornys", + "corriander": "Kolendra", + "cough_drops": "Tabletki na kaszel", + "couscous": "Kuskus", + "covid_rapid_test": "Szybki test COVID", + "cow's_milk": "Mleko krowie", + "cream": "Śmietana", + "cream_cheese": "Ser biały", + "creamed_spinach": "Breja szpinakowa", + "creme_fraiche": "Crème fraîche", + "crepe_tape": "Taśma bibułowa", + "crispbread": "Pieczywo chrupkie", + "cucumber": "Ogórek", + "cumin": "Kumin", + "curd": "Twaróg", + "curry_paste": "Pasta curry", + "curry_powder": "Curry", + "curry_sauce": "Sos curry", + "dates": "Daktyle", + "dental_floss": "Nić dentystyczna", + "deo": "Dezodorant", + "deodorant": "Dezodorant", + "detergent": "Środek czyszczący", + "detergent_sheets": "Arkusze detergentu", + "diarrhea_remedy": "Środek na biegunkę", + "dill": "Koper", + "dishwasher_salt": "Sól do zmywarki", + "dishwasher_tabs": "Tabletki do zmywarki", + "disinfection_spray": "Spray do dezynfekcji", + "dried_tomatoes": "Suszone pomidory", + "edamame": "Edamame", + "egg_salad": "Sałatka jajeczna", + "egg_yolk": "Żółtko jaja", + "eggplant": "Psianka podłużna", + "eggs": "Jajka", + "enoki_mushrooms": "Grzyby enoki", + "eyebrow_gel": "Żel do brwi", + "falafel": "Falafel", + "falafel_powder": "Falafel w proszku", + "fanta": "Fanta", + "feta": "Ser feta", + "ffp2": "Maska FFP2", + "fish_sticks": "Paluszki rybne", + "flour": "Mąka", + "flushing": "Zmywanie", + "fresh_chili_pepper": "Świeże papryczki chili", + "frozen_berries": "Mrożone owoce leśne", + "frozen_fruit": "Mrożone owoce", + "frozen_pizza": "Mrożona pizza", + "frozen_spinach": "Mrożony szpinak", + "funeral_card": "Karta pogrzebowa", + "garam_masala": "Garam Masala", + "garbage_bag": "Worki na śmieci", + "garlic": "Czosnek", + "garlic_dip": "Sos czosnkowy", + "garlic_granules": "Granulat czosnkowy", + "gherkins": "Korniszony", + "ginger": "Imbir", + "glass_noodles": "Szklany makaron", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Baton granola", + "grapes": "Winogrona", + "greek_yogurt": "Jogurt grecki", + "green_asparagus": "Zielone szparagi", + "green_chili": "Zielone chili", + "green_pesto": "Zielone pesto", + "hair_gel": "Żel do włosów", + "hair_ties": "Opaski do włosów", + "hair_wax": "Wosk do włosów", + "hand_soap": "Mydło do rąk", + "handkerchief_box": "Pudełko na chusteczki do nosa", + "handkerchiefs": "Chusteczki do nosa", + "hard_cheese": "Twardy ser", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Orzechy laskowe", + "head_of_lettuce": "Główka sałaty", + "herb_baguettes": "Bagietki ziołowe", + "herb_cream_cheese": "Ziołowy serek śmietankowy", + "honey": "Miód", + "honey_wafers": "Wafle miodowe", + "hot_dog_bun": "Bułka do hot doga", + "ice_cream": "Lody", + "ice_cube": "Kostki lodu", + "iceberg_lettuce": "Sałata lodowa", + "iced_tea": "Mrożona herbata", + "instant_soups": "Zupy błyskawiczne", + "jam": "Dżem", + "jasmine_rice": "Ryż jaśminowy", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Fasola kidney", + "kitchen_roll": "Rolka kuchenna", + "kitchen_towels": "Ręczniki kuchenne", + "kohlrabi": "Kalarepa", + "lasagna": "Lasagna", + "lasagna_noodles": "Makaron lasagne", + "lasagna_plates": "Talerze do lasagne", + "leaf_spinach": "Szpinak liściasty", + "leek": "Por", + "lemon": "Cytryna", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Sok z cytryny", + "lemonade": "Lemoniada", + "lemongrass": "Trawa cytrynowa", + "lentil_stew": "Gulasz z soczewicy", + "lentils": "Soczewica", + "lentils_red": "Czerwona soczewica", + "lettuce": "Sałata", + "lillet": "Lillet", + "lime": "Limonka", + "linguine": "Linguine", + "lip_care": "Pielęgnacja ust", + "low-fat_curd_cheese": "Twaróg o niskiej zawartości tłuszczu", + "maggi": "Maggi", + "magnesium": "Magnez", + "mango": "Mango", + "maple_syrup": "Syrop klonowy", + "margarine": "Margaryna", + "marjoram": "Majeranek", + "marshmallows": "Marshmallows", + "mascara": "Tusz do rzęs", + "mascarpone": "Mascarpone", + "mask": "Maska", + "mayonnaise": "Majonez", + "meat_substitute_product": "Produkt zastępujący mięso", + "microfiber_cloth": "Ściereczka z mikrofibry", + "milk": "Mleko", + "mint": "Mięta", + "mint_candy": "Cukierki miętowe", + "miso_paste": "Pasta miso", + "mixed_vegetables": "Mieszane warzywa", + "mochis": "Mochis", + "mold_remover": "Środek do usuwania pleśni", + "mountain_cheese": "Ser górski", + "mouth_wash": "Płyn do płukania ust", + "mozzarella": "Mozzarella", + "muesli": "Musli", + "muesli_bar": "Baton musli", + "mulled_wine": "Grzane wino", + "mushrooms": "Grzyby", + "mustard": "Musztarda", + "nail_file": "Pilnik do paznokci", + "neutral_oil": "Neutralny olej", + "nori_sheets": "Arkusze nori", + "nutmeg": "Gałka muszkatołowa", + "oat_milk": "Napój owsiany", + "oatmeal": "Płatki owsiane", + "oatmeal_cookies": "Ciasteczka owsiane", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Olej", + "olive_oil": "Oliwa z oliwek", + "olives": "Oliwki", + "onion": "Cebula", + "onion_powder": "Cebula w proszku", + "orange_juice": "Sok pomarańczowy", + "oranges": "Pomarańcze", + "oregano": "Oregano", + "organic_lemon": "Organiczna cytryna", + "organic_waste_bags": "Worki na odpady organiczne", + "pak_choi": "Pak Choi", + "pantyhose": "Rajstopy", + "paprika": "Papryka", + "paprika_seasoning": "Przyprawa paprykowa", + "pardina_lentils_dried": "Suszona soczewica Pardina", + "parmesan": "Parmezan", + "parsley": "Pietruszka", + "pasta": "Makaron", + "peach": "Brzoskwinia", + "peanut_butter": "Masło orzechowe", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Olej arachidowy", + "peanuts": "Orzeszki ziemne", + "pears": "Gruszki", + "peas": "Groszek", + "penne": "Penne", + "pepper": "Pieprz", + "pepper_mill": "Młynek do pieprzu", + "peppers": "Papryka", + "persian_rice": "Ryż perski", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Orzeszki piniowe", + "pineapple": "Ananas", + "pita_bag": "Torba Pita", + "pita_bread": "Chleb pita", + "pizza": "Pizza", + "pizza_dough": "Ciasto na pizzę", + "plant_magarine": "Roślina Magarine", + "plant_oil": "Olej roślinny", + "plaster": "Tynk", + "pointed_peppers": "Papryka ostra", + "porcini_mushrooms": "Grzyby Porcini", + "potato_dumpling_dough": "Ciasto na pyzy ziemniaczane", + "potato_wedges": "Kliny ziemniaczane", + "potatoes": "Ziemniaki", + "potting_soil": "Ziemia doniczkowa", + "powder": "Proszek", + "powdered_sugar": "Cukier puder", + "processed_cheese": "Ser przetworzony", + "prosecco": "Prosecco", + "puff_pastry": "Ciasto francuskie", + "pumpkin": "Dynia", + "pumpkin_seeds": "Pestki dyni", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rzodkiewka", + "ramen": "Ramen", + "rapeseed_oil": "Olej rzepakowy", + "raspberries": "Maliny", + "raspberry_syrup": "Syrop malinowy", + "razor_blades": "Żyletki", + "red_bull": "Red Bull", + "red_chili": "Czerwone chili", + "red_curry_paste": "Czerwona pasta curry", + "red_lentils": "Czerwona soczewica", + "red_onions": "Czerwona cebula", + "red_pesto": "Czerwone pesto", + "red_wine": "Czerwone wino", + "red_wine_vinegar": "Ocet z czerwonego wina", + "rhubarb": "Rabarbar", + "ribbon_noodles": "Makaron wstążkowy", + "rice": "Ryż", + "rice_cakes": "Ciastka ryżowe", + "rice_paper": "Papier ryżowy", + "rice_ribbon_noodles": "Makaron ryżowy wstążkowy", + "rice_vinegar": "Ocet ryżowy", + "ricotta": "Ser ricotta", + "rinse_tabs": "Tabletki do płukania", + "rinsing_agent": "Preparat do płukania", + "risotto_rice": "Ryż do risotto", + "rocket": "Rakieta", + "roll": "Bułka", + "rosemary": "Rozmaryn", + "saffron_threads": "Szafron", + "sage": "Szałwia", + "saitan_powder": "Proszek Saitan", + "salad_mix": "Mix sałatkowy", + "salad_seeds_mix": "Mix nasion sałatkowych", + "salt": "Sól", + "salt_mill": "Młynek do soli", + "sambal_oelek": "Sambal", + "sauce": "Sos", + "sausage": "Kiełbasa", + "sausages": "Kiełbasy", + "savoy_cabbage": "Kapusta włoska", + "scallion": "Szalotka", + "scattered_cheese": "Starty ser", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Budyń semolinowy", + "sesame": "Sezam", + "sesame_oil": "Olej sezamowy", + "shallot": "Szalotka", + "shampoo": "Szampon", + "shawarma_spice": "Przyprawa Shawarma", + "shiitake_mushroom": "Shiitake", + "shoe_insoles": "Wkładki do butów", + "shower_gel": "Żel pod prysznic", + "shredded_cheese": "Tarty ser", + "sieved_tomatoes": "Sitkowane pomidory", + "sliced_cheese": "Ser w plastrach", + "smoked_paprika": "Papryka wędzona", + "smoked_tofu": "Tofu wędzone", + "snacks": "Przekąski", + "soap": "Mydło", + "soba_noodles": "Makaron soba", + "soft_drinks": "Napoje gazowane", + "soup_vegetables": "Warzywa do zupy", + "sour_cream": "Kwaśna śmietana", + "sour_cucumbers": "Kwaśne ogórki", + "soy_cream": "Śmietanka sojowa", + "soy_hack": "Hack na soję", + "soy_sauce": "Sos sojowy", + "soy_shred": "Rozdrobniona soja", + "spaetzle": "Szpecle", + "spaghetti": "Spaghetti", + "sparkling_water": "Woda gazowana", + "spelt": "Orkisz", + "spinach": "Szpinak", + "sponge_cloth": "Ściereczka gąbczasta", + "sponge_fingers": "Palce z gąbki", + "sponge_wipes": "Gąbki do mycia", + "sponges": "Gąbki", + "spreading_cream": "Śmietana do smarowania", + "spring_onions": "Cebulki wiosenne", + "sprite": "Sprite", + "sprouts": "Kiełki", + "sriracha": "Sriracha", + "strained_tomatoes": "Odcedzone pomidory", + "strawberries": "Truskawki", + "sugar": "Cukier", + "summer_roll_paper": "Papier w rolce na lato", + "sunflower_oil": "Olej słonecznikowy", + "sunflower_seeds": "Nasiona słonecznika", + "sunscreen": "Filtr przeciwsłoneczny", + "sushi_rice": "Ryż do sushi", + "swabian_ravioli": "Maultaschen", + "sweet_chili_sauce": "Słodki sos chili", + "sweet_potato": "Batat", + "sweet_potatoes": "Bataty", + "sweets": "Słodycze", + "table_salt": "Sól stołowa", + "tagliatelle": "Makaron tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarynki", + "tape": "Taśma klejąca", + "tapioca_flour": "Mąka z tapioki", + "tea": "Herbata", + "teriyaki_sauce": "Sos teriyaki", + "thyme": "Tymianek", + "toast": "Tosty", + "tofu": "Tofu", + "toilet_paper": "Papier toaletowy", + "tomato_juice": "Sok pomidorowy", + "tomato_paste": "Pasta z pomidorów", + "tomato_sauce": "Sos pomidorowy", + "tomatoes": "Pomidory", + "tonic_water": "Woda tonizująca", + "toothpaste": "Pasta do zębów", + "tortellini": "Tortellini", + "tortilla_chips": "Chipsy Tortilla", + "tuna": "Tuńczyk", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Makaron Udon", + "uht_milk": "Mleko UHT", + "vanilla_sugar": "Cukier waniliowy", + "vegetable_bouillon_cube": "Kostka bulionu warzywnego", + "vegetable_broth": "Bulion warzywny", + "vegetable_oil": "Olej roślinny", + "vegetable_onion": "Cebula warzywna", + "vegetables": "Warzywa", + "vegetarian_cold_cuts": "wędliny wegetariańskie", + "vinegar": "Ocet", + "vitamin_tablets": "Tabletki witaminowe", + "vodka": "Wódka", + "washing_gel": "Żel do mycia", + "washing_powder": "Proszek do prania", + "water": "Woda", + "water_ice": "Lód wodny", + "watermelon": "Arbuz", + "wc_cleaner": "Środek do czyszczenia WC", + "wheat_flour": "Mąka pszenna", + "whipped_cream": "Bita śmietana", + "white_wine": "Białe wino", + "white_wine_vinegar": "Ocet z białego wina", + "whole_canned_tomatoes": "Całe pomidory w puszce", + "wild_berries": "Dzikie jagody", + "wild_rice": "Dziki ryż", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Sos Worcester", + "wrapping_paper": "Papier pakowy", + "wraps": "Owijki", + "yeast": "Drożdże", + "yeast_flakes": "Płatki drożdżowe", + "yoghurt": "Jogurt", + "yogurt": "Jogurt", + "yum_yum": "Mniam mniam", + "zewa": "Zewa", + "zinc_cream": "Krem cynkowy", + "zucchini": "Cukinia" + } +} diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json new file mode 100644 index 00000000..9e4b4f5b --- /dev/null +++ b/backend/templates/l10n/pt.json @@ -0,0 +1,500 @@ +{ + "categories": { + "bread": "🍞 Produtos de pastelaria", + "canned": "🥫 Bens de conserva", + "dairy": "🥛 Lacticínios", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas e legumes", + "grain": "🥟 Massas e noodles", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerados", + "snacks": "🥜 Lanches" + }, + "items": { + "agave_syrup": "Xarope de agave", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Maçã", + "apple_pulp": "Puré de maçã", + "applesauce": "Molho de maçã", + "apricots": "Damascos", + "apérol": "Apérol", + "arugula": "Rúcula", + "asian_egg_noodles": "Noodles com ovos asiáticos", + "asian_noodles": "Noodles", + "asparagus": "Espargos", + "aspirin": "Aspirina", + "avocado": "Abacate", + "baby_potatoes": "Batatas pequenas", + "baby_spinach": "Espinafres bebé", + "bacon": "Bacon", + "baguette": "Baguete", + "bakefish": "Peixe assado", + "baking_cocoa": "Cacau em pó", + "baking_mix": "Massa", + "baking_paper": "Papel manteiga", + "baking_powder": "Fermento em pó", + "baking_soda": "Bicarbonato de sódio", + "baking_yeast": "Fermento para bolos", + "balsamic_vinegar": "Vinagre Balsâmico", + "bananas": "Bananas", + "basil": "Manjericão", + "basmati_rice": "Arroz Basmati", + "bathroom_cleaner": "Limpa casa de banho", + "batteries": "Pilhas", + "bay_leaf": "Louro", + "beans": "Feijão", + "beef": "Bife", + "beef_broth": "Caldo de carne", + "beer": "Cerveja", + "beet": "Beterraba", + "beetroot": "Beterraba Vermelha", + "birthday_card": "Cartão de Aniversário", + "black_beans": "Feijão Preto", + "blister_plaster": "Penso para bolhas", + "bockwurst": "Salsichão", + "bodywash": "Gel de banho", + "bread": "Pão", + "breadcrumbs": "Pão ralado", + "broccoli": "Brócolos", + "brown_sugar": "Açúcar Amarelo", + "brussels_sprouts": "Couve de Bruxelas", + "buffalo_mozzarella": "Mozzarella Bufalina", + "buns": "Pãezinhos", + "burger_buns": "Pães de hambúrguer", + "burger_patties": "Carne de hambúrguer", + "burger_sauces": "Molho de Hambúrguer", + "butter": "Manteiga", + "butter_cookies": "Bolachas de Manteiga", + "butternut_squash": "Abóbora-butternut", + "button_cells": "Pilhas de relógio", + "börek_cheese": "Queijo Börek", + "cake": "Bolo", + "cake_icing": "Cobertura do bolo", + "cane_sugar": "Açúcar de Cana", + "cannelloni": "Canelones", + "canola_oil": "Óleo de Canola", + "cardamom": "Cardamomo", + "carrots": "Cenouras", + "cashews": "Cajus", + "cat_treats": "Guloseimas para gato", + "cauliflower": "Couve-flor", + "celeriac": "Aipo-rábano", + "celery": "Aipo", + "cereal_bar": "Barra de cereais", + "cheddar": "Chedar", + "cheese": "Queijo", + "cherry_tomatoes": "Tomates cherry", + "chickpeas": "Grão de bico", + "chicory": "Chicória", + "chili_oil": "Molho Chili", + "chili_pepper": "Pimenta malagueta", + "chips": "Batata frita de pacote", + "chives": "Cebolinho", + "chocolate": "Chocolate", + "chocolate_chips": "Pedaços de chocolate", + "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates em pedaços", + "ciabatta": "Pão chapata", + "cider_vinegar": "Vinagre de Cidra", + "cilantro": "Coentros", + "cinnamon": "Canela", + "cinnamon_stick": "Pau de canela", + "cocktail_sauce": "Molho de coquetel", + "cocktail_tomatoes": "Tomates para cocktails", + "coconut_flakes": "Flocos de coco", + "coconut_milk": "Leite de coco", + "coconut_oil": "Óleo de coco", + "coffee_powder": "Café em pó", + "colorful_sprinkles": "Pepitas Multicores", + "concealer": "Corretor de olheiras", + "cookies": "Bolachas", + "coriander": "Coentros", + "corn": "Milho", + "cornflakes": "Flocos de Milho", + "cornstarch": "Amido de milho", + "cornys": "Cornys", + "corriander": "Coentros", + "cotton_rounds": "Discos de algodão", + "cough_drops": "Gotas para a tosse", + "couscous": "Couz-couz", + "covid_rapid_test": "Teste rápido COVID", + "cow's_milk": "Leite de vaca", + "cream": "Natas", + "cream_cheese": "Queijo creme", + "creamed_spinach": "Esparregado", + "creme_fraiche": "Creme Fresco", + "crepe_tape": "Fita crepe", + "crispbread": "Pão estaladiço", + "cucumber": "Pepino", + "cumin": "Cominhos", + "curd": "Requeijão", + "curry_paste": "Massa de caril", + "curry_powder": "Caril", + "curry_sauce": "Molho de caril", + "dates": "Tâmaras", + "dental_floss": "Fio dental", + "deo": "Desodorizante", + "deodorant": "Desodorizante", + "detergent": "Detergente", + "detergent_sheets": "Folhas de detergente", + "diarrhea_remedy": "Medicamento para a diarreia", + "dill": "Endro", + "dishwasher_salt": "Sal para máquina da loiça", + "dishwasher_tabs": "Pastilhas para máquina da loiça", + "disinfection_spray": "Spray desinfetante", + "dried_tomatoes": "Tomates secos", + "dry_yeast": "Levedura seca", + "edamame": "Edamame", + "egg_salad": "Salada de ovo", + "egg_yolk": "Gema de ovo", + "eggplant": "Beringela", + "eggs": "Ovos", + "enoki_mushrooms": "Cogumelos Enoki", + "eyebrow_gel": "Gel para sobrancelhas", + "falafel": "Falafel", + "falafel_powder": "Falafel em pó", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Douradinhos", + "flour": "Farinha", + "flushing": "Lavagem", + "fresh_chili_pepper": "Pimenta fresca", + "frozen_berries": "Bagas congeladas", + "frozen_broccoli": "Brócolos congelados", + "frozen_fruit": "Frutas congeladas", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinafres congelados", + "funeral_card": "Cartão funerário", + "garam_masala": "Garam Masala", + "garbage_bag": "Sacos do lixo", + "garlic": "Alho", + "garlic_dip": "Molho de alho", + "garlic_granules": "Grânulos de alho", + "gherkins": "Pepinos", + "ginger": "Gengibre", + "ginger_ale": "Ginger ale", + "glass_noodles": "Massa de vidro", + "gluten": "Glúten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barra de cereais", + "grapes": "Uvas", + "greek_yogurt": "Iogurte grego", + "green_asparagus": "Espargos", + "green_chili": "Pimenta verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel para cabelo", + "hair_ties": "Laços para o cabelo", + "hair_wax": "Cera para pelos", + "ham": "Fiambre", + "ham_cubes": "Cubos de fiambre", + "hand_soap": "Sabonete para as mãos", + "handkerchief_box": "Caixa de lenços", + "handkerchiefs": "Lenços de bolso", + "hard_cheese": "Queijo duro", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Avelãs", + "head_of_lettuce": "Cabeça de alface", + "herb_baguettes": "Baguetes de ervas", + "herb_butter": "Manteiga de ervas", + "herb_cream_cheese": "Queijo creme de ervas", + "honey": "Mel", + "honey_wafers": "Bolachas de mel", + "hot_dog_bun": "Pão de cachorro-quente", + "ice_cream": "Gelado", + "ice_cube": "Cubos de gelo", + "iceberg_lettuce": "Alface iceberg", + "iced_tea": "Ice tea", + "instant_soups": "Sopa instantânea", + "jam": "Geleia", + "jasmine_rice": "Arroz de jasmim", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Feijão vermelho", + "kitchen_roll": "Rolo de cozinha", + "kitchen_towels": "Pano de cozinha", + "kiwi": "Kiwi", + "kohlrabi": "Couve-rábano", + "lasagna": "Lasanha", + "lasagna_noodles": "Massa de lasanha", + "lasagna_plates": "Placas para lasanha", + "leaf_spinach": "Espinafres de folha", + "leek": "Alho francês", + "lemon": "Limão", + "lemon_curd": "Coalhada de limão", + "lemon_juice": "Sumo de limão", + "lemonade": "Limonada", + "lemongrass": "Erva Príncipe", + "lentil_stew": "Guisado de lentilhas", + "lentils": "Lentilhas", + "lentils_red": "Lentilhas vermelhas", + "lettuce": "Alface", + "lillet": "Lillet", + "lime": "Lima", + "linguine": "Linguine", + "lip_care": "Cuidados com os lábios", + "liqueur": "Licor", + "low-fat_curd_cheese": "Requeijão magro", + "maggi": "Maggi", + "magnesium": "Magnésio", + "mango": "Manga", + "maple_syrup": "Xarope de ácer", + "margarine": "Margarina", + "marjoram": "Manjerona", + "marshmallows": "Marshmallows", + "mascara": "Rímel", + "mascarpone": "Mascarpone", + "mask": "Máscara", + "mayonnaise": "Maionese", + "meat_substitute_product": "Produto de substituição de carne", + "microfiber_cloth": "Pano microfibras", + "milk": "Leite", + "mint": "Menta", + "mint_candy": "Doces de menta", + "miso_paste": "Pasta de missô", + "mixed_vegetables": "Legumes mistos", + "mochis": "Mochis", + "mold_remover": "Removedor de bolor", + "mountain_cheese": "Queijo de montanha", + "mouth_wash": "Elixir bocal", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de Muesli", + "mulled_wine": "Vinho quente", + "mushrooms": "Cogumelos", + "mustard": "Mostarda", + "nail_file": "Lima de unhas", + "nail_polish_remover": "Removedor de verniz para unhas", + "neutral_oil": "Óleo neutro", + "nori_sheets": "Folhas Nori", + "nutmeg": "Noz-moscada", + "oat_milk": "Leite de aveia", + "oatmeal": "Farinha de aveia", + "oatmeal_cookies": "Bolachas de aveia", + "oatsome": "Aveia", + "obatzda": "Obatzda", + "oil": "Óleo", + "olive_oil": "Azeite", + "olives": "Azeitonas", + "onion": "Cebola", + "onion_powder": "Cebola em pó", + "orange_juice": "Sumo de laranja", + "oranges": "Laranjas", + "oregano": "Orégãos", + "organic_lemon": "Limão biológico", + "organic_waste_bags": "Sacos para resíduos orgânicos", + "pak_choi": "Pak Choi", + "pantyhose": "Meias-calças", + "papaya": "Papaia", + "paprika": "Paprica", + "paprika_seasoning": "Tempero de paprica", + "pardina_lentils_dried": "Lentilhas Pardina secas", + "parmesan": "Parmesão", + "parsley": "Salsa", + "pasta": "Massa", + "peach": "Pêssego", + "peanut_butter": "Manteiga de amendoim", + "peanut_flips": "Amendoins", + "peanut_oil": "Óleo de amendoim", + "peanuts": "Amendoins", + "pears": "Peras", + "peas": "Ervilhas", + "penne": "Penne", + "pepper": "Pimenta", + "pepper_mill": "Moinho de pimenta", + "peppers": "Pimentos", + "persian_rice": "Arroz persa", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinhões", + "pineapple": "Ananás", + "pita_bag": "Saco de pita", + "pita_bread": "Pão pita", + "pizza": "Pizza", + "pizza_dough": "Massa de pizza", + "plant_magarine": "Planta Magarine", + "plant_oil": "Óleo vegetal", + "plaster": "Gesso", + "pointed_peppers": "Pimentos pontiagudos", + "porcini_mushrooms": "Cogumelos Porcini", + "potato_dumpling_dough": "Massa de bolinhos de batata", + "potato_wedges": "Cunhas de batata", + "potatoes": "Batatas", + "potting_soil": "Terra para vasos", + "powder": "Pó", + "powdered_sugar": "Açúcar em pó", + "processed_cheese": "Queijo fundido", + "prosecco": "Pró Seco", + "puff_pastry": "Massa folhada", + "pumpkin": "Abóbora", + "pumpkin_seeds": "Sementes de abóbora", + "quark": "Quark", + "quinoa": "Quinua", + "radicchio": "Radiquio", + "radish": "Rabanete", + "ramen": "Lamen", + "rapeseed_oil": "Óleo de colza", + "raspberries": "Framboesas", + "raspberry_syrup": "Xarope de framboesa", + "razor_blades": "Lâminas de barbear", + "red_bull": "Red Bull", + "red_chili": "Pimento vermelho", + "red_curry_paste": "Pasta de caril vermelho", + "red_lentils": "Lentilhas vermelhas", + "red_onions": "Cebolas vermelhas", + "red_pesto": "Pesto vermelho", + "red_wine": "Vinho tinto", + "red_wine_vinegar": "Vinagre de vinho tinto", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Massa com fita", + "rice": "Arroz", + "rice_cakes": "Bolos de arroz", + "rice_paper": "Papel de arroz", + "rice_ribbon_noodles": "Massa de arroz com fita", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Ricotta", + "rinse_tabs": "Pastilhas de enxaguamento", + "rinsing_agent": "Agente de enxaguamento", + "risotto_rice": "Arroz risotto", + "rocket": "Foguetão", + "roll": "Rolo", + "rosemary": "Alecrim", + "saffron_threads": "Fios de açafrão", + "sage": "Sálvia", + "saitan_powder": "Saitan em pó", + "salad_mix": "Mistura para salada", + "salad_seeds_mix": "Mistura de sementes para salada", + "salt": "Sal", + "salt_mill": "Moinho de sal", + "sambal_oelek": "Sambal oelek", + "sauce": "Molho", + "sausage": "Salsicha", + "sausages": "Salsichas", + "savoy_cabbage": "Couve-lombarda", + "scallion": "Cebolinha", + "scattered_cheese": "Queijo espalhado", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Papas de sêmola", + "sesame": "Sésamo", + "sesame_oil": "Óleo de sésamo", + "shallot": "Chalota", + "shampoo": "Champô", + "shawarma_spice": "Tempero Shawarma", + "shiitake_mushroom": "Cogumelo Shiitake", + "shoe_insoles": "Palmilhas para sapatos", + "shower_gel": "Gel de duche", + "shredded_cheese": "Queijo ralado", + "sieved_tomatoes": "Tomate peneirado", + "skyr": "Skyr", + "sliced_cheese": "Queijo fatiado", + "smoked_paprika": "Paprika fumada", + "smoked_tofu": "Tofu fumado", + "snacks": "Snacks", + "soap": "Sabonete", + "soba_noodles": "Macarrão Soba", + "soft_drinks": "Refrigerantes", + "soup_vegetables": "Sopa de legumes", + "sour_cream": "Creme de leite", + "sour_cucumbers": "Pepinos azedos", + "soy_cream": "Creme de soja", + "soy_hack": "Carne picada de soja", + "soy_sauce": "Molho de soja", + "soy_shred": "Triturador de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Esparguete", + "sparkling_water": "Água com gás", + "spelt": "Espelta", + "spinach": "Espinafres", + "sponge_cloth": "Pano de esponja", + "sponge_fingers": "Dedos de esponja", + "sponge_wipes": "Toalhetes de esponja", + "sponges": "Esponjas", + "spreading_cream": "Creme para barrar", + "spring_onions": "Cebolinhas", + "sprite": "Sprite", + "sprouts": "Rebentos", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates coados", + "strawberries": "Morangos", + "sugar": "Açúcar", + "summer_roll_paper": "Papel em rolo para o verão", + "sunflower_oil": "Óleo de girassol", + "sunflower_seeds": "Sementes de girassol", + "sunscreen": "Protetor solar", + "sushi_rice": "Arroz de sushi", + "swabian_ravioli": "Ravioli da Suábia", + "sweet_chili_sauce": "Molho de pimentão doce", + "sweet_potato": "Batata doce", + "sweet_potatoes": "Batatas doce", + "sweets": "Doces", + "table_salt": "Sal de mesa", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerinas", + "tape": "Fita", + "tapioca_flour": "Farinha de tapioca", + "tea": "Chá", + "teriyaki_sauce": "Molho Teriyaki", + "thyme": "Tomilho", + "toast": "Tostas", + "tofu": "Tofu", + "toilet_paper": "Papel Higiénico", + "tomato_juice": "Sumo de tomate", + "tomato_paste": "Polpa de tomate", + "tomato_sauce": "Molho de tomate", + "tomatoes": "Tomate", + "tonic_water": "Água tónica", + "toothpaste": "Pasta de dentes", + "tortellini": "Tortellini", + "tortilla_chips": "Batatas fritas de tortilha", + "tuna": "Atum", + "turmeric": "Açafrão-da-terra", + "tzatziki": "Tzatziki", + "udon_noodles": "Macarrão Udon", + "uht_milk": "Leite UHT", + "vanilla_sugar": "Açúcar baunilhado", + "vegetable_bouillon_cube": "Cubo de caldo de legumes", + "vegetable_broth": "Caldo de legumes", + "vegetable_oil": "Óleo vegetal", + "vegetable_onion": "Cebola vegetal", + "vegetables": "Legumes", + "vegetarian_cold_cuts": "charcutaria vegetariana", + "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos de vitaminas", + "vodka": "Vodka", + "walnuts": "Nozes", + "washing_gel": "Gel de lavagem", + "washing_powder": "Pó de lavagem", + "water": "Água", + "water_ice": "Água gelada", + "watermelon": "Melancia", + "wc_cleaner": "Detergente de casa de banho", + "wheat_flour": "Farinha de trigo", + "whipped_cream": "Nata batida", + "white_wine": "Vinho branco", + "white_wine_vinegar": "Vinagre de vinho branco", + "whole_canned_tomatoes": "Tomates inteiros enlatados", + "wild_berries": "Frutos silvestres", + "wild_rice": "Arroz selvagem", + "wildberry_lillet": "Lillet de amora silvestre", + "worcester_sauce": "Molho Worcester", + "wrapping_paper": "Papel de embrulho", + "wraps": "Embrulhos", + "yeast": "Fermento", + "yeast_flakes": "Flocos de levedura", + "yoghurt": "Iogurte", + "yogurt": "Iogurte", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Creme de zinco", + "zucchini": "Courgette" + } +} diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json new file mode 100644 index 00000000..97670bbd --- /dev/null +++ b/backend/templates/l10n/pt_BR.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Padaria", + "canned": "🥫 Comida Enlatada", + "dairy": "Laticínios", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas e Vegetais", + "grain": "🥟 Grãos", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerado", + "snacks": "🥜 Lanches" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Maçã", + "apple_pulp": "Polpa de Maçã", + "applesauce": "Molho de Maçã", + "apricots": "Damascos", + "apérol": "Aperol", + "arugula": "Rúcula", + "asian_egg_noodles": "Macarrão com ovos asiáticos", + "asian_noodles": "Macarrão asiático", + "asparagus": "Aspargos", + "aspirin": "Aspirina", + "avocado": "Abacate", + "baby_potatoes": "Trigêmeos", + "baby_spinach": "Espinafre baby", + "bacon": "Bacon", + "baguette": "Baguete", + "bakefish": "Peixe Assado", + "baking_cocoa": "Cozimento de cacau", + "baking_mix": "Mistura para panificação", + "baking_paper": "Papel para panificação", + "baking_powder": "Pó de fermento", + "baking_soda": "Bicarbonato de sódio", + "baking_yeast": "Levedura para panificação", + "balsamic_vinegar": "Vinagre balsâmico", + "bananas": "Bananas", + "basil": "Manjericão", + "basmati_rice": "Arroz basmati", + "bathroom_cleaner": "Limpador de banheiros", + "batteries": "Baterias", + "bay_leaf": "Folha de baía", + "beans": "Feijão", + "beer": "Cerveja", + "beet": "Beterraba", + "beetroot": "Beterraba", + "birthday_card": "Cartão de aniversário", + "black_beans": "Feijão preto", + "bockwurst": "Salsicha", + "bodywash": "Lavagem do corpo", + "bread": "Pão", + "breadcrumbs": "Farinha de rosca", + "broccoli": "Brócolis", + "brown_sugar": "Açúcar mascavo", + "brussels_sprouts": "Couve-de-bruxelas", + "buffalo_mozzarella": "Mozzarella de búfalo", + "buns": "Pãezinhos", + "burger_buns": "Pães para hambúrguer", + "burger_patties": "Hambúrgueres Patties", + "burger_sauces": "Molhos para hambúrgueres", + "butter": "Manteiga", + "butter_cookies": "Biscoitos de manteiga", + "button_cells": "Células de botão", + "börek_cheese": "Queijo Börek", + "cake": "Bolo", + "cake_icing": "Cobertura de bolo", + "cane_sugar": "Açúcar de cana", + "cannelloni": "Cannelloni", + "canola_oil": "Óleo de canola", + "cardamom": "Cardamomo", + "carrots": "Cenouras", + "cashews": "Cajus", + "cat_treats": "Gatos", + "cauliflower": "Couve-flor", + "celeriac": "aipo", + "celery": "Aipo", + "cereal_bar": "Barra de cereais", + "cheddar": "Cheddar", + "cheese": "Queijo", + "cherry_tomatoes": "Tomates cereja", + "chickpeas": "grão de bico", + "chicory": "Chicória", + "chili_oil": "Óleo de pimenta", + "chili_pepper": "Pimenta malagueta", + "chips": "Fichas", + "chives": "Cebolinho", + "chocolate": "Chocolate", + "chocolate_chips": "Chocolate em pedaços", + "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates em pedaços", + "ciabatta": "Ciabatta", + "cider_vinegar": "Vinagre de cidra", + "cilantro": "Cilantro", + "cinnamon": "Canela", + "cinnamon_stick": "Canela em pau", + "cocktail_sauce": "Molho de coquetel", + "cocktail_tomatoes": "Cocktail de tomates", + "coconut_flakes": "Flocos de coco", + "coconut_milk": "Leite de coco", + "coconut_oil": "Óleo de coco", + "colorful_sprinkles": "Polvilhos coloridos", + "concealer": "Corretivo", + "cookies": "Biscoitos", + "coriander": "Coriander", + "corn": "Milho", + "cornflakes": "Cornflakes", + "cornstarch": "Amido de milho", + "cornys": "Cornys", + "corriander": "Coentro", + "cough_drops": "Rebuçados para a tosse", + "couscous": "Couscous", + "covid_rapid_test": "Teste rápido COVID", + "cow's_milk": "Leite de vaca", + "cream": "Cremes", + "cream_cheese": "Queijo cremoso", + "creamed_spinach": "Espinafres cremosos", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Fita crepe", + "crispbread": "Crispbread", + "cucumber": "Pepino", + "cumin": "Cumin", + "curd": "Coalhada", + "curry_paste": "Pasta de caril", + "curry_powder": "Caril em pó", + "curry_sauce": "Molho de caril", + "dates": "Datas", + "dental_floss": "Fio dental", + "deo": "Desodorante", + "deodorant": "Desodorante", + "detergent": "Detergente", + "detergent_sheets": "Folhas de detergente", + "diarrhea_remedy": "Remédio para diarreia", + "dill": "Endro", + "dishwasher_salt": "Sal de lava-louças", + "dishwasher_tabs": "Abas para lava-louça", + "disinfection_spray": "Spray de desinfecção", + "dried_tomatoes": "Tomates secos", + "edamame": "Edamame", + "egg_salad": "Salada de ovos", + "egg_yolk": "Gema de ovo", + "eggplant": "Berinjela", + "eggs": "Ovos", + "enoki_mushrooms": "Cogumelos Enoki", + "eyebrow_gel": "Gel para sobrancelhas", + "falafel": "Falafel", + "falafel_powder": "Pó de falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Palitos de peixe", + "flour": "Farinha", + "flushing": "Flushing", + "fresh_chili_pepper": "Pimenta cili fresca", + "frozen_berries": "Frutas congeladas", + "frozen_fruit": "Frutas congeladas", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinafres congelados", + "funeral_card": "Cartão de funeral", + "garam_masala": "Garam Masala", + "garbage_bag": "Sacos de lixo", + "garlic": "Alho", + "garlic_dip": "Molho de alho", + "garlic_granules": "Grânulos de alho", + "gherkins": "Gherkins", + "ginger": "Gengibre", + "glass_noodles": "Macarrão de vidro", + "gluten": "Glúten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barra de granola", + "grapes": "Uvas", + "greek_yogurt": "Iogurte grego", + "green_asparagus": "Espargos verdes", + "green_chili": "Pimenta verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel para cabelo", + "hair_ties": "Laços de cabelo", + "hair_wax": "Cera para cabelos", + "hand_soap": "Sabonete para as mãos", + "handkerchief_box": "Caixa de lenços de bolso", + "handkerchiefs": "Lenços de assoar e de bolso", + "hard_cheese": "Queijo duro", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Avelãs", + "head_of_lettuce": "Cabeça de alface", + "herb_baguettes": "Baguetes de ervas", + "herb_cream_cheese": "Queijo creme de ervas", + "honey": "Mel", + "honey_wafers": "Bolachas de mel", + "hot_dog_bun": "Pão de cachorro-quente", + "ice_cream": "Sorvete", + "ice_cube": "Cubos de gelo", + "iceberg_lettuce": "Alface Iceberg", + "iced_tea": "Chá gelado", + "instant_soups": "Sopas instantâneas", + "jam": "Jam", + "jasmine_rice": "Arroz jasmim", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Feijão para os rins", + "kitchen_roll": "Rolo de cozinha", + "kitchen_towels": "Toalhas de cozinha", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasanha", + "lasagna_noodles": "Macarrão Lasagna", + "lasagna_plates": "Placas de lasanha", + "leaf_spinach": "Espinafres de folha", + "leek": "Leek", + "lemon": "Limão", + "lemon_curd": "Coalhada de limão", + "lemon_juice": "Suco de limão", + "lemonade": "Limonada", + "lemongrass": "Capim-limão", + "lentil_stew": "Ensopado de lentilha", + "lentils": "Lentilhas", + "lentils_red": "Lentilhas vermelhas", + "lettuce": "Alface", + "lillet": "Lillet", + "lime": "Cal", + "linguine": "Linguine", + "lip_care": "Cuidados com os lábios", + "low-fat_curd_cheese": "Queijo de coalho de baixo teor de gordura", + "maggi": "Maggi", + "magnesium": "Magnésio", + "mango": "Manga", + "maple_syrup": "Xarope de bordo", + "margarine": "Margarine", + "marjoram": "Manjerona", + "marshmallows": "Marshmallows", + "mascara": "Rímel", + "mascarpone": "Mascarpone", + "mask": "Máscara", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Produto substituto da carne", + "microfiber_cloth": "Pano de microfibra", + "milk": "Leite", + "mint": "Casa da Moeda", + "mint_candy": "Doces de menta", + "miso_paste": "Pasta de missô", + "mixed_vegetables": "Vegetais mistos", + "mochis": "Mochis", + "mold_remover": "Removedor de mofo", + "mountain_cheese": "Queijo de montanha", + "mouth_wash": "Lavagem bucal", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de Muesli", + "mulled_wine": "Vinho de mesa", + "mushrooms": "Cogumelos", + "mustard": "Mostarda", + "nail_file": "Lixa de unha", + "neutral_oil": "Óleo neutro", + "nori_sheets": "Folhas Nori", + "nutmeg": "Nutmeg", + "oat_milk": "Bebida de aveia", + "oatmeal": "Farinha de aveia", + "oatmeal_cookies": "Biscoitos com aveia", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Óleo", + "olive_oil": "Azeite de oliva", + "olives": "Azeitonas", + "onion": "Cebola", + "onion_powder": "Cebola em pó", + "orange_juice": "Suco de laranja", + "oranges": "Laranjas", + "oregano": "Orégano", + "organic_lemon": "Limão orgânico", + "organic_waste_bags": "Sacos para resíduos orgânicos", + "pak_choi": "Pak Choi", + "pantyhose": "Meia-calça", + "paprika": "Paprika", + "paprika_seasoning": "Tempero de páprica", + "pardina_lentils_dried": "Pardina lentilhas secas", + "parmesan": "Parmesão", + "parsley": "Salsa", + "pasta": "Massas alimentícias", + "peach": "Pêssego", + "peanut_butter": "Manteiga de amendoim", + "peanut_flips": "Flips de amendoim", + "peanut_oil": "Óleo de amendoim", + "peanuts": "Amendoins", + "pears": "Peras", + "peas": "Ervilhas", + "penne": "Penne", + "pepper": "Pimenta", + "pepper_mill": "Moinho de pimenta", + "peppers": "Pimentas", + "persian_rice": "Arroz persa", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinhões", + "pineapple": "Abacaxi", + "pita_bag": "Saco Pita", + "pita_bread": "Pão pita", + "pizza": "Pizza", + "pizza_dough": "Massa para pizza", + "plant_magarine": "Planta Magarine", + "plant_oil": "Óleo vegetal", + "plaster": "Gesso", + "pointed_peppers": "Pimentos pontiagudos", + "porcini_mushrooms": "Cogumelos Porcini", + "potato_dumpling_dough": "Massa de bolinho de batata", + "potato_wedges": "Cunhas de batata", + "potatoes": "Batatas", + "potting_soil": "Terra para vaso", + "powder": "Pó", + "powdered_sugar": "Açúcar em pó", + "processed_cheese": "Queijos fundidos", + "prosecco": "Prosecco", + "puff_pastry": "Massa folhada", + "pumpkin": "Abóbora", + "pumpkin_seeds": "Sementes de abóbora", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rabanete", + "ramen": "Ramen", + "rapeseed_oil": "Óleo de colza", + "raspberries": "Framboesas", + "raspberry_syrup": "Xarope de framboesa", + "razor_blades": "Lâminas de barbear", + "red_bull": "Red Bull", + "red_chili": "Pimenta vermelha", + "red_curry_paste": "Pasta de curry vermelho", + "red_lentils": "Lentilhas vermelhas", + "red_onions": "Cebolas vermelhas", + "red_pesto": "Pesto vermelho", + "red_wine": "Vinho tinto", + "red_wine_vinegar": "Vinagre de vinho tinto", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Macarrão de fita", + "rice": "Arroz", + "rice_cakes": "Bolos de arroz", + "rice_paper": "Papel de arroz", + "rice_ribbon_noodles": "Macarrão com fitas de arroz", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Ricotta", + "rinse_tabs": "Abas de enxágüe", + "rinsing_agent": "Agente de enxágüe", + "risotto_rice": "Arroz risoto", + "rocket": "Foguete", + "roll": "Rolo", + "rosemary": "Rosemary", + "saffron_threads": "Fios de açafrão", + "sage": "Sábio", + "saitan_powder": "Pó de saitan", + "salad_mix": "Mistura para salada", + "salad_seeds_mix": "Mistura de sementes para salada", + "salt": "Sal", + "salt_mill": "Moinho de sal", + "sambal_oelek": "Sambal oelek", + "sauce": "Molho", + "sausage": "Salsicha", + "sausages": "Salsichas", + "savoy_cabbage": "Couve-lombarda", + "scallion": "Escalhão", + "scattered_cheese": "Queijo disperso", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Mingau de semolina", + "sesame": "Sésamo", + "sesame_oil": "Óleo de gergelim", + "shallot": "Chalota", + "shampoo": "Shampoo", + "shawarma_spice": "Especiaria Shawarma", + "shiitake_mushroom": "Cogumelo Shiitake", + "shoe_insoles": "Palmilhas de sapato", + "shower_gel": "Gel de ducha", + "shredded_cheese": "Queijo ralado", + "sieved_tomatoes": "Tomates peneirados", + "sliced_cheese": "Queijo fatiado", + "smoked_paprika": "Pimentão-doce defumado", + "smoked_tofu": "Tofu defumado", + "snacks": "Lanches", + "soap": "Sabonete", + "soba_noodles": "Macarrão Soba", + "soft_drinks": "Refrigerantes", + "soup_vegetables": "Sopa de legumes", + "sour_cream": "Creme azedo", + "sour_cucumbers": "Pepinos azedos", + "soy_cream": "Creme de soja", + "soy_hack": "Hack de soja", + "soy_sauce": "Molho de soja", + "soy_shred": "Trituração de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Água com gás", + "spelt": "Espelta", + "spinach": "Espinafres", + "sponge_cloth": "Pano de esponja", + "sponge_fingers": "Dedos de esponja", + "sponge_wipes": "Toalhetes de esponja", + "sponges": "Esponjas", + "spreading_cream": "Creme de espalhamento", + "spring_onions": "Cebola de primavera", + "sprite": "Sprite", + "sprouts": "Brotos", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates deformados", + "strawberries": "Morangos", + "sugar": "Açúcar", + "summer_roll_paper": "Papel em rolo de verão", + "sunflower_oil": "Óleo de girassol", + "sunflower_seeds": "Sementes de girassol", + "sunscreen": "Protetor solar", + "sushi_rice": "Arroz sushi", + "swabian_ravioli": "Ravióli suábio", + "sweet_chili_sauce": "Molho de pimenta doce", + "sweet_potato": "Batata doce", + "sweet_potatoes": "Batata doce", + "sweets": "Doces", + "table_salt": "Sal de mesa", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerinas", + "tape": "Fita", + "tapioca_flour": "Farinha de tapioca", + "tea": "Chá", + "teriyaki_sauce": "Molho Teriyaki", + "thyme": "Tomilho", + "toast": "Brinde", + "tofu": "Tofu", + "toilet_paper": "Papel higiênico", + "tomato_juice": "Suco de tomate", + "tomato_paste": "Pasta de tomate", + "tomato_sauce": "Molho de tomate", + "tomatoes": "Tomate", + "tonic_water": "Água tônica", + "toothpaste": "Pasta de dente", + "tortellini": "Tortellini", + "tortilla_chips": "Batatas fritas Tortilla", + "tuna": "Atum", + "turmeric": "Cúrcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Macarrão Udon", + "uht_milk": "Leite UHT", + "vanilla_sugar": "Açúcar baunilhado", + "vegetable_bouillon_cube": "Cubo de caldo de legumes", + "vegetable_broth": "Caldo de legumes", + "vegetable_oil": "Óleo vegetal", + "vegetable_onion": "Cebola de legumes", + "vegetables": "Legumes", + "vegetarian_cold_cuts": "frios vegetarianos", + "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos de vitaminas", + "vodka": "Vodca", + "washing_gel": "Gel de lavagem", + "washing_powder": "Pó de lavagem", + "water": "Água", + "water_ice": "Gelo de água", + "watermelon": "Melancia", + "wc_cleaner": "Limpador de WC", + "wheat_flour": "Farinha de trigo", + "whipped_cream": "Nata batida", + "white_wine": "Vinho branco", + "white_wine_vinegar": "Vinagre de vinho branco", + "whole_canned_tomatoes": "Tomates inteiros enlatados", + "wild_berries": "Frutos silvestres", + "wild_rice": "Arroz selvagem", + "wildberry_lillet": "Lillet de amora silvestre", + "worcester_sauce": "Molho Worcester", + "wrapping_paper": "Papel de embrulho", + "wraps": "Wraps", + "yeast": "Levedura", + "yeast_flakes": "Flocos de levedura", + "yoghurt": "Iogurte", + "yogurt": "Iogurte", + "yum_yum": "Yum Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Creme de zinco", + "zucchini": "Abobrinha" + } +} diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json new file mode 100644 index 00000000..7b57ed96 --- /dev/null +++ b/backend/templates/l10n/ru.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Хлеб", + "canned": "Консервированная еда", + "dairy": "Молочное", + "drinks": "Напитки", + "freezer": "❄️ Заморозка", + "fruits_vegetables": "Фрукты и овощи", + "grain": "🥟 Крупы", + "hygiene": "Гигиена", + "refrigerated": "💧Охлажденное", + "snacks": "Снэки" + }, + "items": { + "aioli": "Айоли", + "amaretto": "Амаретто", + "apple": "Яблоко", + "apple_pulp": "Яблочное пюре", + "applesauce": "Яблочный сок", + "apricots": "Абрикосы", + "apérol": "Апероль", + "arugula": "Руккола", + "asian_egg_noodles": "Азиатская яичная лапша", + "asian_noodles": "Азиатская лапша", + "asparagus": "Спаржа", + "aspirin": "Аспирин", + "avocado": "Авокадо", + "baby_potatoes": "Тройняшки", + "baby_spinach": "Молодой шпинат", + "bacon": "Бекон", + "baguette": "Багет", + "bakefish": "Пекарня", + "baking_cocoa": "Какао-порошок", + "baking_mix": "Смесь для выпечки", + "baking_paper": "Бумага для выпечки", + "baking_powder": "Разрыхлитель теста", + "baking_soda": "Сода", + "baking_yeast": "Дрожжи хлебопекарные", + "balsamic_vinegar": "Бальзамический уксус", + "bananas": "Бананы", + "basil": "Базилик", + "basmati_rice": "Рис басмати", + "bathroom_cleaner": "Очиститель для ванной комнаты", + "batteries": "Батарейки", + "bay_leaf": "Лавровый лист", + "beans": "Фасоль", + "beer": "Пиво", + "beet": "Свекла", + "beetroot": "Свекла", + "birthday_card": "Поздравительная открытка", + "black_beans": "Черная фасоль", + "bockwurst": "Боквурст", + "bodywash": "Мойка для тела", + "bread": "Хлеб", + "breadcrumbs": "Панировочные сухари", + "broccoli": "Брокколи", + "brown_sugar": "Коричневый сахар", + "brussels_sprouts": "Брюссельская капуста", + "buffalo_mozzarella": "Моцарелла Буффало", + "buns": "Булочки", + "burger_buns": "Булочки для бургеров", + "burger_patties": "Котлеты для бургеров", + "burger_sauces": "Соусы для бургеров", + "butter": "Сливочное масло", + "butter_cookies": "Печенье с маслом", + "button_cells": "Кнопочные ячейки", + "börek_cheese": "Сыр Бёрек", + "cake": "Торт", + "cake_icing": "Глазурь для торта", + "cane_sugar": "Тростниковый сахар", + "cannelloni": "Каннеллони", + "canola_oil": "Масло канолы", + "cardamom": "Кардамон", + "carrots": "Морковь", + "cashews": "Кешью", + "cat_treats": "Лакомства для кошек", + "cauliflower": "Цветная капуста", + "celeriac": "Корень сельдерея", + "celery": "Сельдерей", + "cereal_bar": "Зерновой батончик", + "cheddar": "Чеддер", + "cheese": "Сыр", + "cherry_tomatoes": "Помидоры Черри", + "chickpeas": "Нут", + "chicory": "Цикорий", + "chili_oil": "Масло чили", + "chili_pepper": "Перец чили", + "chips": "Чипсы", + "chives": "Зеленый лук", + "chocolate": "Шоколад", + "chocolate_chips": "Шоколадные чипсы", + "chopped_tomatoes": "Томаты резаные", + "chunky_tomatoes": "Крупноплодные томаты", + "ciabatta": "Чиабатта", + "cider_vinegar": "Яблочный уксус", + "cilantro": "Кинза", + "cinnamon": "Корица", + "cinnamon_stick": "Палочка корицы", + "cocktail_sauce": "Коктейльный соус", + "cocktail_tomatoes": "Коктейльные помидоры", + "coconut_flakes": "Кокосовая стружка", + "coconut_milk": "Кокосовое молоко", + "coconut_oil": "Кокосовое масло", + "colorful_sprinkles": "Разноцветные посыпки", + "concealer": "Консилер", + "cookies": "Печенье", + "coriander": "Кориандр", + "corn": "Кукуруза", + "cornflakes": "Кукурузные хлопья", + "cornstarch": "Кукурузный крахмал", + "cornys": "Cornys", + "corriander": "Кориандр", + "cough_drops": "Капли от кашля", + "couscous": "Кускус", + "covid_rapid_test": "Экспресс-тест COVID", + "cow's_milk": "Коровье молоко", + "cream": "Сливки", + "cream_cheese": "Сливочный сыр", + "creamed_spinach": "Шпинат со сливками", + "creme_fraiche": "Крем-фрайш", + "crepe_tape": "Креповая лента", + "crispbread": "Хлебцы", + "cucumber": "Огурцы", + "cumin": "Тмин", + "curd": "Творог", + "curry_paste": "Паста карри", + "curry_powder": "Карри", + "curry_sauce": "Соус карри", + "dates": "Даты", + "dental_floss": "Зубная нить", + "deo": "Дезодорант", + "deodorant": "Дезодорант", + "detergent": "Моющее средство", + "detergent_sheets": "Листы с моющими средствами", + "diarrhea_remedy": "Средство от диареи", + "dill": "Укроп", + "dishwasher_salt": "Соль для посудомойки", + "dishwasher_tabs": "Таблетки для посудомойки", + "disinfection_spray": "Дезинфицирующий спрей", + "dried_tomatoes": "Сушеные помидоры", + "edamame": "Эдамаме", + "egg_salad": "Яичный салат", + "egg_yolk": "Яичный желток", + "eggplant": "Баклажаны", + "eggs": "Яйца", + "enoki_mushrooms": "Грибы эноки", + "eyebrow_gel": "Гель для бровей", + "falafel": "Фалафель", + "falafel_powder": "Порошок для фалафеля", + "fanta": "Фанта", + "feta": "Сыр Фета", + "ffp2": "FFP2", + "fish_sticks": "Рыбные палочки", + "flour": "Мука", + "flushing": "Промывка", + "fresh_chili_pepper": "Свежий перец чили", + "frozen_berries": "Замороженные ягоды", + "frozen_fruit": "Замороженные фрукты", + "frozen_pizza": "Замороженная пицца", + "frozen_spinach": "Замороженный шпинат", + "funeral_card": "Похоронная карточка", + "garam_masala": "Гарам Масала", + "garbage_bag": "Мешки для мусора", + "garlic": "Чеснок", + "garlic_dip": "Чесночный соус", + "garlic_granules": "Гранулированный чеснок", + "gherkins": "Корнишоны", + "ginger": "Имбирь", + "glass_noodles": "Фунчоза", + "gluten": "Глютен", + "gnocchi": "Ньокки", + "gochujang": "Гочуджан", + "gorgonzola": "Горгонзола", + "gouda": "Гауда", + "granola": "Гранола", + "granola_bar": "Батончик с гранолой", + "grapes": "Виноград", + "greek_yogurt": "Греческий йогурт", + "green_asparagus": "Зеленая спаржа", + "green_chili": "Зеленый перец чили", + "green_pesto": "Зеленый песто", + "hair_gel": "Гель для волос", + "hair_ties": "Завязки для волос", + "hair_wax": "Воск для волос", + "hand_soap": "Ручное мыло", + "handkerchief_box": "Коробка для носовых платков", + "handkerchiefs": "Носовые платки", + "hard_cheese": "Твердый сыр", + "haribo": "Haribo", + "harissa": "Харисса", + "hazelnuts": "Фундук", + "head_of_lettuce": "Головка салата-латука", + "herb_baguettes": "Багеты с травами", + "herb_cream_cheese": "Сливочный сыр с травами", + "honey": "Мед", + "honey_wafers": "Медовые вафли", + "hot_dog_bun": "Булочки для хот-догов", + "ice_cream": "Мороженое", + "ice_cube": "Кубики льда", + "iceberg_lettuce": "Салат Айсберг", + "iced_tea": "Холодный чай", + "instant_soups": "Супы быстрого приготовления", + "jam": "Джем", + "jasmine_rice": "Жасминовый рис", + "katjes": "Katjes", + "ketchup": "Кетчуп", + "kidney_beans": "Почечная фасоль", + "kitchen_roll": "Бумажные полотенца", + "kitchen_towels": "Кухонные полотенца", + "kohlrabi": "Кольраби", + "lasagna": "Лазанья", + "lasagna_noodles": "Макароны для лазаньи", + "lasagna_plates": "Тарелки для лазаньи", + "leaf_spinach": "Листья шпината", + "leek": "Лук-порей", + "lemon": "Лимоны", + "lemon_curd": "Лимонный творог", + "lemon_juice": "Лимонный сок", + "lemonade": "Лимонад", + "lemongrass": "Лемонграсс", + "lentil_stew": "Тушеная чечевица", + "lentils": "Чечевица", + "lentils_red": "Красная чечевица", + "lettuce": "Зелень салата", + "lillet": "Lillet", + "lime": "Лайм", + "linguine": "Макароны лингуини", + "lip_care": "Уход за губами", + "low-fat_curd_cheese": "Обезжиренный творог", + "maggi": "Магги", + "magnesium": "Магний", + "mango": "Манго", + "maple_syrup": "Кленовый сироп", + "margarine": "Маргарин", + "marjoram": "Майоран", + "marshmallows": "Маршмэллоу", + "mascara": "Тушь для ресниц", + "mascarpone": "Маскарпоне", + "mask": "Маска", + "mayonnaise": "Майонез", + "meat_substitute_product": "Продукт, заменяющий мясо", + "microfiber_cloth": "Салфетки из микрофибры", + "milk": "Молоко", + "mint": "Мята", + "mint_candy": "Мятные леденцы", + "miso_paste": "Паста мисо", + "mixed_vegetables": "Овощная смесь", + "mochis": "Мочис", + "mold_remover": "Средство для удаления плесени", + "mountain_cheese": "Горный сыр", + "mouth_wash": "Полоскание рта", + "mozzarella": "Моцарелла", + "muesli": "Мюсли", + "muesli_bar": "Бар мюсли", + "mulled_wine": "Глинтвейн", + "mushrooms": "Грибы", + "mustard": "Горчица", + "nail_file": "Пилка для ногтей", + "neutral_oil": "Рафинированное масло", + "nori_sheets": "Водоросли нори", + "nutmeg": "Мускатный орех", + "oat_milk": "Овсяный напиток", + "oatmeal": "Овсяная каша", + "oatmeal_cookies": "Овсяное печенье", + "oatsome": "Овес", + "obatzda": "Обацда", + "oil": "Нефть", + "olive_oil": "Оливковое масло", + "olives": "Оливки", + "onion": "Лук", + "onion_powder": "Луковый порошок", + "orange_juice": "Апельсиновый сок", + "oranges": "Апельсины", + "oregano": "Орегано", + "organic_lemon": "Органический лимон", + "organic_waste_bags": "Мешки для органических отходов", + "pak_choi": "Пак Чой", + "pantyhose": "Колготки", + "paprika": "Паприка", + "paprika_seasoning": "Приправа паприка", + "pardina_lentils_dried": "Чечевица пардина сушеная", + "parmesan": "Пармезан", + "parsley": "Петрушка", + "pasta": "Паста", + "peach": "Персики", + "peanut_butter": "Арахисовая паста", + "peanut_flips": "Ореховые флипы", + "peanut_oil": "Арахисовое масло", + "peanuts": "Арахис", + "pears": "Груши", + "peas": "Горох", + "penne": "Макароны перья", + "pepper": "Перец", + "pepper_mill": "Мельница для перца", + "peppers": "Перцы", + "persian_rice": "Персидский рис", + "pesto": "Песто", + "pilsner": "Пиво пилснер", + "pine_nuts": "Кедровые орешки", + "pineapple": "Ананасы", + "pita_bag": "Мешок лаваша", + "pita_bread": "Лаваш", + "pizza": "Пицца", + "pizza_dough": "Тесто для пиццы", + "plant_magarine": "Растение Магарин", + "plant_oil": "Растительное масло", + "plaster": "Пластырь", + "pointed_peppers": "Остроконечные перцы", + "porcini_mushrooms": "Белые грибы", + "potato_dumpling_dough": "Тесто для картофельных клецок", + "potato_wedges": "Картофельные дольки", + "potatoes": "Картофель", + "potting_soil": "Посадочная земля", + "powder": "Порошок", + "powdered_sugar": "Сахарная пудра", + "processed_cheese": "Плавленный сыр", + "prosecco": "Просекко", + "puff_pastry": "Слоеное тесто", + "pumpkin": "Тыква", + "pumpkin_seeds": "Тыквенные семечки", + "quark": "Кварк", + "quinoa": "Киноа", + "radicchio": "Радиккио", + "radish": "Редис", + "ramen": "Рамен", + "rapeseed_oil": "Рапсовое масло", + "raspberries": "Малина", + "raspberry_syrup": "Малиновый сироп", + "razor_blades": "Бритвенные лезвия", + "red_bull": "Ред Булл", + "red_chili": "Красный перец чили", + "red_curry_paste": "Красная паста карри", + "red_lentils": "Красная чечевица", + "red_onions": "Красный лук", + "red_pesto": "Красный песто", + "red_wine": "Красное вино", + "red_wine_vinegar": "Красный винный уксус", + "rhubarb": "Ревень", + "ribbon_noodles": "Ленточная лапша", + "rice": "Рис", + "rice_cakes": "Рисовые лепешки", + "rice_paper": "Рисовая бумага", + "rice_ribbon_noodles": "Рисовая ленточная лапша", + "rice_vinegar": "Рисовый уксус", + "ricotta": "Рикотта", + "rinse_tabs": "Таблетки для полоскания", + "rinsing_agent": "Ополаскиватель", + "risotto_rice": "Рис ризотто", + "rocket": "Рукола", + "roll": "Рулон", + "rosemary": "Розмарин", + "saffron_threads": "Шафран", + "sage": "Шалфей", + "saitan_powder": "Сайтанский порошок", + "salad_mix": "Салатная смесь", + "salad_seeds_mix": "Смесь семян для салата", + "salt": "Соль", + "salt_mill": "Мельница для соли", + "sambal_oelek": "Самбал олек", + "sauce": "Соус", + "sausage": "Колбаса", + "sausages": "Сосиски", + "savoy_cabbage": "Савойская капуста", + "scallion": "Зеленый лук", + "scattered_cheese": "Рассыпчатый сыр", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Каша из манной крупы", + "sesame": "Кунжут", + "sesame_oil": "Кунжутное масло", + "shallot": "Лук-шалот", + "shampoo": "Шампунь", + "shawarma_spice": "Приправа для шаурмы", + "shiitake_mushroom": "Грибы шиитаке", + "shoe_insoles": "Стельки", + "shower_gel": "Гель для душа", + "shredded_cheese": "Тертый сыр", + "sieved_tomatoes": "Томатная паста", + "sliced_cheese": "Сыр в нарезке", + "smoked_paprika": "Копченая паприка", + "smoked_tofu": "Копченый тофу", + "snacks": "Снэки", + "soap": "Мыло", + "soba_noodles": "Лапша соба", + "soft_drinks": "Лимонады", + "soup_vegetables": "Суп овощной", + "sour_cream": "Сметана", + "sour_cucumbers": "Маринованные огурцы", + "soy_cream": "Соевый крем", + "soy_hack": "Взлом сои", + "soy_sauce": "Соевый соус", + "soy_shred": "Измельчение сои", + "spaetzle": "Шпецле", + "spaghetti": "Спагетти", + "sparkling_water": "Газированная вода", + "spelt": "Полба", + "spinach": "Шпинат", + "sponge_cloth": "Губчатая ткань", + "sponge_fingers": "Губчатые пальцы", + "sponge_wipes": "Губчатые салфетки", + "sponges": "Губка", + "spreading_cream": "Распределяющий крем", + "spring_onions": "Весенний лук", + "sprite": "Спрайт", + "sprouts": "Проростки", + "sriracha": "Шрирача", + "strained_tomatoes": "Процеженные помидоры", + "strawberries": "Клубника", + "sugar": "Сахар", + "summer_roll_paper": "Летняя рулонная бумага", + "sunflower_oil": "Подсолнечное масло", + "sunflower_seeds": "Семечки", + "sunscreen": "Солнцезащитный крем", + "sushi_rice": "Рис для суши", + "swabian_ravioli": "Швабские равиоли", + "sweet_chili_sauce": "Сладкий соус чили", + "sweet_potato": "Батат", + "sweet_potatoes": "Батат", + "sweets": "Сладости", + "table_salt": "Поваренная соль", + "tagliatelle": "Тальятелле", + "tahini": "Тахина", + "tangerines": "Мандарины", + "tape": "Лента", + "tapioca_flour": "Мука из тапиоки", + "tea": "Чай", + "teriyaki_sauce": "Соус терияки", + "thyme": "Тимьян", + "toast": "Тост", + "tofu": "Тофу", + "toilet_paper": "Туалетная бумага", + "tomato_juice": "Томатный сок", + "tomato_paste": "Томатная паста", + "tomato_sauce": "Томатный соус", + "tomatoes": "Помидоры", + "tonic_water": "Тоник", + "toothpaste": "Зубная паста", + "tortellini": "Тортеллини", + "tortilla_chips": "Чипсы Тортилья", + "tuna": "Тунец", + "turmeric": "Куркума", + "tzatziki": "Дзадзыки", + "udon_noodles": "Лапша удон", + "uht_milk": "Ультрапастеризованное молоко", + "vanilla_sugar": "Ванильный сахар", + "vegetable_bouillon_cube": "Овощной бульонный кубик", + "vegetable_broth": "Овощной бульон", + "vegetable_oil": "Растительное масло", + "vegetable_onion": "Овощной лук", + "vegetables": "Овощи", + "vegetarian_cold_cuts": "вегетарианские холодные закуски", + "vinegar": "Уксус", + "vitamin_tablets": "Витаминные таблетки", + "vodka": "Водка", + "washing_gel": "Моющий гель", + "washing_powder": "Стиральный порошок", + "water": "Вода", + "water_ice": "Водяной лед", + "watermelon": "Арбуз", + "wc_cleaner": "Очиститель унитаза", + "wheat_flour": "Пшеничная мука", + "whipped_cream": "Взбитые сливки", + "white_wine": "Белое вино", + "white_wine_vinegar": "Белый винный уксус", + "whole_canned_tomatoes": "Консервированные помидоры", + "wild_berries": "Дикие ягоды", + "wild_rice": "Дикий рис", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Вустерский соус", + "wrapping_paper": "Оберточная бумага", + "wraps": "Обертывания", + "yeast": "Дрожжи", + "yeast_flakes": "Дрожжевые хлопья", + "yoghurt": "Йогурт", + "yogurt": "Йогурт", + "yum_yum": "Ням-ням", + "zewa": "Zewa", + "zinc_cream": "Цинковый крем", + "zucchini": "Цуккини" + } +} diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json new file mode 100644 index 00000000..1d3200a6 --- /dev/null +++ b/backend/templates/l10n/sv.json @@ -0,0 +1,407 @@ +{ + "categories": { + "bread": "🍞 Bröd & Bageri", + "canned": "🥫 Konserverad Mat", + "dairy": "🥛 Mejeri", + "drinks": "🍹 Drinkar", + "freezer": "❄️ Frys", + "fruits_vegetables": "🥬 Frukt & grönt", + "grain": "🥟 Pasta & nudlar", + "hygiene": "🚽 Hygien", + "refrigerated": "💧 Kylvaror", + "snacks": "🥜 Snacks" + }, + "items": { + "agave_syrup": "Agavesirap", + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Äpple", + "apple_pulp": "Äpplekött", + "applesauce": "Äppelmos", + "apricots": "Aprikos", + "apérol": "Apérol", + "arugula": "Ruccola", + "asian_egg_noodles": "Äggnudlar från Asien", + "asian_noodles": "Nudlar", + "asparagus": "Sparris", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_potatoes": "Trillingar", + "baby_spinach": "Babyspenat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Ugnsbakad fisk", + "baking_cocoa": "Kakao", + "baking_mix": "Bakmix", + "baking_paper": "Bakplåtspapper", + "baking_powder": "Bakpulver", + "baking_soda": "Bikarbonat", + "baking_yeast": "Jäst", + "balsamic_vinegar": "Balsamvinäger", + "bananas": "Bananer", + "basil": "Basilika", + "basmati_rice": "Basmatiris", + "bathroom_cleaner": "Badrumsrengöring", + "batteries": "Batterier", + "bay_leaf": "Lagerblad", + "beans": "Bönor", + "beef": "Nötkött", + "beef_broth": "Köttbuljong", + "beer": "Öl", + "beet": "Beta", + "beetroot": "Rödbeta", + "birthday_card": "Födelsedagskort", + "black_beans": "Svarta bönor", + "blister_plaster": "Skavsårsplåster", + "bockwurst": "Bockwurst", + "bodywash": "Bodywash", + "bread": "Bröd", + "breadcrumbs": "Ströbröd", + "broccoli": "Broccoli", + "brown_sugar": "Brunt socker", + "brussels_sprouts": "Brysselkål", + "buffalo_mozzarella": "Buffelmozzarella", + "buns": "Bullar", + "burger_buns": "Hamburgarbröd", + "burger_patties": "Hamburgare", + "burger_sauces": "Hamburgerdressing", + "butter": "Smör", + "butter_cookies": "Smörkakor", + "butternut_squash": "Butternutpumpa", + "button_cells": "Knappcellsbatterier", + "börek_cheese": "Börek ost", + "cake": "Tårta", + "cake_icing": "Tårtglasyr", + "cane_sugar": "Rörsocker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsolja", + "cardamom": "Kardemumma", + "carrots": "Morötter", + "cashews": "Cashewnötter", + "cat_treats": "Kattgodis", + "cauliflower": "Blomkål", + "celeriac": "Rotselleri", + "celery": "Selleri", + "cereal_bar": "Cornflakes bar", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Körsbärstomater", + "chickpeas": "Kikärtor", + "chicory": "Cikoria", + "chili_oil": "Chiliolja", + "chili_pepper": "Chilipeppar", + "chips": "Chips", + "chives": "Gräslök", + "chocolate": "Choklad", + "chocolate_chips": "Chokladknappar", + "chopped_tomatoes": "Hackade tomater", + "chunky_tomatoes": "Krossade tomater", + "ciabatta": "Cibatta", + "cider_vinegar": "Cidervinäger", + "cilantro": "Koriander", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstång", + "cocktail_sauce": "Cocktailsås", + "cocktail_tomatoes": "Cocktailtomater", + "coconut_flakes": "Kokosflingor", + "coconut_milk": "Kokosmjölk", + "coconut_oil": "Kokosolja", + "colorful_sprinkles": "Färgglatt strössel", + "concealer": "Concealer", + "cookies": "Kakor", + "coriander": "Koriander", + "corn": "Majs", + "cornflakes": "Cornflakes", + "cornstarch": "Majsstärkelse", + "cornys": "Cornys", + "corriander": "Koriander", + "cotton_rounds": "Bomullsrondeller", + "cough_drops": "Halstabletter", + "couscous": "Couscous", + "covid_rapid_test": "COVID snabbtest", + "cow's_milk": "Komjölk", + "cream": "Grädde", + "cream_cheese": "Färskost", + "creamed_spinach": "Stuvad spenat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Maskeringstejp", + "crispbread": "Knäckebröd", + "cucumber": "Gurka", + "cumin": "Spiskummin", + "curd": "Kvarg", + "curry_paste": "Currypasta", + "curry_powder": "Curry", + "curry_sauce": "Currysås", + "dates": "Dadlar", + "dental_floss": "Tandtråd", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Tvättmedel", + "detergent_sheets": "Tvättdukar", + "diarrhea_remedy": "Läkemedel mot diarré", + "dill": "Dill", + "dishwasher_salt": "Diskmaskinssalt", + "dishwasher_tabs": "Diskmaskinstabletter", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Torkade tomater", + "dry_yeast": "Torrjäst", + "edamame": "Edamame", + "egg_salad": "Äggsallad", + "egg_yolk": "Äggula", + "eggplant": "Aubergine", + "eggs": "Ägg", + "enoki_mushrooms": "Enokisvampar", + "eyebrow_gel": "Ögonbrynsgelé", + "falafel": "Falafel", + "falafel_powder": "Falafelpulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskpinnar", + "flour": "Mjöl", + "flushing": "Spolning", + "fresh_chili_pepper": "Färsk chilipeppar", + "frozen_berries": "Frysta bär", + "frozen_broccoli": "Fryst broccoli", + "frozen_fruit": "Fryst frukt", + "frozen_pizza": "Fryspizza", + "frozen_spinach": "Fryst spenat", + "funeral_card": "Kondoleanskort", + "garam_masala": "Garam Masala", + "garbage_bag": "Soppåsar", + "garlic": "Vitlök", + "garlic_dip": "Vitlöksdip", + "garlic_granules": "Vitlöksgranulat", + "gherkins": "Smörgåsgurkor", + "ginger": "Ingefära", + "ginger_ale": "Ginger ale", + "glass_noodles": "Glasnudlar", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Müslibar", + "grapes": "Vindruvor", + "greek_yogurt": "Grekisk Yoghurt", + "green_asparagus": "Grön sparris", + "green_chili": "Grön chili", + "green_pesto": "Grön pesto", + "hair_gel": "Hårgelé", + "hair_ties": "Hårsnodd", + "hair_wax": "Hårvax", + "ham": "Skinka", + "ham_cubes": "Skinkkuber", + "hand_soap": "Handtvål", + "handkerchief_box": "Näsdukslåda", + "handkerchiefs": "Näsdukar", + "hard_cheese": "Hårdost", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnötter", + "head_of_lettuce": "Salladshuvud", + "herb_baguettes": "Örtbaguetter", + "herb_butter": "Örtsmör", + "herb_cream_cheese": "Örtfärskost", + "honey": "Honung", + "honey_wafers": "Honungsvåfflor", + "hot_dog_bun": "Korvbröd", + "ice_cream": "Glass", + "ice_cube": "Isbitar", + "iceberg_lettuce": "Isbergssallad", + "iced_tea": "Iste", + "instant_soups": "Instantsoppa", + "jam": "Sylt", + "jasmine_rice": "Jasminris", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybönor", + "kitchen_roll": "Hushållspapper", + "kitchen_towels": "Hushållspapper", + "kiwi": "Kiwi", + "kohlrabi": "Kålrabbi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagneplattor", + "lasagna_plates": "Lasagneplattor", + "leaf_spinach": "Bladspenat", + "leek": "Purjolök", + "lemon": "Citron", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Citronjuice", + "lemonade": "Lemonad", + "lemongrass": "Citrongräs", + "lentil_stew": "Linsgryta", + "lentils": "Linser", + "lentils_red": "Röda linser", + "lettuce": "Sallad", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Läppvård", + "liqueur": "Likör", + "low-fat_curd_cheese": "Lätt kvarg", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Lönnsirap", + "margarine": "Margarin", + "marjoram": "Mejram", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Mask", + "mayonnaise": "Majonnäs", + "meat_substitute_product": "Köttersättningsprodukt", + "microfiber_cloth": "Mikrofiberduk", + "milk": "Mjölk", + "mint": "Mynta", + "mint_candy": "Mintgodis", + "miso_paste": "Misopasta", + "mixed_vegetables": "Blandade grönsaker", + "mochis": "Mochis", + "mold_remover": "Mögelborttagning", + "mountain_cheese": "Bergsost", + "mouth_wash": "Munskölj", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müslibar", + "mulled_wine": "Glögg", + "mushrooms": "Svamp", + "mustard": "Senap", + "nail_file": "Nagelfil", + "nail_polish_remover": "Nagellacksborttagning", + "neutral_oil": "Neutral olja", + "nori_sheets": "Noriark", + "nutmeg": "Muskot", + "oat_milk": "Havredryck", + "oatmeal": "Havregryn", + "oatmeal_cookies": "Havrekakor", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "oil": "Olja", + "olive_oil": "Olivolja", + "olives": "Oliver", + "onion": "Lök", + "onion_powder": "Lökpulver", + "orange_juice": "Apelsinjuice", + "oranges": "Apelsiner", + "oregano": "Oregano", + "organic_lemon": "Ekologisk citron", + "organic_waste_bags": "Nerbrytningsbara avfallspåsar", + "pak_choi": "Pak Choi", + "pantyhose": "Strumpbyxor", + "papaya": "Papaya", + "paprika": "Paprika", + "paprika_seasoning": "Paprikakrydda", + "pardina_lentils_dried": "Pardinalinser, torkade", + "parmesan": "Parmesan", + "parsley": "Persilja", + "pasta": "Pasta", + "peach": "Persika", + "peanut_butter": "Jordnötssmör", + "peanut_oil": "Jordnötsolja", + "peanuts": "Jordnötter", + "pears": "Päron", + "peas": "Ärtor", + "penne": "Penne", + "pepper": "Peppar", + "pepper_mill": "Pepparkvarn", + "peppers": "Paprika", + "persian_rice": "Persiskt ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjenötter", + "pineapple": "Ananas", + "pita_bag": "Pitabröd", + "pita_bread": "Pitabröd", + "pizza": "Pizza", + "pizza_dough": "Pizzadeg", + "plant_magarine": "Margarin, växtbaserad", + "plaster": "Plåster", + "pointed_peppers": "Spetspaprika", + "porcini_mushrooms": "Stensopp", + "potato_wedges": "Potatisklyftor", + "potatoes": "Potatis", + "potting_soil": "Blomjord", + "powder": "Pulver", + "powdered_sugar": "Florsocker", + "processed_cheese": "Smältost", + "prosecco": "Prosecco", + "puff_pastry": "Smördeg", + "pumpkin": "Pumpa", + "pumpkin_seeds": "Pumpakärnor", + "quark": "Kvarg", + "quinoa": "Quinoa", + "radish": "Rädisa", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolja", + "raspberries": "Hallon", + "raspberry_syrup": "Hallonsirap", + "razor_blades": "Rakblad", + "red_bull": "Red Bull", + "red_chili": "Röd chili", + "red_curry_paste": "Röd currypasta", + "red_lentils": "Röda linser", + "red_onions": "Rödlök", + "red_pesto": "Röd pesto", + "red_wine": "Rödvin", + "red_wine_vinegar": "Rödvinsvinäger", + "rhubarb": "Rabarber", + "rice": "Ris", + "rice_cakes": "Riskakor", + "rice_paper": "Rispapper", + "rice_vinegar": "Risvinsvinäger", + "ricotta": "Ricotta", + "rinse_tabs": "Maskindiskmedel", + "rinsing_agent": "Spolglans", + "risotto_rice": "Risottoris", + "rocket": "Raket", + "roll": "Fralla", + "rosemary": "Rosmarin", + "saffron_threads": "Saffran", + "sage": "Salvia", + "salad_mix": "Salladsmix", + "salt": "Salt", + "salt_mill": "Saltkvarn", + "sambal_oelek": "Sambal oelek", + "sauce": "Sås", + "sausage": "Korv", + "sausages": "Korvar", + "savoy_cabbage": "Savojkål", + "scallion": "Salladslök", + "scattered_cheese": "Riven ost", + "schlemmerfilet": "Fiskgratäng", + "semolina_porridge": "Mannagrynsgröt", + "sesame": "Sesam", + "sesame_oil": "Sesamolja", + "shallot": "Schalottenlök", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarmakrydda", + "shiitake_mushroom": "Shiitake", + "shoe_insoles": "Skoinlägg", + "shower_gel": "Duschgel", + "shredded_cheese": "Riven ost", + "sieved_tomatoes": "Passerade tomater", + "skyr": "Skyr", + "sliced_cheese": "Skivad ost", + "smoked_paprika": "Rökt paprika", + "smoked_tofu": "Rökt tofu", + "snacks": "Snacks", + "soap": "Tvål", + "soba_noodles": "Sobanudlar", + "soft_drinks": "Läsk", + "sour_cream": "Gräddfil", + "sour_cucumbers": "Ättiksgurka", + "soy_cream": "Sojagrädde", + "soy_hack": "Sojafärs", + "soy_sauce": "Sojasås", + "soy_shred": "Sojastrimlor", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Kolsyrad vatten", + "spelt": "Dinkel", + "spinach": "Spenat", + "sponge_cloth": "Kökssvamp" + } +} diff --git a/backend/templates/l10n/tr.json b/backend/templates/l10n/tr.json new file mode 100644 index 00000000..dbda99e8 --- /dev/null +++ b/backend/templates/l10n/tr.json @@ -0,0 +1,481 @@ +{ + "categories": { + "bread": "🍞 Unlu Mamüller", + "canned": "🥫 Konserve", + "dairy": "🥛 Süt Ürünleri", + "drinks": "🍹 İçecek", + "freezer": "❄️ Dondurulmuş", + "fruits_vegetables": "🥬 Meyve ve Sebzeler", + "grain": "🥟 Tahıl Ürünleri", + "hygiene": "🚽 Temizlik", + "refrigerated": "💧 Soğuk Muhafaza", + "snacks": "🥜 Atıştırmalıklar" + }, + "items": { + "aioli": "Sarımsaklı Mayonez", + "amaretto": "Amaretto", + "apple": "Elma", + "apple_pulp": "Posalı Elma", + "applesauce": "Elma püresi", + "apricots": "Kayısı", + "apérol": "Aperol", + "arugula": "Roka", + "asian_egg_noodles": "Yumurtalı Noodle", + "asian_noodles": "Asya eriştesi", + "asparagus": "Kuşkonmaz", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_potatoes": "Üçüzler", + "baby_spinach": "Bebek Ispanak", + "bacon": "Pastırma", + "baguette": "Baget Ekmek", + "bakefish": "Fırın Balığı", + "baking_cocoa": "Pişirmelik Kakao", + "baking_mix": "Pişirme Karışımı", + "baking_paper": "Pişirme Kağıdı", + "baking_powder": "Kabartma Tozu", + "baking_soda": "Karbonat", + "baking_yeast": "Hamur Mayası", + "balsamic_vinegar": "Balzamik Sirke", + "bananas": "Muz", + "basil": "Reyhan", + "basmati_rice": "Basmati Pirinci", + "bathroom_cleaner": "Banyo Temizleyici", + "batteries": "Pil", + "bay_leaf": "Defne", + "beans": "Fasülye", + "beer": "Bira", + "beet": "Pancar", + "beetroot": "Kırmızı Pancar", + "birthday_card": "Doğumgünü Kartı", + "black_beans": "Siyah Fasülye", + "bockwurst": "Bockwurst Sosis", + "bodywash": "Vücut Şampuanı", + "bread": "Ekmek", + "breadcrumbs": "Ekmek Kırıntısı", + "broccoli": "Brokoli", + "brown_sugar": "Esmer Şeker", + "brussels_sprouts": "Brüksel Lahanası", + "buffalo_mozzarella": "Buffalo Mozarella", + "buns": "Poğaça", + "burger_buns": "Burger Ekmeği", + "burger_patties": "Burger Köftesi", + "burger_sauces": "Burger Sosu", + "butter": "Tereyağı", + "butter_cookies": "Tereyağlı Kurabiye", + "button_cells": "Düğme Pil", + "börek_cheese": "Böreklik Peynir", + "cake": "Pasta", + "cake_icing": "Pasta Kreması", + "cane_sugar": "Şekerkamışı Şekeri", + "cannelloni": "Cannelloni Makarna", + "canola_oil": "Kanola Yağı", + "cardamom": "Kakule", + "carrots": "Havuç", + "cashews": "Kaju", + "cat_treats": "Kedi Ödül Maması", + "cauliflower": "Karnabahar", + "celeriac": "Kereviz Kökü", + "celery": "Kereviz", + "cereal_bar": "Tahıl Bar", + "cheddar": "Çedar Peyniri", + "cheese": "Peynir", + "cherry_tomatoes": "Kiraz Domates", + "chickpeas": "Nohut", + "chicory": "Hindiba", + "chili_oil": "Biberli Yağ", + "chili_pepper": "Acı biber", + "chips": "Cips", + "chives": "Frenksoğanı", + "chocolate": "Çikolata", + "chocolate_chips": "Damla Çikolata", + "chopped_tomatoes": "Doğranmış Domates", + "chunky_tomatoes": "Tıknaz domatesler", + "ciabatta": "Ciabatta Ekmeği", + "cider_vinegar": "Elma Sirkesi", + "cilantro": "Kişniş", + "cinnamon": "Tarçın", + "cinnamon_stick": "Tarçın Çubuğu", + "cocktail_sauce": "Kokteyl sosu", + "cocktail_tomatoes": "Kokteyl Domates", + "coconut_flakes": "Hindistancevizi Parçası", + "coconut_milk": "Hindistancevizi Sütü", + "coconut_oil": "Hindistancevizi Yağı", + "colorful_sprinkles": "Pasta Süsü", + "concealer": "Kapatıcı", + "cookies": "Kurabiye", + "coriander": "Aşotu", + "corn": "Mısır", + "cornflakes": "Mısır gevreği", + "cornstarch": "Mısır Nişastası", + "cornys": "Ballı Tahıl", + "corriander": "Corriander", + "cough_drops": "Boğaz Pastili", + "couscous": "Kuskus", + "covid_rapid_test": "COVID hızlı testi", + "cow's_milk": "İnek Sütü", + "cream": "Krema", + "cream_cheese": "Krem Peynir", + "creamed_spinach": "Kremalı Ispanak", + "creme_fraiche": "Taze Krema", + "crepe_tape": "Maskeleme Bandı", + "crispbread": "Kraker", + "cucumber": "Hıyar", + "cumin": "Kimyon", + "curd": "Lor", + "curry_paste": "Köri Ezmesi", + "curry_powder": "Köri Tozu", + "curry_sauce": "Köri Sosu", + "dates": "Hurma", + "dental_floss": "Diş İpi", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Deterjan", + "detergent_sheets": "Deterjan tabakaları", + "diarrhea_remedy": "İshal ilacı", + "dill": "Dereotu", + "dishwasher_salt": "Bulaşık Makinesi Tuzu", + "dishwasher_tabs": "Bulaşık Tableti", + "disinfection_spray": "Dezenfektan Sprey", + "dried_tomatoes": "Kurutulmuş Domates", + "edamame": "Edamame", + "egg_salad": "Yumurta salatası", + "egg_yolk": "Yumurta sarısı", + "eggplant": "Patlıcan", + "eggs": "Yumurta", + "enoki_mushrooms": "Enoki mantarları", + "eyebrow_gel": "Kaş jeli", + "falafel": "Falafel", + "falafel_powder": "Falafel Unu", + "fanta": "Sarı Gazoz", + "feta": "Feta Peyniri", + "ffp2": "FFP2", + "fish_sticks": "Balık Kroket", + "flour": "Un", + "flushing": "Lavaç", + "fresh_chili_pepper": "Taze Kırmızıbiber", + "frozen_berries": "Dondurulmuş Orman Meyvesi", + "frozen_fruit": "Dondurulmuş Meyve", + "frozen_pizza": "Dondurulmuş Pizza", + "frozen_spinach": "Dondurulmuş Ispanak", + "funeral_card": "Cenaze kartı", + "garam_masala": "Garam Masala", + "garbage_bag": "Çöp torbaları", + "garlic": "Sarımsak", + "garlic_dip": "Sarımsaklı Dip Sos", + "garlic_granules": "Sarımsak Granül", + "gherkins": "Kornişon", + "ginger": "Zencefil", + "glass_noodles": "Fasülye Şehriyesi", + "gluten": "Gluten", + "gnocchi": "Niyokki", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola Peyniri", + "gouda": "Gouda Peyniri", + "granola": "Granola", + "granola_bar": "Granola bar", + "grapes": "Üzüm", + "greek_yogurt": "Yunan Yoğurdu", + "green_asparagus": "Yeşil Kuşkonmaz", + "green_chili": "Kıl Biber", + "green_pesto": "Yeşil Pesto", + "hair_gel": "Saç Jölesi", + "hair_ties": "Saç bağları", + "hair_wax": "Saç Sabitleyici", + "hand_soap": "El sabunu", + "handkerchief_box": "Mendil Kutusu", + "handkerchiefs": "Mendil", + "hard_cheese": "Sert peynir", + "haribo": "Jelibon", + "harissa": "Harissa Sosu", + "hazelnuts": "Fındık", + "head_of_lettuce": "Marul Demeti", + "herb_baguettes": "Otlu Ekmek", + "herb_cream_cheese": "Otlu krem peynir", + "honey": "Bal", + "honey_wafers": "Ballı Gofret", + "hot_dog_bun": "Sandviç Ekmeği", + "ice_cream": "Dondurma", + "ice_cube": "Buz küpleri", + "iceberg_lettuce": "Atom Marul", + "iced_tea": "Buzlu Çay", + "instant_soups": "Çabuk Çorba", + "jam": "Reçel", + "jasmine_rice": "Yasemin pirinci", + "katjes": "Katjes Jelibon", + "ketchup": "Ketçap", + "kidney_beans": "Barbunya", + "kitchen_roll": "Kağıt Havlu", + "kitchen_towels": "Mutfak Havlusu", + "kohlrabi": "Alabaş", + "lasagna": "Lazanya", + "lasagna_noodles": "Lazanya Makarnası", + "lasagna_plates": "Lazanya Yaprağı", + "leaf_spinach": "Yaprak Ispanak", + "leek": "Pırasa", + "lemon": "Limon", + "lemon_curd": "Limonlu Lor", + "lemon_juice": "Limon Suyu", + "lemonade": "Limonata", + "lemongrass": "Limon Otu", + "lentil_stew": "Mercimek yahnisi", + "lentils": "Mercimek", + "lentils_red": "Kırmızı mercimek", + "lettuce": "Marul", + "lillet": "Lillet", + "lime": "Misket Limonu", + "linguine": "Uzun Erişte", + "lip_care": "Dudak Bakımı", + "low-fat_curd_cheese": "Böreklik Lor", + "maggi": "Maggi", + "magnesium": "Magnezyum", + "mango": "Mango", + "maple_syrup": "Akçaağaç şurubu", + "margarine": "Margarin", + "marjoram": "Mercanköşk", + "marshmallows": "Marşmelov", + "mascara": "Maskara", + "mascarpone": "Mascarpone", + "mask": "Maske", + "mayonnaise": "Mayonez", + "meat_substitute_product": "Et İkamesi", + "microfiber_cloth": "Mikrofiber Bez", + "milk": "Süt", + "mint": "Nane", + "mint_candy": "Mint Şeker", + "miso_paste": "Miso ezmesi", + "mixed_vegetables": "Karışık Sebze", + "mochis": "Mochis", + "mold_remover": "Küf Sökücü", + "mountain_cheese": "Dağ Peyniri", + "mouth_wash": "Ağız Çalkalama Suyu", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli Bar", + "mulled_wine": "Sıcak şarap", + "mushrooms": "Mantar", + "mustard": "Hardal", + "nail_file": "Tırnak törpüsü", + "neutral_oil": "Kızartma Yağı", + "nori_sheets": "Nori Yaprağı", + "nutmeg": "Muskat", + "oat_milk": "Yulaf Sütü", + "oatmeal": "Yulaf", + "oatmeal_cookies": "Yulaf Kurabiyesi", + "oatsome": "Yulafsütü", + "obatzda": "Obatzda", + "oil": "Yağ", + "olive_oil": "Zeytinyağı", + "olives": "Zeytin", + "onion": "Soğan", + "onion_powder": "Soğan tozu", + "orange_juice": "Portakal suyu", + "oranges": "Portakal", + "oregano": "Güveyotu", + "organic_lemon": "Organik limon", + "organic_waste_bags": "Organik Çöp Torbası", + "pak_choi": "Pak Çoi", + "pantyhose": "Külotlu Çorap", + "paprika": "Kırmızı biber", + "paprika_seasoning": "Kırmızı biber baharatı", + "pardina_lentils_dried": "İspanyol Mercimeği", + "parmesan": "Parmesan", + "parsley": "Maydanoz", + "pasta": "Makarna", + "peach": "Şeftali", + "peanut_butter": "Fıstık ezmesi", + "peanut_flips": "Fıstıklı Cips", + "peanut_oil": "Yerfıstığı Yağı", + "peanuts": "Yerfıstığı", + "pears": "Ayva", + "peas": "Bezelye", + "penne": "Düdük Makarna", + "pepper": "Biber", + "pepper_mill": "Biber Öğütücü", + "peppers": "Biber", + "persian_rice": "Acem Pirinci", + "pesto": "Pesto Sos", + "pilsner": "Pilsen Birası", + "pine_nuts": "Çam Fıstığı", + "pineapple": "Ananas", + "pita_bag": "Pita Poşedi", + "pita_bread": "Pide ekmeği", + "pizza": "Pizza", + "pizza_dough": "Pizza hamuru", + "plant_magarine": "Bitkisel Margarin", + "plant_oil": "Bitkisel Yağ", + "plaster": "Alçı", + "pointed_peppers": "Sivri biberler", + "porcini_mushrooms": "Porcini Mantarı", + "potato_dumpling_dough": "Pişi Hamuru", + "potato_wedges": "Patates Dilimi", + "potatoes": "Patates", + "potting_soil": "Saksı toprağı", + "powder": "Pudra", + "powdered_sugar": "Pudra şekeri", + "processed_cheese": "İşlenmiş peynir", + "prosecco": "Köpüklü Şarap", + "puff_pastry": "Puf Börek", + "pumpkin": "Balkabağı", + "pumpkin_seeds": "Kabak çekirdeği", + "quark": "Quark", + "quinoa": "Kinoa", + "radicchio": "Kırmızı Hindiba", + "radish": "Turp", + "ramen": "Ramen", + "rapeseed_oil": "Kolza Yağı", + "raspberries": "Ahududu", + "raspberry_syrup": "Ahududu Şurubu", + "razor_blades": "Tıraş bıçakları", + "red_bull": "Red Bull", + "red_chili": "Kırmızı acı biber", + "red_curry_paste": "Kırmızı köri ezmesi", + "red_lentils": "Kırmızı mercimek", + "red_onions": "Mor Soğan", + "red_pesto": "Kırmızı Pesto", + "red_wine": "Kırmızı Şarap", + "red_wine_vinegar": "Kırmızı şarap sirkesi", + "rhubarb": "Işgın", + "ribbon_noodles": "Fiyonk Noodle", + "rice": "Pirinç", + "rice_cakes": "Pirinç Keki", + "rice_paper": "Pirinç kağıdı", + "rice_ribbon_noodles": "Pirinç Fiyonk Noodle", + "rice_vinegar": "Pirinç Sirkesi", + "ricotta": "Ricotta Peyniri", + "rinse_tabs": "Durulama Tableti", + "rinsing_agent": "Durulama Suyu", + "risotto_rice": "Risotto Pirinci", + "rocket": "Roka", + "roll": "Rulo", + "rosemary": "Biberiye", + "saffron_threads": "Safran Çubuğu", + "sage": "Adaçayı", + "saitan_powder": "Seitan Tozu", + "salad_mix": "Salata Karışımı", + "salad_seeds_mix": "Tohumlu Salata Karışımı", + "salt": "Tuz", + "salt_mill": "Tuz Öğütücü", + "sambal_oelek": "Sambal Oelek", + "sauce": "Sos", + "sausage": "Sosis", + "sausages": "Sosis", + "savoy_cabbage": "Karalahana", + "scallion": "Taze Soğan", + "scattered_cheese": "Peynir Tozu", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "İrmik Lapası", + "sesame": "Susam", + "sesame_oil": "Susam yağı", + "shallot": "Arpacık soğanı", + "shampoo": "Şampuan", + "shawarma_spice": "Şavarma Baharatı", + "shiitake_mushroom": "Shitakee Mantarı", + "shoe_insoles": "Ayakkabı Tabanlığı", + "shower_gel": "Duş jeli", + "shredded_cheese": "Rendelenmiş peynir", + "sieved_tomatoes": "Domates Tozu", + "sliced_cheese": "Dilimlenmiş peynir", + "smoked_paprika": "Füme Paprika", + "smoked_tofu": "Füme tofu", + "snacks": "Atıştırmalık", + "soap": "Sabun", + "soba_noodles": "Soba eriştesi", + "soft_drinks": "Meşrubat", + "soup_vegetables": "Çorba sebzeleri", + "sour_cream": "Ekşi Krema", + "sour_cucumbers": "Kornişon Turşu", + "soy_cream": "Soya kreması", + "soy_hack": "Soya", + "soy_sauce": "Soya Sosu", + "soy_shred": "Soya Dilimi", + "spaetzle": "Spaetzle", + "spaghetti": "Spagetti", + "sparkling_water": "Soda", + "spelt": "Kavuzlu Buğday", + "spinach": "Ispanak", + "sponge_cloth": "Sünger bez", + "sponge_fingers": "Sünger parmaklar", + "sponge_wipes": "Sarı Bez", + "sponges": "Sünger", + "spreading_cream": "Sürülebilir Peynir", + "spring_onions": "Taze soğan", + "sprite": "Gazoz", + "sprouts": "Filiz", + "sriracha": "Şiraka Acı Sos", + "strained_tomatoes": "Süzme Domates", + "strawberries": "Çilek", + "sugar": "Şeker", + "summer_roll_paper": "Summer Lavaş", + "sunflower_oil": "Ayçiçek yağı", + "sunflower_seeds": "Ay Çekirdeği", + "sunscreen": "Güneş Kremi", + "sushi_rice": "Suşi pirinci", + "swabian_ravioli": "Svabya mantısı", + "sweet_chili_sauce": "Tatlı Acı Sos", + "sweet_potato": "Tatlı Patates", + "sweet_potatoes": "Tatlı patates", + "sweets": "Tatlılar", + "table_salt": "Softa Tuzu", + "tagliatelle": "Tagliatelle", + "tahini": "Tahin", + "tangerines": "Mandalina", + "tape": "Bant", + "tapioca_flour": "Tapyoka unu", + "tea": "Çay", + "teriyaki_sauce": "Teriyaki sosu", + "thyme": "Kekik", + "toast": "Tost", + "tofu": "Tofu", + "toilet_paper": "Tuvalet Kağıdı", + "tomato_juice": "Domates Suyu", + "tomato_paste": "Salça", + "tomato_sauce": "Domates Sosu", + "tomatoes": "Domates", + "tonic_water": "Tonik", + "toothpaste": "Diş Macunu", + "tortellini": "Tortellini", + "tortilla_chips": "Toriccla Cipsi", + "tuna": "Ton Balığı", + "turmeric": "Zerdeçal", + "tzatziki": "Cacık", + "udon_noodles": "Udon Eriştesi", + "uht_milk": "UHT süt", + "vanilla_sugar": "Vanilya şekeri", + "vegetable_bouillon_cube": "Sebze bulyon", + "vegetable_broth": "Sebze Bulyon", + "vegetable_oil": "Bitkisel yağ", + "vegetable_onion": "Sebze Soğanı", + "vegetables": "Sebze", + "vegetarian_cold_cuts": "Vejetaryen Yemek", + "vinegar": "Sirke", + "vitamin_tablets": "Vitamin tabletleri", + "vodka": "Votka", + "washing_gel": "Yıkama jeli", + "washing_powder": "Toz Deterjan", + "water": "Su", + "water_ice": "Dondurulmuş Tatlı", + "watermelon": "Karpuz", + "wc_cleaner": "Tuvalet Temizleyici", + "wheat_flour": "Buğday unu", + "whipped_cream": "Krem Şanti", + "white_wine": "Beyaz şarap", + "white_wine_vinegar": "Beyaz Şarap Sirkesi", + "whole_canned_tomatoes": "Konserve Bütün Domates", + "wild_berries": "Yabani Yemiş", + "wild_rice": "Yabani pirinç", + "wildberry_lillet": "Yabanmersini Lillet", + "worcester_sauce": "Worcester Sos", + "wrapping_paper": "Ambalaj kağıdı", + "wraps": "Dürüm", + "yeast": "Maya", + "yeast_flakes": "Maya gevreği", + "yoghurt": "Yoğurt", + "yogurt": "Yoğurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Çinko Kremi", + "zucchini": "Kabak" + } +} diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json new file mode 100644 index 00000000..6b77a680 --- /dev/null +++ b/backend/templates/l10n/zh_Hans.json @@ -0,0 +1,485 @@ +{ + "categories": { + "bread": "🍞 面包商品", + "canned": "🥫 罐头食品", + "dairy": "🥛 牛乳", + "drinks": "🍹 饮品", + "freezer": "❄️ 冷冻商品", + "fruits_vegetables": "🥬 水果和蔬菜", + "grain": "🥟 米面商品", + "hygiene": "🚽 卫生用品", + "refrigerated": "💧 冷藏商品", + "snacks": "🥜 零食" + }, + "items": { + "agave_syrup": "龙舌兰糖浆", + "aioli": "大蒜蛋黄酱", + "amaretto": "杏仁酒", + "apple": "苹果", + "apple_pulp": "苹果酱", + "applesauce": "苹果酱", + "apricots": "杏子", + "apérol": "Apérol 利口酒", + "arugula": "芝麻菜", + "asian_egg_noodles": "亚洲鸡蛋面", + "asian_noodles": "亚洲面条", + "asparagus": "芦笋", + "aspirin": "阿司匹林", + "avocado": "牛油果", + "baby_potatoes": "三胞胎", + "baby_spinach": "嫩叶菠菜", + "bacon": "培根", + "baguette": "法棍面包", + "bakefish": "烤鱼", + "baking_cocoa": "烘焙可可粉", + "baking_mix": "蛋糕粉", + "baking_paper": "蛋糕纸", + "baking_powder": "泡打粉", + "baking_soda": "小苏打", + "baking_yeast": "烘培酵母", + "balsamic_vinegar": "意大利香醋", + "bananas": "香蕉", + "basil": "罗勒", + "basmati_rice": "印度香米", + "bathroom_cleaner": "卫生间清洁用品", + "batteries": "电池", + "bay_leaf": "香叶", + "beans": "豆", + "beef": "牛肉", + "beef_broth": "牛肉汤", + "beer": "啤酒", + "beet": "甜菜", + "beetroot": "甜根菜", + "birthday_card": "生日贺卡", + "black_beans": "黑豆", + "bockwurst": "烟熏香肠", + "bodywash": "沐浴液", + "bread": "面包", + "breadcrumbs": "面包屑", + "broccoli": "西兰花", + "brown_sugar": "红糖", + "brussels_sprouts": "抱子甘蓝", + "buffalo_mozzarella": "马苏里拉奶酪", + "buns": "馒头", + "burger_buns": "汉堡包", + "burger_patties": "汉堡馅饼", + "burger_sauces": "汉堡酱汁", + "butter": "黄油", + "butter_cookies": "黄油饼干", + "button_cells": "纽扣电池", + "börek_cheese": "Börek奶酪", + "cake": "蛋糕", + "cake_icing": "蛋糕糖衣", + "cane_sugar": "蔗糖", + "cannelloni": "长面条", + "canola_oil": "菜籽油", + "cardamom": "小豆蔻", + "carrots": "胡萝卜", + "cashews": "腰果", + "cat_treats": "猫咪的食物", + "cauliflower": "花椰菜", + "celeriac": "芹菜", + "celery": "芹菜", + "cereal_bar": "谷物棒", + "cheddar": "切达奶酪", + "cheese": "奶酪", + "cherry_tomatoes": "樱桃西红柿", + "chickpeas": "鹰嘴豆", + "chicory": "菊苣", + "chili_oil": "辣椒油", + "chili_pepper": "辣椒", + "chips": "薯片", + "chives": "韭菜", + "chocolate": "巧克力", + "chocolate_chips": "巧克力片", + "chopped_tomatoes": "切碎的西红柿", + "chunky_tomatoes": "大块番茄", + "ciabatta": "玉米饼(Ciabatta", + "cider_vinegar": "苹果醋", + "cilantro": "芫荽", + "cinnamon": "肉桂", + "cinnamon_stick": "肉桂棒", + "cocktail_sauce": "鸡尾酒酱", + "cocktail_tomatoes": "鸡尾酒西红柿", + "coconut_flakes": "椰子片", + "coconut_milk": "椰子汁", + "coconut_oil": "椰子油", + "coffee_powder": "咖啡粉", + "colorful_sprinkles": "五颜六色的喷洒物", + "concealer": "遮瑕膏", + "cookies": "饼干", + "coriander": "芫荽", + "corn": "玉米", + "cornflakes": "玉米片", + "cornstarch": "玉米淀粉", + "cornys": "科尼人", + "corriander": "芫荽", + "cough_drops": "咳嗽滴剂", + "couscous": "库斯库斯", + "covid_rapid_test": "COVID快速检测", + "cow's_milk": "牛奶", + "cream": "奶油", + "cream_cheese": "奶油奶酪", + "creamed_spinach": "奶油菠菜", + "creme_fraiche": "奶油蛋糕", + "crepe_tape": "绉绸带", + "crispbread": "脆皮面包", + "cucumber": "黄瓜", + "cumin": "孜然", + "curd": "凝乳", + "curry_paste": "咖喱酱", + "curry_powder": "咖喱粉", + "curry_sauce": "咖喱酱", + "dates": "日期", + "dental_floss": "牙线", + "deo": "除臭剂", + "deodorant": "除臭剂", + "detergent": "洗涤剂", + "detergent_sheets": "洗涤剂床单", + "diarrhea_remedy": "泻药", + "dill": "莳萝", + "dishwasher_salt": "洗碗机用盐", + "dishwasher_tabs": "洗碗机标签", + "disinfection_spray": "消毒喷雾", + "dried_tomatoes": "西红柿干", + "edamame": "毛豆", + "egg_salad": "鸡蛋沙拉", + "egg_yolk": "蛋黄", + "eggplant": "茄子", + "eggs": "鸡蛋", + "enoki_mushrooms": "金菇", + "eyebrow_gel": "眉毛凝胶", + "falafel": "法拉斐尔", + "falafel_powder": "法拉斐尔粉", + "fanta": "芬达", + "feta": "飞达", + "ffp2": "FFP2", + "fish_sticks": "鱼条", + "flour": "面粉", + "flushing": "法拉盛", + "fresh_chili_pepper": "新鲜辣椒", + "frozen_berries": "冰冻浆果", + "frozen_fruit": "冷冻水果", + "frozen_pizza": "冷冻比萨饼", + "frozen_spinach": "冷冻菠菜", + "funeral_card": "葬礼卡", + "garam_masala": "嘎拉玛沙拉", + "garbage_bag": "垃圾袋", + "garlic": "大蒜", + "garlic_dip": "大蒜蘸酱", + "garlic_granules": "大蒜颗粒", + "gherkins": "小黄瓜", + "ginger": "姜子牙", + "glass_noodles": "玻璃面条", + "gluten": "麸皮", + "gnocchi": "饺子", + "gochujang": "五味子", + "gorgonzola": "戈尔贡佐拉奶酪", + "gouda": "高达", + "granola": "格兰诺拉麦片", + "granola_bar": "燕麦棒", + "grapes": "葡萄", + "greek_yogurt": "希腊酸奶", + "green_asparagus": "绿芦笋", + "green_chili": "绿辣椒", + "green_pesto": "绿酱", + "hair_gel": "发胶", + "hair_ties": "发带", + "hair_wax": "发蜡", + "hand_soap": "洗手液", + "handkerchief_box": "手帕盒", + "handkerchiefs": "手帕", + "hard_cheese": "硬质奶酪", + "haribo": "哈里博", + "harissa": "哈里萨", + "hazelnuts": "榛子", + "head_of_lettuce": "生菜头", + "herb_baguettes": "草本法棍", + "herb_cream_cheese": "草本奶油干酪", + "honey": "蜂蜜", + "honey_wafers": "蜂蜜威化饼", + "hot_dog_bun": "热狗包", + "ice_cream": "冰淇淋", + "ice_cube": "冰块", + "iceberg_lettuce": "冰山生菜", + "iced_tea": "冰茶", + "instant_soups": "速溶汤", + "jam": "果酱", + "jasmine_rice": "茉莉香米", + "katjes": "卡捷斯", + "ketchup": "番茄酱", + "kidney_beans": "肾豆", + "kitchen_roll": "厨房卷", + "kitchen_towels": "厨房毛巾", + "kohlrabi": "高丽菜", + "lasagna": "千层饼", + "lasagna_noodles": "宽面条", + "lasagna_plates": "宽面条盘", + "leaf_spinach": "菠菜叶", + "leek": "韭菜", + "lemon": "柠檬", + "lemon_curd": "柠檬凝乳", + "lemon_juice": "柠檬汁", + "lemonade": "柠檬水", + "lemongrass": "柠檬草", + "lentil_stew": "炖扁豆", + "lentils": "扁豆", + "lentils_red": "红扁豆", + "lettuce": "莴苣", + "lillet": "利莱", + "lime": "石灰", + "linguine": "意大利面条", + "lip_care": "唇部护理", + "low-fat_curd_cheese": "低脂凝乳干酪", + "maggi": "玛吉", + "magnesium": "镁", + "mango": "芒果", + "maple_syrup": "枫糖浆", + "margarine": "人造黄油", + "marjoram": "马兰花", + "marshmallows": "棉花糖", + "mascara": "睫毛膏", + "mascarpone": "马斯卡彭奶酪", + "mask": "面罩", + "mayonnaise": "蛋黄酱", + "meat_substitute_product": "肉类替代产品", + "microfiber_cloth": "超细纤维布", + "milk": "牛奶", + "mint": "薄荷糖", + "mint_candy": "薄荷糖", + "miso_paste": "味噌糊", + "mixed_vegetables": "混合蔬菜", + "mochis": "莫奇斯", + "mold_remover": "除霉剂", + "mountain_cheese": "山地奶酪", + "mouth_wash": "漱口水", + "mozzarella": "莫扎里拉奶酪", + "muesli": "麦片", + "muesli_bar": "麦片吧", + "mulled_wine": "闷酒", + "mushrooms": "蘑菇", + "mustard": "芥末酱", + "nail_file": "指甲锉", + "neutral_oil": "中性油", + "nori_sheets": "紫菜片", + "nutmeg": "肉豆蔻", + "oat_milk": "燕麦饮料", + "oatmeal": "燕麦片", + "oatmeal_cookies": "燕麦饼干", + "oatsome": "燕麦", + "obatzda": "Obatzda", + "oil": "石油", + "olive_oil": "橄榄油", + "olives": "橄榄", + "onion": "洋葱", + "onion_powder": "洋葱粉", + "orange_juice": "橙汁", + "oranges": "橙子", + "oregano": "牛至", + "organic_lemon": "有机柠檬", + "organic_waste_bags": "有机废物袋", + "pak_choi": "白菜", + "pantyhose": "连裤袜", + "paprika": "红辣椒", + "paprika_seasoning": "红辣椒调味料", + "pardina_lentils_dried": "帕迪纳小扁豆干", + "parmesan": "帕玛森", + "parsley": "欧芹", + "pasta": "面食", + "peach": "桃子", + "peanut_butter": "花生酱", + "peanut_flips": "花生翻转", + "peanut_oil": "花生油", + "peanuts": "花生", + "pears": "梨子", + "peas": "豌豆", + "penne": "笔筒", + "pepper": "胡椒粉", + "pepper_mill": "胡椒粉碎机", + "peppers": "辣椒", + "persian_rice": "波斯大米", + "pesto": "香蒜酱", + "pilsner": "比尔森啤酒", + "pine_nuts": "松子", + "pineapple": "菠萝", + "pita_bag": "皮塔袋", + "pita_bread": "皮塔饼", + "pizza": "匹萨", + "pizza_dough": "披萨面团", + "plant_magarine": "植物人马加里", + "plant_oil": "植物油", + "plaster": "石膏", + "pointed_peppers": "尖椒", + "porcini_mushrooms": "牛肝菌", + "potato_dumpling_dough": "马铃薯饺子面团", + "potato_wedges": "马铃薯楔子", + "potatoes": "马铃薯", + "potting_soil": "盆栽土壤", + "powder": "粉末", + "powdered_sugar": "糖粉", + "processed_cheese": "加工奶酪", + "prosecco": "普罗塞克", + "puff_pastry": "酥皮", + "pumpkin": "南瓜", + "pumpkin_seeds": "南瓜籽", + "quark": "夸克", + "quinoa": "藜麦", + "radicchio": "拉迪奇奥", + "radish": "萝卜", + "ramen": "拉面", + "rapeseed_oil": "油菜籽油", + "raspberries": "覆盆子", + "raspberry_syrup": "覆盆子糖浆", + "razor_blades": "剃须刀片", + "red_bull": "红牛", + "red_chili": "红辣椒", + "red_curry_paste": "红咖喱酱", + "red_lentils": "红扁豆", + "red_onions": "红洋葱", + "red_pesto": "红色香蒜酱", + "red_wine": "红葡萄酒", + "red_wine_vinegar": "红酒醋", + "rhubarb": "大黄", + "ribbon_noodles": "带状面条", + "rice": "大米", + "rice_cakes": "米饼", + "rice_paper": "宣纸", + "rice_ribbon_noodles": "米带面", + "rice_vinegar": "米醋", + "ricotta": "蓖麻籽油", + "rinse_tabs": "冲洗片", + "rinsing_agent": "漂洗剂", + "risotto_rice": "烩菜米", + "rocket": "火箭", + "roll": "滚动", + "rosemary": "迷迭香", + "saffron_threads": "藏红花线", + "sage": "圣人", + "saitan_powder": "斋堂粉", + "salad_mix": "沙拉混合", + "salad_seeds_mix": "沙拉种子组合", + "salt": "盐", + "salt_mill": "盐磨", + "sambal_oelek": "凉拌菜", + "sauce": "酱汁", + "sausage": "香肠", + "sausages": "香肠", + "savoy_cabbage": "萨瓦白菜", + "scallion": "斯卡利昂", + "scattered_cheese": "散落的奶酪", + "schlemmerfilet": "薛明辉", + "schupfnudeln": "蛋糕", + "semolina_porridge": "麦片粥", + "sesame": "芝麻", + "sesame_oil": "芝麻油", + "shallot": "大葱", + "shampoo": "洗发水", + "shawarma_spice": "沙瓦玛调料", + "shiitake_mushroom": "香菇", + "shoe_insoles": "鞋垫", + "shower_gel": "沐浴露", + "shredded_cheese": "奶酪丝", + "sieved_tomatoes": "过筛的西红柿", + "sliced_cheese": "芝士片", + "smoked_paprika": "烟熏辣椒粉", + "smoked_tofu": "熏制豆腐", + "snacks": "小吃", + "soap": "肥皂", + "soba_noodles": "荞麦面", + "soft_drinks": "软饮料", + "soup_vegetables": "蔬菜汤", + "sour_cream": "酸奶油", + "sour_cucumbers": "酸黄瓜", + "soy_cream": "大豆奶油", + "soy_hack": "大豆黑客", + "soy_sauce": "酱油", + "soy_shred": "大豆丝", + "spaetzle": "玉米饼", + "spaghetti": "意大利面条", + "sparkling_water": "起泡水", + "spelt": "斯佩尔特", + "spinach": "菠菜", + "sponge_cloth": "海棉布", + "sponge_fingers": "海绵手指", + "sponge_wipes": "海绵擦拭", + "sponges": "海棉", + "spreading_cream": "涂抹式奶油", + "spring_onions": "春天的洋葱", + "sprite": "雪碧", + "sprouts": "萌芽", + "sriracha": "斯里拉查 (Sriracha)", + "strained_tomatoes": "稀释的西红柿", + "strawberries": "草莓", + "sugar": "糖", + "summer_roll_paper": "夏季卷纸", + "sunflower_oil": "葵花籽油", + "sunflower_seeds": "葵花籽", + "sunscreen": "防晒霜", + "sushi_rice": "寿司米", + "swabian_ravioli": "斯瓦比亚的馄饨", + "sweet_chili_sauce": "甜辣椒酱", + "sweet_potato": "红薯", + "sweet_potatoes": "红薯", + "sweets": "糖果", + "table_salt": "食用盐", + "tagliatelle": "塔利亚特面团", + "tahini": "塔希尼", + "tangerines": "橘子", + "tape": "录像带", + "tapioca_flour": "木薯粉", + "tea": "茶叶", + "teriyaki_sauce": "照烧酱", + "thyme": "百里香", + "toast": "吐司", + "tofu": "豆腐", + "toilet_paper": "厕纸", + "tomato_juice": "番茄汁", + "tomato_paste": "番茄酱", + "tomato_sauce": "番茄酱", + "tomatoes": "西红柿", + "tonic_water": "汤力水", + "toothpaste": "牙膏", + "tortellini": "饺子", + "tortilla_chips": "玉米片", + "tuna": "金枪鱼", + "turmeric": "姜黄", + "tzatziki": "塔兹米奇", + "udon_noodles": "乌龙面", + "uht_milk": "UHT牛奶", + "vanilla_sugar": "香草糖", + "vegetable_bouillon_cube": "蔬菜肉汤块", + "vegetable_broth": "蔬菜汤", + "vegetable_oil": "植物油", + "vegetable_onion": "蔬菜洋葱", + "vegetables": "蔬菜", + "vegetarian_cold_cuts": "素食冷盘", + "vinegar": "醋", + "vitamin_tablets": "维生素片", + "vodka": "伏特加", + "washing_gel": "洗涤凝胶", + "washing_powder": "洗衣粉", + "water": "水", + "water_ice": "水冰", + "watermelon": "西瓜", + "wc_cleaner": "厕所清洁剂", + "wheat_flour": "小麦粉", + "whipped_cream": "鲜奶油", + "white_wine": "白葡萄酒", + "white_wine_vinegar": "白葡萄酒醋", + "whole_canned_tomatoes": "完整的罐装西红柿", + "wild_berries": "野生浆果", + "wild_rice": "野生稻", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "喼汁", + "wrapping_paper": "包装纸", + "wraps": "包裹", + "yeast": "酵母菌", + "yeast_flakes": "酵母片", + "yoghurt": "酸奶", + "yogurt": "酸奶", + "yum_yum": "百胜", + "zewa": "Zewa", + "zinc_cream": "锌霜", + "zucchini": "西葫芦" + } +} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..555f6837 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +import app diff --git a/backend/tests/util/__init__.py b/backend/tests/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/util/test_description_merger.py b/backend/tests/util/test_description_merger.py new file mode 100644 index 00000000..f3a3f5e4 --- /dev/null +++ b/backend/tests/util/test_description_merger.py @@ -0,0 +1,45 @@ +import pytest +import app.util.description_merger as description_merger + + +@pytest.mark.parametrize("des,added,result", [ + ("", "", "2x"), + ("", "300ml", "1x, 300ml"), + ("300ml", "1", "300ml, 1"), + ("300ml, 1x", "2", "300ml, 3x"), + ("300ml, 1", "5ml", "305ml, 1"), + ("300ml, 1", "2 halves", "300ml, 1, 2 halves"), + ("300ml, 1", "Gouda", "300ml, 1, Gouda"), + ("½", "1/2", "1"), + ("500g", "1kg", "1500g"), + ("Gouda", "Gouda", "2 Gouda"), + ("Gouda", "Emmentaler", "Gouda, Emmentaler"), + ("Gouda", "", "Gouda, 1x"), + ("1 bag of Kartoffeln", "1 bag of Kartoffeln", "2 bag of Kartoffeln"), + (",500ml,", "500ml", "1L"), + ("2,5ml,", "1,5ml", "4ml"), + ("ml", "1L", "1001ml"), + ("1L", "10ml", "1010ml"), + ("1L", "2L", "3L"), + ("1 cup of 2ml sugar", "other", "1 cup of 2ml sugar, other"), + ("1 TL", "1tl", "2 TL"), + ("1", "1X", "2"), + (".2233", "1/5", "0.4233"), + ("1x", "1/3", "1.33333x"), + ("1", "1, 1, 2", "5"), + ("1, 2", "1", "2, 2"), + ("1,2", "1", "2.2") + # ("1-2", "3-4", "1-2, 3-4"), + # ("100g fresh", "100g fresh", "200g fresh") +]) +def testDescriptionMerge(des, added, result): + assert description_merger.merge(des, added) == result + + +@pytest.mark.parametrize("input,result", [ + ("½", "0.5"), + ("1/2", "0.5"), + ("500/1000", "0.5") +]) +def testClean(input, result): + assert description_merger.clean(input) == result diff --git a/backend/tests/util/test_description_splitter.py b/backend/tests/util/test_description_splitter.py new file mode 100644 index 00000000..1be70ed7 --- /dev/null +++ b/backend/tests/util/test_description_splitter.py @@ -0,0 +1,27 @@ +import pytest +import app.util.description_splitter as description_splitter + + +@pytest.mark.parametrize("query,item,description", [ + ("", "", ""), + ("300ml", "ml", "300"), + ("300ml Milk", "Milk", "300ml"), + ("Gouda", "Gouda", ""), + ("Gouda, Emmentaler", "Gouda, Emmentaler", ""), + ("1 bag of Kartoffeln", "bag of Kartoffeln", "1"), + ("5kg Gouda", "Gouda", "5kg"), + ("Gouda 5g", "Gouda", "5g"), + ("Gouda + 5 Kartoffeln", "Gouda + 5 Kartoffeln", ""), + ("Gouda + 5 Pumpkin", "Gouda + 5 Pumpkin", ""), +]) +def testDescriptionMerge(query, item, description): + assert description_splitter.split(query) == (item, description) + + +@pytest.mark.parametrize("input,result", [ + ("½", "0.5"), + ("1/2", "0.5"), + ("500/1000", "0.5") +]) +def testClean(input, result): + assert description_splitter.clean(input) == result diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py new file mode 100644 index 00000000..ce859ddd --- /dev/null +++ b/backend/upgrade_default_items.py @@ -0,0 +1,14 @@ +from tqdm import tqdm +from app import app +from app.errors import NotFoundRequest +from app.models import Household +from app.service.import_language import importLanguage + + +if __name__ == "__main__": + with app.app_context(): + for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households"): + try: + importLanguage(household.id, household.language, bulkSave=True) + except NotFoundRequest: + pass diff --git a/backend/wsgi.ini b/backend/wsgi.ini new file mode 100644 index 00000000..72337782 --- /dev/null +++ b/backend/wsgi.ini @@ -0,0 +1,27 @@ +[uwsgi] +strict = true +master = true +enable-threads = true +http-websockets = true +lazy-apps=true +vacuum = true +single-interpreter = true +die-on-term = true +need-app = true +chmod-socket = 664 + +wsgi-file = wsgi.py +callable = app +socket = [::]:5000 +procname-prefix-spaced = kitchenowl + +[celery] +ini = :uwsgi +smart-attach-daemon = /tmp/celery.pid celery -A app.celery_app worker -B --pidfile=/tmp/celery.pid + +[web] +ini = :uwsgi +http = [::]:8080 +http-to = :5000 +static-map = /=/var/www/web/kitchenowl +route = ^\/(?!api)[^\.]*$ static:/var/www/web/kitchenowl/index.html diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 00000000..7b8eb729 --- /dev/null +++ b/backend/wsgi.py @@ -0,0 +1,12 @@ +import gevent.monkey +gevent.monkey.patch_all() + +from app import app, socketio, celery_app +import os + +from app.config import UPLOAD_FOLDER + +if __name__ == "__main__": + if not os.path.exists(UPLOAD_FOLDER): + os.makedirs(UPLOAD_FOLDER) + socketio.run(app, debug=True) diff --git a/changelog_configuration.json b/changelog_configuration.json index 5f0b0648..e3f4b828 100644 --- a/changelog_configuration.json +++ b/changelog_configuration.json @@ -26,7 +26,7 @@ "order": "ASC", "on_property": "mergedAt" }, - "template": "${{CHANGELOG}}## Uncategorized\n${{UNCATEGORIZED}}\n\n${{RELEASE_DIFF}}", + "template": "${{CHANGELOG}}\n## Uncategorized\n${{UNCATEGORIZED}}\n\n${{RELEASE_DIFF}}", "pr_template": "- ${{TITLE}} (#${{NUMBER}} by @${{AUTHOR}})", "empty_template": "- no changes", "base_branches": ["main"] diff --git a/icons/README.md b/icons/README.md index e4f4601e..737034e1 100644 --- a/icons/README.md +++ b/icons/README.md @@ -2,7 +2,7 @@ The icons in the `/icons/icons8` folder cannot be extracted, used, or distributed by any third party. See [copyright](./icons8/COPYRIGHT) for more information. -Specials thanks to https://icons8.com/ who are the sole copyright holder. +Special thanks to https://icons8.com/ who are the sole copyright holder. # Contributing diff --git a/generate-item-icons.py b/icons/generate-item-icons.py similarity index 92% rename from generate-item-icons.py rename to icons/generate-item-icons.py index 8c9fdad7..7c2350a7 100644 --- a/generate-item-icons.py +++ b/icons/generate-item-icons.py @@ -76,8 +76,7 @@ def validate_source_directories(self, source_directories): if os.path.exists(directory): ret_directories.append(directory) else: - sys.stderr("path \"%s\" for source svg files does not exist." % \ - directory) + print(f"path \"{directory}\" for source svg files does not exist.\n") if len(ret_directories) == 0: raise NoSourceSvgDirectoriesException("No valid paths for source \ svg files provided") @@ -105,10 +104,11 @@ def generate(self): font.save_to_file(self.target_ttf_file) if __name__ == "__main__": - fontGenerator = SvgToFontGenerator(['./icons/icons8', './icons'], './fonts/Items.ttf') + folderPath = os.path.dirname(os.path.abspath(__file__)) + fontGenerator = SvgToFontGenerator([folderPath + '/icons8', folderPath + '/'], folderPath + '/../kitchenowl/fonts/Items.ttf') fontGenerator.generate() names = [svg.name.lower().replace("-", "_").replace("icons8_","") for svg in fontGenerator.source_svg_files] - with open('./lib/item_icons.dart', 'w') as f: + with open(folderPath + '/../kitchenowl/lib/item_icons.dart', 'w') as f: f.write(""" /* generated code, do not edit */ // ignore_for_file: constant_identifier_names diff --git a/kitchenowl/.dockerignore b/kitchenowl/.dockerignore new file mode 100644 index 00000000..c246b596 --- /dev/null +++ b/kitchenowl/.dockerignore @@ -0,0 +1,112 @@ +# General files +.git +.github + +# Docker ignore (include only web files) +fedora/ +ios/ +android/ +linux/ +macos/ +windows/ + +# .gitignore here: +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.env* + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ +*.code-workspace + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# MkDocs +site/ + +# KitchenOwl +icons/ + +# Development +.devcontainer + +# Test related files +.tox +tests + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ \ No newline at end of file diff --git a/kitchenowl/.gitignore b/kitchenowl/.gitignore new file mode 100644 index 00000000..29a3a501 --- /dev/null +++ b/kitchenowl/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/kitchenowl/.metadata similarity index 100% rename from .metadata rename to kitchenowl/.metadata diff --git a/kitchenowl/Dockerfile b/kitchenowl/Dockerfile new file mode 100644 index 00000000..7e2b7b9d --- /dev/null +++ b/kitchenowl/Dockerfile @@ -0,0 +1,70 @@ +# ------------ +# BUILDER +# ------------ +FROM --platform=$BUILDPLATFORM debian:latest AS builder + +# Install dependencies +RUN apt-get update -y +RUN apt-get upgrade -y +# Install basics +RUN apt-get install -y --no-install-recommends \ + git \ + wget \ + curl \ + zip \ + unzip \ + apt-transport-https \ + ca-certificates \ + gnupg \ + python3 \ + libstdc++6 \ + libglu1-mesa +RUN apt-get clean + +# Clone the flutter repo +RUN git clone https://github.com/flutter/flutter.git -b stable /usr/local/src/flutter + +# Set flutter path +ENV PATH="${PATH}:/usr/local/src/flutter/bin" + +# Enable flutter web +RUN flutter config --enable-web +RUN flutter config --no-analytics +RUN flutter upgrade + +# Run flutter doctor +RUN flutter doctor -v + +# Copy the app files to the container +COPY .metadata l10n.yaml pubspec.yaml /usr/local/src/app/ +COPY lib /usr/local/src/app/lib +COPY web /usr/local/src/app/web +COPY assets /usr/local/src/app/assets +COPY fonts /usr/local/src/app/fonts + +# Set the working directory to the app files within the container +WORKDIR /usr/local/src/app + +# Get App Dependencies +RUN flutter packages get + +# Build the app for the web +RUN flutter build web --release --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ + +# ------------ +# RUNNER +# ------------ +FROM nginx:stable-alpine + +RUN mkdir -p /var/www/web/kitchenowl +COPY --from=builder /usr/local/src/app/build/web /var/www/web/kitchenowl +COPY docker-entrypoint-custom.sh /docker-entrypoint.d/01-kitchenowl-customization.sh +COPY default.conf.template /etc/nginx/templates/ + +HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1 + +# Set ENV +ENV BACK_URL='back:5000' + +# Expose the web server +EXPOSE 80 \ No newline at end of file diff --git a/kitchenowl/README.md b/kitchenowl/README.md new file mode 100644 index 00000000..bf5b0d26 --- /dev/null +++ b/kitchenowl/README.md @@ -0,0 +1,12 @@ +## Contributing + +Take a look at the general contribution rules [here](../CONTRIBUTING.md). + +### Requirements +- [flutter](https://flutter.dev/docs/get-started/install) + +### Setup & Install +- If you haven't already, switch to the frontend folder `cd kitchenowl` +- Install dependencies: `flutter packages get` +- Run app: `flutter run` +``` \ No newline at end of file diff --git a/android/.gitignore b/kitchenowl/android/.gitignore similarity index 100% rename from android/.gitignore rename to kitchenowl/android/.gitignore diff --git a/android/Gemfile b/kitchenowl/android/Gemfile similarity index 100% rename from android/Gemfile rename to kitchenowl/android/Gemfile diff --git a/android/app/build.gradle b/kitchenowl/android/app/build.gradle similarity index 100% rename from android/app/build.gradle rename to kitchenowl/android/app/build.gradle diff --git a/android/app/src/debug/AndroidManifest.xml b/kitchenowl/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from android/app/src/debug/AndroidManifest.xml rename to kitchenowl/android/app/src/debug/AndroidManifest.xml diff --git a/android/app/src/main/AndroidManifest.xml b/kitchenowl/android/app/src/main/AndroidManifest.xml similarity index 100% rename from android/app/src/main/AndroidManifest.xml rename to kitchenowl/android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/ic_launcher-playstore.png b/kitchenowl/android/app/src/main/ic_launcher-playstore.png similarity index 100% rename from android/app/src/main/ic_launcher-playstore.png rename to kitchenowl/android/app/src/main/ic_launcher-playstore.png diff --git a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/kitchenowl/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java similarity index 100% rename from android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java rename to kitchenowl/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java diff --git a/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt b/kitchenowl/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt rename to kitchenowl/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/kitchenowl/android/app/src/main/res/drawable-night-v21/background.png similarity index 100% rename from android/app/src/main/res/drawable-night-v21/background.png rename to kitchenowl/android/app/src/main/res/drawable-night-v21/background.png diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-night-v21/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-night-v21/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-night-v21/launch_background.xml diff --git a/android/app/src/main/res/drawable-night/background.png b/kitchenowl/android/app/src/main/res/drawable-night/background.png similarity index 100% rename from android/app/src/main/res/drawable-night/background.png rename to kitchenowl/android/app/src/main/res/drawable-night/background.png diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-night/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-night/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-night/launch_background.xml diff --git a/android/app/src/main/res/drawable-v21/background.png b/kitchenowl/android/app/src/main/res/drawable-v21/background.png similarity index 100% rename from android/app/src/main/res/drawable-v21/background.png rename to kitchenowl/android/app/src/main/res/drawable-v21/background.png diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-v21/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/android/app/src/main/res/drawable/background.png b/kitchenowl/android/app/src/main/res/drawable/background.png similarity index 100% rename from android/app/src/main/res/drawable/background.png rename to kitchenowl/android/app/src/main/res/drawable/background.png diff --git a/android/app/src/main/res/drawable/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable/launch_background.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml b/kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml rename to kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/kitchenowl/android/app/src/main/res/values-night-v31/styles.xml similarity index 100% rename from android/app/src/main/res/values-night-v31/styles.xml rename to kitchenowl/android/app/src/main/res/values-night-v31/styles.xml diff --git a/android/app/src/main/res/values-night/styles.xml b/kitchenowl/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from android/app/src/main/res/values-night/styles.xml rename to kitchenowl/android/app/src/main/res/values-night/styles.xml diff --git a/android/app/src/main/res/values-v31/styles.xml b/kitchenowl/android/app/src/main/res/values-v31/styles.xml similarity index 100% rename from android/app/src/main/res/values-v31/styles.xml rename to kitchenowl/android/app/src/main/res/values-v31/styles.xml diff --git a/android/app/src/main/res/values/colors.xml b/kitchenowl/android/app/src/main/res/values/colors.xml similarity index 100% rename from android/app/src/main/res/values/colors.xml rename to kitchenowl/android/app/src/main/res/values/colors.xml diff --git a/android/app/src/main/res/values/styles.xml b/kitchenowl/android/app/src/main/res/values/styles.xml similarity index 100% rename from android/app/src/main/res/values/styles.xml rename to kitchenowl/android/app/src/main/res/values/styles.xml diff --git a/android/app/src/main/res/xml/locales_config.xml b/kitchenowl/android/app/src/main/res/xml/locales_config.xml similarity index 100% rename from android/app/src/main/res/xml/locales_config.xml rename to kitchenowl/android/app/src/main/res/xml/locales_config.xml diff --git a/android/app/src/main/res/xml/network_security_config.xml b/kitchenowl/android/app/src/main/res/xml/network_security_config.xml similarity index 100% rename from android/app/src/main/res/xml/network_security_config.xml rename to kitchenowl/android/app/src/main/res/xml/network_security_config.xml diff --git a/android/app/src/profile/AndroidManifest.xml b/kitchenowl/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from android/app/src/profile/AndroidManifest.xml rename to kitchenowl/android/app/src/profile/AndroidManifest.xml diff --git a/android/build.gradle b/kitchenowl/android/build.gradle similarity index 100% rename from android/build.gradle rename to kitchenowl/android/build.gradle diff --git a/android/fastlane/Appfile b/kitchenowl/android/fastlane/Appfile similarity index 100% rename from android/fastlane/Appfile rename to kitchenowl/android/fastlane/Appfile diff --git a/android/fastlane/Fastfile b/kitchenowl/android/fastlane/Fastfile similarity index 100% rename from android/fastlane/Fastfile rename to kitchenowl/android/fastlane/Fastfile diff --git a/android/fastlane/README.md b/kitchenowl/android/fastlane/README.md similarity index 100% rename from android/fastlane/README.md rename to kitchenowl/android/fastlane/README.md diff --git a/android/gradle.properties b/kitchenowl/android/gradle.properties similarity index 100% rename from android/gradle.properties rename to kitchenowl/android/gradle.properties diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/kitchenowl/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.properties rename to kitchenowl/android/gradle/wrapper/gradle-wrapper.properties diff --git a/android/settings.gradle b/kitchenowl/android/settings.gradle similarity index 100% rename from android/settings.gradle rename to kitchenowl/android/settings.gradle diff --git a/assets/icon/icon-foreground.png b/kitchenowl/assets/icon/icon-foreground.png similarity index 100% rename from assets/icon/icon-foreground.png rename to kitchenowl/assets/icon/icon-foreground.png diff --git a/assets/icon/icon-padded.png b/kitchenowl/assets/icon/icon-padded.png similarity index 100% rename from assets/icon/icon-padded.png rename to kitchenowl/assets/icon/icon-padded.png diff --git a/assets/icon/icon-rounded.png b/kitchenowl/assets/icon/icon-rounded.png similarity index 100% rename from assets/icon/icon-rounded.png rename to kitchenowl/assets/icon/icon-rounded.png diff --git a/assets/icon/icon-small.png b/kitchenowl/assets/icon/icon-small.png similarity index 100% rename from assets/icon/icon-small.png rename to kitchenowl/assets/icon/icon-small.png diff --git a/assets/icon/icon.png b/kitchenowl/assets/icon/icon.png similarity index 100% rename from assets/icon/icon.png rename to kitchenowl/assets/icon/icon.png diff --git a/assets/images/google_logo.png b/kitchenowl/assets/images/google_logo.png similarity index 100% rename from assets/images/google_logo.png rename to kitchenowl/assets/images/google_logo.png diff --git a/dart_test.yaml b/kitchenowl/dart_test.yaml similarity index 100% rename from dart_test.yaml rename to kitchenowl/dart_test.yaml diff --git a/debian/build.sh b/kitchenowl/debian/build.sh similarity index 100% rename from debian/build.sh rename to kitchenowl/debian/build.sh diff --git a/debian/kitchenowl/DEBIAN/control b/kitchenowl/debian/kitchenowl/DEBIAN/control similarity index 100% rename from debian/kitchenowl/DEBIAN/control rename to kitchenowl/debian/kitchenowl/DEBIAN/control diff --git a/debian/kitchenowl/DEBIAN/postinst b/kitchenowl/debian/kitchenowl/DEBIAN/postinst similarity index 100% rename from debian/kitchenowl/DEBIAN/postinst rename to kitchenowl/debian/kitchenowl/DEBIAN/postinst diff --git a/debian/kitchenowl/usr/bin/kitchenowl b/kitchenowl/debian/kitchenowl/usr/bin/kitchenowl similarity index 100% rename from debian/kitchenowl/usr/bin/kitchenowl rename to kitchenowl/debian/kitchenowl/usr/bin/kitchenowl diff --git a/default.conf.template b/kitchenowl/default.conf.template similarity index 100% rename from default.conf.template rename to kitchenowl/default.conf.template diff --git a/docker-entrypoint-custom.sh b/kitchenowl/docker-entrypoint-custom.sh similarity index 100% rename from docker-entrypoint-custom.sh rename to kitchenowl/docker-entrypoint-custom.sh diff --git a/fedora/build.sh b/kitchenowl/fedora/build.sh similarity index 100% rename from fedora/build.sh rename to kitchenowl/fedora/build.sh diff --git a/fedora/kitchenowl.spec b/kitchenowl/fedora/kitchenowl.spec similarity index 100% rename from fedora/kitchenowl.spec rename to kitchenowl/fedora/kitchenowl.spec diff --git a/fonts/Items.ttf b/kitchenowl/fonts/Items.ttf similarity index 100% rename from fonts/Items.ttf rename to kitchenowl/fonts/Items.ttf diff --git a/fonts/README.md b/kitchenowl/fonts/README.md similarity index 100% rename from fonts/README.md rename to kitchenowl/fonts/README.md diff --git a/fonts/Roboto-Black.ttf b/kitchenowl/fonts/Roboto-Black.ttf similarity index 100% rename from fonts/Roboto-Black.ttf rename to kitchenowl/fonts/Roboto-Black.ttf diff --git a/fonts/Roboto-BlackItalic.ttf b/kitchenowl/fonts/Roboto-BlackItalic.ttf similarity index 100% rename from fonts/Roboto-BlackItalic.ttf rename to kitchenowl/fonts/Roboto-BlackItalic.ttf diff --git a/fonts/Roboto-Bold.ttf b/kitchenowl/fonts/Roboto-Bold.ttf similarity index 100% rename from fonts/Roboto-Bold.ttf rename to kitchenowl/fonts/Roboto-Bold.ttf diff --git a/fonts/Roboto-BoldItalic.ttf b/kitchenowl/fonts/Roboto-BoldItalic.ttf similarity index 100% rename from fonts/Roboto-BoldItalic.ttf rename to kitchenowl/fonts/Roboto-BoldItalic.ttf diff --git a/fonts/Roboto-Italic.ttf b/kitchenowl/fonts/Roboto-Italic.ttf similarity index 100% rename from fonts/Roboto-Italic.ttf rename to kitchenowl/fonts/Roboto-Italic.ttf diff --git a/fonts/Roboto-Light.ttf b/kitchenowl/fonts/Roboto-Light.ttf similarity index 100% rename from fonts/Roboto-Light.ttf rename to kitchenowl/fonts/Roboto-Light.ttf diff --git a/fonts/Roboto-LightItalic.ttf b/kitchenowl/fonts/Roboto-LightItalic.ttf similarity index 100% rename from fonts/Roboto-LightItalic.ttf rename to kitchenowl/fonts/Roboto-LightItalic.ttf diff --git a/fonts/Roboto-Medium.ttf b/kitchenowl/fonts/Roboto-Medium.ttf similarity index 100% rename from fonts/Roboto-Medium.ttf rename to kitchenowl/fonts/Roboto-Medium.ttf diff --git a/fonts/Roboto-MediumItalic.ttf b/kitchenowl/fonts/Roboto-MediumItalic.ttf similarity index 100% rename from fonts/Roboto-MediumItalic.ttf rename to kitchenowl/fonts/Roboto-MediumItalic.ttf diff --git a/fonts/Roboto-Regular.ttf b/kitchenowl/fonts/Roboto-Regular.ttf similarity index 100% rename from fonts/Roboto-Regular.ttf rename to kitchenowl/fonts/Roboto-Regular.ttf diff --git a/fonts/Roboto-Thin.ttf b/kitchenowl/fonts/Roboto-Thin.ttf similarity index 100% rename from fonts/Roboto-Thin.ttf rename to kitchenowl/fonts/Roboto-Thin.ttf diff --git a/fonts/Roboto-ThinItalic.ttf b/kitchenowl/fonts/Roboto-ThinItalic.ttf similarity index 100% rename from fonts/Roboto-ThinItalic.ttf rename to kitchenowl/fonts/Roboto-ThinItalic.ttf diff --git a/ios/.gitignore b/kitchenowl/ios/.gitignore similarity index 100% rename from ios/.gitignore rename to kitchenowl/ios/.gitignore diff --git a/ios/Flutter/AppFrameworkInfo.plist b/kitchenowl/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from ios/Flutter/AppFrameworkInfo.plist rename to kitchenowl/ios/Flutter/AppFrameworkInfo.plist diff --git a/ios/Flutter/Debug.xcconfig b/kitchenowl/ios/Flutter/Debug.xcconfig similarity index 100% rename from ios/Flutter/Debug.xcconfig rename to kitchenowl/ios/Flutter/Debug.xcconfig diff --git a/ios/Flutter/Release.xcconfig b/kitchenowl/ios/Flutter/Release.xcconfig similarity index 100% rename from ios/Flutter/Release.xcconfig rename to kitchenowl/ios/Flutter/Release.xcconfig diff --git a/ios/Gemfile b/kitchenowl/ios/Gemfile similarity index 100% rename from ios/Gemfile rename to kitchenowl/ios/Gemfile diff --git a/ios/Podfile b/kitchenowl/ios/Podfile similarity index 100% rename from ios/Podfile rename to kitchenowl/ios/Podfile diff --git a/ios/Runner.xcodeproj/project.pbxproj b/kitchenowl/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from ios/Runner.xcodeproj/project.pbxproj rename to kitchenowl/ios/Runner.xcodeproj/project.pbxproj diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kitchenowl/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to kitchenowl/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/kitchenowl/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcworkspace/contents.xcworkspacedata rename to kitchenowl/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kitchenowl/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to kitchenowl/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/ios/Runner/AppDelegate.swift b/kitchenowl/ios/Runner/AppDelegate.swift similarity index 100% rename from ios/Runner/AppDelegate.swift rename to kitchenowl/ios/Runner/AppDelegate.swift diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/kitchenowl/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from ios/Runner/Base.lproj/LaunchScreen.storyboard rename to kitchenowl/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/ios/Runner/Base.lproj/Main.storyboard b/kitchenowl/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from ios/Runner/Base.lproj/Main.storyboard rename to kitchenowl/ios/Runner/Base.lproj/Main.storyboard diff --git a/ios/Runner/Info.plist b/kitchenowl/ios/Runner/Info.plist similarity index 100% rename from ios/Runner/Info.plist rename to kitchenowl/ios/Runner/Info.plist diff --git a/ios/Runner/Runner-Bridging-Header.h b/kitchenowl/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from ios/Runner/Runner-Bridging-Header.h rename to kitchenowl/ios/Runner/Runner-Bridging-Header.h diff --git a/ios/Runner/Runner.entitlements b/kitchenowl/ios/Runner/Runner.entitlements similarity index 100% rename from ios/Runner/Runner.entitlements rename to kitchenowl/ios/Runner/Runner.entitlements diff --git a/ios/RunnerTests/RunnerTests.swift b/kitchenowl/ios/RunnerTests/RunnerTests.swift similarity index 100% rename from ios/RunnerTests/RunnerTests.swift rename to kitchenowl/ios/RunnerTests/RunnerTests.swift diff --git a/ios/ShareExtension/Base.lproj/MainInterface.storyboard b/kitchenowl/ios/ShareExtension/Base.lproj/MainInterface.storyboard similarity index 100% rename from ios/ShareExtension/Base.lproj/MainInterface.storyboard rename to kitchenowl/ios/ShareExtension/Base.lproj/MainInterface.storyboard diff --git a/ios/ShareExtension/Info.plist b/kitchenowl/ios/ShareExtension/Info.plist similarity index 100% rename from ios/ShareExtension/Info.plist rename to kitchenowl/ios/ShareExtension/Info.plist diff --git a/ios/ShareExtension/ShareExtension.entitlements b/kitchenowl/ios/ShareExtension/ShareExtension.entitlements similarity index 100% rename from ios/ShareExtension/ShareExtension.entitlements rename to kitchenowl/ios/ShareExtension/ShareExtension.entitlements diff --git a/ios/ShareExtension/ShareViewController.swift b/kitchenowl/ios/ShareExtension/ShareViewController.swift similarity index 100% rename from ios/ShareExtension/ShareViewController.swift rename to kitchenowl/ios/ShareExtension/ShareViewController.swift diff --git a/ios/fastlane/Appfile b/kitchenowl/ios/fastlane/Appfile similarity index 100% rename from ios/fastlane/Appfile rename to kitchenowl/ios/fastlane/Appfile diff --git a/ios/fastlane/Fastfile b/kitchenowl/ios/fastlane/Fastfile similarity index 100% rename from ios/fastlane/Fastfile rename to kitchenowl/ios/fastlane/Fastfile diff --git a/ios/fastlane/README.md b/kitchenowl/ios/fastlane/README.md similarity index 100% rename from ios/fastlane/README.md rename to kitchenowl/ios/fastlane/README.md diff --git a/l10n.yaml b/kitchenowl/l10n.yaml similarity index 100% rename from l10n.yaml rename to kitchenowl/l10n.yaml diff --git a/lib/app.dart b/kitchenowl/lib/app.dart similarity index 100% rename from lib/app.dart rename to kitchenowl/lib/app.dart diff --git a/lib/config.dart b/kitchenowl/lib/config.dart similarity index 100% rename from lib/config.dart rename to kitchenowl/lib/config.dart diff --git a/lib/cubits/auth_cubit.dart b/kitchenowl/lib/cubits/auth_cubit.dart similarity index 100% rename from lib/cubits/auth_cubit.dart rename to kitchenowl/lib/cubits/auth_cubit.dart diff --git a/lib/cubits/email_confirm_cubit.dart b/kitchenowl/lib/cubits/email_confirm_cubit.dart similarity index 100% rename from lib/cubits/email_confirm_cubit.dart rename to kitchenowl/lib/cubits/email_confirm_cubit.dart diff --git a/lib/cubits/expense_add_update_cubit.dart b/kitchenowl/lib/cubits/expense_add_update_cubit.dart similarity index 100% rename from lib/cubits/expense_add_update_cubit.dart rename to kitchenowl/lib/cubits/expense_add_update_cubit.dart diff --git a/lib/cubits/expense_category_add_update_cubit.dart b/kitchenowl/lib/cubits/expense_category_add_update_cubit.dart similarity index 100% rename from lib/cubits/expense_category_add_update_cubit.dart rename to kitchenowl/lib/cubits/expense_category_add_update_cubit.dart diff --git a/lib/cubits/expense_cubit.dart b/kitchenowl/lib/cubits/expense_cubit.dart similarity index 100% rename from lib/cubits/expense_cubit.dart rename to kitchenowl/lib/cubits/expense_cubit.dart diff --git a/lib/cubits/expense_list_cubit.dart b/kitchenowl/lib/cubits/expense_list_cubit.dart similarity index 100% rename from lib/cubits/expense_list_cubit.dart rename to kitchenowl/lib/cubits/expense_list_cubit.dart diff --git a/lib/cubits/expense_month_list_cubit.dart b/kitchenowl/lib/cubits/expense_month_list_cubit.dart similarity index 100% rename from lib/cubits/expense_month_list_cubit.dart rename to kitchenowl/lib/cubits/expense_month_list_cubit.dart diff --git a/lib/cubits/expense_overview_cubit.dart b/kitchenowl/lib/cubits/expense_overview_cubit.dart similarity index 100% rename from lib/cubits/expense_overview_cubit.dart rename to kitchenowl/lib/cubits/expense_overview_cubit.dart diff --git a/lib/cubits/household_add_update/household_add_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_add_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_add_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_add_cubit.dart diff --git a/lib/cubits/household_add_update/household_add_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_add_update_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_add_update_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_add_update_cubit.dart diff --git a/lib/cubits/household_add_update/household_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_update_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart diff --git a/lib/cubits/household_cubit.dart b/kitchenowl/lib/cubits/household_cubit.dart similarity index 100% rename from lib/cubits/household_cubit.dart rename to kitchenowl/lib/cubits/household_cubit.dart diff --git a/lib/cubits/household_list_cubit.dart b/kitchenowl/lib/cubits/household_list_cubit.dart similarity index 100% rename from lib/cubits/household_list_cubit.dart rename to kitchenowl/lib/cubits/household_list_cubit.dart diff --git a/lib/cubits/household_member_cubit.dart b/kitchenowl/lib/cubits/household_member_cubit.dart similarity index 100% rename from lib/cubits/household_member_cubit.dart rename to kitchenowl/lib/cubits/household_member_cubit.dart diff --git a/lib/cubits/item_edit_cubit.dart b/kitchenowl/lib/cubits/item_edit_cubit.dart similarity index 100% rename from lib/cubits/item_edit_cubit.dart rename to kitchenowl/lib/cubits/item_edit_cubit.dart diff --git a/lib/cubits/item_search_cubit.dart b/kitchenowl/lib/cubits/item_search_cubit.dart similarity index 100% rename from lib/cubits/item_search_cubit.dart rename to kitchenowl/lib/cubits/item_search_cubit.dart diff --git a/lib/cubits/item_selection_cubit.dart b/kitchenowl/lib/cubits/item_selection_cubit.dart similarity index 100% rename from lib/cubits/item_selection_cubit.dart rename to kitchenowl/lib/cubits/item_selection_cubit.dart diff --git a/lib/cubits/password_reset_cubit.dart b/kitchenowl/lib/cubits/password_reset_cubit.dart similarity index 100% rename from lib/cubits/password_reset_cubit.dart rename to kitchenowl/lib/cubits/password_reset_cubit.dart diff --git a/lib/cubits/planner_cubit.dart b/kitchenowl/lib/cubits/planner_cubit.dart similarity index 100% rename from lib/cubits/planner_cubit.dart rename to kitchenowl/lib/cubits/planner_cubit.dart diff --git a/lib/cubits/recipe_add_update_cubit.dart b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart similarity index 100% rename from lib/cubits/recipe_add_update_cubit.dart rename to kitchenowl/lib/cubits/recipe_add_update_cubit.dart diff --git a/lib/cubits/recipe_cubit.dart b/kitchenowl/lib/cubits/recipe_cubit.dart similarity index 100% rename from lib/cubits/recipe_cubit.dart rename to kitchenowl/lib/cubits/recipe_cubit.dart diff --git a/lib/cubits/recipe_list_cubit.dart b/kitchenowl/lib/cubits/recipe_list_cubit.dart similarity index 100% rename from lib/cubits/recipe_list_cubit.dart rename to kitchenowl/lib/cubits/recipe_list_cubit.dart diff --git a/lib/cubits/recipe_scraper_cubit.dart b/kitchenowl/lib/cubits/recipe_scraper_cubit.dart similarity index 100% rename from lib/cubits/recipe_scraper_cubit.dart rename to kitchenowl/lib/cubits/recipe_scraper_cubit.dart diff --git a/lib/cubits/server_info_cubit.dart b/kitchenowl/lib/cubits/server_info_cubit.dart similarity index 100% rename from lib/cubits/server_info_cubit.dart rename to kitchenowl/lib/cubits/server_info_cubit.dart diff --git a/lib/cubits/settings_cubit.dart b/kitchenowl/lib/cubits/settings_cubit.dart similarity index 100% rename from lib/cubits/settings_cubit.dart rename to kitchenowl/lib/cubits/settings_cubit.dart diff --git a/lib/cubits/settings_server_cubit.dart b/kitchenowl/lib/cubits/settings_server_cubit.dart similarity index 100% rename from lib/cubits/settings_server_cubit.dart rename to kitchenowl/lib/cubits/settings_server_cubit.dart diff --git a/lib/cubits/settings_user_cubit.dart b/kitchenowl/lib/cubits/settings_user_cubit.dart similarity index 100% rename from lib/cubits/settings_user_cubit.dart rename to kitchenowl/lib/cubits/settings_user_cubit.dart diff --git a/lib/cubits/shoppinglist_cubit.dart b/kitchenowl/lib/cubits/shoppinglist_cubit.dart similarity index 100% rename from lib/cubits/shoppinglist_cubit.dart rename to kitchenowl/lib/cubits/shoppinglist_cubit.dart diff --git a/lib/cubits/user_search_cubit.dart b/kitchenowl/lib/cubits/user_search_cubit.dart similarity index 100% rename from lib/cubits/user_search_cubit.dart rename to kitchenowl/lib/cubits/user_search_cubit.dart diff --git a/lib/enums/expenselist_sorting.dart b/kitchenowl/lib/enums/expenselist_sorting.dart similarity index 100% rename from lib/enums/expenselist_sorting.dart rename to kitchenowl/lib/enums/expenselist_sorting.dart diff --git a/lib/enums/oidc_provider.dart b/kitchenowl/lib/enums/oidc_provider.dart similarity index 100% rename from lib/enums/oidc_provider.dart rename to kitchenowl/lib/enums/oidc_provider.dart diff --git a/lib/enums/shoppinglist_sorting.dart b/kitchenowl/lib/enums/shoppinglist_sorting.dart similarity index 100% rename from lib/enums/shoppinglist_sorting.dart rename to kitchenowl/lib/enums/shoppinglist_sorting.dart diff --git a/lib/enums/timeframe.dart b/kitchenowl/lib/enums/timeframe.dart similarity index 100% rename from lib/enums/timeframe.dart rename to kitchenowl/lib/enums/timeframe.dart diff --git a/lib/enums/token_type_enum.dart b/kitchenowl/lib/enums/token_type_enum.dart similarity index 100% rename from lib/enums/token_type_enum.dart rename to kitchenowl/lib/enums/token_type_enum.dart diff --git a/lib/enums/update_enum.dart b/kitchenowl/lib/enums/update_enum.dart similarity index 100% rename from lib/enums/update_enum.dart rename to kitchenowl/lib/enums/update_enum.dart diff --git a/lib/enums/views_enum.dart b/kitchenowl/lib/enums/views_enum.dart similarity index 100% rename from lib/enums/views_enum.dart rename to kitchenowl/lib/enums/views_enum.dart diff --git a/lib/helpers/currency_text_input_formatter.dart b/kitchenowl/lib/helpers/currency_text_input_formatter.dart similarity index 100% rename from lib/helpers/currency_text_input_formatter.dart rename to kitchenowl/lib/helpers/currency_text_input_formatter.dart diff --git a/lib/helpers/debouncer.dart b/kitchenowl/lib/helpers/debouncer.dart similarity index 100% rename from lib/helpers/debouncer.dart rename to kitchenowl/lib/helpers/debouncer.dart diff --git a/lib/helpers/fade_through_transition_page.dart b/kitchenowl/lib/helpers/fade_through_transition_page.dart similarity index 100% rename from lib/helpers/fade_through_transition_page.dart rename to kitchenowl/lib/helpers/fade_through_transition_page.dart diff --git a/lib/helpers/named_bytearray.dart b/kitchenowl/lib/helpers/named_bytearray.dart similarity index 100% rename from lib/helpers/named_bytearray.dart rename to kitchenowl/lib/helpers/named_bytearray.dart diff --git a/lib/helpers/recipe_item_markdown_extension.dart b/kitchenowl/lib/helpers/recipe_item_markdown_extension.dart similarity index 100% rename from lib/helpers/recipe_item_markdown_extension.dart rename to kitchenowl/lib/helpers/recipe_item_markdown_extension.dart diff --git a/lib/helpers/shared_axis_transition_page.dart b/kitchenowl/lib/helpers/shared_axis_transition_page.dart similarity index 100% rename from lib/helpers/shared_axis_transition_page.dart rename to kitchenowl/lib/helpers/shared_axis_transition_page.dart diff --git a/lib/helpers/string_scaler.dart b/kitchenowl/lib/helpers/string_scaler.dart similarity index 100% rename from lib/helpers/string_scaler.dart rename to kitchenowl/lib/helpers/string_scaler.dart diff --git a/lib/helpers/url_launcher.dart b/kitchenowl/lib/helpers/url_launcher.dart similarity index 100% rename from lib/helpers/url_launcher.dart rename to kitchenowl/lib/helpers/url_launcher.dart diff --git a/lib/helpers/username_text_input_formatter.dart b/kitchenowl/lib/helpers/username_text_input_formatter.dart similarity index 100% rename from lib/helpers/username_text_input_formatter.dart rename to kitchenowl/lib/helpers/username_text_input_formatter.dart diff --git a/lib/item_icons.dart b/kitchenowl/lib/item_icons.dart similarity index 100% rename from lib/item_icons.dart rename to kitchenowl/lib/item_icons.dart diff --git a/lib/kitchenowl.dart b/kitchenowl/lib/kitchenowl.dart similarity index 100% rename from lib/kitchenowl.dart rename to kitchenowl/lib/kitchenowl.dart diff --git a/lib/l10n/app_be.arb b/kitchenowl/lib/l10n/app_be.arb similarity index 100% rename from lib/l10n/app_be.arb rename to kitchenowl/lib/l10n/app_be.arb diff --git a/lib/l10n/app_ca.arb b/kitchenowl/lib/l10n/app_ca.arb similarity index 100% rename from lib/l10n/app_ca.arb rename to kitchenowl/lib/l10n/app_ca.arb diff --git a/lib/l10n/app_ca_valencia.arb b/kitchenowl/lib/l10n/app_ca_valencia.arb similarity index 100% rename from lib/l10n/app_ca_valencia.arb rename to kitchenowl/lib/l10n/app_ca_valencia.arb diff --git a/lib/l10n/app_cs.arb b/kitchenowl/lib/l10n/app_cs.arb similarity index 100% rename from lib/l10n/app_cs.arb rename to kitchenowl/lib/l10n/app_cs.arb diff --git a/lib/l10n/app_da.arb b/kitchenowl/lib/l10n/app_da.arb similarity index 100% rename from lib/l10n/app_da.arb rename to kitchenowl/lib/l10n/app_da.arb diff --git a/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb similarity index 100% rename from lib/l10n/app_de.arb rename to kitchenowl/lib/l10n/app_de.arb diff --git a/lib/l10n/app_el.arb b/kitchenowl/lib/l10n/app_el.arb similarity index 100% rename from lib/l10n/app_el.arb rename to kitchenowl/lib/l10n/app_el.arb diff --git a/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb similarity index 100% rename from lib/l10n/app_en.arb rename to kitchenowl/lib/l10n/app_en.arb diff --git a/lib/l10n/app_en_AU.arb b/kitchenowl/lib/l10n/app_en_AU.arb similarity index 100% rename from lib/l10n/app_en_AU.arb rename to kitchenowl/lib/l10n/app_en_AU.arb diff --git a/lib/l10n/app_es.arb b/kitchenowl/lib/l10n/app_es.arb similarity index 100% rename from lib/l10n/app_es.arb rename to kitchenowl/lib/l10n/app_es.arb diff --git a/lib/l10n/app_fi.arb b/kitchenowl/lib/l10n/app_fi.arb similarity index 100% rename from lib/l10n/app_fi.arb rename to kitchenowl/lib/l10n/app_fi.arb diff --git a/lib/l10n/app_fr.arb b/kitchenowl/lib/l10n/app_fr.arb similarity index 100% rename from lib/l10n/app_fr.arb rename to kitchenowl/lib/l10n/app_fr.arb diff --git a/lib/l10n/app_hu.arb b/kitchenowl/lib/l10n/app_hu.arb similarity index 100% rename from lib/l10n/app_hu.arb rename to kitchenowl/lib/l10n/app_hu.arb diff --git a/lib/l10n/app_id.arb b/kitchenowl/lib/l10n/app_id.arb similarity index 100% rename from lib/l10n/app_id.arb rename to kitchenowl/lib/l10n/app_id.arb diff --git a/lib/l10n/app_it.arb b/kitchenowl/lib/l10n/app_it.arb similarity index 100% rename from lib/l10n/app_it.arb rename to kitchenowl/lib/l10n/app_it.arb diff --git a/lib/l10n/app_nb.arb b/kitchenowl/lib/l10n/app_nb.arb similarity index 100% rename from lib/l10n/app_nb.arb rename to kitchenowl/lib/l10n/app_nb.arb diff --git a/lib/l10n/app_nl.arb b/kitchenowl/lib/l10n/app_nl.arb similarity index 100% rename from lib/l10n/app_nl.arb rename to kitchenowl/lib/l10n/app_nl.arb diff --git a/lib/l10n/app_pa.arb b/kitchenowl/lib/l10n/app_pa.arb similarity index 100% rename from lib/l10n/app_pa.arb rename to kitchenowl/lib/l10n/app_pa.arb diff --git a/lib/l10n/app_pl.arb b/kitchenowl/lib/l10n/app_pl.arb similarity index 100% rename from lib/l10n/app_pl.arb rename to kitchenowl/lib/l10n/app_pl.arb diff --git a/lib/l10n/app_pt.arb b/kitchenowl/lib/l10n/app_pt.arb similarity index 100% rename from lib/l10n/app_pt.arb rename to kitchenowl/lib/l10n/app_pt.arb diff --git a/lib/l10n/app_pt_BR.arb b/kitchenowl/lib/l10n/app_pt_BR.arb similarity index 100% rename from lib/l10n/app_pt_BR.arb rename to kitchenowl/lib/l10n/app_pt_BR.arb diff --git a/lib/l10n/app_ro.arb b/kitchenowl/lib/l10n/app_ro.arb similarity index 100% rename from lib/l10n/app_ro.arb rename to kitchenowl/lib/l10n/app_ro.arb diff --git a/lib/l10n/app_ru.arb b/kitchenowl/lib/l10n/app_ru.arb similarity index 100% rename from lib/l10n/app_ru.arb rename to kitchenowl/lib/l10n/app_ru.arb diff --git a/lib/l10n/app_sv.arb b/kitchenowl/lib/l10n/app_sv.arb similarity index 100% rename from lib/l10n/app_sv.arb rename to kitchenowl/lib/l10n/app_sv.arb diff --git a/lib/l10n/app_tr.arb b/kitchenowl/lib/l10n/app_tr.arb similarity index 100% rename from lib/l10n/app_tr.arb rename to kitchenowl/lib/l10n/app_tr.arb diff --git a/lib/l10n/app_vi.arb b/kitchenowl/lib/l10n/app_vi.arb similarity index 100% rename from lib/l10n/app_vi.arb rename to kitchenowl/lib/l10n/app_vi.arb diff --git a/lib/l10n/app_zh.arb b/kitchenowl/lib/l10n/app_zh.arb similarity index 100% rename from lib/l10n/app_zh.arb rename to kitchenowl/lib/l10n/app_zh.arb diff --git a/lib/main.dart b/kitchenowl/lib/main.dart similarity index 100% rename from lib/main.dart rename to kitchenowl/lib/main.dart diff --git a/lib/models/category.dart b/kitchenowl/lib/models/category.dart similarity index 100% rename from lib/models/category.dart rename to kitchenowl/lib/models/category.dart diff --git a/lib/models/expense.dart b/kitchenowl/lib/models/expense.dart similarity index 100% rename from lib/models/expense.dart rename to kitchenowl/lib/models/expense.dart diff --git a/lib/models/expense_category.dart b/kitchenowl/lib/models/expense_category.dart similarity index 100% rename from lib/models/expense_category.dart rename to kitchenowl/lib/models/expense_category.dart diff --git a/lib/models/expense_overview.dart b/kitchenowl/lib/models/expense_overview.dart similarity index 100% rename from lib/models/expense_overview.dart rename to kitchenowl/lib/models/expense_overview.dart diff --git a/lib/models/household.dart b/kitchenowl/lib/models/household.dart similarity index 100% rename from lib/models/household.dart rename to kitchenowl/lib/models/household.dart diff --git a/lib/models/import_settings.dart b/kitchenowl/lib/models/import_settings.dart similarity index 100% rename from lib/models/import_settings.dart rename to kitchenowl/lib/models/import_settings.dart diff --git a/lib/models/item.dart b/kitchenowl/lib/models/item.dart similarity index 100% rename from lib/models/item.dart rename to kitchenowl/lib/models/item.dart diff --git a/lib/models/member.dart b/kitchenowl/lib/models/member.dart similarity index 100% rename from lib/models/member.dart rename to kitchenowl/lib/models/member.dart diff --git a/lib/models/model.dart b/kitchenowl/lib/models/model.dart similarity index 100% rename from lib/models/model.dart rename to kitchenowl/lib/models/model.dart diff --git a/lib/models/nullable.dart b/kitchenowl/lib/models/nullable.dart similarity index 100% rename from lib/models/nullable.dart rename to kitchenowl/lib/models/nullable.dart diff --git a/lib/models/planner.dart b/kitchenowl/lib/models/planner.dart similarity index 100% rename from lib/models/planner.dart rename to kitchenowl/lib/models/planner.dart diff --git a/lib/models/recipe.dart b/kitchenowl/lib/models/recipe.dart similarity index 100% rename from lib/models/recipe.dart rename to kitchenowl/lib/models/recipe.dart diff --git a/lib/models/recipe_scrape.dart b/kitchenowl/lib/models/recipe_scrape.dart similarity index 100% rename from lib/models/recipe_scrape.dart rename to kitchenowl/lib/models/recipe_scrape.dart diff --git a/lib/models/shoppinglist.dart b/kitchenowl/lib/models/shoppinglist.dart similarity index 100% rename from lib/models/shoppinglist.dart rename to kitchenowl/lib/models/shoppinglist.dart diff --git a/lib/models/tag.dart b/kitchenowl/lib/models/tag.dart similarity index 100% rename from lib/models/tag.dart rename to kitchenowl/lib/models/tag.dart diff --git a/lib/models/token.dart b/kitchenowl/lib/models/token.dart similarity index 100% rename from lib/models/token.dart rename to kitchenowl/lib/models/token.dart diff --git a/lib/models/update_value.dart b/kitchenowl/lib/models/update_value.dart similarity index 100% rename from lib/models/update_value.dart rename to kitchenowl/lib/models/update_value.dart diff --git a/lib/models/user.dart b/kitchenowl/lib/models/user.dart similarity index 100% rename from lib/models/user.dart rename to kitchenowl/lib/models/user.dart diff --git a/lib/pages/analytics_page.dart b/kitchenowl/lib/pages/analytics_page.dart similarity index 100% rename from lib/pages/analytics_page.dart rename to kitchenowl/lib/pages/analytics_page.dart diff --git a/lib/pages/email_confirm_page.dart b/kitchenowl/lib/pages/email_confirm_page.dart similarity index 100% rename from lib/pages/email_confirm_page.dart rename to kitchenowl/lib/pages/email_confirm_page.dart diff --git a/lib/pages/expense_add_update_page.dart b/kitchenowl/lib/pages/expense_add_update_page.dart similarity index 100% rename from lib/pages/expense_add_update_page.dart rename to kitchenowl/lib/pages/expense_add_update_page.dart diff --git a/lib/pages/expense_category_add_page.dart b/kitchenowl/lib/pages/expense_category_add_page.dart similarity index 100% rename from lib/pages/expense_category_add_page.dart rename to kitchenowl/lib/pages/expense_category_add_page.dart diff --git a/lib/pages/expense_month_list_page.dart b/kitchenowl/lib/pages/expense_month_list_page.dart similarity index 100% rename from lib/pages/expense_month_list_page.dart rename to kitchenowl/lib/pages/expense_month_list_page.dart diff --git a/lib/pages/expense_overview_page.dart b/kitchenowl/lib/pages/expense_overview_page.dart similarity index 100% rename from lib/pages/expense_overview_page.dart rename to kitchenowl/lib/pages/expense_overview_page.dart diff --git a/lib/pages/expense_page.dart b/kitchenowl/lib/pages/expense_page.dart similarity index 100% rename from lib/pages/expense_page.dart rename to kitchenowl/lib/pages/expense_page.dart diff --git a/lib/pages/household_add_page.dart b/kitchenowl/lib/pages/household_add_page.dart similarity index 100% rename from lib/pages/household_add_page.dart rename to kitchenowl/lib/pages/household_add_page.dart diff --git a/lib/pages/household_list_page.dart b/kitchenowl/lib/pages/household_list_page.dart similarity index 100% rename from lib/pages/household_list_page.dart rename to kitchenowl/lib/pages/household_list_page.dart diff --git a/lib/pages/household_member_page.dart b/kitchenowl/lib/pages/household_member_page.dart similarity index 100% rename from lib/pages/household_member_page.dart rename to kitchenowl/lib/pages/household_member_page.dart diff --git a/lib/pages/household_page.dart b/kitchenowl/lib/pages/household_page.dart similarity index 100% rename from lib/pages/household_page.dart rename to kitchenowl/lib/pages/household_page.dart diff --git a/lib/pages/household_page/_export.dart b/kitchenowl/lib/pages/household_page/_export.dart similarity index 100% rename from lib/pages/household_page/_export.dart rename to kitchenowl/lib/pages/household_page/_export.dart diff --git a/lib/pages/household_page/expense_list.dart b/kitchenowl/lib/pages/household_page/expense_list.dart similarity index 100% rename from lib/pages/household_page/expense_list.dart rename to kitchenowl/lib/pages/household_page/expense_list.dart diff --git a/lib/pages/household_page/household_drawer.dart b/kitchenowl/lib/pages/household_page/household_drawer.dart similarity index 100% rename from lib/pages/household_page/household_drawer.dart rename to kitchenowl/lib/pages/household_page/household_drawer.dart diff --git a/lib/pages/household_page/household_navigation_rail.dart b/kitchenowl/lib/pages/household_page/household_navigation_rail.dart similarity index 100% rename from lib/pages/household_page/household_navigation_rail.dart rename to kitchenowl/lib/pages/household_page/household_navigation_rail.dart diff --git a/lib/pages/household_page/planner.dart b/kitchenowl/lib/pages/household_page/planner.dart similarity index 100% rename from lib/pages/household_page/planner.dart rename to kitchenowl/lib/pages/household_page/planner.dart diff --git a/lib/pages/household_page/profile.dart b/kitchenowl/lib/pages/household_page/profile.dart similarity index 100% rename from lib/pages/household_page/profile.dart rename to kitchenowl/lib/pages/household_page/profile.dart diff --git a/lib/pages/household_page/recipe_list.dart b/kitchenowl/lib/pages/household_page/recipe_list.dart similarity index 100% rename from lib/pages/household_page/recipe_list.dart rename to kitchenowl/lib/pages/household_page/recipe_list.dart diff --git a/lib/pages/household_page/shoppinglist.dart b/kitchenowl/lib/pages/household_page/shoppinglist.dart similarity index 100% rename from lib/pages/household_page/shoppinglist.dart rename to kitchenowl/lib/pages/household_page/shoppinglist.dart diff --git a/lib/pages/household_update_page.dart b/kitchenowl/lib/pages/household_update_page.dart similarity index 100% rename from lib/pages/household_update_page.dart rename to kitchenowl/lib/pages/household_update_page.dart diff --git a/lib/pages/icon_selection_page.dart b/kitchenowl/lib/pages/icon_selection_page.dart similarity index 100% rename from lib/pages/icon_selection_page.dart rename to kitchenowl/lib/pages/icon_selection_page.dart diff --git a/lib/pages/item_page.dart b/kitchenowl/lib/pages/item_page.dart similarity index 100% rename from lib/pages/item_page.dart rename to kitchenowl/lib/pages/item_page.dart diff --git a/lib/pages/item_search_page.dart b/kitchenowl/lib/pages/item_search_page.dart similarity index 100% rename from lib/pages/item_search_page.dart rename to kitchenowl/lib/pages/item_search_page.dart diff --git a/lib/pages/item_selection_page.dart b/kitchenowl/lib/pages/item_selection_page.dart similarity index 100% rename from lib/pages/item_selection_page.dart rename to kitchenowl/lib/pages/item_selection_page.dart diff --git a/lib/pages/login_page.dart b/kitchenowl/lib/pages/login_page.dart similarity index 100% rename from lib/pages/login_page.dart rename to kitchenowl/lib/pages/login_page.dart diff --git a/lib/pages/login_redirect_page.dart b/kitchenowl/lib/pages/login_redirect_page.dart similarity index 100% rename from lib/pages/login_redirect_page.dart rename to kitchenowl/lib/pages/login_redirect_page.dart diff --git a/lib/pages/onboarding_page.dart b/kitchenowl/lib/pages/onboarding_page.dart similarity index 100% rename from lib/pages/onboarding_page.dart rename to kitchenowl/lib/pages/onboarding_page.dart diff --git a/lib/pages/page_not_found.dart b/kitchenowl/lib/pages/page_not_found.dart similarity index 100% rename from lib/pages/page_not_found.dart rename to kitchenowl/lib/pages/page_not_found.dart diff --git a/lib/pages/password_forgot_page.dart b/kitchenowl/lib/pages/password_forgot_page.dart similarity index 100% rename from lib/pages/password_forgot_page.dart rename to kitchenowl/lib/pages/password_forgot_page.dart diff --git a/lib/pages/password_reset_page.dart b/kitchenowl/lib/pages/password_reset_page.dart similarity index 100% rename from lib/pages/password_reset_page.dart rename to kitchenowl/lib/pages/password_reset_page.dart diff --git a/lib/pages/photo_view_page.dart b/kitchenowl/lib/pages/photo_view_page.dart similarity index 100% rename from lib/pages/photo_view_page.dart rename to kitchenowl/lib/pages/photo_view_page.dart diff --git a/lib/pages/recipe_add_update_page.dart b/kitchenowl/lib/pages/recipe_add_update_page.dart similarity index 100% rename from lib/pages/recipe_add_update_page.dart rename to kitchenowl/lib/pages/recipe_add_update_page.dart diff --git a/lib/pages/recipe_page.dart b/kitchenowl/lib/pages/recipe_page.dart similarity index 100% rename from lib/pages/recipe_page.dart rename to kitchenowl/lib/pages/recipe_page.dart diff --git a/lib/pages/recipe_scraper_page.dart b/kitchenowl/lib/pages/recipe_scraper_page.dart similarity index 100% rename from lib/pages/recipe_scraper_page.dart rename to kitchenowl/lib/pages/recipe_scraper_page.dart diff --git a/lib/pages/settings/create_user_page.dart b/kitchenowl/lib/pages/settings/create_user_page.dart similarity index 100% rename from lib/pages/settings/create_user_page.dart rename to kitchenowl/lib/pages/settings/create_user_page.dart diff --git a/lib/pages/settings_page.dart b/kitchenowl/lib/pages/settings_page.dart similarity index 100% rename from lib/pages/settings_page.dart rename to kitchenowl/lib/pages/settings_page.dart diff --git a/lib/pages/settings_server_user_page.dart b/kitchenowl/lib/pages/settings_server_user_page.dart similarity index 100% rename from lib/pages/settings_server_user_page.dart rename to kitchenowl/lib/pages/settings_server_user_page.dart diff --git a/lib/pages/settings_user_email_page.dart b/kitchenowl/lib/pages/settings_user_email_page.dart similarity index 100% rename from lib/pages/settings_user_email_page.dart rename to kitchenowl/lib/pages/settings_user_email_page.dart diff --git a/lib/pages/settings_user_linked_accounts_page.dart b/kitchenowl/lib/pages/settings_user_linked_accounts_page.dart similarity index 100% rename from lib/pages/settings_user_linked_accounts_page.dart rename to kitchenowl/lib/pages/settings_user_linked_accounts_page.dart diff --git a/lib/pages/settings_user_page.dart b/kitchenowl/lib/pages/settings_user_page.dart similarity index 100% rename from lib/pages/settings_user_page.dart rename to kitchenowl/lib/pages/settings_user_page.dart diff --git a/lib/pages/settings_user_password_page.dart b/kitchenowl/lib/pages/settings_user_password_page.dart similarity index 100% rename from lib/pages/settings_user_password_page.dart rename to kitchenowl/lib/pages/settings_user_password_page.dart diff --git a/lib/pages/settings_user_sessions_page.dart b/kitchenowl/lib/pages/settings_user_sessions_page.dart similarity index 100% rename from lib/pages/settings_user_sessions_page.dart rename to kitchenowl/lib/pages/settings_user_sessions_page.dart diff --git a/lib/pages/setup_page.dart b/kitchenowl/lib/pages/setup_page.dart similarity index 100% rename from lib/pages/setup_page.dart rename to kitchenowl/lib/pages/setup_page.dart diff --git a/lib/pages/signup_page.dart b/kitchenowl/lib/pages/signup_page.dart similarity index 100% rename from lib/pages/signup_page.dart rename to kitchenowl/lib/pages/signup_page.dart diff --git a/lib/pages/splash_page.dart b/kitchenowl/lib/pages/splash_page.dart similarity index 100% rename from lib/pages/splash_page.dart rename to kitchenowl/lib/pages/splash_page.dart diff --git a/lib/pages/unreachable_page.dart b/kitchenowl/lib/pages/unreachable_page.dart similarity index 100% rename from lib/pages/unreachable_page.dart rename to kitchenowl/lib/pages/unreachable_page.dart diff --git a/lib/pages/unsupported_page.dart b/kitchenowl/lib/pages/unsupported_page.dart similarity index 100% rename from lib/pages/unsupported_page.dart rename to kitchenowl/lib/pages/unsupported_page.dart diff --git a/lib/pages/user_search_page.dart b/kitchenowl/lib/pages/user_search_page.dart similarity index 100% rename from lib/pages/user_search_page.dart rename to kitchenowl/lib/pages/user_search_page.dart diff --git a/lib/router.dart b/kitchenowl/lib/router.dart similarity index 100% rename from lib/router.dart rename to kitchenowl/lib/router.dart diff --git a/lib/services/api/analytics.dart b/kitchenowl/lib/services/api/analytics.dart similarity index 100% rename from lib/services/api/analytics.dart rename to kitchenowl/lib/services/api/analytics.dart diff --git a/lib/services/api/api_service.dart b/kitchenowl/lib/services/api/api_service.dart similarity index 100% rename from lib/services/api/api_service.dart rename to kitchenowl/lib/services/api/api_service.dart diff --git a/lib/services/api/category.dart b/kitchenowl/lib/services/api/category.dart similarity index 100% rename from lib/services/api/category.dart rename to kitchenowl/lib/services/api/category.dart diff --git a/lib/services/api/expense.dart b/kitchenowl/lib/services/api/expense.dart similarity index 100% rename from lib/services/api/expense.dart rename to kitchenowl/lib/services/api/expense.dart diff --git a/lib/services/api/household.dart b/kitchenowl/lib/services/api/household.dart similarity index 100% rename from lib/services/api/household.dart rename to kitchenowl/lib/services/api/household.dart diff --git a/lib/services/api/import_export.dart b/kitchenowl/lib/services/api/import_export.dart similarity index 100% rename from lib/services/api/import_export.dart rename to kitchenowl/lib/services/api/import_export.dart diff --git a/lib/services/api/item.dart b/kitchenowl/lib/services/api/item.dart similarity index 100% rename from lib/services/api/item.dart rename to kitchenowl/lib/services/api/item.dart diff --git a/lib/services/api/planner.dart b/kitchenowl/lib/services/api/planner.dart similarity index 100% rename from lib/services/api/planner.dart rename to kitchenowl/lib/services/api/planner.dart diff --git a/lib/services/api/recipe.dart b/kitchenowl/lib/services/api/recipe.dart similarity index 100% rename from lib/services/api/recipe.dart rename to kitchenowl/lib/services/api/recipe.dart diff --git a/lib/services/api/shoppinglist.dart b/kitchenowl/lib/services/api/shoppinglist.dart similarity index 100% rename from lib/services/api/shoppinglist.dart rename to kitchenowl/lib/services/api/shoppinglist.dart diff --git a/lib/services/api/tag.dart b/kitchenowl/lib/services/api/tag.dart similarity index 100% rename from lib/services/api/tag.dart rename to kitchenowl/lib/services/api/tag.dart diff --git a/lib/services/api/upload.dart b/kitchenowl/lib/services/api/upload.dart similarity index 100% rename from lib/services/api/upload.dart rename to kitchenowl/lib/services/api/upload.dart diff --git a/lib/services/api/user.dart b/kitchenowl/lib/services/api/user.dart similarity index 100% rename from lib/services/api/user.dart rename to kitchenowl/lib/services/api/user.dart diff --git a/lib/services/storage/mem_storage.dart b/kitchenowl/lib/services/storage/mem_storage.dart similarity index 100% rename from lib/services/storage/mem_storage.dart rename to kitchenowl/lib/services/storage/mem_storage.dart diff --git a/lib/services/storage/storage.dart b/kitchenowl/lib/services/storage/storage.dart similarity index 100% rename from lib/services/storage/storage.dart rename to kitchenowl/lib/services/storage/storage.dart diff --git a/lib/services/storage/temp_storage.dart b/kitchenowl/lib/services/storage/temp_storage.dart similarity index 100% rename from lib/services/storage/temp_storage.dart rename to kitchenowl/lib/services/storage/temp_storage.dart diff --git a/lib/services/storage/transaction_storage.dart b/kitchenowl/lib/services/storage/transaction_storage.dart similarity index 100% rename from lib/services/storage/transaction_storage.dart rename to kitchenowl/lib/services/storage/transaction_storage.dart diff --git a/lib/services/transaction.dart b/kitchenowl/lib/services/transaction.dart similarity index 100% rename from lib/services/transaction.dart rename to kitchenowl/lib/services/transaction.dart diff --git a/lib/services/transaction_handler.dart b/kitchenowl/lib/services/transaction_handler.dart similarity index 100% rename from lib/services/transaction_handler.dart rename to kitchenowl/lib/services/transaction_handler.dart diff --git a/lib/services/transactions/category.dart b/kitchenowl/lib/services/transactions/category.dart similarity index 100% rename from lib/services/transactions/category.dart rename to kitchenowl/lib/services/transactions/category.dart diff --git a/lib/services/transactions/expense.dart b/kitchenowl/lib/services/transactions/expense.dart similarity index 100% rename from lib/services/transactions/expense.dart rename to kitchenowl/lib/services/transactions/expense.dart diff --git a/lib/services/transactions/household.dart b/kitchenowl/lib/services/transactions/household.dart similarity index 100% rename from lib/services/transactions/household.dart rename to kitchenowl/lib/services/transactions/household.dart diff --git a/lib/services/transactions/item.dart b/kitchenowl/lib/services/transactions/item.dart similarity index 100% rename from lib/services/transactions/item.dart rename to kitchenowl/lib/services/transactions/item.dart diff --git a/lib/services/transactions/planner.dart b/kitchenowl/lib/services/transactions/planner.dart similarity index 100% rename from lib/services/transactions/planner.dart rename to kitchenowl/lib/services/transactions/planner.dart diff --git a/lib/services/transactions/recipe.dart b/kitchenowl/lib/services/transactions/recipe.dart similarity index 100% rename from lib/services/transactions/recipe.dart rename to kitchenowl/lib/services/transactions/recipe.dart diff --git a/lib/services/transactions/shoppinglist.dart b/kitchenowl/lib/services/transactions/shoppinglist.dart similarity index 100% rename from lib/services/transactions/shoppinglist.dart rename to kitchenowl/lib/services/transactions/shoppinglist.dart diff --git a/lib/services/transactions/tag.dart b/kitchenowl/lib/services/transactions/tag.dart similarity index 100% rename from lib/services/transactions/tag.dart rename to kitchenowl/lib/services/transactions/tag.dart diff --git a/lib/services/transactions/user.dart b/kitchenowl/lib/services/transactions/user.dart similarity index 100% rename from lib/services/transactions/user.dart rename to kitchenowl/lib/services/transactions/user.dart diff --git a/lib/styles/colors.dart b/kitchenowl/lib/styles/colors.dart similarity index 100% rename from lib/styles/colors.dart rename to kitchenowl/lib/styles/colors.dart diff --git a/lib/styles/dynamic.dart b/kitchenowl/lib/styles/dynamic.dart similarity index 100% rename from lib/styles/dynamic.dart rename to kitchenowl/lib/styles/dynamic.dart diff --git a/lib/styles/themes.dart b/kitchenowl/lib/styles/themes.dart similarity index 100% rename from lib/styles/themes.dart rename to kitchenowl/lib/styles/themes.dart diff --git a/lib/widgets/_export.dart b/kitchenowl/lib/widgets/_export.dart similarity index 100% rename from lib/widgets/_export.dart rename to kitchenowl/lib/widgets/_export.dart diff --git a/lib/widgets/chart_bar_member_distribution.dart b/kitchenowl/lib/widgets/chart_bar_member_distribution.dart similarity index 100% rename from lib/widgets/chart_bar_member_distribution.dart rename to kitchenowl/lib/widgets/chart_bar_member_distribution.dart diff --git a/lib/widgets/chart_bar_months.dart b/kitchenowl/lib/widgets/chart_bar_months.dart similarity index 100% rename from lib/widgets/chart_bar_months.dart rename to kitchenowl/lib/widgets/chart_bar_months.dart diff --git a/lib/widgets/chart_line_current_month.dart b/kitchenowl/lib/widgets/chart_line_current_month.dart similarity index 100% rename from lib/widgets/chart_line_current_month.dart rename to kitchenowl/lib/widgets/chart_line_current_month.dart diff --git a/lib/widgets/chart_pie_current_month.dart b/kitchenowl/lib/widgets/chart_pie_current_month.dart similarity index 100% rename from lib/widgets/chart_pie_current_month.dart rename to kitchenowl/lib/widgets/chart_pie_current_month.dart diff --git a/lib/widgets/checkbox_list_tile.dart b/kitchenowl/lib/widgets/checkbox_list_tile.dart similarity index 100% rename from lib/widgets/checkbox_list_tile.dart rename to kitchenowl/lib/widgets/checkbox_list_tile.dart diff --git a/lib/widgets/choice_scroll.dart b/kitchenowl/lib/widgets/choice_scroll.dart similarity index 100% rename from lib/widgets/choice_scroll.dart rename to kitchenowl/lib/widgets/choice_scroll.dart diff --git a/lib/widgets/confirmation_dialog.dart b/kitchenowl/lib/widgets/confirmation_dialog.dart similarity index 100% rename from lib/widgets/confirmation_dialog.dart rename to kitchenowl/lib/widgets/confirmation_dialog.dart diff --git a/lib/widgets/create_user_form_fields.dart b/kitchenowl/lib/widgets/create_user_form_fields.dart similarity index 100% rename from lib/widgets/create_user_form_fields.dart rename to kitchenowl/lib/widgets/create_user_form_fields.dart diff --git a/lib/widgets/dismissible_card.dart b/kitchenowl/lib/widgets/dismissible_card.dart similarity index 100% rename from lib/widgets/dismissible_card.dart rename to kitchenowl/lib/widgets/dismissible_card.dart diff --git a/lib/widgets/expandable_fab.dart b/kitchenowl/lib/widgets/expandable_fab.dart similarity index 100% rename from lib/widgets/expandable_fab.dart rename to kitchenowl/lib/widgets/expandable_fab.dart diff --git a/lib/widgets/expense/timeframe_dropdown_button.dart b/kitchenowl/lib/widgets/expense/timeframe_dropdown_button.dart similarity index 100% rename from lib/widgets/expense/timeframe_dropdown_button.dart rename to kitchenowl/lib/widgets/expense/timeframe_dropdown_button.dart diff --git a/lib/widgets/expense_add_update/paid_for_widget.dart b/kitchenowl/lib/widgets/expense_add_update/paid_for_widget.dart similarity index 100% rename from lib/widgets/expense_add_update/paid_for_widget.dart rename to kitchenowl/lib/widgets/expense_add_update/paid_for_widget.dart diff --git a/lib/widgets/expense_category_icon.dart b/kitchenowl/lib/widgets/expense_category_icon.dart similarity index 100% rename from lib/widgets/expense_category_icon.dart rename to kitchenowl/lib/widgets/expense_category_icon.dart diff --git a/lib/widgets/expense_create_fab.dart b/kitchenowl/lib/widgets/expense_create_fab.dart similarity index 100% rename from lib/widgets/expense_create_fab.dart rename to kitchenowl/lib/widgets/expense_create_fab.dart diff --git a/lib/widgets/expense_item.dart b/kitchenowl/lib/widgets/expense_item.dart similarity index 100% rename from lib/widgets/expense_item.dart rename to kitchenowl/lib/widgets/expense_item.dart diff --git a/lib/widgets/flexible_image_space_bar.dart b/kitchenowl/lib/widgets/flexible_image_space_bar.dart similarity index 100% rename from lib/widgets/flexible_image_space_bar.dart rename to kitchenowl/lib/widgets/flexible_image_space_bar.dart diff --git a/lib/widgets/fractionally_sized_box.dart b/kitchenowl/lib/widgets/fractionally_sized_box.dart similarity index 100% rename from lib/widgets/fractionally_sized_box.dart rename to kitchenowl/lib/widgets/fractionally_sized_box.dart diff --git a/lib/widgets/home_page/sliver_category_item_grid_list.dart b/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart similarity index 100% rename from lib/widgets/home_page/sliver_category_item_grid_list.dart rename to kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart diff --git a/lib/widgets/household_card.dart b/kitchenowl/lib/widgets/household_card.dart similarity index 100% rename from lib/widgets/household_card.dart rename to kitchenowl/lib/widgets/household_card.dart diff --git a/lib/widgets/image_provider.dart b/kitchenowl/lib/widgets/image_provider.dart similarity index 100% rename from lib/widgets/image_provider.dart rename to kitchenowl/lib/widgets/image_provider.dart diff --git a/lib/widgets/image_selector.dart b/kitchenowl/lib/widgets/image_selector.dart similarity index 100% rename from lib/widgets/image_selector.dart rename to kitchenowl/lib/widgets/image_selector.dart diff --git a/lib/widgets/kitchenowl_color_picker_dialog.dart b/kitchenowl/lib/widgets/kitchenowl_color_picker_dialog.dart similarity index 100% rename from lib/widgets/kitchenowl_color_picker_dialog.dart rename to kitchenowl/lib/widgets/kitchenowl_color_picker_dialog.dart diff --git a/lib/widgets/kitchenowl_fab.dart b/kitchenowl/lib/widgets/kitchenowl_fab.dart similarity index 100% rename from lib/widgets/kitchenowl_fab.dart rename to kitchenowl/lib/widgets/kitchenowl_fab.dart diff --git a/lib/widgets/kitchenowl_switch.dart b/kitchenowl/lib/widgets/kitchenowl_switch.dart similarity index 100% rename from lib/widgets/kitchenowl_switch.dart rename to kitchenowl/lib/widgets/kitchenowl_switch.dart diff --git a/lib/widgets/language_dialog.dart b/kitchenowl/lib/widgets/language_dialog.dart similarity index 100% rename from lib/widgets/language_dialog.dart rename to kitchenowl/lib/widgets/language_dialog.dart diff --git a/lib/widgets/left_right_wrap.dart b/kitchenowl/lib/widgets/left_right_wrap.dart similarity index 100% rename from lib/widgets/left_right_wrap.dart rename to kitchenowl/lib/widgets/left_right_wrap.dart diff --git a/lib/widgets/loading_elevated_button.dart b/kitchenowl/lib/widgets/loading_elevated_button.dart similarity index 100% rename from lib/widgets/loading_elevated_button.dart rename to kitchenowl/lib/widgets/loading_elevated_button.dart diff --git a/lib/widgets/loading_elevated_button_icon.dart b/kitchenowl/lib/widgets/loading_elevated_button_icon.dart similarity index 100% rename from lib/widgets/loading_elevated_button_icon.dart rename to kitchenowl/lib/widgets/loading_elevated_button_icon.dart diff --git a/lib/widgets/loading_icon_button.dart b/kitchenowl/lib/widgets/loading_icon_button.dart similarity index 100% rename from lib/widgets/loading_icon_button.dart rename to kitchenowl/lib/widgets/loading_icon_button.dart diff --git a/lib/widgets/loading_list_tile.dart b/kitchenowl/lib/widgets/loading_list_tile.dart similarity index 100% rename from lib/widgets/loading_list_tile.dart rename to kitchenowl/lib/widgets/loading_list_tile.dart diff --git a/lib/widgets/loading_text_button.dart b/kitchenowl/lib/widgets/loading_text_button.dart similarity index 100% rename from lib/widgets/loading_text_button.dart rename to kitchenowl/lib/widgets/loading_text_button.dart diff --git a/lib/widgets/number_selector.dart b/kitchenowl/lib/widgets/number_selector.dart similarity index 100% rename from lib/widgets/number_selector.dart rename to kitchenowl/lib/widgets/number_selector.dart diff --git a/lib/widgets/recipe_card.dart b/kitchenowl/lib/widgets/recipe_card.dart similarity index 100% rename from lib/widgets/recipe_card.dart rename to kitchenowl/lib/widgets/recipe_card.dart diff --git a/lib/widgets/recipe_create_fab.dart b/kitchenowl/lib/widgets/recipe_create_fab.dart similarity index 100% rename from lib/widgets/recipe_create_fab.dart rename to kitchenowl/lib/widgets/recipe_create_fab.dart diff --git a/lib/widgets/recipe_item.dart b/kitchenowl/lib/widgets/recipe_item.dart similarity index 100% rename from lib/widgets/recipe_item.dart rename to kitchenowl/lib/widgets/recipe_item.dart diff --git a/lib/widgets/recipe_source_chip.dart b/kitchenowl/lib/widgets/recipe_source_chip.dart similarity index 100% rename from lib/widgets/recipe_source_chip.dart rename to kitchenowl/lib/widgets/recipe_source_chip.dart diff --git a/lib/widgets/recipe_time_settings.dart b/kitchenowl/lib/widgets/recipe_time_settings.dart similarity index 100% rename from lib/widgets/recipe_time_settings.dart rename to kitchenowl/lib/widgets/recipe_time_settings.dart diff --git a/lib/widgets/rendering/sliver_with_pinned_footer.dart b/kitchenowl/lib/widgets/rendering/sliver_with_pinned_footer.dart similarity index 100% rename from lib/widgets/rendering/sliver_with_pinned_footer.dart rename to kitchenowl/lib/widgets/rendering/sliver_with_pinned_footer.dart diff --git a/lib/widgets/search_text_field.dart b/kitchenowl/lib/widgets/search_text_field.dart similarity index 100% rename from lib/widgets/search_text_field.dart rename to kitchenowl/lib/widgets/search_text_field.dart diff --git a/lib/widgets/select_dialog.dart b/kitchenowl/lib/widgets/select_dialog.dart similarity index 100% rename from lib/widgets/select_dialog.dart rename to kitchenowl/lib/widgets/select_dialog.dart diff --git a/lib/widgets/select_file.dart b/kitchenowl/lib/widgets/select_file.dart similarity index 100% rename from lib/widgets/select_file.dart rename to kitchenowl/lib/widgets/select_file.dart diff --git a/lib/widgets/selectable_button_card.dart b/kitchenowl/lib/widgets/selectable_button_card.dart similarity index 100% rename from lib/widgets/selectable_button_card.dart rename to kitchenowl/lib/widgets/selectable_button_card.dart diff --git a/lib/widgets/selectable_button_list_tile.dart b/kitchenowl/lib/widgets/selectable_button_list_tile.dart similarity index 100% rename from lib/widgets/selectable_button_list_tile.dart rename to kitchenowl/lib/widgets/selectable_button_list_tile.dart diff --git a/lib/widgets/settings/color_button.dart b/kitchenowl/lib/widgets/settings/color_button.dart similarity index 100% rename from lib/widgets/settings/color_button.dart rename to kitchenowl/lib/widgets/settings/color_button.dart diff --git a/lib/widgets/settings/server_user_card.dart b/kitchenowl/lib/widgets/settings/server_user_card.dart similarity index 100% rename from lib/widgets/settings/server_user_card.dart rename to kitchenowl/lib/widgets/settings/server_user_card.dart diff --git a/lib/widgets/settings/token_bottom_sheet.dart b/kitchenowl/lib/widgets/settings/token_bottom_sheet.dart similarity index 100% rename from lib/widgets/settings/token_bottom_sheet.dart rename to kitchenowl/lib/widgets/settings/token_bottom_sheet.dart diff --git a/lib/widgets/settings/token_card.dart b/kitchenowl/lib/widgets/settings/token_card.dart similarity index 100% rename from lib/widgets/settings/token_card.dart rename to kitchenowl/lib/widgets/settings/token_card.dart diff --git a/lib/widgets/settings_household/import_settings_dialog.dart b/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart similarity index 100% rename from lib/widgets/settings_household/import_settings_dialog.dart rename to kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart diff --git a/lib/widgets/settings_household/sliver_household_category_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_category_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_category_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_category_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_danger_zone.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_danger_zone.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart diff --git a/lib/widgets/settings_household/sliver_household_expense_category_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_expense_category_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_expense_category_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_expense_category_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_feature_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_feature_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_feature_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_feature_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_tags_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_tags_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_tags_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_tags_settings.dart diff --git a/lib/widgets/settings_household/update_member_bottom_sheet.dart b/kitchenowl/lib/widgets/settings_household/update_member_bottom_sheet.dart similarity index 100% rename from lib/widgets/settings_household/update_member_bottom_sheet.dart rename to kitchenowl/lib/widgets/settings_household/update_member_bottom_sheet.dart diff --git a/lib/widgets/settings_household/view_settings_list_tile.dart b/kitchenowl/lib/widgets/settings_household/view_settings_list_tile.dart similarity index 100% rename from lib/widgets/settings_household/view_settings_list_tile.dart rename to kitchenowl/lib/widgets/settings_household/view_settings_list_tile.dart diff --git a/lib/widgets/shimmer_card.dart b/kitchenowl/lib/widgets/shimmer_card.dart similarity index 100% rename from lib/widgets/shimmer_card.dart rename to kitchenowl/lib/widgets/shimmer_card.dart diff --git a/lib/widgets/shimmer_shopping_item.dart b/kitchenowl/lib/widgets/shimmer_shopping_item.dart similarity index 100% rename from lib/widgets/shimmer_shopping_item.dart rename to kitchenowl/lib/widgets/shimmer_shopping_item.dart diff --git a/lib/widgets/shopping_item.dart b/kitchenowl/lib/widgets/shopping_item.dart similarity index 100% rename from lib/widgets/shopping_item.dart rename to kitchenowl/lib/widgets/shopping_item.dart diff --git a/lib/widgets/shoppinglist_confirm_remove_fab.dart b/kitchenowl/lib/widgets/shoppinglist_confirm_remove_fab.dart similarity index 100% rename from lib/widgets/shoppinglist_confirm_remove_fab.dart rename to kitchenowl/lib/widgets/shoppinglist_confirm_remove_fab.dart diff --git a/lib/widgets/show_snack_bar.dart b/kitchenowl/lib/widgets/show_snack_bar.dart similarity index 100% rename from lib/widgets/show_snack_bar.dart rename to kitchenowl/lib/widgets/show_snack_bar.dart diff --git a/lib/widgets/sliver_implicit_animated_list.dart b/kitchenowl/lib/widgets/sliver_implicit_animated_list.dart similarity index 100% rename from lib/widgets/sliver_implicit_animated_list.dart rename to kitchenowl/lib/widgets/sliver_implicit_animated_list.dart diff --git a/lib/widgets/sliver_item_grid_list.dart b/kitchenowl/lib/widgets/sliver_item_grid_list.dart similarity index 100% rename from lib/widgets/sliver_item_grid_list.dart rename to kitchenowl/lib/widgets/sliver_item_grid_list.dart diff --git a/lib/widgets/sliver_text.dart b/kitchenowl/lib/widgets/sliver_text.dart similarity index 100% rename from lib/widgets/sliver_text.dart rename to kitchenowl/lib/widgets/sliver_text.dart diff --git a/lib/widgets/sliver_with_pinned_footer.dart b/kitchenowl/lib/widgets/sliver_with_pinned_footer.dart similarity index 100% rename from lib/widgets/sliver_with_pinned_footer.dart rename to kitchenowl/lib/widgets/sliver_with_pinned_footer.dart diff --git a/lib/widgets/string_item_match.dart b/kitchenowl/lib/widgets/string_item_match.dart similarity index 100% rename from lib/widgets/string_item_match.dart rename to kitchenowl/lib/widgets/string_item_match.dart diff --git a/lib/widgets/text_dialog.dart b/kitchenowl/lib/widgets/text_dialog.dart similarity index 100% rename from lib/widgets/text_dialog.dart rename to kitchenowl/lib/widgets/text_dialog.dart diff --git a/lib/widgets/text_with_icon_button.dart b/kitchenowl/lib/widgets/text_with_icon_button.dart similarity index 100% rename from lib/widgets/text_with_icon_button.dart rename to kitchenowl/lib/widgets/text_with_icon_button.dart diff --git a/lib/widgets/trailing_icon_text_button.dart b/kitchenowl/lib/widgets/trailing_icon_text_button.dart similarity index 100% rename from lib/widgets/trailing_icon_text_button.dart rename to kitchenowl/lib/widgets/trailing_icon_text_button.dart diff --git a/lib/widgets/user_list_tile.dart b/kitchenowl/lib/widgets/user_list_tile.dart similarity index 100% rename from lib/widgets/user_list_tile.dart rename to kitchenowl/lib/widgets/user_list_tile.dart diff --git a/linux/.gitignore b/kitchenowl/linux/.gitignore similarity index 100% rename from linux/.gitignore rename to kitchenowl/linux/.gitignore diff --git a/linux/CMakeLists.txt b/kitchenowl/linux/CMakeLists.txt similarity index 100% rename from linux/CMakeLists.txt rename to kitchenowl/linux/CMakeLists.txt diff --git a/linux/flutter/CMakeLists.txt b/kitchenowl/linux/flutter/CMakeLists.txt similarity index 100% rename from linux/flutter/CMakeLists.txt rename to kitchenowl/linux/flutter/CMakeLists.txt diff --git a/linux/flutter/generated_plugin_registrant.cc b/kitchenowl/linux/flutter/generated_plugin_registrant.cc similarity index 100% rename from linux/flutter/generated_plugin_registrant.cc rename to kitchenowl/linux/flutter/generated_plugin_registrant.cc diff --git a/linux/flutter/generated_plugin_registrant.h b/kitchenowl/linux/flutter/generated_plugin_registrant.h similarity index 100% rename from linux/flutter/generated_plugin_registrant.h rename to kitchenowl/linux/flutter/generated_plugin_registrant.h diff --git a/linux/flutter/generated_plugins.cmake b/kitchenowl/linux/flutter/generated_plugins.cmake similarity index 100% rename from linux/flutter/generated_plugins.cmake rename to kitchenowl/linux/flutter/generated_plugins.cmake diff --git a/linux/icon.png b/kitchenowl/linux/icon.png similarity index 100% rename from linux/icon.png rename to kitchenowl/linux/icon.png diff --git a/linux/kitchenowl.desktop b/kitchenowl/linux/kitchenowl.desktop similarity index 100% rename from linux/kitchenowl.desktop rename to kitchenowl/linux/kitchenowl.desktop diff --git a/linux/main.cc b/kitchenowl/linux/main.cc similarity index 100% rename from linux/main.cc rename to kitchenowl/linux/main.cc diff --git a/linux/my_application.cc b/kitchenowl/linux/my_application.cc similarity index 100% rename from linux/my_application.cc rename to kitchenowl/linux/my_application.cc diff --git a/linux/my_application.h b/kitchenowl/linux/my_application.h similarity index 100% rename from linux/my_application.h rename to kitchenowl/linux/my_application.h diff --git a/macos/.gitignore b/kitchenowl/macos/.gitignore similarity index 100% rename from macos/.gitignore rename to kitchenowl/macos/.gitignore diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/kitchenowl/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from macos/Flutter/Flutter-Debug.xcconfig rename to kitchenowl/macos/Flutter/Flutter-Debug.xcconfig diff --git a/macos/Flutter/Flutter-Release.xcconfig b/kitchenowl/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from macos/Flutter/Flutter-Release.xcconfig rename to kitchenowl/macos/Flutter/Flutter-Release.xcconfig diff --git a/macos/Podfile b/kitchenowl/macos/Podfile similarity index 100% rename from macos/Podfile rename to kitchenowl/macos/Podfile diff --git a/macos/Runner.xcodeproj/project.pbxproj b/kitchenowl/macos/Runner.xcodeproj/project.pbxproj similarity index 100% rename from macos/Runner.xcodeproj/project.pbxproj rename to kitchenowl/macos/Runner.xcodeproj/project.pbxproj diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kitchenowl/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to kitchenowl/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/kitchenowl/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from macos/Runner.xcworkspace/contents.xcworkspacedata rename to kitchenowl/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/macos/Runner/AppDelegate.swift b/kitchenowl/macos/Runner/AppDelegate.swift similarity index 100% rename from macos/Runner/AppDelegate.swift rename to kitchenowl/macos/Runner/AppDelegate.swift diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/kitchenowl/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from macos/Runner/Base.lproj/MainMenu.xib rename to kitchenowl/macos/Runner/Base.lproj/MainMenu.xib diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/kitchenowl/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from macos/Runner/Configs/AppInfo.xcconfig rename to kitchenowl/macos/Runner/Configs/AppInfo.xcconfig diff --git a/macos/Runner/Configs/Debug.xcconfig b/kitchenowl/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from macos/Runner/Configs/Debug.xcconfig rename to kitchenowl/macos/Runner/Configs/Debug.xcconfig diff --git a/macos/Runner/Configs/Release.xcconfig b/kitchenowl/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from macos/Runner/Configs/Release.xcconfig rename to kitchenowl/macos/Runner/Configs/Release.xcconfig diff --git a/macos/Runner/Configs/Warnings.xcconfig b/kitchenowl/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from macos/Runner/Configs/Warnings.xcconfig rename to kitchenowl/macos/Runner/Configs/Warnings.xcconfig diff --git a/macos/Runner/DebugProfile.entitlements b/kitchenowl/macos/Runner/DebugProfile.entitlements similarity index 100% rename from macos/Runner/DebugProfile.entitlements rename to kitchenowl/macos/Runner/DebugProfile.entitlements diff --git a/macos/Runner/Info.plist b/kitchenowl/macos/Runner/Info.plist similarity index 100% rename from macos/Runner/Info.plist rename to kitchenowl/macos/Runner/Info.plist diff --git a/macos/Runner/MainFlutterWindow.swift b/kitchenowl/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from macos/Runner/MainFlutterWindow.swift rename to kitchenowl/macos/Runner/MainFlutterWindow.swift diff --git a/macos/Runner/Release.entitlements b/kitchenowl/macos/Runner/Release.entitlements similarity index 100% rename from macos/Runner/Release.entitlements rename to kitchenowl/macos/Runner/Release.entitlements diff --git a/macos/RunnerTests/RunnerTests.swift b/kitchenowl/macos/RunnerTests/RunnerTests.swift similarity index 100% rename from macos/RunnerTests/RunnerTests.swift rename to kitchenowl/macos/RunnerTests/RunnerTests.swift diff --git a/pubspec.lock b/kitchenowl/pubspec.lock similarity index 100% rename from pubspec.lock rename to kitchenowl/pubspec.lock diff --git a/pubspec.yaml b/kitchenowl/pubspec.yaml similarity index 100% rename from pubspec.yaml rename to kitchenowl/pubspec.yaml diff --git a/test/helpers/named_bytearray_test.dart b/kitchenowl/test/helpers/named_bytearray_test.dart similarity index 100% rename from test/helpers/named_bytearray_test.dart rename to kitchenowl/test/helpers/named_bytearray_test.dart diff --git a/test/models/category_test.dart b/kitchenowl/test/models/category_test.dart similarity index 100% rename from test/models/category_test.dart rename to kitchenowl/test/models/category_test.dart diff --git a/test/models/expense_category.dart b/kitchenowl/test/models/expense_category.dart similarity index 100% rename from test/models/expense_category.dart rename to kitchenowl/test/models/expense_category.dart diff --git a/test/models/expense_test.dart b/kitchenowl/test/models/expense_test.dart similarity index 100% rename from test/models/expense_test.dart rename to kitchenowl/test/models/expense_test.dart diff --git a/test/models/household_test.dart b/kitchenowl/test/models/household_test.dart similarity index 100% rename from test/models/household_test.dart rename to kitchenowl/test/models/household_test.dart diff --git a/test/models/item.dart b/kitchenowl/test/models/item.dart similarity index 100% rename from test/models/item.dart rename to kitchenowl/test/models/item.dart diff --git a/web/.well-known/apple-app-site-association b/kitchenowl/web/.well-known/apple-app-site-association similarity index 100% rename from web/.well-known/apple-app-site-association rename to kitchenowl/web/.well-known/apple-app-site-association diff --git a/web/.well-known/assetlinks.json b/kitchenowl/web/.well-known/assetlinks.json similarity index 100% rename from web/.well-known/assetlinks.json rename to kitchenowl/web/.well-known/assetlinks.json diff --git a/web/favicon.ico b/kitchenowl/web/favicon.ico similarity index 100% rename from web/favicon.ico rename to kitchenowl/web/favicon.ico diff --git a/web/favicon.png b/kitchenowl/web/favicon.png similarity index 100% rename from web/favicon.png rename to kitchenowl/web/favicon.png diff --git a/web/icons/Icon-192.png b/kitchenowl/web/icons/Icon-192.png similarity index 100% rename from web/icons/Icon-192.png rename to kitchenowl/web/icons/Icon-192.png diff --git a/web/icons/Icon-512.png b/kitchenowl/web/icons/Icon-512.png similarity index 100% rename from web/icons/Icon-512.png rename to kitchenowl/web/icons/Icon-512.png diff --git a/web/icons/Icon-maskable-192.png b/kitchenowl/web/icons/Icon-maskable-192.png similarity index 100% rename from web/icons/Icon-maskable-192.png rename to kitchenowl/web/icons/Icon-maskable-192.png diff --git a/web/icons/Icon-maskable-512.png b/kitchenowl/web/icons/Icon-maskable-512.png similarity index 100% rename from web/icons/Icon-maskable-512.png rename to kitchenowl/web/icons/Icon-maskable-512.png diff --git a/web/index.html b/kitchenowl/web/index.html similarity index 100% rename from web/index.html rename to kitchenowl/web/index.html diff --git a/web/manifest.json b/kitchenowl/web/manifest.json similarity index 100% rename from web/manifest.json rename to kitchenowl/web/manifest.json diff --git a/windows/.gitignore b/kitchenowl/windows/.gitignore similarity index 100% rename from windows/.gitignore rename to kitchenowl/windows/.gitignore diff --git a/windows/CMakeLists.txt b/kitchenowl/windows/CMakeLists.txt similarity index 100% rename from windows/CMakeLists.txt rename to kitchenowl/windows/CMakeLists.txt diff --git a/windows/flutter/CMakeLists.txt b/kitchenowl/windows/flutter/CMakeLists.txt similarity index 100% rename from windows/flutter/CMakeLists.txt rename to kitchenowl/windows/flutter/CMakeLists.txt diff --git a/windows/flutter/generated_plugin_registrant.cc b/kitchenowl/windows/flutter/generated_plugin_registrant.cc similarity index 100% rename from windows/flutter/generated_plugin_registrant.cc rename to kitchenowl/windows/flutter/generated_plugin_registrant.cc diff --git a/windows/flutter/generated_plugin_registrant.h b/kitchenowl/windows/flutter/generated_plugin_registrant.h similarity index 100% rename from windows/flutter/generated_plugin_registrant.h rename to kitchenowl/windows/flutter/generated_plugin_registrant.h diff --git a/windows/flutter/generated_plugins.cmake b/kitchenowl/windows/flutter/generated_plugins.cmake similarity index 100% rename from windows/flutter/generated_plugins.cmake rename to kitchenowl/windows/flutter/generated_plugins.cmake diff --git a/windows/runner/CMakeLists.txt b/kitchenowl/windows/runner/CMakeLists.txt similarity index 100% rename from windows/runner/CMakeLists.txt rename to kitchenowl/windows/runner/CMakeLists.txt diff --git a/windows/runner/Runner.rc b/kitchenowl/windows/runner/Runner.rc similarity index 100% rename from windows/runner/Runner.rc rename to kitchenowl/windows/runner/Runner.rc diff --git a/windows/runner/flutter_window.cpp b/kitchenowl/windows/runner/flutter_window.cpp similarity index 100% rename from windows/runner/flutter_window.cpp rename to kitchenowl/windows/runner/flutter_window.cpp diff --git a/windows/runner/flutter_window.h b/kitchenowl/windows/runner/flutter_window.h similarity index 100% rename from windows/runner/flutter_window.h rename to kitchenowl/windows/runner/flutter_window.h diff --git a/windows/runner/main.cpp b/kitchenowl/windows/runner/main.cpp similarity index 100% rename from windows/runner/main.cpp rename to kitchenowl/windows/runner/main.cpp diff --git a/windows/runner/resource.h b/kitchenowl/windows/runner/resource.h similarity index 100% rename from windows/runner/resource.h rename to kitchenowl/windows/runner/resource.h diff --git a/windows/runner/resources/app_icon.ico b/kitchenowl/windows/runner/resources/app_icon.ico similarity index 100% rename from windows/runner/resources/app_icon.ico rename to kitchenowl/windows/runner/resources/app_icon.ico diff --git a/windows/runner/runner.exe.manifest b/kitchenowl/windows/runner/runner.exe.manifest similarity index 100% rename from windows/runner/runner.exe.manifest rename to kitchenowl/windows/runner/runner.exe.manifest diff --git a/windows/runner/utils.cpp b/kitchenowl/windows/runner/utils.cpp similarity index 100% rename from windows/runner/utils.cpp rename to kitchenowl/windows/runner/utils.cpp diff --git a/windows/runner/utils.h b/kitchenowl/windows/runner/utils.h similarity index 100% rename from windows/runner/utils.h rename to kitchenowl/windows/runner/utils.h diff --git a/windows/runner/win32_window.cpp b/kitchenowl/windows/runner/win32_window.cpp similarity index 100% rename from windows/runner/win32_window.cpp rename to kitchenowl/windows/runner/win32_window.cpp diff --git a/windows/runner/win32_window.h b/kitchenowl/windows/runner/win32_window.h similarity index 100% rename from windows/runner/win32_window.h rename to kitchenowl/windows/runner/win32_window.h