", 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