diff --git a/public/locales/ar.json b/public/locales/ar.json
index e8e5cbf..c436961 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "التحويلات والهدايا",
"catGovernmentBenefits": "المزايا الاجتماعية",
"catOtherIncome": "دخل آخر",
- "loadingIndicator": "جارٍ التحميل…"
+ "loadingIndicator": "جارٍ التحميل…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "الإعدادات",
diff --git a/public/locales/de.json b/public/locales/de.json
index d78d2a9..b6b6501 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -469,11 +469,11 @@
"trendNeutral": "- wie {{month}}",
"validAmountRequired": "Gültigen Betrag eingeben",
"dateRequired": "Datum ist erforderlich",
- "catFood": "Lebensmittel",
+ "catFood": "Ernährung",
"catRent": "Miete",
"catInsurance": "Versicherung",
"catMobility": "Mobilität",
- "catLeisure": "Freizeit",
+ "catLeisure": "Freizeit und Unterhaltung",
"catClothing": "Kleidung",
"catHealth": "Gesundheit",
"catEducation": "Bildung",
@@ -483,7 +483,54 @@
"catTransferGiftIncome": "Geschenke & Transfers",
"catGovernmentBenefits": "Sozialleistungen",
"catOtherIncome": "Sonstiges Einkommen",
- "loadingIndicator": "Lade…"
+ "loadingIndicator": "Lade…",
+ "subcategoryLabel": "Unterkategorie",
+ "catHousing": "Wohnen / Zuhause",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Körperpflege / Gesundheit",
+ "catShoppingClothing": "Einkäufe und Kleidung",
+ "catFinancialOther": "Finanzdienstleistungen und Sonstiges",
+ "subcatRentMortgage": "Miete / Kreditrate",
+ "subcatCondominium": "Hausgeld",
+ "subcatUtilities": "Strom / Wasser / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Telefon",
+ "subcatRenovationMaintenance": "Renovierung / Instandhaltung",
+ "subcatCleaning": "Reinigung",
+ "subcatGroceries": "Supermarkt",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bäckerei",
+ "subcatFuel": "Kraftstoff",
+ "subcatParkingTolls": "Parken / Maut",
+ "subcatPublicTransport": "Öffentliche Verkehrsmittel",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Wartung / Versicherung",
+ "subcatPharmacy": "Apotheke",
+ "subcatHealthInsurance": "Krankenversicherung",
+ "subcatGymSports": "Fitnessstudio / Sport",
+ "subcatBeautyCosmetics": "Schönheit / Kosmetik",
+ "subcatTravel": "Reisen",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Veranstaltungen",
+ "subcatHobbies": "Hobbys",
+ "subcatClothesShoes": "Kleidung / Schuhe",
+ "subcatElectronics": "Elektronik",
+ "subcatGifts": "Geschenke",
+ "subcatCoursesCollege": "Kurse / Hochschule",
+ "subcatSchoolSupplies": "Schulmaterial",
+ "subcatLanguages": "Sprachen",
+ "subcatLoansInterest": "Kredite / Zinsen",
+ "subcatBankFees": "Bankgebühren",
+ "subcatInsuranceOther": "Versicherungen",
+ "subcatInvestments": "Investitionen",
+ "subcatTaxes": "Steuern",
+ "metaLoadError": "Budget-Kategorien konnten nicht geladen werden.",
+ "addCategory": "+ Kategorie",
+ "addSubcategory": "+ Unterkategorie",
+ "newCategoryPrompt": "Name der neuen Kategorie:",
+ "newSubcategoryPrompt": "Name der neuen Unterkategorie:",
+ "categoryAddedToast": "Kategorie hinzugefügt.",
+ "subcategoryAddedToast": "Unterkategorie hinzugefügt."
},
"settings": {
"title": "Einstellungen",
diff --git a/public/locales/el.json b/public/locales/el.json
index 5ac786d..309c515 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Μεταφορές και δώρα",
"catGovernmentBenefits": "Κοινωνικές παροχές",
"catOtherIncome": "Άλλο εισόδημα",
- "loadingIndicator": "Φόρτωση…"
+ "loadingIndicator": "Φόρτωση…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Ρυθμίσεις",
diff --git a/public/locales/en.json b/public/locales/en.json
index f81727a..b72af7f 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -463,11 +463,11 @@
"trendNeutral": "- same as {{month}}",
"validAmountRequired": "Please enter a valid amount",
"dateRequired": "Date is required",
- "catFood": "Groceries",
+ "catFood": "Food",
"catRent": "Rent",
"catInsurance": "Insurance",
"catMobility": "Transport",
- "catLeisure": "Leisure",
+ "catLeisure": "Leisure and Entertainment",
"catClothing": "Clothing",
"catHealth": "Health",
"catEducation": "Education",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transfer & Gift Income",
"catGovernmentBenefits": "Government & Social Benefits",
"catOtherIncome": "Other Income",
- "loadingIndicator": "Loading…"
+ "loadingIndicator": "Loading…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Settings",
diff --git a/public/locales/es.json b/public/locales/es.json
index b3faf96..3a1e8c9 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -467,7 +467,7 @@
"catRent": "Alquiler",
"catInsurance": "Seguro",
"catMobility": "Movilidad",
- "catLeisure": "Ocio",
+ "catLeisure": "Ocio y entretenimiento",
"catClothing": "Ropa",
"catHealth": "Salud",
"catEducation": "Educación",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferencias y Regalos",
"catGovernmentBenefits": "Prestaciones Sociales",
"catOtherIncome": "Otros Ingresos",
- "loadingIndicator": "Cargando…"
+ "loadingIndicator": "Cargando…",
+ "subcategoryLabel": "Subcategoría",
+ "catHousing": "Vivienda / Casa",
+ "catTransport": "Transporte",
+ "catPersonalHealth": "Cuidado personal / Salud",
+ "catShoppingClothing": "Compras y ropa",
+ "catFinancialOther": "Servicios financieros y otros",
+ "subcatRentMortgage": "Alquiler / Hipoteca",
+ "subcatCondominium": "Comunidad",
+ "subcatUtilities": "Luz / Agua / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Teléfono",
+ "subcatRenovationMaintenance": "Reforma / Mantenimiento",
+ "subcatCleaning": "Limpieza",
+ "subcatGroceries": "Supermercado",
+ "subcatRestaurantsBars": "Restaurantes / Bares",
+ "subcatSnacksFastFood": "Snacks / Comida rápida",
+ "subcatBakery": "Panadería",
+ "subcatFuel": "Combustible",
+ "subcatParkingTolls": "Aparcamiento / Peajes",
+ "subcatPublicTransport": "Transporte público",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Mantenimiento / Seguro",
+ "subcatPharmacy": "Farmacia",
+ "subcatHealthInsurance": "Seguro médico",
+ "subcatGymSports": "Gimnasio / Deportes",
+ "subcatBeautyCosmetics": "Belleza / Cosmética",
+ "subcatTravel": "Viajes",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Eventos",
+ "subcatHobbies": "Aficiones",
+ "subcatClothesShoes": "Ropa / Calzado",
+ "subcatElectronics": "Electrónica",
+ "subcatGifts": "Regalos",
+ "subcatCoursesCollege": "Cursos / Universidad",
+ "subcatSchoolSupplies": "Material escolar",
+ "subcatLanguages": "Idiomas",
+ "subcatLoansInterest": "Préstamos / Intereses",
+ "subcatBankFees": "Comisiones bancarias",
+ "subcatInsuranceOther": "Seguros",
+ "subcatInvestments": "Inversiones",
+ "subcatTaxes": "Impuestos",
+ "metaLoadError": "No se pudieron cargar las categorías del presupuesto.",
+ "addCategory": "+ categoría",
+ "addSubcategory": "+ subcategoría",
+ "newCategoryPrompt": "Nombre de la nueva categoría:",
+ "newSubcategoryPrompt": "Nombre de la nueva subcategoría:",
+ "categoryAddedToast": "Categoría añadida.",
+ "subcategoryAddedToast": "Subcategoría añadida."
},
"settings": {
"title": "Ajustes",
diff --git a/public/locales/fr.json b/public/locales/fr.json
index 583be21..af83829 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -467,7 +467,7 @@
"catRent": "Loyer",
"catInsurance": "Assurance",
"catMobility": "Transport",
- "catLeisure": "Loisirs",
+ "catLeisure": "Loisirs et divertissement",
"catClothing": "Vêtements",
"catHealth": "Santé",
"catEducation": "Éducation",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferts et Cadeaux",
"catGovernmentBenefits": "Allocations Sociales",
"catOtherIncome": "Autres Revenus",
- "loadingIndicator": "Chargement…"
+ "loadingIndicator": "Chargement…",
+ "subcategoryLabel": "Sous-catégorie",
+ "catHousing": "Logement / Maison",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Soins personnels / Santé",
+ "catShoppingClothing": "Achats et vêtements",
+ "catFinancialOther": "Services financiers et autres",
+ "subcatRentMortgage": "Loyer / Crédit immobilier",
+ "subcatCondominium": "Copropriété",
+ "subcatUtilities": "Électricité / Eau / Gaz",
+ "subcatInternetTvPhone": "Internet / TV / Téléphone",
+ "subcatRenovationMaintenance": "Rénovation / Entretien",
+ "subcatCleaning": "Nettoyage",
+ "subcatGroceries": "Supermarché",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast-food",
+ "subcatBakery": "Boulangerie",
+ "subcatFuel": "Carburant",
+ "subcatParkingTolls": "Parking / Péages",
+ "subcatPublicTransport": "Transports publics",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Entretien / Assurance",
+ "subcatPharmacy": "Pharmacie",
+ "subcatHealthInsurance": "Assurance santé",
+ "subcatGymSports": "Salle de sport / Sports",
+ "subcatBeautyCosmetics": "Beauté / Cosmétiques",
+ "subcatTravel": "Voyages",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Événements",
+ "subcatHobbies": "Loisirs",
+ "subcatClothesShoes": "Vêtements / Chaussures",
+ "subcatElectronics": "Électronique",
+ "subcatGifts": "Cadeaux",
+ "subcatCoursesCollege": "Cours / Université",
+ "subcatSchoolSupplies": "Fournitures scolaires",
+ "subcatLanguages": "Langues",
+ "subcatLoansInterest": "Prêts / Intérêts",
+ "subcatBankFees": "Frais bancaires",
+ "subcatInsuranceOther": "Assurances",
+ "subcatInvestments": "Investissements",
+ "subcatTaxes": "Impôts",
+ "metaLoadError": "Impossible de charger les catégories du budget.",
+ "addCategory": "+ catégorie",
+ "addSubcategory": "+ sous-catégorie",
+ "newCategoryPrompt": "Nom de la nouvelle catégorie :",
+ "newSubcategoryPrompt": "Nom de la nouvelle sous-catégorie :",
+ "categoryAddedToast": "Catégorie ajoutée.",
+ "subcategoryAddedToast": "Sous-catégorie ajoutée."
},
"settings": {
"title": "Paramètres",
diff --git a/public/locales/hi.json b/public/locales/hi.json
index be3288f..fdb4036 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "स्थानांतरण और उपहार",
"catGovernmentBenefits": "सामाजिक लाभ",
"catOtherIncome": "अन्य आय",
- "loadingIndicator": "लोड हो रहा है…"
+ "loadingIndicator": "लोड हो रहा है…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "सेटिंग्स",
diff --git a/public/locales/it.json b/public/locales/it.json
index 13ad9c4..8824541 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -43,7 +43,7 @@
"main": "Navigazione principale",
"navigation": "Navigazione",
"quickActions": "Azioni rapide",
- "recipes": "Ricette",
+ "recipes": "Recipes",
"more": "Altro"
},
"dashboard": {
@@ -255,10 +255,10 @@
"recipeUrlLabel": "Link ricetta (opzionale)",
"recipeUrlPlaceholder": "https://…",
"openRecipe": "Apri ricetta",
- "savedRecipeLabel": "Ricette salvate",
- "savedRecipePlaceholder": "Seleziona ricetta",
- "saveAsRecipe": "Salva come ricetta",
- "recipeScaleLabel": "Scala ingredienti"
+ "savedRecipeLabel": "Saved recipe",
+ "savedRecipePlaceholder": "Select recipe",
+ "saveAsRecipe": "Save as recipe",
+ "recipeScaleLabel": "Scale ingredients"
},
"calendar": {
"title": "Calendario",
@@ -463,11 +463,11 @@
"trendNeutral": "- come {{month}}",
"validAmountRequired": "Inserisci un importo valido",
"dateRequired": "La data è obbligatoria",
- "catFood": "Spesa alimentare",
+ "catFood": "Alimentazione",
"catRent": "Affitto",
"catInsurance": "Assicurazione",
"catMobility": "Trasporti",
- "catLeisure": "Tempo libero",
+ "catLeisure": "Tempo libero e intrattenimento",
"catClothing": "Abbigliamento",
"catHealth": "Salute",
"catEducation": "Istruzione",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Trasferimenti e Regali",
"catGovernmentBenefits": "Prestazioni Sociali",
"catOtherIncome": "Altro Reddito",
- "loadingIndicator": "Caricamento…"
+ "loadingIndicator": "Caricamento…",
+ "subcategoryLabel": "Sottocategoria",
+ "catHousing": "Abitazione / Casa",
+ "catTransport": "Trasporti",
+ "catPersonalHealth": "Cura personale / Salute",
+ "catShoppingClothing": "Acquisti e abbigliamento",
+ "catFinancialOther": "Servizi finanziari e altro",
+ "subcatRentMortgage": "Affitto / Mutuo",
+ "subcatCondominium": "Condominio",
+ "subcatUtilities": "Luce / Acqua / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Telefono",
+ "subcatRenovationMaintenance": "Ristrutturazione / Manutenzione",
+ "subcatCleaning": "Pulizia",
+ "subcatGroceries": "Supermercato",
+ "subcatRestaurantsBars": "Ristoranti / Bar",
+ "subcatSnacksFastFood": "Snack / Fast food",
+ "subcatBakery": "Panetteria",
+ "subcatFuel": "Carburante",
+ "subcatParkingTolls": "Parcheggio / Pedaggi",
+ "subcatPublicTransport": "Trasporto pubblico",
+ "subcatAppsTaxi": "App / Taxi",
+ "subcatMaintenanceInsurance": "Manutenzione / Assicurazione",
+ "subcatPharmacy": "Farmacia",
+ "subcatHealthInsurance": "Assicurazione sanitaria",
+ "subcatGymSports": "Palestra / Sport",
+ "subcatBeautyCosmetics": "Bellezza / Cosmetici",
+ "subcatTravel": "Viaggi",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Eventi",
+ "subcatHobbies": "Hobby",
+ "subcatClothesShoes": "Vestiti / Scarpe",
+ "subcatElectronics": "Elettronica",
+ "subcatGifts": "Regali",
+ "subcatCoursesCollege": "Corsi / Università",
+ "subcatSchoolSupplies": "Materiale scolastico",
+ "subcatLanguages": "Lingue",
+ "subcatLoansInterest": "Prestiti / Interessi",
+ "subcatBankFees": "Commissioni bancarie",
+ "subcatInsuranceOther": "Assicurazioni",
+ "subcatInvestments": "Investimenti",
+ "subcatTaxes": "Imposte",
+ "metaLoadError": "Impossibile caricare le categorie del budget.",
+ "addCategory": "+ categoria",
+ "addSubcategory": "+ sottocategoria",
+ "newCategoryPrompt": "Nome della nuova categoria:",
+ "newSubcategoryPrompt": "Nome della nuova sottocategoria:",
+ "categoryAddedToast": "Categoria aggiunta.",
+ "subcategoryAddedToast": "Sottocategoria aggiunta."
},
"settings": {
"title": "Impostazioni",
@@ -659,28 +706,28 @@
"unitMonths": "mesi"
},
"recipes": {
- "title": "Ricette",
- "addRecipe": "Aggiungi ricetta",
- "editRecipe": "Modifica ricetta",
- "emptyTitle": "Nessuna ricetta",
- "emptyDescription": "Salva le tue ricette preferite e riutilizzale nella pianificazione dei pasti.",
- "titleLabel": "Titolo *",
- "titlePlaceholder": "es. Pasta Carbonara",
- "notesLabel": "Note",
- "notesPlaceholder": "Opzionale...",
- "urlLabel": "Link della ricetta",
+ "title": "Recipes",
+ "addRecipe": "Add recipe",
+ "editRecipe": "Edit recipe",
+ "emptyTitle": "No recipes yet",
+ "emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
+ "titleLabel": "Title *",
+ "titlePlaceholder": "e.g. Pasta Carbonara",
+ "notesLabel": "Notes",
+ "notesPlaceholder": "Optional...",
+ "urlLabel": "Recipe link",
"urlPlaceholder": "https://...",
- "ingredientsLabel": "Ingredienti",
- "addToMeals": "Aggiungi al piano alimentare",
- "openLink": "Apri il link della ricetta",
- "deleteConfirm": "Eliminare la ricetta \"{{title}}\"?",
- "created": "Ricetta salvata.",
- "updated": "Ricetta aggiornata.",
- "deleted": "Ricetta eliminata.",
- "titleRequired": "È richiesto il titolo",
- "duplicate": "Duplicato",
- "duplicated": "Ricetta duplicata.",
- "copySuffix": "copia"
+ "ingredientsLabel": "Ingredients",
+ "addToMeals": "Add to meal plan",
+ "openLink": "Open recipe link",
+ "deleteConfirm": "Delete recipe \"{{title}}\"?",
+ "created": "Recipe saved.",
+ "updated": "Recipe updated.",
+ "deleted": "Recipe deleted.",
+ "titleRequired": "Title is required",
+ "duplicate": "Duplicate",
+ "duplicated": "Recipe duplicated.",
+ "copySuffix": "copy"
},
"search": {
"title": "Ricerca",
diff --git a/public/locales/ja.json b/public/locales/ja.json
index d799c7e..6cf9582 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "譲渡・贈与",
"catGovernmentBenefits": "社会保障給付",
"catOtherIncome": "その他の収入",
- "loadingIndicator": "読み込み中…"
+ "loadingIndicator": "読み込み中…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "設定",
diff --git a/public/locales/pt.json b/public/locales/pt.json
index 8f56de8..5c86ccf 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -467,7 +467,7 @@
"catRent": "Aluguel",
"catInsurance": "Seguro",
"catMobility": "Transporte",
- "catLeisure": "Lazer",
+ "catLeisure": "Lazer e Entretenimento",
"catClothing": "Roupas",
"catHealth": "Saúde",
"catEducation": "Educação",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferências e Presentes",
"catGovernmentBenefits": "Benefícios Sociais",
"catOtherIncome": "Outras Rendas",
- "loadingIndicator": "Carregando…"
+ "loadingIndicator": "Carregando…",
+ "subcategoryLabel": "Subcategoria",
+ "catHousing": "Habitação / Casa",
+ "catTransport": "Transporte",
+ "catPersonalHealth": "Cuidados Pessoais / Saúde",
+ "catShoppingClothing": "Compras e Vestuário",
+ "catFinancialOther": "Serviços Financeiros e Outros",
+ "subcatRentMortgage": "Aluguel / Prestação",
+ "subcatCondominium": "Condomínio",
+ "subcatUtilities": "Luz / Água / Gás",
+ "subcatInternetTvPhone": "Internet / TV / Telefone",
+ "subcatRenovationMaintenance": "Reforma / Manutenção",
+ "subcatCleaning": "Limpeza",
+ "subcatGroceries": "Supermercado",
+ "subcatRestaurantsBars": "Restaurante / Bares",
+ "subcatSnacksFastFood": "Lanches / Fast Food",
+ "subcatBakery": "Padaria",
+ "subcatFuel": "Combustível",
+ "subcatParkingTolls": "Estacionamento / Pedágio",
+ "subcatPublicTransport": "Transporte Público",
+ "subcatAppsTaxi": "Aplicativos / Táxi",
+ "subcatMaintenanceInsurance": "Manutenção / Seguro",
+ "subcatPharmacy": "Farmácia",
+ "subcatHealthInsurance": "Plano de Saúde",
+ "subcatGymSports": "Academia / Esportes",
+ "subcatBeautyCosmetics": "Beleza / Cosméticos",
+ "subcatTravel": "Viagens",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Eventos",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Roupas / Calçados",
+ "subcatElectronics": "Eletrônicos",
+ "subcatGifts": "Presentes",
+ "subcatCoursesCollege": "Cursos / Faculdade",
+ "subcatSchoolSupplies": "Material Escolar",
+ "subcatLanguages": "Idiomas",
+ "subcatLoansInterest": "Empréstimos / Juros",
+ "subcatBankFees": "Tarifas Bancárias",
+ "subcatInsuranceOther": "Seguros",
+ "subcatInvestments": "Investimentos",
+ "subcatTaxes": "Impostos",
+ "metaLoadError": "Categorias do orçamento não puderam ser carregadas.",
+ "addCategory": "+ categoria",
+ "addSubcategory": "+ subcategoria",
+ "newCategoryPrompt": "Nome da nova categoria:",
+ "newSubcategoryPrompt": "Nome da nova subcategoria:",
+ "categoryAddedToast": "Categoria adicionada.",
+ "subcategoryAddedToast": "Subcategoria adicionada."
},
"settings": {
"title": "Configurações",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 6363454..01762ca 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Переводы и подарки",
"catGovernmentBenefits": "Социальные пособия",
"catOtherIncome": "Прочие доходы",
- "loadingIndicator": "Загрузка…"
+ "loadingIndicator": "Загрузка…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Настройки",
diff --git a/public/locales/sv.json b/public/locales/sv.json
index a0949af..0ddfd0b 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Överföringar och gåvor",
"catGovernmentBenefits": "Socialförmåner",
"catOtherIncome": "Övrig inkomst",
- "loadingIndicator": "Laddar…"
+ "loadingIndicator": "Laddar…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Inställningar",
diff --git a/public/locales/tr.json b/public/locales/tr.json
index c25ee13..4f98a3f 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferler ve Hediyeler",
"catGovernmentBenefits": "Sosyal Yardımlar",
"catOtherIncome": "Diğer Gelir",
- "loadingIndicator": "Yükleniyor…"
+ "loadingIndicator": "Yükleniyor…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Ayarlar",
diff --git a/public/locales/uk.json b/public/locales/uk.json
index 1dfd859..31c9cb9 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Переводи та подарунки",
"catGovernmentBenefits": "Соціальні виплати",
"catOtherIncome": "Інші доходи",
- "loadingIndicator": "Завантаження…"
+ "loadingIndicator": "Завантаження…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "Налаштування",
diff --git a/public/locales/zh.json b/public/locales/zh.json
index 16a1f7d..20d2d7f 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "转账和礼物",
"catGovernmentBenefits": "社会福利",
"catOtherIncome": "其他收入",
- "loadingIndicator": "加载中…"
+ "loadingIndicator": "加载中…",
+ "subcategoryLabel": "Subcategory",
+ "catHousing": "Housing / Home",
+ "catTransport": "Transport",
+ "catPersonalHealth": "Personal Care / Health",
+ "catShoppingClothing": "Shopping and Clothing",
+ "catFinancialOther": "Financial Services and Other",
+ "subcatRentMortgage": "Rent / Mortgage",
+ "subcatCondominium": "Condominium fees",
+ "subcatUtilities": "Electricity / Water / Gas",
+ "subcatInternetTvPhone": "Internet / TV / Phone",
+ "subcatRenovationMaintenance": "Renovation / Maintenance",
+ "subcatCleaning": "Cleaning",
+ "subcatGroceries": "Groceries",
+ "subcatRestaurantsBars": "Restaurants / Bars",
+ "subcatSnacksFastFood": "Snacks / Fast Food",
+ "subcatBakery": "Bakery",
+ "subcatFuel": "Fuel",
+ "subcatParkingTolls": "Parking / Tolls",
+ "subcatPublicTransport": "Public transport",
+ "subcatAppsTaxi": "Apps / Taxi",
+ "subcatMaintenanceInsurance": "Maintenance / Insurance",
+ "subcatPharmacy": "Pharmacy",
+ "subcatHealthInsurance": "Health insurance",
+ "subcatGymSports": "Gym / Sports",
+ "subcatBeautyCosmetics": "Beauty / Cosmetics",
+ "subcatTravel": "Travel",
+ "subcatStreaming": "Streaming",
+ "subcatEvents": "Events",
+ "subcatHobbies": "Hobbies",
+ "subcatClothesShoes": "Clothes / Shoes",
+ "subcatElectronics": "Electronics",
+ "subcatGifts": "Gifts",
+ "subcatCoursesCollege": "Courses / College",
+ "subcatSchoolSupplies": "School supplies",
+ "subcatLanguages": "Languages",
+ "subcatLoansInterest": "Loans / Interest",
+ "subcatBankFees": "Bank fees",
+ "subcatInsuranceOther": "Insurance",
+ "subcatInvestments": "Investments",
+ "subcatTaxes": "Taxes",
+ "metaLoadError": "Budget categories could not be loaded.",
+ "addCategory": "+ category",
+ "addSubcategory": "+ subcategory",
+ "newCategoryPrompt": "Name of the new category:",
+ "newSubcategoryPrompt": "Name of the new subcategory:",
+ "categoryAddedToast": "Category added.",
+ "subcategoryAddedToast": "Subcategory added."
},
"settings": {
"title": "设置",
diff --git a/public/pages/budget.js b/public/pages/budget.js
index 5eb29d8..df34311 100644
--- a/public/pages/budget.js
+++ b/public/pages/budget.js
@@ -15,29 +15,16 @@ import { esc } from '/utils/html.js';
// Konstanten
// --------------------------------------------------------
-const EXPENSE_CATEGORIES = [
- 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
- 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
-];
-
-const INCOME_CATEGORIES = [
- 'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers',
- 'Sozialleistungen', 'Sonstiges Einkommen',
-];
-
-const CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES];
-
-const CATEGORY_LABELS = () => ({
+const CATEGORY_I18N = () => ({
// Expense categories
- 'Lebensmittel': t('budget.catFood'),
- 'Miete': t('budget.catRent'),
- 'Versicherung': t('budget.catInsurance'),
- 'Mobilität': t('budget.catMobility'),
- 'Freizeit': t('budget.catLeisure'),
- 'Kleidung': t('budget.catClothing'),
- 'Gesundheit': t('budget.catHealth'),
- 'Bildung': t('budget.catEducation'),
- 'Sonstiges': t('budget.catMisc'),
+ housing: t('budget.catHousing'),
+ food: t('budget.catFood'),
+ transport: t('budget.catTransport'),
+ personal_health: t('budget.catPersonalHealth'),
+ leisure: t('budget.catLeisure'),
+ shopping_clothing: t('budget.catShoppingClothing'),
+ education: t('budget.catEducation'),
+ financial_other: t('budget.catFinancialOther'),
// Income categories
'Erwerbseinkommen': t('budget.catEarnedIncome'),
'Kapitalerträge': t('budget.catInvestmentIncome'),
@@ -46,6 +33,82 @@ const CATEGORY_LABELS = () => ({
'Sonstiges Einkommen': t('budget.catOtherIncome'),
});
+const SUBCATEGORY_I18N = () => ({
+ rent_mortgage: t('budget.subcatRentMortgage'),
+ condominium: t('budget.subcatCondominium'),
+ utilities: t('budget.subcatUtilities'),
+ internet_tv_phone: t('budget.subcatInternetTvPhone'),
+ renovation_maintenance: t('budget.subcatRenovationMaintenance'),
+ cleaning: t('budget.subcatCleaning'),
+ groceries: t('budget.subcatGroceries'),
+ restaurants_bars: t('budget.subcatRestaurantsBars'),
+ snacks_fast_food: t('budget.subcatSnacksFastFood'),
+ bakery: t('budget.subcatBakery'),
+ fuel: t('budget.subcatFuel'),
+ parking_tolls: t('budget.subcatParkingTolls'),
+ public_transport: t('budget.subcatPublicTransport'),
+ apps_taxi: t('budget.subcatAppsTaxi'),
+ maintenance_insurance: t('budget.subcatMaintenanceInsurance'),
+ pharmacy: t('budget.subcatPharmacy'),
+ health_insurance: t('budget.subcatHealthInsurance'),
+ gym_sports: t('budget.subcatGymSports'),
+ beauty_cosmetics: t('budget.subcatBeautyCosmetics'),
+ travel: t('budget.subcatTravel'),
+ streaming: t('budget.subcatStreaming'),
+ events: t('budget.subcatEvents'),
+ hobbies: t('budget.subcatHobbies'),
+ clothes_shoes: t('budget.subcatClothesShoes'),
+ electronics: t('budget.subcatElectronics'),
+ gifts: t('budget.subcatGifts'),
+ courses_college: t('budget.subcatCoursesCollege'),
+ school_supplies: t('budget.subcatSchoolSupplies'),
+ languages: t('budget.subcatLanguages'),
+ loans_interest: t('budget.subcatLoansInterest'),
+ bank_fees: t('budget.subcatBankFees'),
+ insurance_other: t('budget.subcatInsuranceOther'),
+ investments: t('budget.subcatInvestments'),
+ taxes: t('budget.subcatTaxes'),
+});
+
+function categoryLabel(category) {
+ const item = typeof category === 'object'
+ ? category
+ : [...expenseCategories(), ...incomeCategories()].find((c) => c.key === category);
+ const key = item?.key ?? category;
+ const name = item?.name ?? category;
+ return CATEGORY_I18N()[key] ?? name;
+}
+
+function subcategoryLabel(subcategory) {
+ const item = typeof subcategory === 'object'
+ ? subcategory
+ : Object.values(state.meta.expenseSubcategories ?? {}).flat().find((s) => s.key === subcategory);
+ const key = item?.key ?? subcategory;
+ const name = item?.name ?? subcategory;
+ return SUBCATEGORY_I18N()[key] ?? name;
+}
+
+function expenseCategories() {
+ return state.meta.expenseCategories ?? [];
+}
+
+function incomeCategories() {
+ return state.meta.incomeCategories ?? [];
+}
+
+function getSubcategories(category) {
+ return state.meta.expenseSubcategories?.[category] || [];
+}
+
+function defaultSubcategory(category) {
+ return getSubcategories(category)[0]?.key || '';
+}
+
+function defaultCategory(type) {
+ const cats = type === 'income' ? incomeCategories() : expenseCategories();
+ return cats[0]?.key || '';
+}
+
function getMonthName(monthIndex) {
// monthIndex: 0-based (0=Januar, 11=Dezember)
const date = new Date(2000, monthIndex, 1);
@@ -62,6 +125,7 @@ let state = {
summary: null,
prevSummary: null, // Vormonat für Monatsvergleich
currency: 'EUR',
+ meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
};
let _container = null;
@@ -110,6 +174,21 @@ async function loadMonth(month) {
}
}
+async function loadBudgetMeta() {
+ try {
+ const res = await api.get('/budget/meta');
+ state.meta = {
+ expenseCategories: res.data?.expenseCategories ?? [],
+ incomeCategories: res.data?.incomeCategories ?? [],
+ expenseSubcategories: res.data?.expenseSubcategories ?? {},
+ };
+ } catch (err) {
+ console.error('[Budget] meta Fehler:', err);
+ state.meta = { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} };
+ window.oikos?.showToast(t('budget.metaLoadError'), 'danger');
+ }
+}
+
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
@@ -120,7 +199,10 @@ export async function render(container, { user }) {
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
try {
- const prefsRes = await api.get('/preferences');
+ const [prefsRes] = await Promise.all([
+ api.get('/preferences'),
+ loadBudgetMeta(),
+ ]);
state.currency = prefsRes.data?.currency ?? 'EUR';
} catch (_) { /* Fallback auf EUR */ }
@@ -274,7 +356,7 @@ function renderCategoryBars(byCategory) {
return `
-
${esc(CATEGORY_LABELS()[c.category] ?? c.category)}
+
${esc(categoryLabel(c.category))}
@@ -305,13 +387,16 @@ function renderEntries() {
const sign = isIncome ? '+' : '';
const date = formatEntryDate(e.date);
const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : '');
+ const categoryMeta = isIncome || !e.subcategory
+ ? categoryLabel(e.category)
+ : `${categoryLabel(e.category)} · ${subcategoryLabel(e.subcategory)}`;
return `
${esc(e.title)}
-
${date} · ${esc(CATEGORY_LABELS()[e.category] ?? e.category)}${recurTag}
+
${date} · ${esc(categoryMeta)}${recurTag}
${sign}${formatAmount(e.amount)}
-
+
+
+
+
+
+
{
- const catLabels = CATEGORY_LABELS();
- const cats = currentType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
+ const updateCategoryOptions = (preferredCategory = '') => {
+ const cats = currentType === 'income' ? incomeCategories() : expenseCategories();
const catSelect = panel.querySelector('#bm-category');
- const currentValue = catSelect.value;
+ const currentValue = preferredCategory || catSelect.value;
const options = cats.map((c) => {
const opt = document.createElement('option');
- opt.value = c;
- opt.textContent = catLabels[c] || c;
- opt.selected = currentValue === c;
+ opt.value = c.key;
+ opt.textContent = categoryLabel(c);
+ opt.selected = currentValue === c.key;
return opt;
});
catSelect.replaceChildren(...options);
+ if (!cats.some((c) => c.key === catSelect.value)) catSelect.value = cats[0]?.key || '';
+ updateSubcategoryOptions();
+ };
+
+ const updateSubcategoryOptions = (preferredSubcategory = '') => {
+ const catSelect = panel.querySelector('#bm-category');
+ const subcatGroup = panel.querySelector('#bm-subcategory-group');
+ const subcatSelect = panel.querySelector('#bm-subcategory');
+ const subcategories = currentType === 'expense' ? getSubcategories(catSelect.value) : [];
+ const currentValue = preferredSubcategory || subcatSelect.value;
+
+ subcatGroup.hidden = currentType !== 'expense';
+ subcatSelect.replaceChildren(...subcategories.map((s) => {
+ const opt = document.createElement('option');
+ opt.value = s.key;
+ opt.textContent = subcategoryLabel(s);
+ opt.selected = currentValue === s.key;
+ return opt;
+ }));
+ if (subcategories.length && !subcategories.some((s) => s.key === subcatSelect.value)) {
+ subcatSelect.value = subcategories[0].key;
+ }
+ };
+
+ const addCategory = async () => {
+ const name = window.prompt(t('budget.newCategoryPrompt'));
+ if (!name?.trim()) return;
+ try {
+ const res = await api.post('/budget/categories', { name: name.trim(), type: currentType });
+ await loadBudgetMeta();
+ updateCategoryOptions(res.data.key);
+ window.oikos?.showToast(t('budget.categoryAddedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
+ }
+ };
+
+ const addSubcategory = async () => {
+ if (currentType !== 'expense') return;
+ const category = panel.querySelector('#bm-category').value;
+ if (!category) return;
+ const name = window.prompt(t('budget.newSubcategoryPrompt'));
+ if (!name?.trim()) return;
+ try {
+ const res = await api.post(`/budget/categories/${encodeURIComponent(category)}/subcategories`, { name: name.trim() });
+ await loadBudgetMeta();
+ updateSubcategoryOptions(res.data.key);
+ window.oikos?.showToast(t('budget.subcategoryAddedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
+ }
};
panel.querySelector('#type-expense').addEventListener('click', () => {
@@ -450,6 +600,9 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#type-expense').classList.remove('amount-type-btn--active');
updateCategoryOptions();
});
+ panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions());
+ panel.querySelector('#bm-add-category').addEventListener('click', addCategory);
+ panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory);
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
@@ -463,6 +616,7 @@ function openBudgetModal({ mode, entry = null }) {
const title = panel.querySelector('#bm-title').value.trim();
const absVal = parseFloat(panel.querySelector('#bm-amount').value);
const category = panel.querySelector('#bm-category').value;
+ const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').value : '';
const date = panel.querySelector('#bm-date').value;
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
@@ -476,7 +630,7 @@ function openBudgetModal({ mode, entry = null }) {
saveBtn.textContent = '…';
try {
- const body = { title, amount, category, date, is_recurring: recurring };
+ const body = { title, amount, category, subcategory, date, is_recurring: recurring };
if (mode === 'create') {
const res = await api.post('/budget', body);
state.entries.unshift(res.data);
@@ -523,4 +677,3 @@ async function deleteEntry(id) {
// --------------------------------------------------------
// Hilfsfunktion
// --------------------------------------------------------
-
diff --git a/public/styles/budget.css b/public/styles/budget.css
index c6b61dd..d9c0e3e 100644
--- a/public/styles/budget.css
+++ b/public/styles/budget.css
@@ -331,3 +331,20 @@
color: var(--color-text-on-accent);
}
+.budget-field-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ margin-bottom: var(--space-1);
+}
+
+.budget-field-header .form-label {
+ margin-bottom: 0;
+}
+
+.budget-inline-add {
+ min-height: auto;
+ padding: 2px var(--space-2);
+ font-size: var(--text-xs);
+}
diff --git a/public/sw.js b/public/sw.js
index 5772d64..96ab73d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -13,9 +13,9 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/
-const SHELL_CACHE = 'oikos-shell-v50';
-const PAGES_CACHE = 'oikos-pages-v45';
-const ASSETS_CACHE = 'oikos-assets-v45';
+const SHELL_CACHE = 'oikos-shell-v52';
+const PAGES_CACHE = 'oikos-pages-v47';
+const ASSETS_CACHE = 'oikos-assets-v47';
const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
diff --git a/server/auth.js b/server/auth.js
index ecb372c..31a62ec 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -136,7 +136,7 @@ function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
- res.status(401).json({ error: 'Nicht authentifiziert.', code: 401 });
+ res.status(401).json({ error: 'Not authenticated.', code: 401 });
}
/**
@@ -146,7 +146,7 @@ function requireAdmin(req, res, next) {
if (req.session && req.session.role === 'admin') {
return next();
}
- res.status(403).json({ error: 'Keine Berechtigung.', code: 403 });
+ res.status(403).json({ error: 'Permission denied.', code: 403 });
}
// --------------------------------------------------------
@@ -165,11 +165,11 @@ router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
- return res.status(400).json({ error: 'Benutzername und Passwort erforderlich.', code: 400 });
+ return res.status(400).json({ error: 'Username and password are required.', code: 400 });
}
if (username.length > 64 || password.length > 1024) {
- return res.status(400).json({ error: 'Eingabe zu lang.', code: 400 });
+ return res.status(400).json({ error: 'Input is too long.', code: 400 });
}
const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username);
@@ -177,18 +177,18 @@ router.post('/login', loginLimiter, async (req, res) => {
if (!user) {
// Timing-Attack-Schutz: trotzdem bcrypt ausführen
await bcrypt.compare(password, '$2b$12$invalidhashfortimingprotection000000000000000000000');
- return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
+ return res.status(401).json({ error: 'Invalid credentials.', code: 401 });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
- return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
+ return res.status(401).json({ error: 'Invalid credentials.', code: 401 });
}
req.session.regenerate((err) => {
if (err) {
- log.error('Session-Regenerierung fehlgeschlagen:', err);
- return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('Session regeneration failed:', err);
+ return res.status(500).json({ error: 'Internal server error.', code: 500 });
}
req.session.userId = user.id;
@@ -215,8 +215,8 @@ router.post('/login', loginLimiter, async (req, res) => {
});
});
} catch (err) {
- log.error('Login-Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('Login error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -227,8 +227,8 @@ router.post('/login', loginLimiter, async (req, res) => {
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
req.session.destroy((err) => {
if (err) {
- log.error('Logout-Fehler:', err);
- return res.status(500).json({ error: 'Logout fehlgeschlagen.', code: 500 });
+ log.error('Logout error:', err);
+ return res.status(500).json({ error: 'Logout failed.', code: 500 });
}
res.clearCookie('oikos.sid');
res.json({ ok: true });
@@ -246,7 +246,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
try {
const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get();
if (count > 0) {
- return res.status(403).json({ error: 'Setup bereits abgeschlossen.', code: 403 });
+ return res.status(403).json({ error: 'Setup has already been completed.', code: 403 });
}
const username = (req.body.username || '').trim();
@@ -254,16 +254,16 @@ router.post('/setup', loginLimiter, async (req, res) => {
const { password } = req.body;
if (!username || !display_name || !password) {
- return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 });
+ return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 });
}
if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
- return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 });
+ return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 });
}
if (display_name.length > 128) {
- return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 });
+ return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
}
if (password.length < 8) {
- return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 });
+ return res.status(400).json({ error: 'Password must be at least 8 characters long.', code: 400 });
}
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
@@ -278,10 +278,10 @@ router.post('/setup', loginLimiter, async (req, res) => {
});
} catch (err) {
if (err.message?.includes('UNIQUE constraint')) {
- return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 });
+ return res.status(409).json({ error: 'Username is already taken.', code: 409 });
}
log.error('Setup error:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -297,7 +297,7 @@ router.get('/me', requireAuth, (req, res) => {
if (!user) {
req.session.destroy(() => {});
- return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 });
+ return res.status(401).json({ error: 'User not found.', code: 401 });
}
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
@@ -315,8 +315,8 @@ router.get('/me', requireAuth, (req, res) => {
res.json({ user, csrfToken: req.session.csrfToken });
} catch (err) {
- log.error('/me Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('/me error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -332,8 +332,8 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
.all();
res.json({ data: users });
} catch (err) {
- log.error('Users-Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('Users error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -348,23 +348,23 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
if (!username || !display_name || !password) {
- return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 });
+ return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 });
}
if (password.length < 8) {
- return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 });
+ return res.status(400).json({ error: 'Password must be at least 8 characters long.', code: 400 });
}
if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
- return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 });
+ return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 });
}
if (display_name.length > 128) {
- return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 });
+ return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
}
if (!['admin', 'member'].includes(role)) {
- return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 });
+ return res.status(400).json({ error: 'Invalid role.', code: 400 });
}
const hash = await bcrypt.hash(password, 12);
@@ -381,10 +381,10 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
});
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
- return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 });
+ return res.status(409).json({ error: 'Username is already taken.', code: 409 });
}
- log.error('User-Erstellen-Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('User creation error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -399,17 +399,17 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
const { current_password, new_password } = req.body;
if (!current_password || !new_password) {
- return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich.', code: 400 });
+ return res.status(400).json({ error: 'Current and new password are required.', code: 400 });
}
if (new_password.length < 8) {
- return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.', code: 400 });
+ return res.status(400).json({ error: 'New password must be at least 8 characters long.', code: 400 });
}
const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId);
- if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
+ if (!user) return res.status(404).json({ error: 'User not found.', code: 404 });
const valid = await bcrypt.compare(current_password, user.password_hash);
- if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort falsch.', code: 401 });
+ if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 });
const hash = await bcrypt.hash(new_password, 12);
db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId);
@@ -429,8 +429,8 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
res.json({ ok: true });
} catch (err) {
- log.error('Passwort-Aendern-Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('Password change error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -444,13 +444,13 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
const userId = parseInt(req.params.id, 10);
if (userId === req.session.userId) {
- return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden.', code: 400 });
+ return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 });
}
const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
if (result.changes === 0) {
- return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'User not found.', code: 404 });
}
// Alle aktiven Sessions des geloeschten Users invalidieren
@@ -466,8 +466,8 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
res.json({ ok: true });
} catch (err) {
- log.error('User-Loeschen-Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('User deletion error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index 03f9024..e34e67c 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -121,6 +121,7 @@ const MIGRATIONS_SQL = {
title TEXT NOT NULL,
amount REAL NOT NULL,
category TEXT NOT NULL DEFAULT 'Sonstiges',
+ subcategory TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
@@ -128,6 +129,21 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
+ CREATE TABLE IF NOT EXISTS budget_categories (
+ key TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+ CREATE TABLE IF NOT EXISTS budget_subcategories (
+ key TEXT PRIMARY KEY,
+ category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ UNIQUE(category_key, name)
+ );
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
diff --git a/server/db.js b/server/db.js
index 5906850..4ef2853 100644
--- a/server/db.js
+++ b/server/db.js
@@ -41,7 +41,7 @@ function init() {
try {
db.prepare('SELECT count(*) FROM sqlite_master').get();
} catch {
- throw new Error('[DB] Falscher Verschlüsselungsschlüssel oder keine SQLCipher-Unterstützung.');
+ throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.');
}
}
@@ -52,7 +52,7 @@ function init() {
migrate();
- log.info(`Verbunden: ${DB_PATH} | Schema v${currentVersion()}`);
+ log.info(`Connected: ${DB_PATH} | Schema v${currentVersion()}`);
return db;
}
@@ -67,7 +67,7 @@ function init() {
const MIGRATIONS = [
{
version: 1,
- description: 'Initiales Schema',
+ description: 'Initial schema',
up: `
-- Benutzer
CREATE TABLE IF NOT EXISTS users (
@@ -269,7 +269,7 @@ const MIGRATIONS = [
},
{
version: 2,
- description: 'Sync-Konfigurationstabelle für Google/Apple Calendar',
+ description: 'Sync configuration table for Google/Apple Calendar',
up: `
CREATE TABLE IF NOT EXISTS sync_config (
key TEXT PRIMARY KEY,
@@ -282,7 +282,7 @@ const MIGRATIONS = [
},
{
version: 3,
- description: 'Wiederkehrende Budget-Einträge: parent-Referenz und Skip-Tabelle',
+ description: 'Recurring budget entries: parent reference and skip table',
up: `
ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER
REFERENCES budget_entries(id) ON DELETE SET NULL;
@@ -298,7 +298,7 @@ const MIGRATIONS = [
},
{
version: 4,
- description: 'Priorität "none" erlauben und als Default setzen',
+ description: 'Allow "none" priority and set it as default',
up: `
-- SQLite erlaubt kein ALTER CHECK, daher Tabelle neu erstellen
CREATE TABLE tasks_new (
@@ -333,7 +333,7 @@ const MIGRATIONS = [
},
{
version: 5,
- description: 'Einkaufskategorien als eigene Tabelle (anpassbar, sortierbar)',
+ description: 'Shopping categories as a separate table (customizable, sortable)',
up: `
CREATE TABLE IF NOT EXISTS shopping_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -357,21 +357,21 @@ const MIGRATIONS = [
},
{
version: 6,
- description: 'Rezept-URL für Mahlzeiten',
+ description: 'Recipe URL for meals',
up: `
ALTER TABLE meals ADD COLUMN recipe_url TEXT;
`,
},
{
version: 7,
- description: 'Kategorie pro Zutat für Einkaufslisten-Transfer',
+ description: 'Category per ingredient for shopping list transfer',
up: `
ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges';
`,
},
{
version: 8,
- description: 'Erinnerungen (Reminders) für Aufgaben und Kalender-Events',
+ description: 'Reminders for tasks and calendar events',
up: `
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -390,7 +390,7 @@ const MIGRATIONS = [
},
{
version: 9,
- description: 'Task-Kategorien auf englische Schlüssel migrieren',
+ description: 'Migrate task categories to English keys',
up: `
UPDATE tasks SET category = CASE category
WHEN 'Haushalt' THEN 'household'
@@ -407,7 +407,7 @@ const MIGRATIONS = [
},
{
version: 10,
- description: 'ICS-Abonnements Tabelle',
+ description: 'ICS subscriptions table',
up: `
CREATE TABLE IF NOT EXISTS ics_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -477,7 +477,7 @@ const MIGRATIONS = [
},
{
version: 12,
- description: 'calendar_events: partiellen Unique-Index durch vollständigen ersetzen (ON CONFLICT support)',
+ description: 'calendar_events: replace partial unique index with full index (ON CONFLICT support)',
up: `
DROP INDEX IF EXISTS idx_calendar_sub_extid;
CREATE UNIQUE INDEX idx_calendar_sub_extid
@@ -486,7 +486,7 @@ const MIGRATIONS = [
},
{
version: 13,
- description: 'Rezepte-Tabelle und Mahlzeiten-Verknuepfung',
+ description: 'Recipes table and meal association',
up: `
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -525,7 +525,7 @@ const MIGRATIONS = [
},
{
version: 14,
- description: 'Externe Kalender-Metadaten (Name, Farbe) und Verknüpfung mit Events',
+ description: 'External calendar metadata (name, color) and event association',
up: `
CREATE TABLE IF NOT EXISTS external_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -544,6 +544,133 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id);
`,
},
+ {
+ version: 15,
+ description: 'Budget expense categories as stable keys with subcategories',
+ up: `
+ ALTER TABLE budget_entries ADD COLUMN subcategory TEXT NOT NULL DEFAULT '';
+
+ UPDATE budget_entries
+ SET category = CASE category
+ WHEN 'Lebensmittel' THEN 'food'
+ WHEN 'Miete' THEN 'housing'
+ WHEN 'Versicherung' THEN 'financial_other'
+ WHEN 'Mobilität' THEN 'transport'
+ WHEN 'Freizeit' THEN 'leisure'
+ WHEN 'Kleidung' THEN 'shopping_clothing'
+ WHEN 'Gesundheit' THEN 'personal_health'
+ WHEN 'Bildung' THEN 'education'
+ WHEN 'Sonstiges' THEN 'financial_other'
+ ELSE category
+ END
+ WHERE amount < 0;
+
+ UPDATE budget_entries
+ SET subcategory = CASE category
+ WHEN 'housing' THEN 'rent_mortgage'
+ WHEN 'food' THEN 'groceries'
+ WHEN 'transport' THEN 'fuel'
+ WHEN 'personal_health' THEN 'pharmacy'
+ WHEN 'leisure' THEN 'events'
+ WHEN 'shopping_clothing' THEN 'clothes_shoes'
+ WHEN 'education' THEN 'courses_college'
+ WHEN 'financial_other' THEN 'insurance_other'
+ ELSE ''
+ END
+ WHERE amount < 0 AND subcategory = '';
+
+ UPDATE budget_entries
+ SET category = 'Sonstiges Einkommen'
+ WHERE amount > 0 AND category = 'Sonstiges';
+ `,
+ },
+ {
+ version: 16,
+ description: 'Move budget categories and subcategories to separate tables',
+ up: `
+ CREATE TABLE IF NOT EXISTS budget_categories (
+ key TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS budget_subcategories (
+ key TEXT PRIMARY KEY,
+ category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ UNIQUE(category_key, name)
+ );
+
+ INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) VALUES
+ ('housing', 'Housing / Home', 'expense', 0),
+ ('food', 'Food', 'expense', 1),
+ ('transport', 'Transport', 'expense', 2),
+ ('personal_health', 'Personal Care / Health', 'expense', 3),
+ ('leisure', 'Leisure and Entertainment', 'expense', 4),
+ ('shopping_clothing', 'Shopping and Clothing', 'expense', 5),
+ ('education', 'Education', 'expense', 6),
+ ('financial_other', 'Financial Services and Other', 'expense', 7),
+ ('Erwerbseinkommen', 'Erwerbseinkommen', 'income', 0),
+ ('Kapitalerträge', 'Kapitalerträge', 'income', 1),
+ ('Geschenke & Transfers', 'Geschenke & Transfers', 'income', 2),
+ ('Sozialleistungen', 'Sozialleistungen', 'income', 3),
+ ('Sonstiges Einkommen', 'Sonstiges Einkommen', 'income', 4);
+
+ INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) VALUES
+ ('rent_mortgage', 'housing', 'Rent / Mortgage', 0),
+ ('condominium', 'housing', 'Condominium fees', 1),
+ ('utilities', 'housing', 'Electricity / Water / Gas', 2),
+ ('internet_tv_phone', 'housing', 'Internet / TV / Phone', 3),
+ ('renovation_maintenance', 'housing', 'Renovation / Maintenance', 4),
+ ('cleaning', 'housing', 'Cleaning', 5),
+ ('groceries', 'food', 'Groceries', 0),
+ ('restaurants_bars', 'food', 'Restaurants / Bars', 1),
+ ('snacks_fast_food', 'food', 'Snacks / Fast Food', 2),
+ ('bakery', 'food', 'Bakery', 3),
+ ('fuel', 'transport', 'Fuel', 0),
+ ('parking_tolls', 'transport', 'Parking / Tolls', 1),
+ ('public_transport', 'transport', 'Public transport', 2),
+ ('apps_taxi', 'transport', 'Apps / Taxi', 3),
+ ('maintenance_insurance', 'transport', 'Maintenance / Insurance', 4),
+ ('pharmacy', 'personal_health', 'Pharmacy', 0),
+ ('health_insurance', 'personal_health', 'Health insurance', 1),
+ ('gym_sports', 'personal_health', 'Gym / Sports', 2),
+ ('beauty_cosmetics', 'personal_health', 'Beauty / Cosmetics', 3),
+ ('travel', 'leisure', 'Travel', 0),
+ ('streaming', 'leisure', 'Streaming', 1),
+ ('events', 'leisure', 'Events', 2),
+ ('hobbies', 'leisure', 'Hobbies', 3),
+ ('clothes_shoes', 'shopping_clothing', 'Clothes / Shoes', 0),
+ ('electronics', 'shopping_clothing', 'Electronics', 1),
+ ('gifts', 'shopping_clothing', 'Gifts', 2),
+ ('courses_college', 'education', 'Courses / College', 0),
+ ('school_supplies', 'education', 'School supplies', 1),
+ ('languages', 'education', 'Languages', 2),
+ ('loans_interest', 'financial_other', 'Loans / Interest', 0),
+ ('bank_fees', 'financial_other', 'Bank fees', 1),
+ ('insurance_other', 'financial_other', 'Insurance', 2),
+ ('investments', 'financial_other', 'Investments', 3),
+ ('taxes', 'financial_other', 'Taxes', 4);
+
+ INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order)
+ SELECT category, category, CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END, 1000
+ FROM budget_entries
+ WHERE category NOT IN (SELECT key FROM budget_categories)
+ GROUP BY category;
+
+ INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order)
+ SELECT subcategory, category, subcategory, 1000
+ FROM budget_entries
+ WHERE subcategory != ''
+ AND subcategory NOT IN (SELECT key FROM budget_subcategories)
+ AND category IN (SELECT key FROM budget_categories WHERE type = 'expense')
+ GROUP BY category, subcategory;
+ `,
+ },
];
/**
@@ -571,7 +698,7 @@ function migrate() {
db.exec(migration.up);
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
.run(migration.version, migration.description);
- log.info(`Migration ${migration.version} angewendet: ${migration.description}`);
+ log.info(`Migration ${migration.version} applied: ${migration.description}`);
});
for (const migration of pending) {
@@ -602,7 +729,7 @@ function currentVersion() {
* @returns {import('better-sqlite3').Database}
*/
function get() {
- if (!db) throw new Error('[DB] Nicht initialisiert - init() zuerst aufrufen.');
+ if (!db) throw new Error('[DB] Not initialized - call init() first.');
return db;
}
diff --git a/server/index.js b/server/index.js
index 8f49777..120d75b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -90,10 +90,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// JSON-Parse-Fehler abfangen (gibt sonst HTML zurück)
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') {
- return res.status(400).json({ error: 'Ungültiges JSON im Request-Body.', code: 400 });
+ return res.status(400).json({ error: 'Invalid JSON in request body.', code: 400 });
}
if (err.type === 'entity.too.large') {
- return res.status(413).json({ error: 'Request-Body zu groß (max. 1 MB).', code: 413 });
+ return res.status(413).json({ error: 'Request body too large (max. 1 MB).', code: 413 });
}
next(err);
});
@@ -151,7 +151,7 @@ const apiLimiter = rateLimit({
max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App)
standardHeaders: true,
legacyHeaders: false,
- message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 },
+ message: { error: 'Too many requests. Please wait a moment.', code: 429 },
skip: (req) => req.path === '/health', // Health-Check ausgenommen
});
app.use('/api/', apiLimiter);
@@ -198,7 +198,7 @@ const spaLimiter = rateLimit({
max: 200,
standardHeaders: true,
legacyHeaders: false,
- message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 },
+ message: { error: 'Too many requests. Please wait a moment.', code: 429 },
});
// --------------------------------------------------------
@@ -206,7 +206,7 @@ const spaLimiter = rateLimit({
// --------------------------------------------------------
app.get('/{*path}', spaLimiter, (req, res) => {
if (req.path.startsWith('/api/')) {
- return res.status(404).json({ error: 'Nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'Not found.', code: 404 });
}
res.sendFile(path.join(import.meta.dirname, '..', 'public', 'index.html'));
});
@@ -215,8 +215,8 @@ app.get('/{*path}', spaLimiter, (req, res) => {
// Globaler Error-Handler
// --------------------------------------------------------
app.use((err, req, res, _next) => {
- log.error('Unbehandelter Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('Unhandled error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
});
// --------------------------------------------------------
@@ -228,30 +228,30 @@ const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15)
async function runSync() {
const { connected: googleConnected } = googleCalendar.getStatus();
if (googleConnected) {
- googleCalendar.sync().catch((e) => logSync.error('Google Fehler:', e.message));
+ googleCalendar.sync().catch((e) => logSync.error('Google error:', e.message));
}
const { configured: appleConfigured } = appleCalendar.getStatus();
if (appleConfigured) {
- appleCalendar.sync().catch((e) => logSync.error('Apple Fehler:', e.message));
+ appleCalendar.sync().catch((e) => logSync.error('Apple error:', e.message));
}
// ICS: kein Guard nötig — sync() fragt die DB ab und kehrt sofort zurück wenn keine Abonnements existieren
- icsSubscription.sync().catch((e) => logSync.error('ICS Fehler:', e.message));
+ icsSubscription.sync().catch((e) => logSync.error('ICS error:', e.message));
}
// --------------------------------------------------------
// Server starten
// --------------------------------------------------------
app.listen(PORT, () => {
- logOikos.info(`Server laeuft auf Port ${PORT}`);
- logOikos.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`);
+ logOikos.info(`Server running on port ${PORT}`);
+ logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
setTimeout(() => {
runSync();
setInterval(runSync, SYNC_INTERVAL_MS);
- logSync.info(`Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`);
+ logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`);
}, 10_000);
});
diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js
index 472ecd8..82ea63b 100644
--- a/server/middleware/csrf.js
+++ b/server/middleware/csrf.js
@@ -71,7 +71,7 @@ function csrfMiddleware(req, res, next) {
}
if (!tokenValid) {
- return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 });
+ return res.status(403).json({ error: 'Invalid CSRF token.', code: 403 });
}
next();
diff --git a/server/middleware/validate.js b/server/middleware/validate.js
index dc6e861..ad1a46a 100644
--- a/server/middleware/validate.js
+++ b/server/middleware/validate.js
@@ -29,12 +29,12 @@ const RRULE_RE = /^(FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=\d{1,2})?(;BYDAY=[A
*/
function str(val, field, { max = MAX_TITLE, required = true } = {}) {
if (val === undefined || val === null || val === '') {
- if (required) return { value: null, error: `${field} ist erforderlich.` };
+ if (required) return { value: null, error: `${field} is required.` };
return { value: null, error: null };
}
const s = String(val).trim();
- if (required && !s) return { value: null, error: `${field} darf nicht leer sein.` };
- if (s.length > max) return { value: null, error: `${field} darf maximal ${max} Zeichen haben.` };
+ if (required && !s) return { value: null, error: `${field} must not be empty.` };
+ if (s.length > max) return { value: null, error: `${field} may be at most ${max} characters long.` };
return { value: s || null, error: null };
}
@@ -48,7 +48,7 @@ function str(val, field, { max = MAX_TITLE, required = true } = {}) {
function oneOf(val, allowed, field) {
if (val === undefined || val === null || val === '') return { value: null, error: null };
if (!allowed.includes(val))
- return { value: null, error: `${field} muss eines von: ${allowed.join(', ')} sein.` };
+ return { value: null, error: `${field} must be one of: ${allowed.join(', ')}.` };
return { value: val, error: null };
}
@@ -60,11 +60,11 @@ function oneOf(val, allowed, field) {
*/
function date(val, field, required = false) {
if (!val) {
- if (required) return { value: null, error: `${field} ist erforderlich.` };
+ if (required) return { value: null, error: `${field} is required.` };
return { value: null, error: null };
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(val)))
- return { value: null, error: `${field} muss im Format YYYY-MM-DD sein.` };
+ return { value: null, error: `${field} must be in YYYY-MM-DD format.` };
return { value: String(val), error: null };
}
@@ -74,7 +74,7 @@ function date(val, field, required = false) {
function time(val, field) {
if (!val) return { value: null, error: null };
if (!/^\d{2}:\d{2}$/.test(String(val)))
- return { value: null, error: `${field} muss im Format HH:MM sein.` };
+ return { value: null, error: `${field} must be in HH:MM format.` };
return { value: String(val), error: null };
}
@@ -83,11 +83,11 @@ function time(val, field) {
*/
function num(val, field, { required = false } = {}) {
if (val === undefined || val === null || val === '') {
- if (required) return { value: null, error: `${field} ist erforderlich.` };
+ if (required) return { value: null, error: `${field} is required.` };
return { value: null, error: null };
}
const n = Number(val);
- if (!isFinite(n)) return { value: null, error: `${field} muss eine gültige Zahl sein.` };
+ if (!isFinite(n)) return { value: null, error: `${field} must be a valid number.` };
return { value: n, error: null };
}
@@ -97,7 +97,7 @@ function num(val, field, { required = false } = {}) {
function color(val, field) {
if (!val) return { value: null, error: null };
if (!/^#[0-9A-Fa-f]{6}$/.test(String(val)))
- return { value: null, error: `${field} muss ein gültiger HEX-Farbwert sein (#RRGGBB).` };
+ return { value: null, error: `${field} must be a valid HEX color (#RRGGBB).` };
return { value: String(val), error: null };
}
@@ -115,11 +115,11 @@ function collectErrors(results) {
*/
function datetime(val, field, required = false) {
if (!val) {
- if (required) return { value: null, error: `${field} ist erforderlich.` };
+ if (required) return { value: null, error: `${field} is required.` };
return { value: null, error: null };
}
if (!DATETIME_RE.test(String(val)))
- return { value: null, error: `${field} muss im Format YYYY-MM-DD oder YYYY-MM-DDTHH:MM sein.` };
+ return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` };
return { value: String(val), error: null };
}
@@ -129,7 +129,7 @@ function datetime(val, field, required = false) {
function month(val, field) {
if (!val) return { value: null, error: null };
if (!MONTH_RE.test(String(val)))
- return { value: null, error: `${field} muss im Format YYYY-MM sein.` };
+ return { value: null, error: `${field} must be in YYYY-MM format.` };
return { value: String(val), error: null };
}
@@ -140,10 +140,10 @@ function rrule(val, field) {
if (!val) return { value: null, error: null };
const s = String(val).trim();
if (s.length > MAX_RRULE)
- return { value: null, error: `${field} darf maximal ${MAX_RRULE} Zeichen haben.` };
+ return { value: null, error: `${field} may be at most ${MAX_RRULE} characters long.` };
// Grundlegende Struktur: KEY=VALUE;KEY=VALUE
if (!/^FREQ=(DAILY|WEEKLY|MONTHLY)/.test(s))
- return { value: null, error: `${field}: Ungültige Wiederholungsregel.` };
+ return { value: null, error: `${field}: invalid recurrence rule.` };
return { value: s, error: null };
}
@@ -152,7 +152,7 @@ function rrule(val, field) {
*/
function id(val, field) {
const n = parseInt(val, 10);
- if (!n || n < 1) return { value: null, error: `${field} muss eine positive Zahl sein.` };
+ if (!n || n < 1) return { value: null, error: `${field} must be a positive number.` };
return { value: n, error: null };
}
diff --git a/server/routes/budget.js b/server/routes/budget.js
index c5bfdf4..304b3df 100644
--- a/server/routes/budget.js
+++ b/server/routes/budget.js
@@ -7,7 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
-import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } from '../middleware/validate.js';
+import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
const log = createLogger('Budget');
@@ -57,23 +57,87 @@ function generateRecurringInstances(database, month) {
database.prepare(`
INSERT INTO budget_entries
- (title, amount, category, date, is_recurring, recurrence_parent_id, created_by)
- VALUES (?, ?, ?, ?, 0, ?, ?)
- `).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by);
+ (title, amount, category, subcategory, date, is_recurring, recurrence_parent_id, created_by)
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?)
+ `).run(orig.title, orig.amount, orig.category, orig.subcategory || '', instanceDate, orig.id, orig.created_by);
}
}
-const EXPENSE_CATEGORIES = [
- 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
- 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
-];
+function slugify(value) {
+ return String(value || '')
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .slice(0, 48) || 'category';
+}
-const INCOME_CATEGORIES = [
- 'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers',
- 'Sozialleistungen', 'Sonstiges Einkommen',
-];
+function uniqueKey(table, base) {
+ const normalized = slugify(base);
+ let key = normalized;
+ let i = 2;
+ const exists = db.get().prepare(`SELECT 1 FROM ${table} WHERE key = ?`);
+ while (exists.get(key)) {
+ key = `${normalized}_${i}`;
+ i += 1;
+ }
+ return key;
+}
-const VALID_CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES];
+function loadBudgetMeta() {
+ const categories = db.get().prepare(`
+ SELECT key, name, type, sort_order
+ FROM budget_categories
+ ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC
+ `).all();
+ const subcategories = db.get().prepare(`
+ SELECT key, category_key, name, sort_order
+ FROM budget_subcategories
+ ORDER BY sort_order ASC, name COLLATE NOCASE ASC
+ `).all();
+
+ const expenseCategories = categories.filter((c) => c.type === 'expense');
+ const incomeCategories = categories.filter((c) => c.type === 'income');
+ const expenseSubcategories = {};
+ for (const sub of subcategories) {
+ if (!expenseSubcategories[sub.category_key]) expenseSubcategories[sub.category_key] = [];
+ expenseSubcategories[sub.category_key].push(sub);
+ }
+
+ return { categories, expenseCategories, incomeCategories, expenseSubcategories };
+}
+
+function validCategoryKeys() {
+ return db.get().prepare('SELECT key FROM budget_categories').all().map((c) => c.key);
+}
+
+function validExpenseCategoryKeys() {
+ return db.get().prepare("SELECT key FROM budget_categories WHERE type = 'expense'").all().map((c) => c.key);
+}
+
+function defaultCategory(type) {
+ const row = db.get().prepare(`
+ SELECT key FROM budget_categories WHERE type = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1
+ `).get(type);
+ return row?.key || (type === 'expense' ? 'financial_other' : 'Sonstiges Einkommen');
+}
+
+function defaultSubcategory(category) {
+ const row = db.get().prepare(`
+ SELECT key FROM budget_subcategories WHERE category_key = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1
+ `).get(category);
+ return row?.key || '';
+}
+
+function validateSubcategory(category, subcategory) {
+ if (!validExpenseCategoryKeys().includes(category)) return '';
+ if (!subcategory) return defaultSubcategory(category);
+ const row = db.get().prepare(`
+ SELECT 1 FROM budget_subcategories WHERE category_key = ? AND key = ?
+ `).get(category, subcategory);
+ return row ? subcategory : null;
+}
// --------------------------------------------------------
// Statische Routen vor /:id
@@ -127,7 +191,7 @@ router.get('/summary', (req, res) => {
});
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -155,7 +219,7 @@ router.get('/export', (req, res) => {
ORDER BY b.date ASC
`).all(from, to);
- const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
+ const header = 'Date,Title,Amount,Category,Subcategory,Recurring,Created by\n';
const csvSafe = (val) => {
let s = String(val || '').replace(/"/g, '""');
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
@@ -167,7 +231,8 @@ router.get('/export', (req, res) => {
csvSafe(e.title),
e.amount.toFixed(2).replace('.', ','),
e.category,
- e.is_recurring ? 'Ja' : 'Nein',
+ e.subcategory || '',
+ e.is_recurring ? 'Yes' : 'No',
csvSafe(e.creator_name),
].join(',')
).join('\n');
@@ -177,7 +242,7 @@ router.get('/export', (req, res) => {
res.send('\uFEFF' + header + rows); // BOM für Excel
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -187,7 +252,70 @@ router.get('/export', (req, res) => {
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
- res.json({ data: { categories: VALID_CATEGORIES } });
+ res.json({ data: loadBudgetMeta() });
+});
+
+router.post('/categories', (req, res) => {
+ try {
+ const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
+ const vType = oneOf(req.body.type || 'expense', ['expense', 'income'], 'Typ');
+ const errors = collectErrors([vName, vType]);
+ if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
+
+ const conflict = db.get().prepare(`
+ SELECT key FROM budget_categories WHERE type = ? AND name = ? COLLATE NOCASE
+ `).get(vType.value, vName.value);
+ if (conflict) return res.status(409).json({ error: 'Category already exists.', code: 409 });
+
+ const maxOrder = db.get().prepare(`
+ SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_categories WHERE type = ?
+ `).get(vType.value).m;
+ const key = uniqueKey('budget_categories', vName.value);
+
+ db.get().prepare(`
+ INSERT INTO budget_categories (key, name, type, sort_order) VALUES (?, ?, ?, ?)
+ `).run(key, vName.value, vType.value, maxOrder + 1);
+
+ const cat = db.get().prepare('SELECT key, name, type, sort_order FROM budget_categories WHERE key = ?').get(key);
+ res.status(201).json({ data: cat });
+ } catch (err) {
+ log.error('POST /categories error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
+ }
+});
+
+router.post('/categories/:categoryKey/subcategories', (req, res) => {
+ try {
+ const cat = db.get().prepare(`
+ SELECT * FROM budget_categories WHERE key = ? AND type = 'expense'
+ `).get(req.params.categoryKey);
+ if (!cat) return res.status(404).json({ error: 'Category not found.', code: 404 });
+
+ const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
+ if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
+
+ const conflict = db.get().prepare(`
+ SELECT key FROM budget_subcategories WHERE category_key = ? AND name = ? COLLATE NOCASE
+ `).get(cat.key, vName.value);
+ if (conflict) return res.status(409).json({ error: 'Subcategory already exists.', code: 409 });
+
+ const maxOrder = db.get().prepare(`
+ SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_subcategories WHERE category_key = ?
+ `).get(cat.key).m;
+ const key = uniqueKey('budget_subcategories', `${cat.key}_${vName.value}`);
+
+ db.get().prepare(`
+ INSERT INTO budget_subcategories (key, category_key, name, sort_order) VALUES (?, ?, ?, ?)
+ `).run(key, cat.key, vName.value, maxOrder + 1);
+
+ const sub = db.get().prepare(`
+ SELECT key, category_key, name, sort_order FROM budget_subcategories WHERE key = ?
+ `).get(key);
+ res.status(201).json({ data: sub });
+ } catch (err) {
+ log.error('POST /categories/:categoryKey/subcategories error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
+ }
});
// --------------------------------------------------------
@@ -220,7 +348,7 @@ router.get('/', (req, res) => {
`;
const params = [from, to];
- if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
+ if (req.query.category && validCategoryKeys().includes(req.query.category)) {
sql += ' AND b.category = ?';
params.push(req.query.category);
}
@@ -231,31 +359,36 @@ router.get('/', (req, res) => {
res.json({ data: entries });
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
/**
* POST /api/v1/budget
* Neuen Eintrag anlegen.
- * Body: { title, amount, category?, date, is_recurring?, recurrence_rule? }
+ * Body: { title, amount, category?, subcategory?, date, is_recurring?, recurrence_rule? }
* Response: { data: Entry }
*/
router.post('/', (req, res) => {
try {
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vAmount = num(req.body.amount, 'Betrag', { required: true });
- const vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie');
+ const fallbackCategory = defaultCategory(Number(req.body.amount) < 0 ? 'expense' : 'income');
+ const vCat = oneOf(req.body.category || fallbackCategory, validCategoryKeys(), 'Kategorie');
const vDate = validateDate(req.body.date, 'Datum', true);
const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
+ const subcategory = validateSubcategory(vCat.value, req.body.subcategory);
+ if (subcategory === null) {
+ return res.status(400).json({ error: 'Invalid subcategory.', code: 400 });
+ }
const result = db.get().prepare(`
- INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
- vTitle.value, vAmount.value, vCat.value || 'Sonstiges', vDate.value,
+ vTitle.value, vAmount.value, vCat.value || fallbackCategory, subcategory, vDate.value,
req.body.is_recurring ? 1 : 0, vRrule.value,
req.session.userId
);
@@ -269,7 +402,7 @@ router.post('/', (req, res) => {
res.status(201).json({ data: entry });
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -283,23 +416,31 @@ router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
- if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
+ if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 });
const checks = [];
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
if (req.body.amount !== undefined) checks.push(num(req.body.amount, 'Betrag'));
- if (req.body.category !== undefined) checks.push(oneOf(req.body.category, VALID_CATEGORIES, 'Kategorie'));
+ if (req.body.category !== undefined) checks.push(oneOf(req.body.category, validCategoryKeys(), 'Kategorie'));
if (req.body.date !== undefined) checks.push(validateDate(req.body.date, 'Datum'));
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
- const { title, amount, category, date, is_recurring, recurrence_rule } = req.body;
+ const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body;
+ const nextCategory = category ?? entry.category;
+ const subcategory = requestedSubcategory !== undefined || category !== undefined
+ ? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory)
+ : undefined;
+ if (subcategory === null) {
+ return res.status(400).json({ error: 'Invalid subcategory.', code: 400 });
+ }
db.get().prepare(`
UPDATE budget_entries
SET title = COALESCE(?, title),
amount = COALESCE(?, amount),
category = COALESCE(?, category),
+ subcategory = COALESCE(?, subcategory),
date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ?
@@ -308,6 +449,7 @@ router.put('/:id', (req, res) => {
title?.trim() ?? null,
amount !== undefined ? Number(amount) : null,
category ?? null,
+ subcategory !== undefined ? subcategory : null,
date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
@@ -322,7 +464,7 @@ router.put('/:id', (req, res) => {
res.json({ data: updated });
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -335,7 +477,7 @@ router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
- if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
+ if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 });
db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
@@ -350,7 +492,7 @@ router.delete('/:id', (req, res) => {
res.status(204).end();
} catch (err) {
log.error('', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index b499af2..77a13bc 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -257,7 +257,7 @@ router.get('/google/callback', async (req, res) => {
await googleCalendar.handleCallback(code);
// Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen)
- googleCalendar.sync().catch((e) => log.error('Initialer Sync fehlgeschlagen:', e.message));
+ googleCalendar.sync().catch((e) => log.error('Initial sync failed:', e.message));
res.redirect('/settings?sync_ok=google');
} catch (err) {
diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js
index 7e36483..9bb2f32 100644
--- a/server/routes/dashboard.js
+++ b/server/routes/dashboard.js
@@ -53,7 +53,7 @@ router.get('/', (req, res) => {
LIMIT 5
`).all(now.toISOString());
} catch (err) {
- log.error('upcomingEvents-Fehler:', err.message);
+ log.error('upcomingEvents error:', err.message);
result.upcomingEvents = [];
}
@@ -91,7 +91,7 @@ router.get('/', (req, res) => {
result.urgentTasks = allOpen.slice(0, 5);
} catch (err) {
- log.error('urgentTasks-Fehler:', err.message);
+ log.error('urgentTasks error:', err.message);
result.urgentTasks = [];
}
@@ -116,7 +116,7 @@ router.get('/', (req, res) => {
END
`).all(todayStr, ...visibleTypes);
} catch (err) {
- log.error('todayMeals-Fehler:', err.message);
+ log.error('todayMeals error:', err.message);
result.todayMeals = [];
}
@@ -130,7 +130,7 @@ router.get('/', (req, res) => {
LIMIT 3
`).all();
} catch (err) {
- log.error('pinnedNotes-Fehler:', err.message);
+ log.error('pinnedNotes error:', err.message);
result.pinnedNotes = [];
}
@@ -157,7 +157,7 @@ router.get('/', (req, res) => {
}
result.shoppingLists = lists;
} catch (err) {
- log.error('shoppingLists-Fehler:', err.message);
+ log.error('shoppingLists error:', err.message);
result.shoppingLists = [];
}
@@ -172,8 +172,8 @@ router.get('/', (req, res) => {
res.json(result);
} catch (err) {
- log.error('Kritischer Fehler:', err.message);
- res.status(500).json({ error: 'Dashboard konnte nicht geladen werden.', code: 500 });
+ log.error('Critical error:', err.message);
+ res.status(500).json({ error: 'Dashboard could not be loaded.', code: 500 });
}
});
diff --git a/server/routes/recipes.js b/server/routes/recipes.js
index e301bd3..17448d4 100644
--- a/server/routes/recipes.js
+++ b/server/routes/recipes.js
@@ -59,8 +59,8 @@ router.get('/', (_req, res) => {
res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) });
} catch (err) {
- log.error('GET / Fehler:', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ log.error('GET / error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -100,8 +100,8 @@ router.post('/', (req, res) => {
const created = loadRecipeWithIngredients(recipeId);
res.status(201).json({ data: created });
} catch (err) {
- log.error('POST / Fehler:', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ log.error('POST / error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
@@ -111,8 +111,8 @@ router.put('/:id', (req, res) => {
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
- if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
- if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
+ if (!existing) return res.status(404).json({ error: 'Recipe not found', code: 404 });
+ if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 });
const { ingredients = [] } = req.body;
@@ -147,27 +147,27 @@ router.put('/:id', (req, res) => {
const updated = loadRecipeWithIngredients(id);
res.json({ data: updated });
} catch (err) {
- log.error('PUT /:id Fehler:', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ log.error('PUT /:id error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
- if (!id) return res.status(400).json({ error: 'Ungültige Rezept-ID.', code: 400 });
+ if (!id) return res.status(400).json({ error: 'Invalid recipe ID.', code: 400 });
const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
- if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden.', code: 404 });
- if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
+ if (!existing) return res.status(404).json({ error: 'Recipe not found.', code: 404 });
+ if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 });
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
- if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
+ if (result.changes === 0) return res.status(404).json({ error: 'Recipe not found', code: 404 });
res.status(204).end();
} catch (err) {
- log.error('DELETE /:id Fehler:', err);
- res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ log.error('DELETE /:id error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
}
});
diff --git a/server/routes/reminders.js b/server/routes/reminders.js
index 00de8bc..e27c894 100644
--- a/server/routes/reminders.js
+++ b/server/routes/reminders.js
@@ -41,8 +41,8 @@ router.get('/pending', (req, res) => {
res.json({ data: rows });
} catch (err) {
- log.error('Fehler beim Laden fälliger Erinnerungen:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error loading due reminders:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
@@ -69,8 +69,8 @@ router.get('/', (req, res) => {
res.json({ data: row || null });
} catch (err) {
- log.error('Fehler beim Laden der Erinnerung:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error loading reminder:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
@@ -115,8 +115,8 @@ router.post('/', (req, res) => {
const row = db.get().prepare('SELECT * FROM reminders WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: row });
} catch (err) {
- log.error('Fehler beim Erstellen der Erinnerung:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error creating reminder:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
@@ -145,8 +145,8 @@ router.patch('/:id/dismiss', (req, res) => {
db.get().prepare('UPDATE reminders SET dismissed = 1 WHERE id = ?').run(reminderId);
res.json({ data: { id: reminderId } });
} catch (err) {
- log.error('Fehler beim Verwerfen der Erinnerung:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error dismissing reminder:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
@@ -175,8 +175,8 @@ router.delete('/:id', (req, res) => {
db.get().prepare('DELETE FROM reminders WHERE id = ?').run(reminderId);
res.status(204).end();
} catch (err) {
- log.error('Fehler beim Löschen der Erinnerung:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error deleting reminder:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
@@ -202,8 +202,8 @@ router.delete('/', (req, res) => {
res.status(204).end();
} catch (err) {
- log.error('Fehler beim Löschen der Erinnerungen:', err.message);
- res.status(500).json({ error: 'Interner Fehler.', code: 500 });
+ log.error('Error deleting reminders:', err.message);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
}
});
diff --git a/server/routes/shopping.js b/server/routes/shopping.js
index 6857a08..2d7fbae 100644
--- a/server/routes/shopping.js
+++ b/server/routes/shopping.js
@@ -39,8 +39,8 @@ router.get('/categories', (_req, res) => {
try {
res.json({ data: loadCategories() });
} catch (err) {
- log.error('GET /categories Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET /categories error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -58,7 +58,7 @@ router.post('/categories', (req, res) => {
const existing = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE')
.get(vName.value);
- if (existing) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
+ if (existing) return res.status(409).json({ error: 'Category already exists.', code: 409 });
const maxOrder = db.get()
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories')
@@ -73,8 +73,8 @@ router.post('/categories', (req, res) => {
.get(result.lastInsertRowid);
res.status(201).json({ data: cat });
} catch (err) {
- log.error('POST /categories Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('POST /categories error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -89,7 +89,7 @@ router.put('/categories/:catId', (req, res) => {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
- if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
+ if (!cat) return res.status(404).json({ error: 'Category not found.', code: 404 });
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
@@ -97,7 +97,7 @@ router.put('/categories/:catId', (req, res) => {
const conflict = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?')
.get(vName.value, cat.id);
- if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
+ if (conflict) return res.status(409).json({ error: 'Category already exists.', code: 409 });
// Artikel, die die alte Kategorie nutzen, mitumbenennen
db.get().transaction(() => {
@@ -114,8 +114,8 @@ router.put('/categories/:catId', (req, res) => {
.get(cat.id);
res.json({ data: updated });
} catch (err) {
- log.error('PUT /categories/:catId Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PUT /categories/:catId error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -130,12 +130,12 @@ router.delete('/categories/:catId', (req, res) => {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
- if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
+ if (!cat) return res.status(404).json({ error: 'Category not found.', code: 404 });
const total = db.get()
.prepare('SELECT COUNT(*) AS c FROM shopping_categories')
.get().c;
- if (total <= 1) return res.status(400).json({ error: 'Letzte Kategorie kann nicht gelöscht werden.', code: 400 });
+ if (total <= 1) return res.status(400).json({ error: 'The last category cannot be deleted.', code: 400 });
// Fallback-Kategorie: erste andere Kategorie nach sort_order
const fallback = db.get()
@@ -153,8 +153,8 @@ router.delete('/categories/:catId', (req, res) => {
res.json({ ok: true });
} catch (err) {
- log.error('DELETE /categories/:catId Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('DELETE /categories/:catId error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -177,8 +177,8 @@ router.patch('/categories/reorder', (req, res) => {
res.json({ data: loadCategories() });
} catch (err) {
- log.error('PATCH /categories/reorder Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PATCH /categories/reorder error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -201,8 +201,8 @@ router.get('/suggestions', (req, res) => {
res.json({ data: rows.map((r) => r.name) });
} catch (err) {
- log.error('suggestions Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('suggestions error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -217,7 +217,7 @@ router.patch('/items/:itemId', (req, res) => {
const item = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?')
.get(req.params.itemId);
- if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
+ if (!item) return res.status(404).json({ error: 'Item not found.', code: 404 });
const {
is_checked = item.is_checked,
@@ -230,7 +230,7 @@ router.patch('/items/:itemId', (req, res) => {
const validNames = validCategoryNames();
if (category && !validNames.includes(category))
- return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
+ return res.status(400).json({ error: 'Invalid category.', code: 400 });
db.get().prepare(`
UPDATE shopping_items
@@ -243,8 +243,8 @@ router.patch('/items/:itemId', (req, res) => {
.get(req.params.itemId);
res.json({ data: updated });
} catch (err) {
- log.error('PATCH items/:id Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PATCH items/:id error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -259,11 +259,11 @@ router.delete('/items/:itemId', (req, res) => {
.prepare('DELETE FROM shopping_items WHERE id = ?')
.run(req.params.itemId);
if (result.changes === 0)
- return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'Item not found.', code: 404 });
res.json({ ok: true });
} catch (err) {
- log.error('DELETE items/:id Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('DELETE items/:id error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -286,8 +286,8 @@ router.get('/', (req, res) => {
`).all();
res.json({ data: lists });
} catch (err) {
- log.error('GET / Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET / error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -311,8 +311,8 @@ router.post('/', (req, res) => {
.get(result.lastInsertRowid);
res.status(201).json({ data: list });
} catch (err) {
- log.error('POST / Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('POST / error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -331,15 +331,15 @@ router.put('/:listId', (req, res) => {
.prepare('UPDATE shopping_lists SET name = ? WHERE id = ?')
.run(vName.value, req.params.listId);
if (result.changes === 0)
- return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'List not found.', code: 404 });
const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
res.json({ data: list });
} catch (err) {
- log.error('PUT /:listId Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PUT /:listId error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -354,11 +354,11 @@ router.delete('/:listId', (req, res) => {
.prepare('DELETE FROM shopping_lists WHERE id = ?')
.run(req.params.listId);
if (result.changes === 0)
- return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'List not found.', code: 404 });
res.json({ ok: true });
} catch (err) {
- log.error('DELETE /:listId Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('DELETE /:listId error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -373,7 +373,7 @@ router.get('/:listId/items', (req, res) => {
const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
- if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
+ if (!list) return res.status(404).json({ error: 'List not found.', code: 404 });
const categories = loadCategories();
const categoryOrder = categories.map((c, i) => `WHEN '${c.name.replace(/'/g, "''")}' THEN ${i}`).join(' ');
@@ -389,8 +389,8 @@ router.get('/:listId/items', (req, res) => {
res.json({ data: items, list, categories });
} catch (err) {
- log.error('GET /:listId/items Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET /:listId/items error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -405,7 +405,7 @@ router.post('/:listId/items', (req, res) => {
const list = db.get()
.prepare('SELECT id FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
- if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
+ if (!list) return res.status(404).json({ error: 'List not found.', code: 404 });
const validNames = validCategoryNames();
const defaultCat = validNames[0] ?? 'Sonstiges';
@@ -427,8 +427,8 @@ router.post('/:listId/items', (req, res) => {
.get(result.lastInsertRowid);
res.status(201).json({ data: item });
} catch (err) {
- log.error('POST /:listId/items Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('POST /:listId/items error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -444,8 +444,8 @@ router.delete('/:listId/items/checked', (req, res) => {
`).run(req.params.listId);
res.json({ deleted: result.changes });
} catch (err) {
- log.error('DELETE /:listId/items/checked Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('DELETE /:listId/items/checked error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index 2950e1e..65afef5 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -103,8 +103,8 @@ router.get('/', (req, res) => {
res.json({ data: db.get().prepare(sql).all(...params) });
} catch (err) {
- log.error('GET / Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET / error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -122,13 +122,13 @@ router.get('/:id', (req, res) => {
WHERE t.id = ? AND t.parent_task_id IS NULL
`).get(req.params.id);
- if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
+ if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 });
task.subtasks = loadSubtasks(task.id);
res.json({ data: task });
} catch (err) {
- log.error('GET /:id Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET /:id error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -161,7 +161,7 @@ router.post('/', (req, res) => {
if (parent_task_id) {
const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?')
.get(parent_task_id);
- if (!parent) return res.status(404).json({ error: 'Übergeordnete Aufgabe nicht gefunden.', code: 404 });
+ if (!parent) return res.status(404).json({ error: 'Parent task not found.', code: 404 });
if (parent.parent_task_id)
return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 });
}
@@ -185,8 +185,8 @@ router.post('/', (req, res) => {
res.status(201).json({ data: task });
} catch (err) {
- log.error('POST / Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('POST / error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -200,7 +200,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
try {
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
- if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
+ if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 });
const errors = validateTaskInput(req.body, false);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
@@ -237,8 +237,8 @@ router.put('/:id', (req, res) => {
res.json({ data: updated });
} catch (err) {
- log.error('PUT /:id Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PUT /:id error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -252,13 +252,13 @@ router.patch('/:id/status', (req, res) => {
try {
const { status } = req.body;
if (!VALID_STATUSES.includes(status))
- return res.status(400).json({ error: `Ungültiger Status. Erlaubt: ${VALID_STATUSES.join(', ')}`, code: 400 });
+ return res.status(400).json({ error: `Invalid status. Allowed: ${VALID_STATUSES.join(', ')}`, code: 400 });
const result = db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?')
.run(status, req.params.id);
if (result.changes === 0)
- return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'Task not found.', code: 404 });
// Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
if (status === 'done') {
@@ -281,8 +281,8 @@ router.patch('/:id/status', (req, res) => {
res.json({ data: { id: Number(req.params.id), status } });
} catch (err) {
- log.error('PATCH /:id/status Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('PATCH /:id/status error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -295,11 +295,11 @@ router.delete('/:id', (req, res) => {
try {
const result = db.get().prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
if (result.changes === 0)
- return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
+ return res.status(404).json({ error: 'Task not found.', code: 404 });
res.json({ ok: true });
} catch (err) {
- log.error('DELETE /:id Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('DELETE /:id error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
@@ -315,8 +315,8 @@ router.get('/meta/options', (req, res) => {
).all();
res.json({ users, priorities: VALID_PRIORITIES, statuses: VALID_STATUSES, categories: VALID_CATEGORIES });
} catch (err) {
- log.error('GET /meta/options Fehler:', err);
- res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ log.error('GET /meta/options error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
diff --git a/server/routes/weather.js b/server/routes/weather.js
index bb59ea4..dbddf49 100644
--- a/server/routes/weather.js
+++ b/server/routes/weather.js
@@ -45,7 +45,7 @@ router.get('/', async (req, res) => {
const currentUrl = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}`;
const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) });
if (!currentRes.ok) {
- log.warn(`API Fehler: ${currentRes.status}`);
+ log.warn(`API error: ${currentRes.status}`);
return res.json({ data: null });
}
const currentJson = await currentRes.json();
@@ -116,7 +116,7 @@ router.get('/', async (req, res) => {
cache = { data, ts: Date.now() };
res.json({ data });
} catch (err) {
- log.warn('Fehler:', err.message);
+ log.warn('Error:', err.message);
res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen
}
});
@@ -145,8 +145,8 @@ router.get('/icon/:code', async (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 Stunden
upstream.body.pipe(res);
} catch (err) {
- log.warn('Icon-Proxy Fehler:', err.message);
- res.status(502).json({ error: 'Icon-Proxy fehlgeschlagen.', code: 502 });
+ log.warn('Icon proxy error:', err.message);
+ res.status(502).json({ error: 'Icon proxy failed.', code: 502 });
}
});
diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js
index b16d317..60ea995 100644
--- a/server/services/apple-calendar.js
+++ b/server/services/apple-calendar.js
@@ -80,7 +80,7 @@ function getCredentials() {
function saveCredentials(url, username, password) {
// Warnung wenn DB-Verschluesselung nicht aktiv - Credentials liegen dann im Klartext
if (!process.env.DB_ENCRYPTION_KEY) {
- log.warn('WARNUNG: DB_ENCRYPTION_KEY nicht gesetzt - CalDAV-Credentials werden unverschluesselt gespeichert.');
+ log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CalDAV credentials will be stored unencrypted.');
}
cfgSet('apple_caldav_url', url);
cfgSet('apple_username', username);
@@ -89,7 +89,7 @@ function saveCredentials(url, username, password) {
function clearCredentials() {
['apple_caldav_url', 'apple_username', 'apple_app_password', 'apple_last_sync'].forEach(cfgDel);
- log.info('Verbindung getrennt.');
+ log.info('Disconnected.');
}
// --------------------------------------------------------
@@ -110,7 +110,7 @@ function getStatus() {
*/
async function testConnection() {
const creds = getCredentials();
- if (!creds) throw new Error('[Apple] Keine Credentials konfiguriert.');
+ if (!creds) throw new Error('[Apple] No credentials configured.');
const { createDAVClient } = await import('tsdav');
const client = await createDAVClient({
@@ -121,7 +121,7 @@ async function testConnection() {
});
const calendars = await client.fetchCalendars();
- if (!calendars.length) throw new Error('[Apple] Verbunden, aber keine Kalender gefunden.');
+ if (!calendars.length) throw new Error('[Apple] Connected, but no calendars found.');
return { ok: true, calendarCount: calendars.length };
}
@@ -196,7 +196,7 @@ function unescapeICS(str) {
async function sync() {
const creds = getCredentials();
if (!creds) {
- throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
+ throw new Error('[Apple] No credentials configured (neither in DB nor in .env).');
}
// tsdav ist eine optionale Abhängigkeit - dynamischer Import für graceful degradation
@@ -211,14 +211,14 @@ async function sync() {
const calendars = await client.fetchCalendars();
if (!calendars.length) {
- log.warn('Keine Kalender gefunden.');
+ log.warn('No calendars found.');
return;
}
// created_by: ersten existierenden User verwenden (nicht hardcoded ID 1)
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
if (!owner) {
- log.warn('Kein User in der Datenbank - Sync übersprungen.');
+ log.warn('No user in database - sync skipped.');
return;
}
const createdBy = owner.id;
@@ -233,7 +233,7 @@ async function sync() {
try {
calObjects = await client.fetchCalendarObjects({ calendar: cal });
} catch (err) {
- log.warn(`Kalender "${cal.displayName || '(unbenannt)'}" nicht abrufbar: ${err.message}`);
+ log.warn(`Calendar "${cal.displayName || '(unnamed)'}" is not accessible: ${err.message}`);
continue;
}
@@ -277,7 +277,7 @@ async function sync() {
);
}
} catch (err) {
- log.error(`Upsert-Fehler für UID ${ev.uid}:`, err.message);
+ log.error(`Upsert error for UID ${ev.uid}:`, err.message);
}
}
}
@@ -308,12 +308,12 @@ async function sync() {
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ?
`).run(uid, event.id);
} catch (err) {
- log.error(`Outbound-Fehler für Event ${event.id}:`, err.message);
+ log.error(`Outbound error for event ${event.id}:`, err.message);
}
}
cfgSet('apple_last_sync', new Date().toISOString());
- log.info(`Sync abgeschlossen - ${totalObjects} Objekte aus ${syncCalendars.length} Kalendern inbound, ${localEvents.length} lokal → iCloud.`);
+ log.info(`Sync completed - ${totalObjects} objects from ${syncCalendars.length} calendars inbound, ${localEvents.length} local → iCloud.`);
}
export { sync, getStatus, saveCredentials, clearCredentials, testConnection };
diff --git a/server/services/google-calendar.js b/server/services/google-calendar.js
index 1f9d2a7..662b9f0 100644
--- a/server/services/google-calendar.js
+++ b/server/services/google-calendar.js
@@ -42,7 +42,7 @@ function createClient() {
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
- throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET und GOOGLE_REDIRECT_URI müssen gesetzt sein.');
+ throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI must be set.');
}
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
@@ -79,7 +79,7 @@ function loadAuthorizedClient() {
const refreshToken = cfgGet('google_refresh_token');
if (!accessToken || !refreshToken) {
- throw new Error('[Google] Nicht konfiguriert - zuerst OAuth durchführen.');
+ throw new Error('[Google] Not configured - complete OAuth first.');
}
const client = createClient();
@@ -133,14 +133,14 @@ async function handleCallback(code) {
const { tokens } = await client.getToken(code);
if (!tokens.refresh_token) {
- throw new Error('[Google] Kein Refresh Token erhalten. Bitte Zugriff in Google-Konto widerrufen und erneut verbinden.');
+ throw new Error('[Google] No refresh token received. Revoke access in your Google account and connect again.');
}
cfgSet('google_access_token', tokens.access_token);
cfgSet('google_refresh_token', tokens.refresh_token);
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
- log.info('OAuth erfolgreich - Tokens gespeichert.');
+ log.info('OAuth successful - tokens saved.');
}
/**
@@ -160,7 +160,7 @@ function getStatus() {
function disconnect() {
['google_access_token', 'google_refresh_token', 'google_token_expiry',
'google_sync_token', 'google_last_sync'].forEach(cfgDel);
- log.info('Verbindung getrennt.');
+ log.info('Disconnected.');
}
/**
@@ -181,7 +181,7 @@ async function sync() {
const calName = meta.data.summary || 'Google Calendar';
calRefId = upsertExternalCalendar('google', 'primary', calName, calColor);
} catch (err) {
- log.warn('Kalender-Metadaten nicht abrufbar:', err.message);
+ log.warn('Calendar metadata is not accessible:', err.message);
}
// --------------------------------------------------------
@@ -214,7 +214,7 @@ async function sync() {
} catch (err) {
if (err.code === 410) {
// syncToken abgelaufen → vollständiger Resync
- log.warn('syncToken ungültig - vollständiger Resync.');
+ log.warn('syncToken invalid - full resync.');
cfgDel('google_sync_token');
syncToken = null;
continue;
@@ -250,12 +250,12 @@ async function sync() {
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ?
`).run(created.data.id, event.id);
} catch (err) {
- log.error(`Outbound-Fehler für Event ${event.id}:`, err.message);
+ log.error(`Outbound error for event ${event.id}:`, err.message);
}
}
cfgSet('google_last_sync', new Date().toISOString());
- log.info(`Sync abgeschlossen - ${localEvents.length} lokal → Google, Inbound via syncToken.`);
+ log.info(`Sync completed - ${localEvents.length} local → Google, inbound via syncToken.`);
}
// --------------------------------------------------------
@@ -306,7 +306,7 @@ function upsertGoogleEvents(items, calRefId = null, calColor = GOOGLE_COLOR) {
try {
insertOrUpdate(item);
} catch (err) {
- log.error(`Upsert-Fehler für Event ${item.id}:`, err.message);
+ log.error(`Upsert error for event ${item.id}:`, err.message);
}
}
}
diff --git a/server/services/ics-subscription.js b/server/services/ics-subscription.js
index e670977..e2eb127 100644
--- a/server/services/ics-subscription.js
+++ b/server/services/ics-subscription.js
@@ -27,7 +27,7 @@ const syncingNow = new Set();
function normalizeUrl(raw) {
const url = new URL(raw.replace(/^webcal:\/\//i, 'https://'));
- if (url.protocol !== 'https:') throw new Error('Nur https:// und webcal:// URLs sind erlaubt.');
+ if (url.protocol !== 'https:') throw new Error('Only https:// and webcal:// URLs are allowed.');
return url.href;
}
@@ -37,7 +37,7 @@ async function checkSSRF(urlStr) {
const v6 = await dns.resolve6(hostname).catch(() => []);
for (const addr of [...v4, ...v6]) {
if (PRIVATE_RANGES.some((re) => re.test(addr))) {
- throw new Error(`URL löst auf eine private IP-Adresse auf: ${addr}`);
+ throw new Error(`URL resolves to a private IP address: ${addr}`);
}
}
}
@@ -61,12 +61,12 @@ async function fetchAndParse(urlRaw, etag, lastModified) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const cl = parseInt(res.headers.get('content-length') || '0', 10);
- if (cl > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.');
+ if (cl > MAX_RESPONSE_BYTES) throw new Error('ICS file exceeds the 10 MB limit.');
let body = '', received = 0;
for await (const chunk of res.body) {
received += chunk.length;
- if (received > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.');
+ if (received > MAX_RESPONSE_BYTES) throw new Error('ICS file exceeds the 10 MB limit.');
body += chunk.toString();
}
@@ -87,7 +87,7 @@ function syncWindow() {
async function syncOne(sub) {
if (syncingNow.has(sub.id)) {
- log.info(`Abonnement ${sub.id} wird bereits synchronisiert - übersprungen.`);
+ log.info(`Subscription ${sub.id} is already syncing - skipped.`);
return;
}
syncingNow.add(sub.id);
@@ -95,7 +95,7 @@ async function syncOne(sub) {
let result;
try { result = await fetchAndParse(sub.url, sub.etag, sub.last_modified); }
catch (err) {
- log.warn(`Abonnement ${sub.id} (${sub.name}): Fetch fehlgeschlagen - ${err.message}`);
+ log.warn(`Subscription ${sub.id} (${sub.name}): fetch failed - ${err.message}`);
return;
}
@@ -109,7 +109,7 @@ async function syncOne(sub) {
const { windowStart, windowEnd } = syncWindow();
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
const createdBy = sub.created_by ?? owner?.id;
- if (!createdBy) { log.warn('Kein User gefunden.'); return; }
+ if (!createdBy) { log.warn('No user found.'); return; }
const flatEvents = [];
for (const ev of events) {
@@ -157,7 +157,7 @@ async function syncOne(sub) {
.run(new Date().toISOString(), newEtag, newLastModified, sub.id);
})();
- log.info(`Abonnement ${sub.id} (${sub.name}): ${flatEvents.length} Events synchronisiert.`);
+ log.info(`Subscription ${sub.id} (${sub.name}): ${flatEvents.length} events synced.`);
} finally { syncingNow.delete(sub.id); }
}
@@ -167,7 +167,7 @@ async function sync(subscriptionId) {
: db.get().prepare('SELECT * FROM ics_subscriptions').all();
for (const sub of subs) {
try { await syncOne(sub); }
- catch (err) { log.error(`Sync Abonnement ${sub.id} fehlgeschlagen: ${err.message}`); }
+ catch (err) { log.error(`Subscription ${sub.id} sync failed: ${err.message}`); }
}
}
@@ -192,7 +192,7 @@ async function create(userId, { name, url, color, shared }) {
function update(userId, subId, fields, isAdmin) {
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return null;
- if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.');
+ if (!isAdmin && sub.created_by !== userId) throw new Error('Not authorized.');
const name = fields.name !== undefined ? fields.name : sub.name;
const color = fields.color !== undefined ? fields.color : sub.color;
const shared = fields.shared !== undefined ? (fields.shared ? 1 : 0) : sub.shared;
@@ -204,7 +204,7 @@ function update(userId, subId, fields, isAdmin) {
function remove(userId, subId, isAdmin) {
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return false;
- if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.');
+ if (!isAdmin && sub.created_by !== userId) throw new Error('Not authorized.');
db.get().prepare('DELETE FROM ics_subscriptions WHERE id = ?').run(subId);
return true;
}
diff --git a/setup.js b/setup.js
index 28b59bb..d300f86 100644
--- a/setup.js
+++ b/setup.js
@@ -69,45 +69,45 @@ async function main() {
.get();
if (existingAdmin) {
- console.log('ℹ Es existiert bereits ein Admin-Account.\n');
- const proceed = await prompt('Trotzdem einen weiteren Admin anlegen? (j/N): ');
- if (proceed.toLowerCase() !== 'j') {
- console.log('Setup abgebrochen.');
+ console.log('ℹ An admin account already exists.\n');
+ const proceed = await prompt('Create another admin anyway? (y/N): ');
+ if (proceed.toLowerCase() !== 'y') {
+ console.log('Setup cancelled.');
rl.close();
process.exit(0);
}
}
- console.log('Admin-Account anlegen:\n');
+ console.log('Create admin account:\n');
- const username = (await prompt('Benutzername: ')).trim();
+ const username = (await prompt('Username: ')).trim();
if (!username || username.length < 3) {
- console.error('Fehler: Benutzername muss mindestens 3 Zeichen lang sein.');
+ console.error('Error: username must be at least 3 characters long.');
process.exit(1);
}
- const displayName = (await prompt('Anzeigename (z.B. "Max Mustermann"): ')).trim();
+ const displayName = (await prompt('Display name (e.g. "Max Mustermann"): ')).trim();
if (!displayName) {
- console.error('Fehler: Anzeigename darf nicht leer sein.');
+ console.error('Error: display name must not be empty.');
process.exit(1);
}
- const password = await promptPassword('Passwort: ');
+ const password = await promptPassword('Password: ');
if (password.length < 8) {
- console.error('Fehler: Passwort muss mindestens 8 Zeichen lang sein.');
+ console.error('Error: password must be at least 8 characters long.');
process.exit(1);
}
- const passwordConfirm = await promptPassword('Passwort bestätigen: ');
+ const passwordConfirm = await promptPassword('Confirm password: ');
if (password !== passwordConfirm) {
- console.error('Fehler: Passwörter stimmen nicht überein.');
+ console.error('Error: passwords do not match.');
process.exit(1);
}
const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55'];
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
- console.log('\nAccount wird erstellt …');
+ console.log('\nCreating account …');
const hash = await bcrypt.hash(password, 12);
@@ -122,23 +122,23 @@ async function main() {
const port = process.env.PORT || 3000;
const host = getLocalIP();
- console.log(`\n✅ Admin-Account erfolgreich erstellt!`);
+ console.log(`\n✅ Admin account created successfully!`);
console.log(`${'─'.repeat(40)}`);
- console.log(` Benutzername: ${username}`);
- console.log(` Anzeigename: ${displayName}`);
- console.log(` Rolle: Admin`);
+ console.log(` Username: ${username}`);
+ console.log(` Display name: ${displayName}`);
+ console.log(` Role: Admin`);
console.log(`${'─'.repeat(40)}`);
- console.log(`\n🌐 Oikos ist erreichbar unter:\n`);
- console.log(` Lokal: http://localhost:${port}`);
+ console.log(`\n🌐 Oikos is available at:\n`);
+ console.log(` Local: http://localhost:${port}`);
if (host) {
- console.log(` Netzwerk: http://${host}:${port}`);
+ console.log(` Network: http://${host}:${port}`);
}
- console.log(`\n Melde dich mit deinem neuen Account an. Viel Spaß!\n`);
+ console.log(`\n Sign in with your new account.\n`);
} catch (err) {
if (err.message?.includes('UNIQUE constraint')) {
- console.error(`\nFehler: Benutzername "${username}" ist bereits vergeben.`);
+ console.error(`\nError: username "${username}" is already taken.`);
} else {
- console.error('\nFehler beim Erstellen:', err.message);
+ console.error('\nCreation error:', err.message);
}
process.exit(1);
}
@@ -148,6 +148,6 @@ async function main() {
}
main().catch((err) => {
- console.error('Unerwarteter Fehler:', err.message);
+ console.error('Unexpected error:', err.message);
process.exit(1);
});
diff --git a/test-api.js b/test-api.js
index 776f6da..206a5f9 100644
--- a/test-api.js
+++ b/test-api.js
@@ -43,7 +43,7 @@ function setup() {
test('auth.login: 401 feuert kein auth:expired', async () => {
setup();
- _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 });
+ _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 });
await assert.rejects(
() => auth.login('user', 'wrong'),
@@ -60,7 +60,7 @@ test('auth.login: 401 feuert kein auth:expired', async () => {
test('auth.login: 401 wirft ApiError mit status 401', async () => {
setup();
- _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 });
+ _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 });
let thrownErr;
try {
@@ -77,7 +77,7 @@ test('auth.login: 401 wirft ApiError mit status 401', async () => {
test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => {
setup();
- _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 });
+ _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 });
await assert.rejects(() => api.get('/tasks'));
@@ -87,7 +87,7 @@ test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => {
test('api.post: 401 auf Logout-Endpunkt feuert auth:expired', async () => {
setup();
- _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 });
+ _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 });
await assert.rejects(() => api.post('/auth/logout', {}));
diff --git a/test-notes-contacts-budget.js b/test-notes-contacts-budget.js
index 0e14785..a2cb3e1 100644
--- a/test-notes-contacts-budget.js
+++ b/test-notes-contacts-budget.js
@@ -178,30 +178,30 @@ console.log('\n[Budget-Test] Einnahmen, Ausgaben, Saldo, Aggregation, CSV-Vorber
let bId1, bId2, bId3, bId4;
-test('Ausgabe eintragen (Lebensmittel)', () => {
- const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
- VALUES ('REWE', -85.40, 'Lebensmittel', '2026-03-10', ?)`).run(uid);
+test('Ausgabe eintragen (Supermarkt)', () => {
+ const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('REWE', -85.40, 'food', 'groceries', '2026-03-10', ?)`).run(uid);
bId1 = r.lastInsertRowid;
assert(bId1 > 0);
});
test('Einnahme eintragen (Gehalt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
- VALUES ('Gehalt März', 2800.00, 'Sonstiges', '2026-03-01', ?)`).run(uid);
+ VALUES ('Gehalt März', 2800.00, 'Sonstiges Einkommen', '2026-03-01', ?)`).run(uid);
bId2 = r.lastInsertRowid;
assert(bId2 > 0);
});
-test('Ausgabe (Miete)', () => {
- const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, is_recurring, created_by)
- VALUES ('Miete', -950.00, 'Miete', '2026-03-01', 1, ?)`).run(uid);
+test('Ausgabe (Aluguel / Prestação)', () => {
+ const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by)
+ VALUES ('Miete', -950.00, 'housing', 'rent_mortgage', '2026-03-01', 1, ?)`).run(uid);
bId3 = r.lastInsertRowid;
assert(bId3 > 0);
});
test('Ausgabe im anderen Monat (April)', () => {
- const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
- VALUES ('Strom April', -55.00, 'Sonstiges', '2026-04-15', ?)`).run(uid);
+ const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Strom April', -55.00, 'housing', 'utilities', '2026-04-15', ?)`).run(uid);
bId4 = r.lastInsertRowid;
assert(bId4 > 0);
});
@@ -258,12 +258,18 @@ test('Aggregation nach Kategorie', () => {
GROUP BY category ORDER BY ABS(SUM(amount)) DESC
`).all();
assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`);
- // Miete sollte die größte Ausgabe sein
- const miete = cats.find((c) => c.category === 'Miete');
- assert(miete, 'Miete in Kategorien vorhanden');
+ // Housing should be the largest expense category.
+ const miete = cats.find((c) => c.category === 'housing');
+ assert(miete, 'Housing in Kategorien vorhanden');
assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`);
});
+test('Unterkategorie gespeichert', () => {
+ const r = db.prepare('SELECT category, subcategory FROM budget_entries WHERE id = ?').get(bId1);
+ assert(r.category === 'food', `Kategorie: ${r.category}`);
+ assert(r.subcategory === 'groceries', `Unterkategorie: ${r.subcategory}`);
+});
+
test('Wiederkehrend-Flag korrekt', () => {
const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3);
assert(r.is_recurring === 1, 'Miete ist wiederkehrend');