mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-06-23 15:15:42 +08:00
Fix remaining German comment
This commit is contained in:
@@ -68,7 +68,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if (channel) {
|
||||
if (!CHANNELS.has(channel)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
|
||||
}
|
||||
filter.channel = channel
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export default defineEventHandler(async (event) => {
|
||||
filter.usedBy = { $exists: false }
|
||||
filter.expiresAt = { $lt: now }
|
||||
} else {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Status' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid status' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,21 +69,21 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if (channel) {
|
||||
if (!CHANNELS.has(channel)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
|
||||
}
|
||||
filter.channel = channel as TransmissionLogDocument['channel']
|
||||
}
|
||||
|
||||
if (direction) {
|
||||
if (!DIRECTIONS.has(direction)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige Richtung' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid direction' })
|
||||
}
|
||||
filter.direction = direction as TransmissionLogDocument['direction']
|
||||
}
|
||||
|
||||
if (role) {
|
||||
if (!ROLES.has(role)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unbekannte Rolle' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unknown role' })
|
||||
}
|
||||
filter.role = role
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (sinceRaw) {
|
||||
const since = new Date(sinceRaw)
|
||||
if (Number.isNaN(since.valueOf())) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Zeitraum' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid timeframe' })
|
||||
}
|
||||
filter.createdAt = { ...(filter.createdAt as any), $gte: since }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if (role) {
|
||||
if (!['user', 'admin', 'dev'].includes(role)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
|
||||
}
|
||||
filter.role = role
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ export default defineEventHandler(async (event) => {
|
||||
const userId = params?.id
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'User-ID fehlt' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing user ID' })
|
||||
}
|
||||
|
||||
const body = await readBody<UpdateRoleBody>(event).catch(() => ({}))
|
||||
const role = body.role?.trim()
|
||||
|
||||
if (!role || !['user', 'admin', 'dev'].includes(role)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
|
||||
}
|
||||
|
||||
const target = await User.findById(userId)
|
||||
if (!target) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
const previousRole = target.role
|
||||
|
||||
@@ -77,11 +77,11 @@ function decodeAudioPayload(encoded: string): Buffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Audio zu WAV konvertieren für bessere Whisper-Kompatibilität
|
||||
// Convert audio to WAV for better Whisper compatibility
|
||||
async function convertToWav(inputPath: string, outputPath: string) {
|
||||
await sh("ffmpeg", [
|
||||
"-y", "-i", inputPath,
|
||||
"-ar", "16000", // 16kHz für Whisper
|
||||
"-ar", "16000", // 16 kHz for Whisper
|
||||
"-ac", "1", // Mono
|
||||
"-f", "wav",
|
||||
outputPath
|
||||
@@ -104,11 +104,11 @@ export default defineEventHandler(async (event) => {
|
||||
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
|
||||
|
||||
try {
|
||||
// 1. Audio aus Base64 dekodieren und speichern
|
||||
// 1. Decode audio from base64 and save
|
||||
const audioBuffer = decodeAudioPayload(body.audio);
|
||||
await writeFile(tmpAudioInput, audioBuffer);
|
||||
|
||||
// 2. Zu WAV konvertieren falls nötig (nur wenn FFmpeg verfügbar)
|
||||
// 2. Convert to WAV if needed (only when FFmpeg is available)
|
||||
let audioFileForWhisper = tmpAudioInput;
|
||||
if (format !== 'wav') {
|
||||
try {
|
||||
@@ -119,7 +119,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. OpenAI Whisper für Transkription
|
||||
// 3. OpenAI Whisper for transcription
|
||||
const openai = getOpenAIClient();
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: createReadStream(audioFileForWhisper),
|
||||
@@ -143,7 +143,7 @@ export default defineEventHandler(async (event) => {
|
||||
let decision: PTTResponse['decision'];
|
||||
|
||||
if (shouldAutoDecide) {
|
||||
// 4. Direkt LLM Decision aufrufen mit transkribiertem Text
|
||||
// 4. Call the LLM decision directly with the transcribed text
|
||||
const decisionInput = {
|
||||
...body.context,
|
||||
pilot_utterance: transcribedText
|
||||
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
// Cleanup bei Fehler
|
||||
// Cleanup on error
|
||||
await rm(tmpAudioInput).catch(() => {});
|
||||
await rm(tmpAudioWav).catch(() => {});
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export default defineEventHandler(async (event) => {
|
||||
let actualMime = mime;
|
||||
|
||||
if (useSpeaches) {
|
||||
// Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM)
|
||||
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
|
||||
const baseUrl = runtimeConfig.speachesBaseUrl || "";
|
||||
const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low";
|
||||
if (!baseUrl) {
|
||||
@@ -169,16 +169,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
audioBuffer = await speachesTTS(normalized, voice, model, fmt, baseUrl);
|
||||
modelUsed = model;
|
||||
// Server liefert korrektes Format gemäß response_format
|
||||
// Server returns the correct format according to response_format
|
||||
actualMime = fmtToMime(fmt);
|
||||
} else if (usePiper) {
|
||||
// Lokaler Piper
|
||||
// Local Piper
|
||||
audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort);
|
||||
modelUsed = "piper-local";
|
||||
// Piper liefert WAV
|
||||
// Piper returns WAV
|
||||
actualMime = "audio/wav";
|
||||
} else {
|
||||
// OpenAI (Fallback)
|
||||
// OpenAI (fallback)
|
||||
const tts = await normalize.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
|
||||
actualMime = "audio/wav";
|
||||
}
|
||||
|
||||
// Optional speichern
|
||||
// Optional persistence
|
||||
// await ensureDir(baseDir);
|
||||
// await writeFile(fileOut, audioBuffer);
|
||||
const meta = {
|
||||
|
||||
@@ -14,16 +14,16 @@ export default defineEventHandler(async (event) => {
|
||||
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
|
||||
|
||||
if (accountAgeMs < twoWeeksMs) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
|
||||
throw createError({ statusCode: 403, statusMessage: 'Invitation codes become available after 14 days' })
|
||||
}
|
||||
|
||||
if (user.invitationCodesIssued >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
|
||||
throw createError({ statusCode: 403, statusMessage: 'Limit of two invitation codes reached' })
|
||||
}
|
||||
|
||||
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
|
||||
if (activeCodes >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
|
||||
throw createError({ statusCode: 403, statusMessage: 'Please use your existing invitation codes first' })
|
||||
}
|
||||
|
||||
const code = generateCode()
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const { decision, trace } = await routeDecision(body)
|
||||
|
||||
// Log für Debugging bei off-schema oder radio check
|
||||
// Log for debugging when off-schema or radio check triggers
|
||||
if (decision.off_schema) {
|
||||
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte eine E-Mail-Adresse angeben' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address' })
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email })
|
||||
@@ -32,21 +32,21 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const salutation = user.name ? ` ${user.name}` : ''
|
||||
const lines = [
|
||||
`Hallo${salutation},`,
|
||||
`Hi${salutation},`,
|
||||
'',
|
||||
'du hast den Link zum Zurücksetzen deines OpenSquawk-Passworts angefordert.',
|
||||
`Klicke innerhalb von ${RESET_TOKEN_TTL_MINUTES} Minuten auf den folgenden Link, um ein neues Passwort festzulegen:`,
|
||||
'You requested a link to reset your OpenSquawk password.',
|
||||
`Click the link below within ${RESET_TOKEN_TTL_MINUTES} minutes to choose a new password:`,
|
||||
resetLink,
|
||||
'',
|
||||
'Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.',
|
||||
'If you did not make this request, you can safely ignore this email.',
|
||||
'',
|
||||
'Blue skies,',
|
||||
'dein OpenSquawk-Team',
|
||||
'Your OpenSquawk crew',
|
||||
]
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: 'OpenSquawk Passwort zurücksetzen',
|
||||
subject: 'Reset your OpenSquawk password',
|
||||
text: lines.join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,17 +13,17 @@ export default defineEventHandler(async (event) => {
|
||||
const password = body.password?.trim()
|
||||
|
||||
if (!email || !password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide email and password' })
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email })
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date()
|
||||
|
||||
@@ -23,36 +23,36 @@ export default defineEventHandler(async (event) => {
|
||||
const email = emailInput.toLowerCase()
|
||||
|
||||
if (!emailInput || !password || !code) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide email, password and invitation code' })
|
||||
}
|
||||
|
||||
if (!body.acceptPrivacy || !body.acceptTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please accept the terms and privacy policy' })
|
||||
}
|
||||
|
||||
if (!isValidEmail(emailInput)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte eine gültige E-Mail-Adresse angeben' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide a valid email address' })
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordStrength(password)
|
||||
if (!passwordValidation.valid) {
|
||||
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Passwort ist zu schwach' })
|
||||
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Password is too weak' })
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email })
|
||||
if (existingUser) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
|
||||
throw createError({ statusCode: 409, statusMessage: 'An account already exists for this email address' })
|
||||
}
|
||||
|
||||
const invitation = await InvitationCode.findOne({ code })
|
||||
if (!invitation) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
|
||||
throw createError({ statusCode: 404, statusMessage: 'Invitation code not found' })
|
||||
}
|
||||
if (invitation.usedBy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invitation code has already been used' })
|
||||
}
|
||||
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invitation code has expired' })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
@@ -15,24 +15,24 @@ export default defineEventHandler(async (event) => {
|
||||
const password = body.password?.trim()
|
||||
|
||||
if (!token || !password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Token und neues Passwort erforderlich' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Token and new password required' })
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Das Passwort muss mindestens 8 Zeichen enthalten' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' })
|
||||
}
|
||||
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
const resetToken = await PasswordResetToken.findOne({ tokenHash })
|
||||
|
||||
if (!resetToken || (resetToken.expiresAt && resetToken.expiresAt < new Date()) || resetToken.usedAt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
|
||||
}
|
||||
|
||||
const user = await User.findById(resetToken.user)
|
||||
if (!user) {
|
||||
await resetToken.deleteOne().catch(() => undefined)
|
||||
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
|
||||
}
|
||||
|
||||
user.passwordHash = await hashPassword(password)
|
||||
|
||||
@@ -7,11 +7,11 @@ const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '202
|
||||
export default defineEventHandler(async (event) => {
|
||||
const now = new Date()
|
||||
if (Number.isNaN(CREATION_DEADLINE.getTime())) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für Bootstrap-Deadline ungültig' })
|
||||
throw createError({ statusCode: 500, statusMessage: 'Invalid bootstrap deadline configuration' })
|
||||
}
|
||||
|
||||
if (now > CREATION_DEADLINE) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Bootstrap-Zeitraum abgelaufen' })
|
||||
throw createError({ statusCode: 403, statusMessage: 'Bootstrap window has expired' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))
|
||||
|
||||
@@ -31,14 +31,14 @@ export default defineEventHandler<ManualInviteResponse>(async (event) => {
|
||||
const expectedPassword = (config.manualInvitePassword as string | undefined)?.trim() || ''
|
||||
|
||||
if (!expectedPassword) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für manuellen Einladungscode fehlt' })
|
||||
throw createError({ statusCode: 500, statusMessage: 'Manual invitation code configuration missing' })
|
||||
}
|
||||
|
||||
const body = await readBody<ManualInviteRequestBody>(event).catch(() => ({}) as ManualInviteRequestBody)
|
||||
const providedPassword = body.password?.trim() || ''
|
||||
|
||||
if (!providedPassword || !safeComparePassword(providedPassword, expectedPassword)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültiges Passwort' })
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
@@ -21,19 +21,19 @@ export default defineEventHandler(async (event) => {
|
||||
const allowContact = Boolean(body.allowContact && email)
|
||||
|
||||
if (!title || title.length < MIN_TITLE_LENGTH) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide a short title (at least 4 characters).' })
|
||||
}
|
||||
|
||||
if (!details || details.length < MIN_DETAILS_LENGTH) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please describe your suggestion in at least 20 characters.' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'We need your consent to the privacy policy.' })
|
||||
}
|
||||
|
||||
if (body.allowContact && !email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address so we can contact you.' })
|
||||
}
|
||||
|
||||
const suggestion = await RoadmapSuggestion.create({
|
||||
@@ -45,15 +45,15 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
const dataEntries = [
|
||||
['Titel', title],
|
||||
['Beschreibung', details],
|
||||
['E-Mail', email || null],
|
||||
['Kontaktaufnahme erlaubt', allowContact],
|
||||
['Title', title],
|
||||
['Description', details],
|
||||
['Email', email || null],
|
||||
['Contact allowed', allowContact],
|
||||
]
|
||||
|
||||
await sendAdminNotification({
|
||||
event: 'Neuer Roadmap-Vorschlag',
|
||||
summary: `Neuer Roadmap-Vorschlag: ${title}`,
|
||||
event: 'New roadmap suggestion',
|
||||
summary: `New roadmap suggestion: ${title}`,
|
||||
data: dataEntries,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ votes?: RoadmapVotePayload[] }>(event)
|
||||
|
||||
if (!body?.votes || !Array.isArray(body.votes) || body.votes.length === 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Keine Stimmen übermittelt' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'No votes submitted' })
|
||||
}
|
||||
|
||||
if (body.votes.length > MAX_VOTES_PER_SUBMISSION) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Zu viele Stimmen in einer Anfrage' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Too many votes in a single request' })
|
||||
}
|
||||
|
||||
const normalized = new Map<string, number>()
|
||||
@@ -35,17 +35,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const key = String(vote.key ?? '').trim()
|
||||
if (!ROADMAP_ITEM_KEYS.has(key)) {
|
||||
throw createError({ statusCode: 400, statusMessage: `Unbekannter Roadmap-Eintrag: ${key || '?'}` })
|
||||
throw createError({ statusCode: 400, statusMessage: `Unknown roadmap entry: ${key || '?'}` })
|
||||
}
|
||||
const importance = Math.round(Number(vote.importance ?? 0))
|
||||
if (!Number.isFinite(importance) || importance < 1 || importance > 5) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bewertung muss zwischen 1 und 5 liegen' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Rating must be between 1 and 5' })
|
||||
}
|
||||
normalized.set(key, importance)
|
||||
}
|
||||
|
||||
if (normalized.size === 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Keine gültigen Stimmen gefunden' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'No valid votes found' })
|
||||
}
|
||||
|
||||
const clientHash = buildClientHash(event)
|
||||
|
||||
@@ -17,15 +17,15 @@ export default defineEventHandler(async (event) => {
|
||||
const source = body.source?.trim() || 'landing-updates'
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please confirm the privacy policy' })
|
||||
}
|
||||
|
||||
if (!body.consentMarketing) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'We need your consent to email you product updates' })
|
||||
}
|
||||
|
||||
const result = await registerUpdateSubscriber({
|
||||
@@ -38,13 +38,13 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if (result.created) {
|
||||
const dataEntries = [
|
||||
['E-Mail', email],
|
||||
['Email', email],
|
||||
['Name', name || null],
|
||||
['Quelle', source],
|
||||
['Source', source],
|
||||
]
|
||||
await sendAdminNotification({
|
||||
event: 'Neue Updates-Liste',
|
||||
summary: `Neue Updates-Anmeldung: ${email}`,
|
||||
event: 'New updates signup',
|
||||
summary: `New updates signup: ${email}`,
|
||||
data: dataEntries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export default defineEventHandler(async (event) => {
|
||||
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy || !body.consentTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Accepting the terms and privacy policy is required' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
@@ -57,20 +57,20 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if (!previouslyWantedUpdates && updateResult.created) {
|
||||
const dataEntries = [
|
||||
['E-Mail', email],
|
||||
['Email', email],
|
||||
]
|
||||
if (name) {
|
||||
dataEntries.push(['Name', name])
|
||||
}
|
||||
if (notes) {
|
||||
dataEntries.push(['Notizen', notes])
|
||||
dataEntries.push(['Notes', notes])
|
||||
}
|
||||
dataEntries.push(['Quelle', source])
|
||||
dataEntries.push(['Opt-In', 'Produkt-Updates'])
|
||||
dataEntries.push(['Source', source])
|
||||
dataEntries.push(['Opt-in', 'Product updates'])
|
||||
|
||||
await sendAdminNotification({
|
||||
event: 'Neue Updates-Liste (via Warteliste)',
|
||||
summary: `Produkt-Updates Opt-in (Warteliste): ${email}`,
|
||||
event: 'New updates signup (waitlist)',
|
||||
summary: `Product updates opt-in (waitlist): ${email}`,
|
||||
data: dataEntries,
|
||||
})
|
||||
}
|
||||
@@ -106,20 +106,20 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const dataEntries = [
|
||||
['E-Mail', email],
|
||||
['Email', email],
|
||||
]
|
||||
if (name) {
|
||||
dataEntries.push(['Name', name])
|
||||
}
|
||||
if (notes) {
|
||||
dataEntries.push(['Notizen', notes])
|
||||
dataEntries.push(['Notes', notes])
|
||||
}
|
||||
dataEntries.push(['Quelle', source])
|
||||
dataEntries.push(['Opt-In', wantsProductUpdates ? 'Produkt-Updates' : 'Nur Warteliste'])
|
||||
dataEntries.push(['Source', source])
|
||||
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
|
||||
|
||||
await sendAdminNotification({
|
||||
event: 'Neue Wartelisten-Anmeldung',
|
||||
summary: `Neue Wartelisten-Anmeldung: ${email}`,
|
||||
event: 'New waitlist signup',
|
||||
summary: `New waitlist signup: ${email}`,
|
||||
data: dataEntries,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,81 +9,81 @@ export interface RoadmapItemDefinition {
|
||||
export const ROADMAP_ITEMS: RoadmapItemDefinition[] = [
|
||||
{
|
||||
key: 'touch-ptt-app',
|
||||
title: 'Touch-Webapp für Handy & Tablet',
|
||||
title: 'Touch web app for phones & tablets',
|
||||
description:
|
||||
'Progressive Web-App mit großem Push-to-Talk-Button, vereinfachten Funk-Prompts und geführten Readbacks – ideal zum Üben unterwegs.',
|
||||
'Progressive web app with a large push-to-talk button, simplified radio prompts and guided readbacks — ideal for practice on the go.',
|
||||
category: 'Training',
|
||||
icon: 'mdi-cellphone-sound',
|
||||
},
|
||||
{
|
||||
key: 'realism-upgrades',
|
||||
title: 'Realismus-Boost für Phraseologie',
|
||||
title: 'Phraseology realism boost',
|
||||
description:
|
||||
'Feintuning für Stimmen, Hintergrundrauschen und prozedurale Antworten, damit Clearance, Handoffs und Phraseologie wie am echten Radar klingen.',
|
||||
'Fine-tune voices, background noise and procedural replies so clearances, handoffs and phraseology sound like real radar.',
|
||||
category: 'Simulation',
|
||||
icon: 'mdi-rocket-launch'
|
||||
},
|
||||
{
|
||||
key: 'cockpit-intercom',
|
||||
title: 'Virtuelles Intercom & Checklisten',
|
||||
title: 'Virtual intercom & checklists',
|
||||
description:
|
||||
'Sprich mit einer KI-Copilot:in, lass dir SOP-Checklisten vorlesen und hake Abläufe via Voice oder Touch ab.',
|
||||
'Talk to an AI co-pilot, hear SOP checklists read aloud and tick off flows via voice or touch.',
|
||||
category: 'Crew',
|
||||
icon: 'mdi-account-voice',
|
||||
},
|
||||
{
|
||||
key: 'emergency-training',
|
||||
title: 'Mayday & Pan-Pan Trainingsflows',
|
||||
title: 'Mayday & pan-pan training flows',
|
||||
description:
|
||||
'Geführte Szenarien für Notrufe inkl. Standard-Callouts, Priorisierung durch den Tower und Nachbereitung mit Debrief.',
|
||||
'Guided emergency scenarios with standard callouts, tower prioritization and debrief follow-up.',
|
||||
category: 'Safety',
|
||||
icon: 'mdi-alert-decagram',
|
||||
},
|
||||
{
|
||||
key: 'taxi-routing',
|
||||
title: 'Airport-genaue Taxi-Anweisungen',
|
||||
title: 'Airport-specific taxi instructions',
|
||||
description:
|
||||
'Apt.dat- & OSM-gestütztes Routing mit individuellen Taxi-Flows, Hotspots und visuellen Rollkarten pro Airport.',
|
||||
'Routing powered by apt.dat and OSM with airport-specific taxi flows, hotspots and visual charts.',
|
||||
category: 'Ground',
|
||||
icon: 'mdi-map-marker-path',
|
||||
},
|
||||
{
|
||||
key: 'atc-learning-platform',
|
||||
title: 'ATC-Only Lernplattform',
|
||||
title: 'ATC-only learning platform',
|
||||
description:
|
||||
'Browser-Trainings zum Hören, Buchstabieren und Störgeräusch-Filtern – ICAO-Alphabet, Speed-Drills und Readback-Checks ohne Simulator.',
|
||||
'Browser trainings for listening, spelling and filtering interference — ICAO alphabet, speed drills and readback checks without a simulator.',
|
||||
category: 'Academy',
|
||||
icon: 'mdi-headset',
|
||||
},
|
||||
{
|
||||
key: 'self-hosting',
|
||||
title: 'Selfhosting mit lokalen Modellen',
|
||||
title: 'Self-hosting with local models',
|
||||
description:
|
||||
'Docker-/Compose-Blueprints plus Offline-ASR/TTS-Optionen für lokales Hosting ohne Cloud-Abhängigkeit.',
|
||||
'Docker/Compose blueprints plus offline ASR/TTS options for local hosting without cloud dependencies.',
|
||||
category: 'Infra',
|
||||
icon: 'mdi-server',
|
||||
},
|
||||
{
|
||||
key: 'premium-api-access',
|
||||
title: 'Premium-Zugriff auf schnelle APIs',
|
||||
title: 'Premium access to high-performance APIs',
|
||||
description:
|
||||
'Optionale Monatsabos für performantere Speech- & Sim-APIs mit priorisierten Kontingenten zu niedrigen Beträgen.',
|
||||
'Optional monthly plans for faster speech and sim APIs with prioritized quotas at low prices.',
|
||||
category: 'Business',
|
||||
icon: 'mdi-credit-card-clock',
|
||||
},
|
||||
{
|
||||
key: 'multi-voice',
|
||||
title: 'Mehrere ATC-Stimmen',
|
||||
title: 'Multiple ATC voices',
|
||||
description:
|
||||
'Wechselnde Stimmen je Position, inklusive regionaler Akzente und geschlechtsneutraler Optionen.',
|
||||
'Rotating voices per position, including regional accents and gender-neutral options.',
|
||||
category: 'Immersion',
|
||||
icon: 'mdi-account-multiple',
|
||||
},
|
||||
{
|
||||
key: 'ai-traffic',
|
||||
title: 'AI-generierter ATC-Traffic',
|
||||
title: 'AI-generated ATC traffic',
|
||||
description:
|
||||
'Simulierte andere Piloten für Frequenzaufkommen, inklusive korrekter Callsigns, Handovers und Konflikt-Handling.',
|
||||
'Simulated fellow pilots to increase frequency traffic, including correct callsigns, handovers and conflict handling.',
|
||||
category: 'Traffic',
|
||||
icon: 'mdi-airplane-takeoff',
|
||||
},
|
||||
|
||||
@@ -16,10 +16,10 @@ export const LLM_MODEL = llmModel;
|
||||
export const TTS_MODEL = ttsModel;
|
||||
|
||||
/* =========================
|
||||
LLM PROMPTS (überarbeitet)
|
||||
LLM PROMPTS (refined)
|
||||
=========================
|
||||
Ziel: LLM liefert kompakte, maschinenfreundliche ICAO-Zeile, die unser Normalizer→TTS perfekt erweitert.
|
||||
WICHTIG: Zahlen/Marker exakt im unten definierten Output-Format, keine ausgeschriebenen Wörter.
|
||||
Goal: the LLM returns a compact, machine-friendly ICAO line that our normalizer → TTS can expand perfectly.
|
||||
IMPORTANT: Use the exact output format defined below; do not spell out numbers.
|
||||
*/
|
||||
|
||||
export const ATC_OUTPUT_SPEC = `
|
||||
@@ -43,9 +43,9 @@ OUTPUT RULES (STRICT):
|
||||
- Use standard order for the phase (e.g., taxi: destination RWY first, then route, then hold short).
|
||||
`.trim();
|
||||
|
||||
/** System-Prompt: legt Rolle/Regeln fest */
|
||||
/** System prompt: defines the role and rules */
|
||||
export function atcSystemPrompt(opts?: {
|
||||
regionHint?: "EUR" | "US" | "INTL"; // nur als Soft-Hinweis, default INTL
|
||||
regionHint?: "EUR" | "US" | "INTL"; // soft hint only, defaults to INTL
|
||||
}) {
|
||||
const region = opts?.regionHint ?? "INTL";
|
||||
return [
|
||||
@@ -56,7 +56,7 @@ export function atcSystemPrompt(opts?: {
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
/** Seed-ATC ohne Pilot-Input (rückwärtskompatible Signatur, aber reicherer Prompt) */
|
||||
/** Seed ATC without pilot input (backward-compatible signature, but richer prompt) */
|
||||
export function atcSeedPrompt(s: {
|
||||
airport: string; // e.g., "EDDF"
|
||||
aircraft: string; // e.g., "A320"
|
||||
@@ -66,11 +66,11 @@ export function atcSeedPrompt(s: {
|
||||
sid?: string; // e.g., "MARUN 7F"
|
||||
squawk?: string; // "4723"
|
||||
freq?: string; // "121.800"
|
||||
runway?: string; // "25R" (optional: falls bekannt)
|
||||
runway?: string; // "25R" (optional if known)
|
||||
phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing";
|
||||
notes?: string; // z.B. "TWY N closed between N2–N4"
|
||||
notes?: string; // e.g. "TWY N closed between N2–N4"
|
||||
}) {
|
||||
// Default-Phase: clearance
|
||||
// Default phase: clearance
|
||||
const phase = s.phase || "clearance";
|
||||
const ctx = [
|
||||
`Airport ${s.airport}`,
|
||||
@@ -106,12 +106,12 @@ export function atcSeedPrompt(s: {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Pilot→ATC (rückwärtskompatibler Name, aber mit robustem Rahmen) */
|
||||
/** Pilot → ATC (same legacy name, but with a sturdier framework) */
|
||||
export function atcReplyPrompt(userText: string, state?: {
|
||||
airport?: string; runway?: string; sid?: string; dep?: string;
|
||||
lastSquawk?: string; lastFreq?: string; lastQNH?: string;
|
||||
phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing";
|
||||
constraints?: string; // z.B. "TWY N closed", "no intersection deps on 25C"
|
||||
constraints?: string; // e.g. "TWY N closed", "no intersection deps on 25C"
|
||||
}) {
|
||||
const ctx = [
|
||||
state?.airport ? `Airport ${state.airport}` : null,
|
||||
@@ -137,10 +137,10 @@ export function atcReplyPrompt(userText: string, state?: {
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Normalizer → TTS (wie zuvor)
|
||||
Normalizer → TTS (unchanged)
|
||||
========================= */
|
||||
|
||||
// Airline-Telephony (erweiterbar)
|
||||
// Airline telephony (extensible)
|
||||
export const CALLSIGN_MAP: Record<string,string> = {
|
||||
DLH: "Lufthansa",
|
||||
EWG: "Eurowings",
|
||||
@@ -185,16 +185,16 @@ export async function speakATC(text: string, filePath = "atc.mp3") {
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Beispiele
|
||||
Examples
|
||||
=========================
|
||||
|
||||
— Seed (Clearance):
|
||||
— Seed (clearance):
|
||||
const sys = atcSystemPrompt();
|
||||
const usr = atcSeedPrompt({
|
||||
airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155",
|
||||
dep: "EHAM", sid: "MARUN 7F", runway: "25R", freq: "121.800"
|
||||
});
|
||||
// → LLM antwortet z.B.:
|
||||
// → The LLM might respond:
|
||||
// "DLH359, cleared to EHAM via MARUN 7F, initial 5000 ft, squawk 4723. QNH 1013."
|
||||
|
||||
— Taxi:
|
||||
@@ -205,12 +205,12 @@ const usrTaxi = atcSeedPrompt({
|
||||
});
|
||||
// → "DLH359, taxi to RWY 25R via A3 A N2, hold short."
|
||||
|
||||
— Pilot→ATC:
|
||||
— Pilot → ATC:
|
||||
const usrReply = atcReplyPrompt(
|
||||
"DLH359 ready for departure RWY 25R",
|
||||
{ airport: "EDDF", runway: "25R", phase: "lineup", lastFreq: "121.800" }
|
||||
);
|
||||
// → "DLH359, line up and wait RWY 25R."
|
||||
|
||||
Nach dem LLM-Output: `speakATC(llmText)` ruft Normalizer→TTS.
|
||||
After receiving the LLM output, call `speakATC(llmText)` to trigger normalizer → TTS.
|
||||
*/
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function sendMail(options: MailOptions) {
|
||||
|
||||
const success = await sendViaSmtp(payload)
|
||||
if (!success) {
|
||||
console.info(`[mail:fallback] ${options.subject}\nEmpfänger: ${options.to}\n${options.text}`)
|
||||
console.info(`[mail:fallback] ${options.subject}\nRecipient: ${options.to}\n${options.text}`)
|
||||
}
|
||||
return success
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export async function sendAdminNotification(notification: string | AdminNotifica
|
||||
|
||||
const success = await sendMail(mailOptions)
|
||||
if (!success) {
|
||||
console.info(`[notify:fallback] ${mailOptions.subject}\nEmpfänger: ${to}\n${mailOptions.text}`)
|
||||
console.info(`[notify:fallback] ${mailOptions.subject}\nRecipient: ${to}\n${mailOptions.text}`)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ function ensureOpenAI(): OpenAI {
|
||||
if (!openaiClient) {
|
||||
const { openaiKey, openaiProject, llmModel } = getServerRuntimeConfig()
|
||||
if (!openaiKey) {
|
||||
throw new Error('OPENAI_API_KEY fehlt. Bitte den Schlüssel setzen, bevor KI-Funktionen genutzt werden.')
|
||||
throw new Error('OPENAI_API_KEY is missing. Please set the key before using AI features.')
|
||||
}
|
||||
const clientOptions: ConstructorParameters<typeof OpenAI>[0] = { apiKey: openaiKey }
|
||||
if (openaiProject) {
|
||||
@@ -246,9 +246,9 @@ function extractTemplateVariables(text?: string): string[] {
|
||||
return matches.map(match => match.slice(1, -1)) // Remove { }
|
||||
}
|
||||
|
||||
// Optimierte aber ausreichende Eingabe für gute Entscheidungen
|
||||
// Optimized yet sufficient input for reliable decisions
|
||||
function optimizeInputForLLM(input: LLMDecisionInput) {
|
||||
// Sammle alle verfügbaren Variablen aus dem Decision Tree
|
||||
// Collect all available variables from the decision tree
|
||||
const availableVariables = [
|
||||
'callsign', 'dest', 'dep', 'runway', 'squawk', 'sid', 'transition',
|
||||
'initial_altitude_ft', 'climb_altitude_ft', 'cruise_flight_level',
|
||||
@@ -308,7 +308,7 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
|
||||
current_role: input.state.role,
|
||||
state_summary: stateSummary,
|
||||
candidates: candidates,
|
||||
available_variables: availableVariables, // Alle verfügbaren Variablen
|
||||
available_variables: availableVariables, // All available variables
|
||||
candidate_variables: Array.from(candidateVars), // Variablen die Candidates verwenden
|
||||
pilot_utterance: input.pilot_utterance,
|
||||
decision_hints: {
|
||||
@@ -318,7 +318,7 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
|
||||
has_interrupt_candidate: input.candidates.some(c => c.id.startsWith('INT_')),
|
||||
readback_check_state: Boolean(readbackKeys.length)
|
||||
},
|
||||
// Nur aktueller Context ohne Werte (für Token-Sparen)
|
||||
// Current context only without values (to save tokens)
|
||||
context: {
|
||||
callsign: input.variables.callsign,
|
||||
current_unit: input.flags.current_unit,
|
||||
@@ -459,7 +459,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
}
|
||||
}
|
||||
|
||||
// Sofortige Erkennung ohne LLM für häufige Cases
|
||||
// Instant detection without the LLM for common cases
|
||||
if (pilotText.includes('radio check') || pilotText.includes('signal test') ||
|
||||
(pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) {
|
||||
return finalize({
|
||||
@@ -479,17 +479,17 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
const optimizedInput = optimizeInputForLLM(input)
|
||||
|
||||
// Prüfe ob nächste States ATC-Responses brauchen
|
||||
// Check whether the next states require ATC responses
|
||||
const atcCandidates = input.candidates.filter(c =>
|
||||
c.state.role === 'atc' || c.state.say_tpl || c.id.startsWith('INT_')
|
||||
)
|
||||
|
||||
// Wenn keine ATC-States verfügbar, einfache Transition ohne Response
|
||||
// If no ATC states are available, perform a simple transition without a response
|
||||
if (atcCandidates.length === 0 && input.candidates.length > 0) {
|
||||
return finalize({ next_state: input.candidates[0].id })
|
||||
}
|
||||
|
||||
// Kompakter aber informativer Prompt - mit Variable-Info für intelligente Responses
|
||||
// Compact yet informative prompt — includes variable info for intelligent responses
|
||||
const system = [
|
||||
'You are an ATC state router. Return strict JSON.',
|
||||
'Keys: next_state, controller_say_tpl (optional), off_schema (optional), intent (optional).',
|
||||
@@ -593,7 +593,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
})
|
||||
}
|
||||
|
||||
// Pilot readback oder acknowledgment → keine ATC response nötig
|
||||
// Pilot readback or acknowledgment → no ATC response required
|
||||
if (pilotText.includes('wilco') || pilotText.includes('roger') ||
|
||||
pilotText.includes('cleared') || pilotText.includes('copied')) {
|
||||
fallbackInfo.selected = 'acknowledge'
|
||||
|
||||
@@ -58,7 +58,7 @@ export function getServerRuntimeConfig(): ServerRuntimeConfig {
|
||||
|
||||
const openaiKey = String(runtimeConfig.openaiKey || '').trim()
|
||||
if (!openaiKey && !warnedMissingOpenAIKey) {
|
||||
console.warn('[OpenSquawk] OPENAI_API_KEY fehlt. Einige KI-Funktionen stehen ohne Schlüssel nicht zur Verfügung.')
|
||||
console.warn('[OpenSquawk] OPENAI_API_KEY is missing. Some AI features are unavailable without a key.')
|
||||
warnedMissingOpenAIKey = true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ export interface PasswordValidationResult {
|
||||
export function validatePasswordStrength(password: string): PasswordValidationResult {
|
||||
const trimmed = password.trim()
|
||||
if (trimmed.length < 10) {
|
||||
return { valid: false, message: 'Passwort muss mindestens 10 Zeichen lang sein.' }
|
||||
return { valid: false, message: 'Password must be at least 10 characters long.' }
|
||||
}
|
||||
if (/\s/.test(trimmed)) {
|
||||
return { valid: false, message: 'Passwort darf keine Leerzeichen enthalten.' }
|
||||
return { valid: false, message: 'Password cannot contain spaces.' }
|
||||
}
|
||||
if (!/[A-Za-zÄÖÜäöüß]/.test(trimmed) || !/[0-9]/.test(trimmed)) {
|
||||
return { valid: false, message: 'Bitte Buchstaben und Zahlen kombinieren.' }
|
||||
return { valid: false, message: 'Please use both letters and numbers.' }
|
||||
}
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmed)) {
|
||||
return { valid: false, message: 'Mindestens ein Sonderzeichen erhöht die Sicherheit.' }
|
||||
return { valid: false, message: 'Include at least one special character for better security.' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user