feat: budget subcategories, customizable categories, and English log messages (#86)

- Budget categories and subcategories moved to database tables (migration 15+16)
- Expense categories migrated from German strings to stable English slugs
- New `/budget/categories` and `/budget/categories/:key/subcategories` POST endpoints
- Budget modal now shows subcategory selector with inline add-new buttons
- Added 35 predefined subcategories across 8 expense categories (housing, food,
  transport, personal_health, leisure, shopping_clothing, education, financial_other)
- CSV export updated to include subcategory column and English headers
- All server log messages and error strings translated to English
- i18n: all 14 non-German locales extended with new budget category/subcategory keys
- Service worker cache version bumped (v52/v47)

Contributed by @rafaelfoster

Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-25 17:01:15 +02:00
38 changed files with 1551 additions and 385 deletions
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "التحويلات والهدايا", "catTransferGiftIncome": "التحويلات والهدايا",
"catGovernmentBenefits": "المزايا الاجتماعية", "catGovernmentBenefits": "المزايا الاجتماعية",
"catOtherIncome": "دخل آخر", "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": { "settings": {
"title": "الإعدادات", "title": "الإعدادات",
+50 -3
View File
@@ -469,11 +469,11 @@
"trendNeutral": "- wie {{month}}", "trendNeutral": "- wie {{month}}",
"validAmountRequired": "Gültigen Betrag eingeben", "validAmountRequired": "Gültigen Betrag eingeben",
"dateRequired": "Datum ist erforderlich", "dateRequired": "Datum ist erforderlich",
"catFood": "Lebensmittel", "catFood": "Ernährung",
"catRent": "Miete", "catRent": "Miete",
"catInsurance": "Versicherung", "catInsurance": "Versicherung",
"catMobility": "Mobilität", "catMobility": "Mobilität",
"catLeisure": "Freizeit", "catLeisure": "Freizeit und Unterhaltung",
"catClothing": "Kleidung", "catClothing": "Kleidung",
"catHealth": "Gesundheit", "catHealth": "Gesundheit",
"catEducation": "Bildung", "catEducation": "Bildung",
@@ -483,7 +483,54 @@
"catTransferGiftIncome": "Geschenke & Transfers", "catTransferGiftIncome": "Geschenke & Transfers",
"catGovernmentBenefits": "Sozialleistungen", "catGovernmentBenefits": "Sozialleistungen",
"catOtherIncome": "Sonstiges Einkommen", "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": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Μεταφορές και δώρα", "catTransferGiftIncome": "Μεταφορές και δώρα",
"catGovernmentBenefits": "Κοινωνικές παροχές", "catGovernmentBenefits": "Κοινωνικές παροχές",
"catOtherIncome": "Άλλο εισόδημα", "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": { "settings": {
"title": "Ρυθμίσεις", "title": "Ρυθμίσεις",
+50 -3
View File
@@ -463,11 +463,11 @@
"trendNeutral": "- same as {{month}}", "trendNeutral": "- same as {{month}}",
"validAmountRequired": "Please enter a valid amount", "validAmountRequired": "Please enter a valid amount",
"dateRequired": "Date is required", "dateRequired": "Date is required",
"catFood": "Groceries", "catFood": "Food",
"catRent": "Rent", "catRent": "Rent",
"catInsurance": "Insurance", "catInsurance": "Insurance",
"catMobility": "Transport", "catMobility": "Transport",
"catLeisure": "Leisure", "catLeisure": "Leisure and Entertainment",
"catClothing": "Clothing", "catClothing": "Clothing",
"catHealth": "Health", "catHealth": "Health",
"catEducation": "Education", "catEducation": "Education",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transfer & Gift Income", "catTransferGiftIncome": "Transfer & Gift Income",
"catGovernmentBenefits": "Government & Social Benefits", "catGovernmentBenefits": "Government & Social Benefits",
"catOtherIncome": "Other Income", "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": { "settings": {
"title": "Settings", "title": "Settings",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Alquiler", "catRent": "Alquiler",
"catInsurance": "Seguro", "catInsurance": "Seguro",
"catMobility": "Movilidad", "catMobility": "Movilidad",
"catLeisure": "Ocio", "catLeisure": "Ocio y entretenimiento",
"catClothing": "Ropa", "catClothing": "Ropa",
"catHealth": "Salud", "catHealth": "Salud",
"catEducation": "Educación", "catEducation": "Educación",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferencias y Regalos", "catTransferGiftIncome": "Transferencias y Regalos",
"catGovernmentBenefits": "Prestaciones Sociales", "catGovernmentBenefits": "Prestaciones Sociales",
"catOtherIncome": "Otros Ingresos", "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": { "settings": {
"title": "Ajustes", "title": "Ajustes",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Loyer", "catRent": "Loyer",
"catInsurance": "Assurance", "catInsurance": "Assurance",
"catMobility": "Transport", "catMobility": "Transport",
"catLeisure": "Loisirs", "catLeisure": "Loisirs et divertissement",
"catClothing": "Vêtements", "catClothing": "Vêtements",
"catHealth": "Santé", "catHealth": "Santé",
"catEducation": "Éducation", "catEducation": "Éducation",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferts et Cadeaux", "catTransferGiftIncome": "Transferts et Cadeaux",
"catGovernmentBenefits": "Allocations Sociales", "catGovernmentBenefits": "Allocations Sociales",
"catOtherIncome": "Autres Revenus", "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": { "settings": {
"title": "Paramètres", "title": "Paramètres",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "स्थानांतरण और उपहार", "catTransferGiftIncome": "स्थानांतरण और उपहार",
"catGovernmentBenefits": "सामाजिक लाभ", "catGovernmentBenefits": "सामाजिक लाभ",
"catOtherIncome": "अन्य आय", "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": { "settings": {
"title": "सेटिंग्स", "title": "सेटिंग्स",
+76 -29
View File
@@ -43,7 +43,7 @@
"main": "Navigazione principale", "main": "Navigazione principale",
"navigation": "Navigazione", "navigation": "Navigazione",
"quickActions": "Azioni rapide", "quickActions": "Azioni rapide",
"recipes": "Ricette", "recipes": "Recipes",
"more": "Altro" "more": "Altro"
}, },
"dashboard": { "dashboard": {
@@ -255,10 +255,10 @@
"recipeUrlLabel": "Link ricetta (opzionale)", "recipeUrlLabel": "Link ricetta (opzionale)",
"recipeUrlPlaceholder": "https://…", "recipeUrlPlaceholder": "https://…",
"openRecipe": "Apri ricetta", "openRecipe": "Apri ricetta",
"savedRecipeLabel": "Ricette salvate", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Seleziona ricetta", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Salva come ricetta", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scala ingredienti" "recipeScaleLabel": "Scale ingredients"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -463,11 +463,11 @@
"trendNeutral": "- come {{month}}", "trendNeutral": "- come {{month}}",
"validAmountRequired": "Inserisci un importo valido", "validAmountRequired": "Inserisci un importo valido",
"dateRequired": "La data è obbligatoria", "dateRequired": "La data è obbligatoria",
"catFood": "Spesa alimentare", "catFood": "Alimentazione",
"catRent": "Affitto", "catRent": "Affitto",
"catInsurance": "Assicurazione", "catInsurance": "Assicurazione",
"catMobility": "Trasporti", "catMobility": "Trasporti",
"catLeisure": "Tempo libero", "catLeisure": "Tempo libero e intrattenimento",
"catClothing": "Abbigliamento", "catClothing": "Abbigliamento",
"catHealth": "Salute", "catHealth": "Salute",
"catEducation": "Istruzione", "catEducation": "Istruzione",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Trasferimenti e Regali", "catTransferGiftIncome": "Trasferimenti e Regali",
"catGovernmentBenefits": "Prestazioni Sociali", "catGovernmentBenefits": "Prestazioni Sociali",
"catOtherIncome": "Altro Reddito", "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": { "settings": {
"title": "Impostazioni", "title": "Impostazioni",
@@ -659,28 +706,28 @@
"unitMonths": "mesi" "unitMonths": "mesi"
}, },
"recipes": { "recipes": {
"title": "Ricette", "title": "Recipes",
"addRecipe": "Aggiungi ricetta", "addRecipe": "Add recipe",
"editRecipe": "Modifica ricetta", "editRecipe": "Edit recipe",
"emptyTitle": "Nessuna ricetta", "emptyTitle": "No recipes yet",
"emptyDescription": "Salva le tue ricette preferite e riutilizzale nella pianificazione dei pasti.", "emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
"titleLabel": "Titolo *", "titleLabel": "Title *",
"titlePlaceholder": "es. Pasta Carbonara", "titlePlaceholder": "e.g. Pasta Carbonara",
"notesLabel": "Note", "notesLabel": "Notes",
"notesPlaceholder": "Opzionale...", "notesPlaceholder": "Optional...",
"urlLabel": "Link della ricetta", "urlLabel": "Recipe link",
"urlPlaceholder": "https://...", "urlPlaceholder": "https://...",
"ingredientsLabel": "Ingredienti", "ingredientsLabel": "Ingredients",
"addToMeals": "Aggiungi al piano alimentare", "addToMeals": "Add to meal plan",
"openLink": "Apri il link della ricetta", "openLink": "Open recipe link",
"deleteConfirm": "Eliminare la ricetta \"{{title}}\"?", "deleteConfirm": "Delete recipe \"{{title}}\"?",
"created": "Ricetta salvata.", "created": "Recipe saved.",
"updated": "Ricetta aggiornata.", "updated": "Recipe updated.",
"deleted": "Ricetta eliminata.", "deleted": "Recipe deleted.",
"titleRequired": "È richiesto il titolo", "titleRequired": "Title is required",
"duplicate": "Duplicato", "duplicate": "Duplicate",
"duplicated": "Ricetta duplicata.", "duplicated": "Recipe duplicated.",
"copySuffix": "copia" "copySuffix": "copy"
}, },
"search": { "search": {
"title": "Ricerca", "title": "Ricerca",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "譲渡・贈与", "catTransferGiftIncome": "譲渡・贈与",
"catGovernmentBenefits": "社会保障給付", "catGovernmentBenefits": "社会保障給付",
"catOtherIncome": "その他の収入", "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": { "settings": {
"title": "設定", "title": "設定",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Aluguel", "catRent": "Aluguel",
"catInsurance": "Seguro", "catInsurance": "Seguro",
"catMobility": "Transporte", "catMobility": "Transporte",
"catLeisure": "Lazer", "catLeisure": "Lazer e Entretenimento",
"catClothing": "Roupas", "catClothing": "Roupas",
"catHealth": "Saúde", "catHealth": "Saúde",
"catEducation": "Educação", "catEducation": "Educação",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferências e Presentes", "catTransferGiftIncome": "Transferências e Presentes",
"catGovernmentBenefits": "Benefícios Sociais", "catGovernmentBenefits": "Benefícios Sociais",
"catOtherIncome": "Outras Rendas", "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": { "settings": {
"title": "Configurações", "title": "Configurações",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Переводы и подарки", "catTransferGiftIncome": "Переводы и подарки",
"catGovernmentBenefits": "Социальные пособия", "catGovernmentBenefits": "Социальные пособия",
"catOtherIncome": "Прочие доходы", "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": { "settings": {
"title": "Настройки", "title": "Настройки",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Överföringar och gåvor", "catTransferGiftIncome": "Överföringar och gåvor",
"catGovernmentBenefits": "Socialförmåner", "catGovernmentBenefits": "Socialförmåner",
"catOtherIncome": "Övrig inkomst", "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": { "settings": {
"title": "Inställningar", "title": "Inställningar",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferler ve Hediyeler", "catTransferGiftIncome": "Transferler ve Hediyeler",
"catGovernmentBenefits": "Sosyal Yardımlar", "catGovernmentBenefits": "Sosyal Yardımlar",
"catOtherIncome": "Diğer Gelir", "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": { "settings": {
"title": "Ayarlar", "title": "Ayarlar",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Переводи та подарунки", "catTransferGiftIncome": "Переводи та подарунки",
"catGovernmentBenefits": "Соціальні виплати", "catGovernmentBenefits": "Соціальні виплати",
"catOtherIncome": "Інші доходи", "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": { "settings": {
"title": "Налаштування", "title": "Налаштування",
+48 -1
View File
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "转账和礼物", "catTransferGiftIncome": "转账和礼物",
"catGovernmentBenefits": "社会福利", "catGovernmentBenefits": "社会福利",
"catOtherIncome": "其他收入", "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": { "settings": {
"title": "设置", "title": "设置",
+191 -38
View File
@@ -15,29 +15,16 @@ import { esc } from '/utils/html.js';
// Konstanten // Konstanten
// -------------------------------------------------------- // --------------------------------------------------------
const EXPENSE_CATEGORIES = [ const CATEGORY_I18N = () => ({
'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 = () => ({
// Expense categories // Expense categories
'Lebensmittel': t('budget.catFood'), housing: t('budget.catHousing'),
'Miete': t('budget.catRent'), food: t('budget.catFood'),
'Versicherung': t('budget.catInsurance'), transport: t('budget.catTransport'),
'Mobilität': t('budget.catMobility'), personal_health: t('budget.catPersonalHealth'),
'Freizeit': t('budget.catLeisure'), leisure: t('budget.catLeisure'),
'Kleidung': t('budget.catClothing'), shopping_clothing: t('budget.catShoppingClothing'),
'Gesundheit': t('budget.catHealth'), education: t('budget.catEducation'),
'Bildung': t('budget.catEducation'), financial_other: t('budget.catFinancialOther'),
'Sonstiges': t('budget.catMisc'),
// Income categories // Income categories
'Erwerbseinkommen': t('budget.catEarnedIncome'), 'Erwerbseinkommen': t('budget.catEarnedIncome'),
'Kapitalerträge': t('budget.catInvestmentIncome'), 'Kapitalerträge': t('budget.catInvestmentIncome'),
@@ -46,6 +33,82 @@ const CATEGORY_LABELS = () => ({
'Sonstiges Einkommen': t('budget.catOtherIncome'), '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) { function getMonthName(monthIndex) {
// monthIndex: 0-based (0=Januar, 11=Dezember) // monthIndex: 0-based (0=Januar, 11=Dezember)
const date = new Date(2000, monthIndex, 1); const date = new Date(2000, monthIndex, 1);
@@ -62,6 +125,7 @@ let state = {
summary: null, summary: null,
prevSummary: null, // Vormonat für Monatsvergleich prevSummary: null, // Vormonat für Monatsvergleich
currency: 'EUR', currency: 'EUR',
meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
}; };
let _container = null; 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 // Entry Point
// -------------------------------------------------------- // --------------------------------------------------------
@@ -120,7 +199,10 @@ export async function render(container, { user }) {
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
try { try {
const prefsRes = await api.get('/preferences'); const [prefsRes] = await Promise.all([
api.get('/preferences'),
loadBudgetMeta(),
]);
state.currency = prefsRes.data?.currency ?? 'EUR'; state.currency = prefsRes.data?.currency ?? 'EUR';
} catch (_) { /* Fallback auf EUR */ } } catch (_) { /* Fallback auf EUR */ }
@@ -274,7 +356,7 @@ function renderCategoryBars(byCategory) {
return ` return `
<div class="budget-bar-row"> <div class="budget-bar-row">
<div class="budget-bar-row__label" title="${esc(CATEGORY_LABELS()[c.category] ?? c.category)}">${esc(CATEGORY_LABELS()[c.category] ?? c.category)}</div> <div class="budget-bar-row__label" title="${esc(categoryLabel(c.category))}">${esc(categoryLabel(c.category))}</div>
<div class="budget-bar-row__track"> <div class="budget-bar-row__track">
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div> <div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
</div> </div>
@@ -305,13 +387,16 @@ function renderEntries() {
const sign = isIncome ? '+' : ''; const sign = isIncome ? '+' : '';
const date = formatEntryDate(e.date); const date = formatEntryDate(e.date);
const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : ''); const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : '');
const categoryMeta = isIncome || !e.subcategory
? categoryLabel(e.category)
: `${categoryLabel(e.category)} · ${subcategoryLabel(e.subcategory)}`;
return ` return `
<div class="budget-entry" data-id="${e.id}"> <div class="budget-entry" data-id="${e.id}">
<div class="budget-entry__indicator ${indClass}"></div> <div class="budget-entry__indicator ${indClass}"></div>
<div class="budget-entry__body"> <div class="budget-entry__body">
<div class="budget-entry__title">${esc(e.title)}</div> <div class="budget-entry__title">${esc(e.title)}</div>
<div class="budget-entry__meta">${date} · ${esc(CATEGORY_LABELS()[e.category] ?? e.category)}${recurTag}</div> <div class="budget-entry__meta">${date} · ${esc(categoryMeta)}${recurTag}</div>
</div> </div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div> <div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}"> <button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
@@ -359,10 +444,14 @@ function openBudgetModal({ mode, entry = null }) {
const isExpense = isEdit ? entry.amount < 0 : true; const isExpense = isEdit ? entry.amount < 0 : true;
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
const catLabels = CATEGORY_LABELS(); const initialCats = isExpense ? expenseCategories() : incomeCategories();
const initialCats = isExpense ? EXPENSE_CATEGORIES : INCOME_CATEGORIES;
const catOpts = initialCats.map((c) => const catOpts = initialCats.map((c) =>
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${catLabels[c] || c}</option>` `<option value="${esc(c.key)}" ${isEdit && entry.category === c.key ? 'selected' : ''}>${esc(categoryLabel(c))}</option>`
).join('');
const initialCategory = isEdit ? entry.category : initialCats[0]?.key;
const initialSubcategory = isEdit ? entry.subcategory : defaultSubcategory(initialCategory);
const subcatOpts = getSubcategories(initialCategory).map((s) =>
`<option value="${esc(s.key)}" ${initialSubcategory === s.key ? 'selected' : ''}>${esc(subcategoryLabel(s))}</option>`
).join(''); ).join('');
const content = ` const content = `
@@ -387,10 +476,21 @@ function openBudgetModal({ mode, entry = null }) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label> <div class="budget-field-header">
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label>
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-category">${t('budget.addCategory')}</button>
</div>
<select class="form-input" id="bm-category">${catOpts}</select> <select class="form-input" id="bm-category">${catOpts}</select>
</div> </div>
<div class="form-group" id="bm-subcategory-group" ${isExpense ? '' : 'hidden'}>
<div class="budget-field-header">
<label class="form-label" for="bm-subcategory">${t('budget.subcategoryLabel')}</label>
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-subcategory">${t('budget.addSubcategory')}</button>
</div>
<select class="form-input" id="bm-subcategory">${subcatOpts}</select>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label> <label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
<input type="date" class="form-input" id="bm-date" <input type="date" class="form-input" id="bm-date"
@@ -422,20 +522,70 @@ function openBudgetModal({ mode, entry = null }) {
onSave(panel) { onSave(panel) {
let currentType = isExpense ? 'expense' : 'income'; let currentType = isExpense ? 'expense' : 'income';
const updateCategoryOptions = () => { const updateCategoryOptions = (preferredCategory = '') => {
const catLabels = CATEGORY_LABELS(); const cats = currentType === 'income' ? incomeCategories() : expenseCategories();
const cats = currentType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
const catSelect = panel.querySelector('#bm-category'); const catSelect = panel.querySelector('#bm-category');
const currentValue = catSelect.value; const currentValue = preferredCategory || catSelect.value;
const options = cats.map((c) => { const options = cats.map((c) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c; opt.value = c.key;
opt.textContent = catLabels[c] || c; opt.textContent = categoryLabel(c);
opt.selected = currentValue === c; opt.selected = currentValue === c.key;
return opt; return opt;
}); });
catSelect.replaceChildren(...options); 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', () => { 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'); panel.querySelector('#type-expense').classList.remove('amount-type-btn--active');
updateCategoryOptions(); 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); 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 title = panel.querySelector('#bm-title').value.trim();
const absVal = parseFloat(panel.querySelector('#bm-amount').value); const absVal = parseFloat(panel.querySelector('#bm-amount').value);
const category = panel.querySelector('#bm-category').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 date = panel.querySelector('#bm-date').value;
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
@@ -476,7 +630,7 @@ function openBudgetModal({ mode, entry = null }) {
saveBtn.textContent = '…'; saveBtn.textContent = '…';
try { try {
const body = { title, amount, category, date, is_recurring: recurring }; const body = { title, amount, category, subcategory, date, is_recurring: recurring };
if (mode === 'create') { if (mode === 'create') {
const res = await api.post('/budget', body); const res = await api.post('/budget', body);
state.entries.unshift(res.data); state.entries.unshift(res.data);
@@ -523,4 +677,3 @@ async function deleteEntry(id) {
// -------------------------------------------------------- // --------------------------------------------------------
// Hilfsfunktion // Hilfsfunktion
// -------------------------------------------------------- // --------------------------------------------------------
+17
View File
@@ -331,3 +331,20 @@
color: var(--color-text-on-accent); 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);
}
+3 -3
View File
@@ -13,9 +13,9 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/ */
const SHELL_CACHE = 'oikos-shell-v50'; const SHELL_CACHE = 'oikos-shell-v52';
const PAGES_CACHE = 'oikos-pages-v45'; const PAGES_CACHE = 'oikos-pages-v47';
const ASSETS_CACHE = 'oikos-assets-v45'; const ASSETS_CACHE = 'oikos-assets-v47';
const BYPASS_CACHE = 'oikos-bypass-flag'; const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
+42 -42
View File
@@ -136,7 +136,7 @@ function requireAuth(req, res, next) {
if (req.session && req.session.userId) { if (req.session && req.session.userId) {
return next(); 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') { if (req.session && req.session.role === 'admin') {
return next(); 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; const { username, password } = req.body;
if (!username || !password) { 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) { 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); 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) { if (!user) {
// Timing-Attack-Schutz: trotzdem bcrypt ausführen // Timing-Attack-Schutz: trotzdem bcrypt ausführen
await bcrypt.compare(password, '$2b$12$invalidhashfortimingprotection000000000000000000000'); 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); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) { 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) => { req.session.regenerate((err) => {
if (err) { if (err) {
log.error('Session-Regenerierung fehlgeschlagen:', err); log.error('Session regeneration failed:', err);
return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); return res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
req.session.userId = user.id; req.session.userId = user.id;
@@ -215,8 +215,8 @@ router.post('/login', loginLimiter, async (req, res) => {
}); });
}); });
} catch (err) { } catch (err) {
log.error('Login-Fehler:', err); log.error('Login error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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) => { router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
req.session.destroy((err) => { req.session.destroy((err) => {
if (err) { if (err) {
log.error('Logout-Fehler:', err); log.error('Logout error:', err);
return res.status(500).json({ error: 'Logout fehlgeschlagen.', code: 500 }); return res.status(500).json({ error: 'Logout failed.', code: 500 });
} }
res.clearCookie('oikos.sid'); res.clearCookie('oikos.sid');
res.json({ ok: true }); res.json({ ok: true });
@@ -246,7 +246,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
try { try {
const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get(); const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get();
if (count > 0) { 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(); const username = (req.body.username || '').trim();
@@ -254,16 +254,16 @@ router.post('/setup', loginLimiter, async (req, res) => {
const { password } = req.body; const { password } = req.body;
if (!username || !display_name || !password) { 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)) { 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) { 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) { 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)]; const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
@@ -278,10 +278,10 @@ router.post('/setup', loginLimiter, async (req, res) => {
}); });
} catch (err) { } catch (err) {
if (err.message?.includes('UNIQUE constraint')) { 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); 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) { if (!user) {
req.session.destroy(() => {}); 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: // 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 }); res.json({ user, csrfToken: req.session.csrfToken });
} catch (err) { } catch (err) {
log.error('/me Fehler:', err); log.error('/me error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -332,8 +332,8 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
.all(); .all();
res.json({ data: users }); res.json({ data: users });
} catch (err) { } catch (err) {
log.error('Users-Fehler:', err); log.error('Users error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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; const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
if (!username || !display_name || !password) { 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) { 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)) { 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) { 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)) { 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); const hash = await bcrypt.hash(password, 12);
@@ -381,10 +381,10 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
}); });
} catch (err) { } catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) { 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); log.error('User creation error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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; const { current_password, new_password } = req.body;
if (!current_password || !new_password) { 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) { 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); 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); 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); const hash = await bcrypt.hash(new_password, 12);
db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId); 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('Passwort-Aendern-Fehler:', err); log.error('Password change error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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); const userId = parseInt(req.params.id, 10);
if (userId === req.session.userId) { 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); const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
if (result.changes === 0) { 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 // Alle aktiven Sessions des geloeschten Users invalidieren
@@ -466,8 +466,8 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('User-Loeschen-Fehler:', err); log.error('User deletion error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
+16
View File
@@ -121,6 +121,7 @@ const MIGRATIONS_SQL = {
title TEXT NOT NULL, title TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
category TEXT NOT NULL DEFAULT 'Sonstiges', category TEXT NOT NULL DEFAULT 'Sonstiges',
subcategory TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL, date TEXT NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0, is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT, recurrence_rule TEXT,
@@ -128,6 +129,21 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 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')) 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 CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW 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; BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
+144 -17
View File
@@ -41,7 +41,7 @@ function init() {
try { try {
db.prepare('SELECT count(*) FROM sqlite_master').get(); db.prepare('SELECT count(*) FROM sqlite_master').get();
} catch { } 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(); migrate();
log.info(`Verbunden: ${DB_PATH} | Schema v${currentVersion()}`); log.info(`Connected: ${DB_PATH} | Schema v${currentVersion()}`);
return db; return db;
} }
@@ -67,7 +67,7 @@ function init() {
const MIGRATIONS = [ const MIGRATIONS = [
{ {
version: 1, version: 1,
description: 'Initiales Schema', description: 'Initial schema',
up: ` up: `
-- Benutzer -- Benutzer
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -269,7 +269,7 @@ const MIGRATIONS = [
}, },
{ {
version: 2, version: 2,
description: 'Sync-Konfigurationstabelle für Google/Apple Calendar', description: 'Sync configuration table for Google/Apple Calendar',
up: ` up: `
CREATE TABLE IF NOT EXISTS sync_config ( CREATE TABLE IF NOT EXISTS sync_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -282,7 +282,7 @@ const MIGRATIONS = [
}, },
{ {
version: 3, version: 3,
description: 'Wiederkehrende Budget-Einträge: parent-Referenz und Skip-Tabelle', description: 'Recurring budget entries: parent reference and skip table',
up: ` up: `
ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER
REFERENCES budget_entries(id) ON DELETE SET NULL; REFERENCES budget_entries(id) ON DELETE SET NULL;
@@ -298,7 +298,7 @@ const MIGRATIONS = [
}, },
{ {
version: 4, version: 4,
description: 'Priorität "none" erlauben und als Default setzen', description: 'Allow "none" priority and set it as default',
up: ` up: `
-- SQLite erlaubt kein ALTER CHECK, daher Tabelle neu erstellen -- SQLite erlaubt kein ALTER CHECK, daher Tabelle neu erstellen
CREATE TABLE tasks_new ( CREATE TABLE tasks_new (
@@ -333,7 +333,7 @@ const MIGRATIONS = [
}, },
{ {
version: 5, version: 5,
description: 'Einkaufskategorien als eigene Tabelle (anpassbar, sortierbar)', description: 'Shopping categories as a separate table (customizable, sortable)',
up: ` up: `
CREATE TABLE IF NOT EXISTS shopping_categories ( CREATE TABLE IF NOT EXISTS shopping_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -357,21 +357,21 @@ const MIGRATIONS = [
}, },
{ {
version: 6, version: 6,
description: 'Rezept-URL für Mahlzeiten', description: 'Recipe URL for meals',
up: ` up: `
ALTER TABLE meals ADD COLUMN recipe_url TEXT; ALTER TABLE meals ADD COLUMN recipe_url TEXT;
`, `,
}, },
{ {
version: 7, version: 7,
description: 'Kategorie pro Zutat für Einkaufslisten-Transfer', description: 'Category per ingredient for shopping list transfer',
up: ` up: `
ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges'; ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges';
`, `,
}, },
{ {
version: 8, version: 8,
description: 'Erinnerungen (Reminders) für Aufgaben und Kalender-Events', description: 'Reminders for tasks and calendar events',
up: ` up: `
CREATE TABLE IF NOT EXISTS reminders ( CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -390,7 +390,7 @@ const MIGRATIONS = [
}, },
{ {
version: 9, version: 9,
description: 'Task-Kategorien auf englische Schlüssel migrieren', description: 'Migrate task categories to English keys',
up: ` up: `
UPDATE tasks SET category = CASE category UPDATE tasks SET category = CASE category
WHEN 'Haushalt' THEN 'household' WHEN 'Haushalt' THEN 'household'
@@ -407,7 +407,7 @@ const MIGRATIONS = [
}, },
{ {
version: 10, version: 10,
description: 'ICS-Abonnements Tabelle', description: 'ICS subscriptions table',
up: ` up: `
CREATE TABLE IF NOT EXISTS ics_subscriptions ( CREATE TABLE IF NOT EXISTS ics_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -477,7 +477,7 @@ const MIGRATIONS = [
}, },
{ {
version: 12, 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: ` up: `
DROP INDEX IF EXISTS idx_calendar_sub_extid; DROP INDEX IF EXISTS idx_calendar_sub_extid;
CREATE UNIQUE INDEX idx_calendar_sub_extid CREATE UNIQUE INDEX idx_calendar_sub_extid
@@ -486,7 +486,7 @@ const MIGRATIONS = [
}, },
{ {
version: 13, version: 13,
description: 'Rezepte-Tabelle und Mahlzeiten-Verknuepfung', description: 'Recipes table and meal association',
up: ` up: `
CREATE TABLE IF NOT EXISTS recipes ( CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -525,7 +525,7 @@ const MIGRATIONS = [
}, },
{ {
version: 14, version: 14,
description: 'Externe Kalender-Metadaten (Name, Farbe) und Verknüpfung mit Events', description: 'External calendar metadata (name, color) and event association',
up: ` up: `
CREATE TABLE IF NOT EXISTS external_calendars ( CREATE TABLE IF NOT EXISTS external_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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); 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.exec(migration.up);
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)') db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
.run(migration.version, migration.description); .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) { for (const migration of pending) {
@@ -602,7 +729,7 @@ function currentVersion() {
* @returns {import('better-sqlite3').Database} * @returns {import('better-sqlite3').Database}
*/ */
function get() { 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; return db;
} }
+13 -13
View File
@@ -90,10 +90,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// JSON-Parse-Fehler abfangen (gibt sonst HTML zurück) // JSON-Parse-Fehler abfangen (gibt sonst HTML zurück)
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') { 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') { 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); next(err);
}); });
@@ -151,7 +151,7 @@ const apiLimiter = rateLimit({
max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App) max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App)
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, 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 skip: (req) => req.path === '/health', // Health-Check ausgenommen
}); });
app.use('/api/', apiLimiter); app.use('/api/', apiLimiter);
@@ -198,7 +198,7 @@ const spaLimiter = rateLimit({
max: 200, max: 200,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, 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) => { app.get('/{*path}', spaLimiter, (req, res) => {
if (req.path.startsWith('/api/')) { 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')); res.sendFile(path.join(import.meta.dirname, '..', 'public', 'index.html'));
}); });
@@ -215,8 +215,8 @@ app.get('/{*path}', spaLimiter, (req, res) => {
// Globaler Error-Handler // Globaler Error-Handler
// -------------------------------------------------------- // --------------------------------------------------------
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
log.error('Unbehandelter Fehler:', err); log.error('Unhandled error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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() { async function runSync() {
const { connected: googleConnected } = googleCalendar.getStatus(); const { connected: googleConnected } = googleCalendar.getStatus();
if (googleConnected) { 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(); const { configured: appleConfigured } = appleCalendar.getStatus();
if (appleConfigured) { 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 // 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 // Server starten
// -------------------------------------------------------- // --------------------------------------------------------
app.listen(PORT, () => { app.listen(PORT, () => {
logOikos.info(`Server laeuft auf Port ${PORT}`); logOikos.info(`Server running on port ${PORT}`);
logOikos.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`); logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert) // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
setTimeout(() => { setTimeout(() => {
runSync(); runSync();
setInterval(runSync, SYNC_INTERVAL_MS); 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); }, 10_000);
}); });
+1 -1
View File
@@ -71,7 +71,7 @@ function csrfMiddleware(req, res, next) {
} }
if (!tokenValid) { 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(); next();
+16 -16
View File
@@ -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 } = {}) { function str(val, field, { max = MAX_TITLE, required = true } = {}) {
if (val === undefined || val === null || val === '') { 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 }; return { value: null, error: null };
} }
const s = String(val).trim(); const s = String(val).trim();
if (required && !s) return { value: null, error: `${field} darf nicht leer sein.` }; if (required && !s) return { value: null, error: `${field} must not be empty.` };
if (s.length > max) return { value: null, error: `${field} darf maximal ${max} Zeichen haben.` }; if (s.length > max) return { value: null, error: `${field} may be at most ${max} characters long.` };
return { value: s || null, error: null }; return { value: s || null, error: null };
} }
@@ -48,7 +48,7 @@ function str(val, field, { max = MAX_TITLE, required = true } = {}) {
function oneOf(val, allowed, field) { function oneOf(val, allowed, field) {
if (val === undefined || val === null || val === '') return { value: null, error: null }; if (val === undefined || val === null || val === '') return { value: null, error: null };
if (!allowed.includes(val)) 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 }; return { value: val, error: null };
} }
@@ -60,11 +60,11 @@ function oneOf(val, allowed, field) {
*/ */
function date(val, field, required = false) { function date(val, field, required = false) {
if (!val) { 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 }; return { value: null, error: null };
} }
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(val))) 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 }; return { value: String(val), error: null };
} }
@@ -74,7 +74,7 @@ function date(val, field, required = false) {
function time(val, field) { function time(val, field) {
if (!val) return { value: null, error: null }; if (!val) return { value: null, error: null };
if (!/^\d{2}:\d{2}$/.test(String(val))) 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 }; return { value: String(val), error: null };
} }
@@ -83,11 +83,11 @@ function time(val, field) {
*/ */
function num(val, field, { required = false } = {}) { function num(val, field, { required = false } = {}) {
if (val === undefined || val === null || val === '') { 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 }; return { value: null, error: null };
} }
const n = Number(val); 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 }; return { value: n, error: null };
} }
@@ -97,7 +97,7 @@ function num(val, field, { required = false } = {}) {
function color(val, field) { function color(val, field) {
if (!val) return { value: null, error: null }; if (!val) return { value: null, error: null };
if (!/^#[0-9A-Fa-f]{6}$/.test(String(val))) 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 }; return { value: String(val), error: null };
} }
@@ -115,11 +115,11 @@ function collectErrors(results) {
*/ */
function datetime(val, field, required = false) { function datetime(val, field, required = false) {
if (!val) { 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 }; return { value: null, error: null };
} }
if (!DATETIME_RE.test(String(val))) 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 }; return { value: String(val), error: null };
} }
@@ -129,7 +129,7 @@ function datetime(val, field, required = false) {
function month(val, field) { function month(val, field) {
if (!val) return { value: null, error: null }; if (!val) return { value: null, error: null };
if (!MONTH_RE.test(String(val))) 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 }; return { value: String(val), error: null };
} }
@@ -140,10 +140,10 @@ function rrule(val, field) {
if (!val) return { value: null, error: null }; if (!val) return { value: null, error: null };
const s = String(val).trim(); const s = String(val).trim();
if (s.length > MAX_RRULE) 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 // Grundlegende Struktur: KEY=VALUE;KEY=VALUE
if (!/^FREQ=(DAILY|WEEKLY|MONTHLY)/.test(s)) 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 }; return { value: s, error: null };
} }
@@ -152,7 +152,7 @@ function rrule(val, field) {
*/ */
function id(val, field) { function id(val, field) {
const n = parseInt(val, 10); 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 }; return { value: n, error: null };
} }
+174 -32
View File
@@ -7,7 +7,7 @@
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
import express from 'express'; import express from 'express';
import * as db from '../db.js'; 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'); const log = createLogger('Budget');
@@ -57,23 +57,87 @@ function generateRecurringInstances(database, month) {
database.prepare(` database.prepare(`
INSERT INTO budget_entries INSERT INTO budget_entries
(title, amount, category, date, is_recurring, recurrence_parent_id, created_by) (title, amount, category, subcategory, date, is_recurring, recurrence_parent_id, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by); `).run(orig.title, orig.amount, orig.category, orig.subcategory || '', instanceDate, orig.id, orig.created_by);
} }
} }
const EXPENSE_CATEGORIES = [ function slugify(value) {
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', return String(value || '')
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', .normalize('NFD')
]; .replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48) || 'category';
}
const INCOME_CATEGORIES = [ function uniqueKey(table, base) {
'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers', const normalized = slugify(base);
'Sozialleistungen', 'Sonstiges Einkommen', 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 // Statische Routen vor /:id
@@ -127,7 +191,7 @@ router.get('/summary', (req, res) => {
}); });
} catch (err) { } catch (err) {
log.error('', 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 ORDER BY b.date ASC
`).all(from, to); `).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) => { const csvSafe = (val) => {
let s = String(val || '').replace(/"/g, '""'); let s = String(val || '').replace(/"/g, '""');
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
@@ -167,7 +231,8 @@ router.get('/export', (req, res) => {
csvSafe(e.title), csvSafe(e.title),
e.amount.toFixed(2).replace('.', ','), e.amount.toFixed(2).replace('.', ','),
e.category, e.category,
e.is_recurring ? 'Ja' : 'Nein', e.subcategory || '',
e.is_recurring ? 'Yes' : 'No',
csvSafe(e.creator_name), csvSafe(e.creator_name),
].join(',') ].join(',')
).join('\n'); ).join('\n');
@@ -177,7 +242,7 @@ router.get('/export', (req, res) => {
res.send('\uFEFF' + header + rows); // BOM für Excel res.send('\uFEFF' + header + rows); // BOM für Excel
} catch (err) { } catch (err) {
log.error('', 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 } } * Response: { data: { categories } }
*/ */
router.get('/meta', (req, res) => { 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]; 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 = ?'; sql += ' AND b.category = ?';
params.push(req.query.category); params.push(req.query.category);
} }
@@ -231,31 +359,36 @@ router.get('/', (req, res) => {
res.json({ data: entries }); res.json({ data: entries });
} catch (err) { } catch (err) {
log.error('', 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 * POST /api/v1/budget
* Neuen Eintrag anlegen. * 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 } * Response: { data: Entry }
*/ */
router.post('/', (req, res) => { router.post('/', (req, res) => {
try { try {
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE }); const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vAmount = num(req.body.amount, 'Betrag', { required: true }); 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 vDate = validateDate(req.body.date, 'Datum', true);
const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung'); const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]); const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); 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(` const result = db.get().prepare(`
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by) INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).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.body.is_recurring ? 1 : 0, vRrule.value,
req.session.userId req.session.userId
); );
@@ -269,7 +402,7 @@ router.post('/', (req, res) => {
res.status(201).json({ data: entry }); res.status(201).json({ data: entry });
} catch (err) { } catch (err) {
log.error('', 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 { try {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id); 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 = []; const checks = [];
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false })); 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.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.date !== undefined) checks.push(validateDate(req.body.date, 'Datum'));
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung')); if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
const errors = collectErrors(checks); const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); 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(` db.get().prepare(`
UPDATE budget_entries UPDATE budget_entries
SET title = COALESCE(?, title), SET title = COALESCE(?, title),
amount = COALESCE(?, amount), amount = COALESCE(?, amount),
category = COALESCE(?, category), category = COALESCE(?, category),
subcategory = COALESCE(?, subcategory),
date = COALESCE(?, date), date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring), is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ? recurrence_rule = ?
@@ -308,6 +449,7 @@ router.put('/:id', (req, res) => {
title?.trim() ?? null, title?.trim() ?? null,
amount !== undefined ? Number(amount) : null, amount !== undefined ? Number(amount) : null,
category ?? null, category ?? null,
subcategory !== undefined ? subcategory : null,
date ?? null, date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null, is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule, recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
@@ -322,7 +464,7 @@ router.put('/:id', (req, res) => {
res.json({ data: updated }); res.json({ data: updated });
} catch (err) { } catch (err) {
log.error('', 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 { try {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id); 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); db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
@@ -350,7 +492,7 @@ router.delete('/:id', (req, res) => {
res.status(204).end(); res.status(204).end();
} catch (err) { } catch (err) {
log.error('', err); log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Internal error', code: 500 });
} }
}); });
+1 -1
View File
@@ -257,7 +257,7 @@ router.get('/google/callback', async (req, res) => {
await googleCalendar.handleCallback(code); await googleCalendar.handleCallback(code);
// Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen) // 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'); res.redirect('/settings?sync_ok=google');
} catch (err) { } catch (err) {
+7 -7
View File
@@ -53,7 +53,7 @@ router.get('/', (req, res) => {
LIMIT 5 LIMIT 5
`).all(now.toISOString()); `).all(now.toISOString());
} catch (err) { } catch (err) {
log.error('upcomingEvents-Fehler:', err.message); log.error('upcomingEvents error:', err.message);
result.upcomingEvents = []; result.upcomingEvents = [];
} }
@@ -91,7 +91,7 @@ router.get('/', (req, res) => {
result.urgentTasks = allOpen.slice(0, 5); result.urgentTasks = allOpen.slice(0, 5);
} catch (err) { } catch (err) {
log.error('urgentTasks-Fehler:', err.message); log.error('urgentTasks error:', err.message);
result.urgentTasks = []; result.urgentTasks = [];
} }
@@ -116,7 +116,7 @@ router.get('/', (req, res) => {
END END
`).all(todayStr, ...visibleTypes); `).all(todayStr, ...visibleTypes);
} catch (err) { } catch (err) {
log.error('todayMeals-Fehler:', err.message); log.error('todayMeals error:', err.message);
result.todayMeals = []; result.todayMeals = [];
} }
@@ -130,7 +130,7 @@ router.get('/', (req, res) => {
LIMIT 3 LIMIT 3
`).all(); `).all();
} catch (err) { } catch (err) {
log.error('pinnedNotes-Fehler:', err.message); log.error('pinnedNotes error:', err.message);
result.pinnedNotes = []; result.pinnedNotes = [];
} }
@@ -157,7 +157,7 @@ router.get('/', (req, res) => {
} }
result.shoppingLists = lists; result.shoppingLists = lists;
} catch (err) { } catch (err) {
log.error('shoppingLists-Fehler:', err.message); log.error('shoppingLists error:', err.message);
result.shoppingLists = []; result.shoppingLists = [];
} }
@@ -172,8 +172,8 @@ router.get('/', (req, res) => {
res.json(result); res.json(result);
} catch (err) { } catch (err) {
log.error('Kritischer Fehler:', err.message); log.error('Critical error:', err.message);
res.status(500).json({ error: 'Dashboard konnte nicht geladen werden.', code: 500 }); res.status(500).json({ error: 'Dashboard could not be loaded.', code: 500 });
} }
}); });
+14 -14
View File
@@ -59,8 +59,8 @@ router.get('/', (_req, res) => {
res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) }); res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) });
} catch (err) { } catch (err) {
log.error('GET / Fehler:', err); log.error('GET / error:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Internal error', code: 500 });
} }
}); });
@@ -100,8 +100,8 @@ router.post('/', (req, res) => {
const created = loadRecipeWithIngredients(recipeId); const created = loadRecipeWithIngredients(recipeId);
res.status(201).json({ data: created }); res.status(201).json({ data: created });
} catch (err) { } catch (err) {
log.error('POST / Fehler:', err); log.error('POST / error:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); 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 }); 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); 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) return res.status(404).json({ error: 'Recipe not found', code: 404 });
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 });
const { ingredients = [] } = req.body; const { ingredients = [] } = req.body;
@@ -147,27 +147,27 @@ router.put('/:id', (req, res) => {
const updated = loadRecipeWithIngredients(id); const updated = loadRecipeWithIngredients(id);
res.json({ data: updated }); res.json({ data: updated });
} catch (err) { } catch (err) {
log.error('PUT /:id Fehler:', err); log.error('PUT /:id error:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Internal error', code: 500 });
} }
}); });
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const id = parseInt(req.params.id, 10); 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); 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) return res.status(404).json({ error: 'Recipe not found.', code: 404 });
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); 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); 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(); res.status(204).end();
} catch (err) { } catch (err) {
log.error('DELETE /:id Fehler:', err); log.error('DELETE /:id error:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Internal error', code: 500 });
} }
}); });
+12 -12
View File
@@ -41,8 +41,8 @@ router.get('/pending', (req, res) => {
res.json({ data: rows }); res.json({ data: rows });
} catch (err) { } catch (err) {
log.error('Fehler beim Laden fälliger Erinnerungen:', err.message); log.error('Error loading due reminders:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); res.status(500).json({ error: 'Internal error.', code: 500 });
} }
}); });
@@ -69,8 +69,8 @@ router.get('/', (req, res) => {
res.json({ data: row || null }); res.json({ data: row || null });
} catch (err) { } catch (err) {
log.error('Fehler beim Laden der Erinnerung:', err.message); log.error('Error loading reminder:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); 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); const row = db.get().prepare('SELECT * FROM reminders WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: row }); res.status(201).json({ data: row });
} catch (err) { } catch (err) {
log.error('Fehler beim Erstellen der Erinnerung:', err.message); log.error('Error creating reminder:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); 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); db.get().prepare('UPDATE reminders SET dismissed = 1 WHERE id = ?').run(reminderId);
res.json({ data: { id: reminderId } }); res.json({ data: { id: reminderId } });
} catch (err) { } catch (err) {
log.error('Fehler beim Verwerfen der Erinnerung:', err.message); log.error('Error dismissing reminder:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); 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); db.get().prepare('DELETE FROM reminders WHERE id = ?').run(reminderId);
res.status(204).end(); res.status(204).end();
} catch (err) { } catch (err) {
log.error('Fehler beim Löschen der Erinnerung:', err.message); log.error('Error deleting reminder:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); res.status(500).json({ error: 'Internal error.', code: 500 });
} }
}); });
@@ -202,8 +202,8 @@ router.delete('/', (req, res) => {
res.status(204).end(); res.status(204).end();
} catch (err) { } catch (err) {
log.error('Fehler beim Löschen der Erinnerungen:', err.message); log.error('Error deleting reminders:', err.message);
res.status(500).json({ error: 'Interner Fehler.', code: 500 }); res.status(500).json({ error: 'Internal error.', code: 500 });
} }
}); });
+42 -42
View File
@@ -39,8 +39,8 @@ router.get('/categories', (_req, res) => {
try { try {
res.json({ data: loadCategories() }); res.json({ data: loadCategories() });
} catch (err) { } catch (err) {
log.error('GET /categories Fehler:', err); log.error('GET /categories error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -58,7 +58,7 @@ router.post('/categories', (req, res) => {
const existing = db.get() const existing = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE') .prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE')
.get(vName.value); .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() const maxOrder = db.get()
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories') .prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories')
@@ -73,8 +73,8 @@ router.post('/categories', (req, res) => {
.get(result.lastInsertRowid); .get(result.lastInsertRowid);
res.status(201).json({ data: cat }); res.status(201).json({ data: cat });
} catch (err) { } catch (err) {
log.error('POST /categories Fehler:', err); log.error('POST /categories error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -89,7 +89,7 @@ router.put('/categories/:catId', (req, res) => {
const cat = db.get() const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?') .prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId); .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 }); const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 }); 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() const conflict = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?')
.get(vName.value, cat.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 // Artikel, die die alte Kategorie nutzen, mitumbenennen
db.get().transaction(() => { db.get().transaction(() => {
@@ -114,8 +114,8 @@ router.put('/categories/:catId', (req, res) => {
.get(cat.id); .get(cat.id);
res.json({ data: updated }); res.json({ data: updated });
} catch (err) { } catch (err) {
log.error('PUT /categories/:catId Fehler:', err); log.error('PUT /categories/:catId error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -130,12 +130,12 @@ router.delete('/categories/:catId', (req, res) => {
const cat = db.get() const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?') .prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId); .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() const total = db.get()
.prepare('SELECT COUNT(*) AS c FROM shopping_categories') .prepare('SELECT COUNT(*) AS c FROM shopping_categories')
.get().c; .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 // Fallback-Kategorie: erste andere Kategorie nach sort_order
const fallback = db.get() const fallback = db.get()
@@ -153,8 +153,8 @@ router.delete('/categories/:catId', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('DELETE /categories/:catId Fehler:', err); log.error('DELETE /categories/:catId error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -177,8 +177,8 @@ router.patch('/categories/reorder', (req, res) => {
res.json({ data: loadCategories() }); res.json({ data: loadCategories() });
} catch (err) { } catch (err) {
log.error('PATCH /categories/reorder Fehler:', err); log.error('PATCH /categories/reorder error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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) }); res.json({ data: rows.map((r) => r.name) });
} catch (err) { } catch (err) {
log.error('suggestions Fehler:', err); log.error('suggestions error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -217,7 +217,7 @@ router.patch('/items/:itemId', (req, res) => {
const item = db.get() const item = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?') .prepare('SELECT * FROM shopping_items WHERE id = ?')
.get(req.params.itemId); .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 { const {
is_checked = item.is_checked, is_checked = item.is_checked,
@@ -230,7 +230,7 @@ router.patch('/items/:itemId', (req, res) => {
const validNames = validCategoryNames(); const validNames = validCategoryNames();
if (category && !validNames.includes(category)) 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(` db.get().prepare(`
UPDATE shopping_items UPDATE shopping_items
@@ -243,8 +243,8 @@ router.patch('/items/:itemId', (req, res) => {
.get(req.params.itemId); .get(req.params.itemId);
res.json({ data: updated }); res.json({ data: updated });
} catch (err) { } catch (err) {
log.error('PATCH items/:id Fehler:', err); log.error('PATCH items/:id error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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 = ?') .prepare('DELETE FROM shopping_items WHERE id = ?')
.run(req.params.itemId); .run(req.params.itemId);
if (result.changes === 0) 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('DELETE items/:id Fehler:', err); log.error('DELETE items/:id error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -286,8 +286,8 @@ router.get('/', (req, res) => {
`).all(); `).all();
res.json({ data: lists }); res.json({ data: lists });
} catch (err) { } catch (err) {
log.error('GET / Fehler:', err); log.error('GET / error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -311,8 +311,8 @@ router.post('/', (req, res) => {
.get(result.lastInsertRowid); .get(result.lastInsertRowid);
res.status(201).json({ data: list }); res.status(201).json({ data: list });
} catch (err) { } catch (err) {
log.error('POST / Fehler:', err); log.error('POST / error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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 = ?') .prepare('UPDATE shopping_lists SET name = ? WHERE id = ?')
.run(vName.value, req.params.listId); .run(vName.value, req.params.listId);
if (result.changes === 0) 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() const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?') .prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId); .get(req.params.listId);
res.json({ data: list }); res.json({ data: list });
} catch (err) { } catch (err) {
log.error('PUT /:listId Fehler:', err); log.error('PUT /:listId error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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 = ?') .prepare('DELETE FROM shopping_lists WHERE id = ?')
.run(req.params.listId); .run(req.params.listId);
if (result.changes === 0) 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('DELETE /:listId Fehler:', err); log.error('DELETE /:listId error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -373,7 +373,7 @@ router.get('/:listId/items', (req, res) => {
const list = db.get() const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?') .prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId); .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 categories = loadCategories();
const categoryOrder = categories.map((c, i) => `WHEN '${c.name.replace(/'/g, "''")}' THEN ${i}`).join(' '); 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 }); res.json({ data: items, list, categories });
} catch (err) { } catch (err) {
log.error('GET /:listId/items Fehler:', err); log.error('GET /:listId/items error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -405,7 +405,7 @@ router.post('/:listId/items', (req, res) => {
const list = db.get() const list = db.get()
.prepare('SELECT id FROM shopping_lists WHERE id = ?') .prepare('SELECT id FROM shopping_lists WHERE id = ?')
.get(req.params.listId); .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 validNames = validCategoryNames();
const defaultCat = validNames[0] ?? 'Sonstiges'; const defaultCat = validNames[0] ?? 'Sonstiges';
@@ -427,8 +427,8 @@ router.post('/:listId/items', (req, res) => {
.get(result.lastInsertRowid); .get(result.lastInsertRowid);
res.status(201).json({ data: item }); res.status(201).json({ data: item });
} catch (err) { } catch (err) {
log.error('POST /:listId/items Fehler:', err); log.error('POST /:listId/items error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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); `).run(req.params.listId);
res.json({ deleted: result.changes }); res.json({ deleted: result.changes });
} catch (err) { } catch (err) {
log.error('DELETE /:listId/items/checked Fehler:', err); log.error('DELETE /:listId/items/checked error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
+20 -20
View File
@@ -103,8 +103,8 @@ router.get('/', (req, res) => {
res.json({ data: db.get().prepare(sql).all(...params) }); res.json({ data: db.get().prepare(sql).all(...params) });
} catch (err) { } catch (err) {
log.error('GET / Fehler:', err); log.error('GET / error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); 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 WHERE t.id = ? AND t.parent_task_id IS NULL
`).get(req.params.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 });
task.subtasks = loadSubtasks(task.id); task.subtasks = loadSubtasks(task.id);
res.json({ data: task }); res.json({ data: task });
} catch (err) { } catch (err) {
log.error('GET /:id Fehler:', err); log.error('GET /:id error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -161,7 +161,7 @@ router.post('/', (req, res) => {
if (parent_task_id) { if (parent_task_id) {
const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?') const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?')
.get(parent_task_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) if (parent.parent_task_id)
return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 }); 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 }); res.status(201).json({ data: task });
} catch (err) { } catch (err) {
log.error('POST / Fehler:', err); log.error('POST / error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -200,7 +200,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id); 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); const errors = validateTaskInput(req.body, false);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); 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 }); res.json({ data: updated });
} catch (err) { } catch (err) {
log.error('PUT /:id Fehler:', err); log.error('PUT /:id error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -252,13 +252,13 @@ router.patch('/:id/status', (req, res) => {
try { try {
const { status } = req.body; const { status } = req.body;
if (!VALID_STATUSES.includes(status)) 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 = ?') const result = db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?')
.run(status, req.params.id); .run(status, req.params.id);
if (result.changes === 0) 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 // Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
if (status === 'done') { if (status === 'done') {
@@ -281,8 +281,8 @@ router.patch('/:id/status', (req, res) => {
res.json({ data: { id: Number(req.params.id), status } }); res.json({ data: { id: Number(req.params.id), status } });
} catch (err) { } catch (err) {
log.error('PATCH /:id/status Fehler:', err); log.error('PATCH /:id/status error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -295,11 +295,11 @@ router.delete('/:id', (req, res) => {
try { try {
const result = db.get().prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id); const result = db.get().prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
if (result.changes === 0) 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
log.error('DELETE /:id Fehler:', err); log.error('DELETE /:id error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
@@ -315,8 +315,8 @@ router.get('/meta/options', (req, res) => {
).all(); ).all();
res.json({ users, priorities: VALID_PRIORITIES, statuses: VALID_STATUSES, categories: VALID_CATEGORIES }); res.json({ users, priorities: VALID_PRIORITIES, statuses: VALID_STATUSES, categories: VALID_CATEGORIES });
} catch (err) { } catch (err) {
log.error('GET /meta/options Fehler:', err); log.error('GET /meta/options error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
} }
}); });
+4 -4
View File
@@ -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 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) }); const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) });
if (!currentRes.ok) { if (!currentRes.ok) {
log.warn(`API Fehler: ${currentRes.status}`); log.warn(`API error: ${currentRes.status}`);
return res.json({ data: null }); return res.json({ data: null });
} }
const currentJson = await currentRes.json(); const currentJson = await currentRes.json();
@@ -116,7 +116,7 @@ router.get('/', async (req, res) => {
cache = { data, ts: Date.now() }; cache = { data, ts: Date.now() };
res.json({ data }); res.json({ data });
} catch (err) { } catch (err) {
log.warn('Fehler:', err.message); log.warn('Error:', err.message);
res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen 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 res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 Stunden
upstream.body.pipe(res); upstream.body.pipe(res);
} catch (err) { } catch (err) {
log.warn('Icon-Proxy Fehler:', err.message); log.warn('Icon proxy error:', err.message);
res.status(502).json({ error: 'Icon-Proxy fehlgeschlagen.', code: 502 }); res.status(502).json({ error: 'Icon proxy failed.', code: 502 });
} }
}); });
+11 -11
View File
@@ -80,7 +80,7 @@ function getCredentials() {
function saveCredentials(url, username, password) { function saveCredentials(url, username, password) {
// Warnung wenn DB-Verschluesselung nicht aktiv - Credentials liegen dann im Klartext // Warnung wenn DB-Verschluesselung nicht aktiv - Credentials liegen dann im Klartext
if (!process.env.DB_ENCRYPTION_KEY) { 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_caldav_url', url);
cfgSet('apple_username', username); cfgSet('apple_username', username);
@@ -89,7 +89,7 @@ function saveCredentials(url, username, password) {
function clearCredentials() { function clearCredentials() {
['apple_caldav_url', 'apple_username', 'apple_app_password', 'apple_last_sync'].forEach(cfgDel); ['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() { async function testConnection() {
const creds = getCredentials(); 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 { createDAVClient } = await import('tsdav');
const client = await createDAVClient({ const client = await createDAVClient({
@@ -121,7 +121,7 @@ async function testConnection() {
}); });
const calendars = await client.fetchCalendars(); 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 }; return { ok: true, calendarCount: calendars.length };
} }
@@ -196,7 +196,7 @@ function unescapeICS(str) {
async function sync() { async function sync() {
const creds = getCredentials(); const creds = getCredentials();
if (!creds) { 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 // tsdav ist eine optionale Abhängigkeit - dynamischer Import für graceful degradation
@@ -211,14 +211,14 @@ async function sync() {
const calendars = await client.fetchCalendars(); const calendars = await client.fetchCalendars();
if (!calendars.length) { if (!calendars.length) {
log.warn('Keine Kalender gefunden.'); log.warn('No calendars found.');
return; return;
} }
// created_by: ersten existierenden User verwenden (nicht hardcoded ID 1) // 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(); const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
if (!owner) { if (!owner) {
log.warn('Kein User in der Datenbank - Sync übersprungen.'); log.warn('No user in database - sync skipped.');
return; return;
} }
const createdBy = owner.id; const createdBy = owner.id;
@@ -233,7 +233,7 @@ async function sync() {
try { try {
calObjects = await client.fetchCalendarObjects({ calendar: cal }); calObjects = await client.fetchCalendarObjects({ calendar: cal });
} catch (err) { } catch (err) {
log.warn(`Kalender "${cal.displayName || '(unbenannt)'}" nicht abrufbar: ${err.message}`); log.warn(`Calendar "${cal.displayName || '(unnamed)'}" is not accessible: ${err.message}`);
continue; continue;
} }
@@ -277,7 +277,7 @@ async function sync() {
); );
} }
} catch (err) { } 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 = ? UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ?
`).run(uid, event.id); `).run(uid, event.id);
} catch (err) { } 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()); 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 }; export { sync, getStatus, saveCredentials, clearCredentials, testConnection };
+10 -10
View File
@@ -42,7 +42,7 @@ function createClient() {
const redirectUri = process.env.GOOGLE_REDIRECT_URI; const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) { 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); return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
@@ -79,7 +79,7 @@ function loadAuthorizedClient() {
const refreshToken = cfgGet('google_refresh_token'); const refreshToken = cfgGet('google_refresh_token');
if (!accessToken || !refreshToken) { 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(); const client = createClient();
@@ -133,14 +133,14 @@ async function handleCallback(code) {
const { tokens } = await client.getToken(code); const { tokens } = await client.getToken(code);
if (!tokens.refresh_token) { 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_access_token', tokens.access_token);
cfgSet('google_refresh_token', tokens.refresh_token); cfgSet('google_refresh_token', tokens.refresh_token);
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date)); 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() { function disconnect() {
['google_access_token', 'google_refresh_token', 'google_token_expiry', ['google_access_token', 'google_refresh_token', 'google_token_expiry',
'google_sync_token', 'google_last_sync'].forEach(cfgDel); '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'; const calName = meta.data.summary || 'Google Calendar';
calRefId = upsertExternalCalendar('google', 'primary', calName, calColor); calRefId = upsertExternalCalendar('google', 'primary', calName, calColor);
} catch (err) { } 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) { } catch (err) {
if (err.code === 410) { if (err.code === 410) {
// syncToken abgelaufen → vollständiger Resync // syncToken abgelaufen → vollständiger Resync
log.warn('syncToken ungültig - vollständiger Resync.'); log.warn('syncToken invalid - full resync.');
cfgDel('google_sync_token'); cfgDel('google_sync_token');
syncToken = null; syncToken = null;
continue; continue;
@@ -250,12 +250,12 @@ async function sync() {
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ? UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ?
`).run(created.data.id, event.id); `).run(created.data.id, event.id);
} catch (err) { } 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()); 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 { try {
insertOrUpdate(item); insertOrUpdate(item);
} catch (err) { } catch (err) {
log.error(`Upsert-Fehler für Event ${item.id}:`, err.message); log.error(`Upsert error for event ${item.id}:`, err.message);
} }
} }
} }
+11 -11
View File
@@ -27,7 +27,7 @@ const syncingNow = new Set();
function normalizeUrl(raw) { function normalizeUrl(raw) {
const url = new URL(raw.replace(/^webcal:\/\//i, 'https://')); 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; return url.href;
} }
@@ -37,7 +37,7 @@ async function checkSSRF(urlStr) {
const v6 = await dns.resolve6(hostname).catch(() => []); const v6 = await dns.resolve6(hostname).catch(() => []);
for (const addr of [...v4, ...v6]) { for (const addr of [...v4, ...v6]) {
if (PRIVATE_RANGES.some((re) => re.test(addr))) { 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}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const cl = parseInt(res.headers.get('content-length') || '0', 10); 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; let body = '', received = 0;
for await (const chunk of res.body) { for await (const chunk of res.body) {
received += chunk.length; 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(); body += chunk.toString();
} }
@@ -87,7 +87,7 @@ function syncWindow() {
async function syncOne(sub) { async function syncOne(sub) {
if (syncingNow.has(sub.id)) { if (syncingNow.has(sub.id)) {
log.info(`Abonnement ${sub.id} wird bereits synchronisiert - übersprungen.`); log.info(`Subscription ${sub.id} is already syncing - skipped.`);
return; return;
} }
syncingNow.add(sub.id); syncingNow.add(sub.id);
@@ -95,7 +95,7 @@ async function syncOne(sub) {
let result; let result;
try { result = await fetchAndParse(sub.url, sub.etag, sub.last_modified); } try { result = await fetchAndParse(sub.url, sub.etag, sub.last_modified); }
catch (err) { 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; return;
} }
@@ -109,7 +109,7 @@ async function syncOne(sub) {
const { windowStart, windowEnd } = syncWindow(); const { windowStart, windowEnd } = syncWindow();
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get(); const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
const createdBy = sub.created_by ?? owner?.id; 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 = []; const flatEvents = [];
for (const ev of events) { for (const ev of events) {
@@ -157,7 +157,7 @@ async function syncOne(sub) {
.run(new Date().toISOString(), newEtag, newLastModified, sub.id); .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); } } finally { syncingNow.delete(sub.id); }
} }
@@ -167,7 +167,7 @@ async function sync(subscriptionId) {
: db.get().prepare('SELECT * FROM ics_subscriptions').all(); : db.get().prepare('SELECT * FROM ics_subscriptions').all();
for (const sub of subs) { for (const sub of subs) {
try { await syncOne(sub); } 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) { function update(userId, subId, fields, isAdmin) {
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return null; 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 name = fields.name !== undefined ? fields.name : sub.name;
const color = fields.color !== undefined ? fields.color : sub.color; const color = fields.color !== undefined ? fields.color : sub.color;
const shared = fields.shared !== undefined ? (fields.shared ? 1 : 0) : sub.shared; 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) { function remove(userId, subId, isAdmin) {
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return false; 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); db.get().prepare('DELETE FROM ics_subscriptions WHERE id = ?').run(subId);
return true; return true;
} }
+25 -25
View File
@@ -69,45 +69,45 @@ async function main() {
.get(); .get();
if (existingAdmin) { if (existingAdmin) {
console.log(' Es existiert bereits ein Admin-Account.\n'); console.log(' An admin account already exists.\n');
const proceed = await prompt('Trotzdem einen weiteren Admin anlegen? (j/N): '); const proceed = await prompt('Create another admin anyway? (y/N): ');
if (proceed.toLowerCase() !== 'j') { if (proceed.toLowerCase() !== 'y') {
console.log('Setup abgebrochen.'); console.log('Setup cancelled.');
rl.close(); rl.close();
process.exit(0); 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) { 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); 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) { if (!displayName) {
console.error('Fehler: Anzeigename darf nicht leer sein.'); console.error('Error: display name must not be empty.');
process.exit(1); process.exit(1);
} }
const password = await promptPassword('Passwort: '); const password = await promptPassword('Password: ');
if (password.length < 8) { 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); process.exit(1);
} }
const passwordConfirm = await promptPassword('Passwort bestätigen: '); const passwordConfirm = await promptPassword('Confirm password: ');
if (password !== passwordConfirm) { if (password !== passwordConfirm) {
console.error('Fehler: Passwörter stimmen nicht überein.'); console.error('Error: passwords do not match.');
process.exit(1); process.exit(1);
} }
const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55']; const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55'];
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)]; 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); const hash = await bcrypt.hash(password, 12);
@@ -122,23 +122,23 @@ async function main() {
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const host = getLocalIP(); const host = getLocalIP();
console.log(`\n✅ Admin-Account erfolgreich erstellt!`); console.log(`\n✅ Admin account created successfully!`);
console.log(`${'─'.repeat(40)}`); console.log(`${'─'.repeat(40)}`);
console.log(` Benutzername: ${username}`); console.log(` Username: ${username}`);
console.log(` Anzeigename: ${displayName}`); console.log(` Display name: ${displayName}`);
console.log(` Rolle: Admin`); console.log(` Role: Admin`);
console.log(`${'─'.repeat(40)}`); console.log(`${'─'.repeat(40)}`);
console.log(`\n🌐 Oikos ist erreichbar unter:\n`); console.log(`\n🌐 Oikos is available at:\n`);
console.log(` Lokal: http://localhost:${port}`); console.log(` Local: http://localhost:${port}`);
if (host) { 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) { } catch (err) {
if (err.message?.includes('UNIQUE constraint')) { if (err.message?.includes('UNIQUE constraint')) {
console.error(`\nFehler: Benutzername "${username}" ist bereits vergeben.`); console.error(`\nError: username "${username}" is already taken.`);
} else { } else {
console.error('\nFehler beim Erstellen:', err.message); console.error('\nCreation error:', err.message);
} }
process.exit(1); process.exit(1);
} }
@@ -148,6 +148,6 @@ async function main() {
} }
main().catch((err) => { main().catch((err) => {
console.error('Unerwarteter Fehler:', err.message); console.error('Unexpected error:', err.message);
process.exit(1); process.exit(1);
}); });
+4 -4
View File
@@ -43,7 +43,7 @@ function setup() {
test('auth.login: 401 feuert kein auth:expired', async () => { test('auth.login: 401 feuert kein auth:expired', async () => {
setup(); setup();
_mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 });
await assert.rejects( await assert.rejects(
() => auth.login('user', 'wrong'), () => 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 () => { test('auth.login: 401 wirft ApiError mit status 401', async () => {
setup(); setup();
_mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 });
let thrownErr; let thrownErr;
try { 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 () => { test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => {
setup(); setup();
_mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 });
await assert.rejects(() => api.get('/tasks')); 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 () => { test('api.post: 401 auf Logout-Endpunkt feuert auth:expired', async () => {
setup(); setup();
_mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 });
await assert.rejects(() => api.post('/auth/logout', {})); await assert.rejects(() => api.post('/auth/logout', {}));
+18 -12
View File
@@ -178,30 +178,30 @@ console.log('\n[Budget-Test] Einnahmen, Ausgaben, Saldo, Aggregation, CSV-Vorber
let bId1, bId2, bId3, bId4; let bId1, bId2, bId3, bId4;
test('Ausgabe eintragen (Lebensmittel)', () => { test('Ausgabe eintragen (Supermarkt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
VALUES ('REWE', -85.40, 'Lebensmittel', '2026-03-10', ?)`).run(uid); VALUES ('REWE', -85.40, 'food', 'groceries', '2026-03-10', ?)`).run(uid);
bId1 = r.lastInsertRowid; bId1 = r.lastInsertRowid;
assert(bId1 > 0); assert(bId1 > 0);
}); });
test('Einnahme eintragen (Gehalt)', () => { test('Einnahme eintragen (Gehalt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) 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; bId2 = r.lastInsertRowid;
assert(bId2 > 0); assert(bId2 > 0);
}); });
test('Ausgabe (Miete)', () => { test('Ausgabe (Aluguel / Prestação)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, is_recurring, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by)
VALUES ('Miete', -950.00, 'Miete', '2026-03-01', 1, ?)`).run(uid); VALUES ('Miete', -950.00, 'housing', 'rent_mortgage', '2026-03-01', 1, ?)`).run(uid);
bId3 = r.lastInsertRowid; bId3 = r.lastInsertRowid;
assert(bId3 > 0); assert(bId3 > 0);
}); });
test('Ausgabe im anderen Monat (April)', () => { test('Ausgabe im anderen Monat (April)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
VALUES ('Strom April', -55.00, 'Sonstiges', '2026-04-15', ?)`).run(uid); VALUES ('Strom April', -55.00, 'housing', 'utilities', '2026-04-15', ?)`).run(uid);
bId4 = r.lastInsertRowid; bId4 = r.lastInsertRowid;
assert(bId4 > 0); assert(bId4 > 0);
}); });
@@ -258,12 +258,18 @@ test('Aggregation nach Kategorie', () => {
GROUP BY category ORDER BY ABS(SUM(amount)) DESC GROUP BY category ORDER BY ABS(SUM(amount)) DESC
`).all(); `).all();
assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`); assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`);
// Miete sollte die größte Ausgabe sein // Housing should be the largest expense category.
const miete = cats.find((c) => c.category === 'Miete'); const miete = cats.find((c) => c.category === 'housing');
assert(miete, 'Miete in Kategorien vorhanden'); assert(miete, 'Housing in Kategorien vorhanden');
assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`); 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', () => { test('Wiederkehrend-Flag korrekt', () => {
const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3); const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3);
assert(r.is_recurring === 1, 'Miete ist wiederkehrend'); assert(r.is_recurring === 1, 'Miete ist wiederkehrend');