localization for ntfy and mail

This commit is contained in:
MDeeApp
2025-10-11 19:34:54 +02:00
parent 17e094e8ac
commit adc4cfbee8
25 changed files with 609 additions and 74 deletions

View File

@@ -6,6 +6,8 @@ export interface ServerConfig {
databasePath: string;
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
appExternalUrl: string | null;
appLocale: string;
paperlessConfigured: boolean;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
@@ -26,6 +28,8 @@ export interface SettingsResponse {
values: {
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
appExternalUrl: string | null;
appLocale: string;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailServer: string | null;
@@ -51,6 +55,8 @@ export interface SettingsResponse {
export type UpdateSettingsPayload = Partial<{
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
appExternalUrl: string | null;
appLocale: string;
paperlessToken: string | null;
schedulerIntervalMinutes: number;
alertDaysBefore: number;

View File

@@ -79,6 +79,7 @@
"edit": "Bearbeiten",
"deleted": "Vertrag gelöscht",
"deleteError": "Löschen fehlgeschlagen",
"categoryAddTitle": "Kategorie hinzufügen",
"categoryAdd": "Neue Kategorie hinzufügen",
"categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.",
"categoryNameLabel": "Name der Kategorie",
@@ -86,6 +87,7 @@
"categoryNameRequired": "Bitte einen Kategorienamen angeben.",
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
"categoryNone": "Keine Kategorie",
"categoryLoading": "Kategorien werden geladen…",
"categoryLoadError": "Kategorien konnten nicht geladen werden."
@@ -177,7 +179,14 @@
"icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
"appExternalUrl": "Externe URL der App",
"paperlessExample": "https://paperless.example.com",
"appExternalExample": "https://contracts.example.com",
"appExternalUrlHelp": "Diese URL wird in iCal-Feeds und Links verwendet. Leer lassen, um Host des Aufrufs zu nutzen.",
"appLocaleLabel": "Sprache für Benachrichtigungen",
"appLocaleHelp": "Bestimmt die Sprache für ntfy-Pushs und Mail-Betreffs.",
"appLocaleGerman": "Deutsch",
"appLocaleEnglish": "Englisch",
"paperlessToken": "Paperless Token",
"paperlessTokenNew": "Neuen Token hinterlegen",
"paperlessTokenPlaceholder": "Token eingeben",
@@ -236,6 +245,7 @@
"categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.",
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
"categoryDeleted": "Kategorie gelöscht",
"categoryDeleteError": "Kategorie konnte nicht gelöscht werden",
"categoryEmpty": "Noch keine Kategorien vorhanden.",

View File

@@ -79,6 +79,7 @@
"edit": "Edit",
"deleted": "Contract deleted",
"deleteError": "Failed to delete",
"categoryAddTitle": "Add category",
"categoryAdd": "Add new category",
"categoryAddDescription": "Create a new category you can select immediately.",
"categoryNameLabel": "Category name",
@@ -86,6 +87,7 @@
"categoryNameRequired": "Please provide a category name.",
"categoryCreated": "Category \"{{name}}\" saved",
"categoryCreateError": "Failed to create category",
"categoryExists": "Category \"{{name}}\" already exists",
"categoryNone": "No category",
"categoryLoading": "Loading categories…",
"categoryLoadError": "Could not load categories."
@@ -177,7 +179,14 @@
"icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless external URL (for direct link)",
"appExternalUrl": "App external URL",
"paperlessExample": "https://paperless.example.com",
"appExternalExample": "https://contracts.example.com",
"appExternalUrlHelp": "Used for links and iCal feeds. Leave empty to use the request host.",
"appLocaleLabel": "Notification language",
"appLocaleHelp": "Controls the language used in ntfy pushes and email subjects.",
"appLocaleGerman": "German",
"appLocaleEnglish": "English",
"paperlessToken": "Paperless token",
"paperlessTokenNew": "Provide new token",
"paperlessTokenPlaceholder": "Enter token",
@@ -236,6 +245,7 @@
"categoryNameRequired": "Please enter a category name.",
"categoryCreated": "Category \"{{name}}\" saved",
"categoryCreateError": "Failed to create category",
"categoryExists": "Category \"{{name}}\" already exists",
"categoryDeleted": "Category deleted",
"categoryDeleteError": "Failed to delete category",
"categoryEmpty": "No categories defined yet.",

View File

@@ -17,12 +17,13 @@ import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
import { fetchServerConfig, ServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { formatCurrency, formatDate } from "../utils/date";
import { translateCategoryName } from "../utils/categories";
export default function ContractDetail() {
const { contractId } = useParams<{ contractId: string }>();
const navigate = useNavigate();
const id = Number(contractId);
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { data: contract, isLoading } = useQuery({
queryKey: ["contracts", id],
@@ -56,6 +57,9 @@ export default function ContractDetail() {
? t("contractForm.fields.autoRenew")
: "";
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
const categoryValue = contract?.category
? translateCategoryName(contract.category, i18n.language) || contract.category
: "";
if (!Number.isFinite(id)) {
return <Typography>{t("contractForm.loadError")}</Typography>;
@@ -89,7 +93,7 @@ export default function ContractDetail() {
<Detail label={t("contractDetail.notice")} value={terminationValue} />
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
<Detail label={t("contractDetail.category")} value={contract.category ?? ""} />
<Detail label={t("contractDetail.category")} value={categoryValue} />
<Detail label={t("contractDetail.notes")} value={notesValue} />
</Stack>

View File

@@ -33,6 +33,7 @@ import {
} from "../api/contracts";
import { fetchServerConfig } from "../api/config";
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
@@ -94,7 +95,7 @@ export default function ContractForm({ mode }: Props) {
const id = contractId ? Number(contractId) : null;
const queryClient = useQueryClient();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const {
control,
@@ -160,7 +161,8 @@ export default function ContractForm({ mode }: Props) {
await refetchCategories();
queryClient.invalidateQueries({ queryKey: ["categories"] });
setValue("category", category.name, { shouldDirty: true });
showMessage(t("contracts.categoryCreated", { name: category.name }), "success");
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
showMessage(t("contracts.categoryCreated", { name: displayName }), "success");
setCategoryDialogOpen(false);
setNewCategoryName("");
},
@@ -171,6 +173,34 @@ export default function ContractForm({ mode }: Props) {
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [newCategoryName, setNewCategoryName] = useState("");
const trimmedDialogCategoryName = newCategoryName.trim();
const dialogCategoryExists = useMemo(() => {
if (!trimmedDialogCategoryName) {
return false;
}
if (!categories) {
return false;
}
const normalizedInput = trimmedDialogCategoryName.toLowerCase();
const canonicalInput = getCanonicalDefaultCategoryName(trimmedDialogCategoryName)?.toLowerCase() ?? null;
return categories.some((category) => {
const normalizedCategory = category.name.toLowerCase();
return normalizedCategory === normalizedInput || (canonicalInput ? normalizedCategory === canonicalInput : false);
});
}, [categories, trimmedDialogCategoryName]);
const handleDialogCreateCategory = () => {
if (!trimmedDialogCategoryName) {
showMessage(t("contracts.categoryNameRequired"), "warning");
return;
}
if (dialogCategoryExists) {
const existingDisplayName = translateCategoryName(trimmedDialogCategoryName, i18n.language) || trimmedDialogCategoryName;
showMessage(t("contracts.categoryExists", { name: existingDisplayName }), "info");
return;
}
createCategoryMutation.mutate(trimmedDialogCategoryName);
};
const providerSuggestion = useMemo(
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
@@ -342,7 +372,15 @@ export default function ContractForm({ mode }: Props) {
render={({ field }) => {
const options = categories ?? [];
const value = field.value ?? "";
const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase());
const normalizedValue = String(value).toLowerCase();
const canonicalValue = value ? getCanonicalDefaultCategoryName(String(value))?.toLowerCase() ?? null : null;
const hasCurrentValue = Boolean(value) && options.some((category) => {
const normalizedCategory = category.name.toLowerCase();
if (normalizedCategory === normalizedValue) {
return true;
}
return canonicalValue ? normalizedCategory === canonicalValue : false;
});
return (
<TextField
label={t("contractForm.fields.category")}
@@ -362,6 +400,7 @@ export default function ContractForm({ mode }: Props) {
field.onChange(nextValue);
}}
SelectProps={{ displayEmpty: true }}
InputLabelProps={{ shrink: true }}
disabled={loadingCategories}
error={Boolean(categoriesError)}
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
@@ -380,13 +419,13 @@ export default function ContractForm({ mode }: Props) {
) : (
options.map((category) => (
<MenuItem key={category.id} value={category.name}>
{category.name}
{translateCategoryName(category.name, i18n.language) || category.name}
</MenuItem>
))
)}
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
<MenuItem value={value as string}>
{String(value)}
{translateCategoryName(String(value), i18n.language) || String(value)}
</MenuItem>
)}
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
@@ -559,6 +598,12 @@ export default function ContractForm({ mode }: Props) {
label={t("contracts.categoryNameLabel")}
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleDialogCreateCategory();
}
}}
fullWidth
/>
</DialogContent>
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
{t("actions.cancel")}
</Button>
<Button
onClick={() => {
const trimmed = newCategoryName.trim();
if (!trimmed) {
showMessage(t("contracts.categoryNameRequired"), "warning");
return;
}
createCategoryMutation.mutate(trimmed);
}}
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
onClick={handleDialogCreateCategory}
disabled={createCategoryMutation.isPending || !trimmedDialogCategoryName}
variant="contained"
>
{t("contracts.categoryCreate")}

View File

@@ -34,12 +34,13 @@ import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
import { Contract } from "../types";
import { formatCurrency, formatDate } from "../utils/date";
import { translateCategoryName } from "../utils/categories";
export default function ContractsList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
const {
@@ -72,7 +73,7 @@ export default function ContractsList() {
return normalizedContracts.filter((contract) => {
const searchMatch =
!search ||
[contract.title, contract.provider, contract.notes, contract.category]
[contract.title, contract.provider, contract.notes, contract.category, translateCategoryName(contract.category ?? "", i18n.language)]
.filter(Boolean)
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
@@ -130,7 +131,7 @@ export default function ContractsList() {
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
{(categoryOptions ?? []).map((item) => (
<MenuItem key={item.id} value={item.name}>
{item.name}
{translateCategoryName(item.name, i18n.language) || item.name}
</MenuItem>
))}
</TextField>
@@ -190,7 +191,11 @@ export default function ContractsList() {
</Typography>
</TableCell>
<TableCell>{contract.provider ?? ""}</TableCell>
<TableCell>{contract.category ?? ""}</TableCell>
<TableCell>
{contract.category
? translateCategoryName(contract.category, i18n.language) || contract.category
: ""}
</TableCell>
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
<TableCell>

View File

@@ -16,6 +16,7 @@ import {
Grid,
IconButton,
InputAdornment,
MenuItem,
List,
ListItem,
ListItemAvatar,
@@ -27,7 +28,7 @@ import {
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
@@ -47,6 +48,7 @@ import PageHeader from "../components/PageHeader";
import { useAuth } from "../contexts/AuthContext";
import { useSnackbar } from "../hooks/useSnackbar";
import { useTranslation } from "react-i18next";
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
interface HealthResponse {
status: string;
@@ -55,6 +57,8 @@ interface HealthResponse {
type FormValues = {
paperlessBaseUrl: string;
paperlessExternalUrl: string;
appExternalUrl: string;
appLocale: string;
paperlessToken: string;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
@@ -76,6 +80,8 @@ type FormValues = {
const defaultValues: FormValues = {
paperlessBaseUrl: "",
paperlessExternalUrl: "",
appExternalUrl: "",
appLocale: "de",
paperlessToken: "",
schedulerIntervalMinutes: 60,
alertDaysBefore: 30,
@@ -97,7 +103,7 @@ const defaultValues: FormValues = {
export default function SettingsPage() {
const { authEnabled } = useAuth();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -159,6 +165,8 @@ export default function SettingsPage() {
const values: FormValues = {
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
appExternalUrl: settingsData.values.appExternalUrl ?? "",
appLocale: settingsData.values.appLocale ?? "de",
paperlessToken: "",
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
alertDaysBefore: settingsData.values.alertDaysBefore,
@@ -223,7 +231,8 @@ export default function SettingsPage() {
await refetchCategories();
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCategoryName("");
showMessage(t("settings.categoryCreated", { name: category.name }), "success");
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
showMessage(t("settings.categoryCreated", { name: displayName }), "success");
},
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error")
});
@@ -238,6 +247,38 @@ export default function SettingsPage() {
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error")
});
const trimmedCategoryName = newCategoryName.trim();
const categoryExists = useMemo(() => {
if (!trimmedCategoryName) {
return false;
}
if (!categories) {
return false;
}
const normalizedInput = trimmedCategoryName.toLowerCase();
const canonicalInput = getCanonicalDefaultCategoryName(trimmedCategoryName)?.toLowerCase() ?? null;
return categories.some((category) => {
const normalizedName = category.name.toLowerCase();
return (
normalizedName === normalizedInput ||
(canonicalInput ? normalizedName === canonicalInput : false)
);
});
}, [categories, trimmedCategoryName]);
const handleAddCategory = useCallback(() => {
if (!trimmedCategoryName) {
showMessage(t("settings.categoryNameRequired"), "warning");
return;
}
if (categoryExists) {
const existingDisplayName = translateCategoryName(trimmedCategoryName, i18n.language) || trimmedCategoryName;
showMessage(t("settings.categoryExists", { name: existingDisplayName }), "info");
return;
}
createCategoryMutation.mutate(trimmedCategoryName);
}, [categoryExists, createCategoryMutation, i18n.language, showMessage, t, trimmedCategoryName]);
const settings = settingsData;
const icalSecret = settings?.icalSecret ?? null;
const icalUrl = useMemo(() => {
@@ -265,6 +306,12 @@ export default function SettingsPage() {
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
}
if (formValues.appExternalUrl !== initial.appExternalUrl) {
payload.appExternalUrl = trimOrNull(formValues.appExternalUrl);
}
if (formValues.appLocale !== initial.appLocale) {
payload.appLocale = formValues.appLocale;
}
if (removePaperlessToken) {
payload.paperlessToken = null;
@@ -521,19 +568,18 @@ export default function SettingsPage() {
label={t("settings.categoryName")}
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddCategory();
}
}}
fullWidth
/>
<Button
variant="outlined"
onClick={() => {
const trimmed = newCategoryName.trim();
if (!trimmed) {
showMessage(t("settings.categoryNameRequired"), "warning");
return;
}
createCategoryMutation.mutate(trimmed);
}}
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
onClick={handleAddCategory}
disabled={createCategoryMutation.isPending || !trimmedCategoryName}
>
{t("settings.categoryAdd")}
</Button>
@@ -543,7 +589,7 @@ export default function SettingsPage() {
{categories.map((category) => (
<Chip
key={category.id}
label={category.name}
label={translateCategoryName(category.name, i18n.language) || category.name}
onDelete={() => deleteCategoryMutation.mutate(category.id)}
disabled={deleteCategoryMutation.isPending}
/>
@@ -579,6 +625,23 @@ export default function SettingsPage() {
placeholder={t("settings.paperlessExample")}
fullWidth
/>
<TextField
label={t("settings.appExternalUrl")}
{...register("appExternalUrl")}
placeholder={t("settings.appExternalExample")}
fullWidth
helperText={t("settings.appExternalUrlHelp")}
/>
<TextField
select
label={t("settings.appLocaleLabel")}
{...register("appLocale")}
fullWidth
helperText={t("settings.appLocaleHelp")}
>
<MenuItem value="de">{t("settings.appLocaleGerman")}</MenuItem>
<MenuItem value="en">{t("settings.appLocaleEnglish")}</MenuItem>
</TextField>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}

10
frontend/src/types/shared.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module "@shared/defaultCategories.json" {
interface DefaultCategory {
key: string;
de: string;
en: string;
}
const categories: DefaultCategory[];
export default categories;
}

View File

@@ -0,0 +1,45 @@
import defaultCategories from "@shared/defaultCategories.json";
type DefaultCategory = {
key: string;
de: string;
en: string;
};
const DEFAULT_CATEGORY_DATA = defaultCategories as DefaultCategory[];
function normalize(input: string): string {
return input.trim().toLowerCase();
}
const translationsByNormalizedName = new Map<string, DefaultCategory>();
for (const category of DEFAULT_CATEGORY_DATA) {
translationsByNormalizedName.set(normalize(category.de), category);
translationsByNormalizedName.set(normalize(category.en), category);
}
export function getCanonicalDefaultCategoryName(input: string): string | null {
const normalized = normalize(input);
const match = translationsByNormalizedName.get(normalized);
return match ? match.de : null;
}
export function translateCategoryName(name: string | null | undefined, language: string): string {
if (!name) {
return "";
}
const normalized = normalize(name);
const match = translationsByNormalizedName.get(normalized);
if (!match) {
return name;
}
if (language.startsWith("de")) {
return match.de;
}
if (language.startsWith("en")) {
return match.en;
}
// For other languages we fall back to English to keep labels readable.
return match.en;
}