diff --git a/App.tsx b/App.tsx index 915f348..9a72f0f 100644 --- a/App.tsx +++ b/App.tsx @@ -27,6 +27,7 @@ import { AnalyseScreen } from './src/screens/AnalyseScreen'; import { AntwortScreen } from './src/screens/AntwortScreen'; import { GlossarScreen } from './src/screens/GlossarScreen'; import { EinstellungenScreen } from './src/screens/EinstellungenScreen'; +import { VerbindenScreen } from './src/screens/VerbindenScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -131,6 +132,7 @@ export default function App() { + diff --git a/src/components/Ikone.tsx b/src/components/Ikone.tsx index 6dd6c72..8c0fcd7 100644 --- a/src/components/Ikone.tsx +++ b/src/components/Ikone.tsx @@ -32,7 +32,9 @@ export type IkonenName = | 'archiv' | 'schloss' | 'lupe' - | 'pfeilLinks'; + | 'pfeilLinks' + | 'computer' + | 'qrScannen'; // `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen // die offizielle SF-Symbol-/Material-Namensliste von expo-symbols @@ -62,6 +64,8 @@ const NAMEN = { schloss: { ios: 'lock.fill', android: 'lock' }, lupe: { ios: 'magnifyingglass', android: 'search' }, pfeilLinks: { ios: 'chevron.left', android: 'chevron_left' }, + computer: { ios: 'desktopcomputer', android: 'computer' }, + qrScannen: { ios: 'qrcode.viewfinder', android: 'qr_code_scanner' }, } as const; interface Props { diff --git a/src/screens/AnalyseScreen.tsx b/src/screens/AnalyseScreen.tsx index ffab6df..413f60a 100644 --- a/src/screens/AnalyseScreen.tsx +++ b/src/screens/AnalyseScreen.tsx @@ -287,6 +287,16 @@ export function AnalyseScreen({ navigation, route }: Props) { )} + {/* ── In der Beratung: Brief auf dem Bildschirm zeigen ── */} + + navigation.navigate('Verbinden', { modus: 'senden', briefId: brief.id })} + /> + + {/* ── Datenschutz-Fuß + Löschen ── */} diff --git a/src/screens/ArchivScreen.tsx b/src/screens/ArchivScreen.tsx index 4c4dc3c..2f08119 100644 --- a/src/screens/ArchivScreen.tsx +++ b/src/screens/ArchivScreen.tsx @@ -12,6 +12,7 @@ import { useAppStore } from '../store/useAppStore'; import { berechneAmpel } from '../utils/ampel'; import { formatiereDatum } from '../services/erinnerungen'; import { Ikone } from '../components/Ikone'; +import { GrossButton } from '../components/GrossButton'; import { farben, schrift, abstand, TOUCH_TARGET } from '../theme'; type Nav = NativeStackNavigationProp; @@ -144,6 +145,16 @@ export function ArchivScreen() { })} )} + + {/* Ergebnis vom Beratungs-PC per QR-Code übernehmen */} + + navigation.navigate('Verbinden', { modus: 'empfangen' })} + /> + ); } diff --git a/src/screens/VerbindenScreen.tsx b/src/screens/VerbindenScreen.tsx new file mode 100644 index 0000000..85292a4 --- /dev/null +++ b/src/screens/VerbindenScreen.tsx @@ -0,0 +1,191 @@ +/** + * QR-Code vom Beratungs-PC scannen (Übergabe Handy ↔ PC). + * + * Zwei Wege, beide Ende-zu-Ende verschlüsselt: + * - „senden": ein Brief aus dem Archiv erscheint am PC (der PC zeigt dafür + * auf behoerdenklar.de/demo „Lieber mit dem Handy fotografieren") + * - „empfangen": das Ergebnis vom PC landet im Archiv, mit Erinnerungen + * (der PC zeigt dafür „Ergebnis aufs Handy mitnehmen") + * + * Ohne Kamera (Simulator, Erlaubnis verweigert) kann man den Link einfügen. + */ +import React, { useRef, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TextInput, View, ScrollView } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../types'; +import { useAppStore, neueId } from '../store/useAppStore'; +import { planeErinnerungen } from '../services/erinnerungen'; +import { holeAusUebergabe, loescheUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../services/uebergabe'; +import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief, WebPaket } from '../utils/uebergabeFormat'; +import { GrossButton } from '../components/GrossButton'; +import { Ikone } from '../components/Ikone'; +import { farben, schrift, abstand } from '../theme'; + +type Props = NativeStackScreenProps; + +type Zustand = + | { art: 'scannen' } + | { art: 'arbeitet'; text: string } + | { art: 'fertig'; text: string } + | { art: 'fehler'; text: string }; + +export function VerbindenScreen({ navigation, route }: Props) { + const modus = route.params.modus; + const briefId = route.params.modus === 'senden' ? route.params.briefId : null; + const brief = useAppStore((s) => s.briefe.find((b) => b.id === briefId)); + const addBrief = useAppStore((s) => s.addBrief); + const [erlaubnis, frageErlaubnis] = useCameraPermissions(); + const [zustand, setZustand] = useState({ art: 'scannen' }); + const [eingefuegt, setEingefuegt] = useState(''); + const beschaeftigt = useRef(false); // Kamera meldet denselben Code mehrfach + + async function verarbeite(text: string) { + if (beschaeftigt.current) return; + beschaeftigt.current = true; + try { + const link = leseUebergabeLink(text); + if (!link) { + setZustand({ art: 'fehler', text: 'Das ist kein QR-Code von BehördenKlar. Bitte den Code auf dem Bildschirm der Beratung scannen.' }); + return; + } + if (link.art === 'g') { + setZustand({ art: 'fehler', text: 'Dieser Code ist für die Webseite gedacht. Öffnen Sie ihn mit der normalen Handy-Kamera.' }); + return; + } + + if (modus === 'senden') { + if (link.art !== 'k') { + setZustand({ art: 'fehler', text: 'Dieser Code ist zum Mitnehmen. Gehen Sie dafür ins Archiv auf „Vom Computer übernehmen".' }); + return; + } + if (!brief) { + setZustand({ art: 'fehler', text: 'Der Brief wurde nicht gefunden.' }); + return; + } + setZustand({ art: 'arbeitet', text: 'Der Brief wird verschlüsselt geschickt …' }); + const u = await oeffneUebergabe(link); + await sendeAnUebergabe(u, 'status', { schritt: 'verbunden' }); + await sendeAnUebergabe(u, 'ergebnis', briefZuWebPaket(brief)); + setZustand({ art: 'fertig', text: 'Fertig. Der Brief ist jetzt auf dem Bildschirm der Beratung zu sehen.' }); + return; + } + + // empfangen + if (link.art !== 'm') { + setZustand({ art: 'fehler', text: 'Dieser Code wartet auf einen Brief. Öffnen Sie dafür den Brief und tippen Sie auf „Am Computer zeigen".' }); + return; + } + setZustand({ art: 'arbeitet', text: 'Das Ergebnis wird geholt …' }); + const u = await oeffneUebergabe(link); + const faecher = await holeAusUebergabe(u); + const paket = faecher.ergebnis as WebPaket | undefined; + if (!paket?.analyse) { + setZustand({ art: 'fehler', text: 'Dieser Code ist abgelaufen oder wurde schon benutzt (er gilt 15 Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.' }); + return; + } + loescheUebergabe(u); + const neu = webPaketZuBrief(paket, neueId(), new Date()); + await addBrief(neu); + planeErinnerungen(neu).catch(() => {}); + navigation.replace('Analyse', { briefId: neu.id }); + } catch { + setZustand({ art: 'fehler', text: 'Keine Verbindung. Bitte prüfen Sie das Internet und versuchen Sie es noch einmal.' }); + } finally { + beschaeftigt.current = false; + } + } + + const titel = modus === 'senden' ? 'Am Computer zeigen' : 'Vom Computer übernehmen'; + const anleitung = + modus === 'senden' + ? 'Die Beratung öffnet behoerdenklar.de/demo und tippt auf „Lieber mit dem Handy fotografieren". Scannen Sie den QR-Code, dann erscheint Ihr Brief auf dem Bildschirm.' + : 'Die Beratung tippt am Computer auf „Ergebnis aufs Handy mitnehmen". Scannen Sie den QR-Code, dann landet der Brief verschlüsselt in Ihrem Archiv.'; + + return ( + + {titel} + {anleitung} + + {zustand.art === 'scannen' && ( + + {erlaubnis?.granted ? ( + verarbeite(data)} + /> + ) : ( + + Für den QR-Code braucht die App kurz die Kamera. + + + )} + + )} + + {zustand.art === 'arbeitet' && ( + + + {zustand.text} + + )} + {zustand.art === 'fertig' && ( + + + {zustand.text} + navigation.goBack()} /> + + )} + {zustand.art === 'fehler' && ( + + + {zustand.text} + setZustand({ art: 'scannen' })} /> + + )} + + + + Ende-zu-Ende verschlüsselt · nach 15 Minuten gelöscht + + + {zustand.art === 'scannen' && ( + + Kein Scannen möglich? Link hier einfügen: + + verarbeite(eingefuegt)} /> + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: farben.hintergrund }, + titel: { fontSize: schrift.titel, fontWeight: '700', color: farben.text, marginBottom: abstand.s }, + text: { fontSize: schrift.basis, color: farben.text, lineHeight: 26 }, + klein: { fontSize: schrift.klein, color: farben.textSekundaer, marginBottom: abstand.s }, + kameraRahmen: { + marginTop: abstand.m, height: 300, borderRadius: 16, overflow: 'hidden', + backgroundColor: farben.flaecheSekundaer, + }, + kameraLeer: { flex: 1, justifyContent: 'center', padding: abstand.m, gap: abstand.m }, + meldung: { marginTop: abstand.m, gap: abstand.m, padding: abstand.m, borderRadius: 16, backgroundColor: farben.flaeche }, + schutzZeile: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: abstand.m }, + schutzText: { fontSize: 13, color: farben.textTertiaer }, + feld: { + backgroundColor: farben.flaeche, borderRadius: 12, padding: 14, fontSize: schrift.klein, + color: farben.text, borderWidth: 1, borderColor: farben.rand, marginBottom: abstand.s, + }, +}); diff --git a/src/services/uebergabe.ts b/src/services/uebergabe.ts new file mode 100644 index 0000000..1b685a2 --- /dev/null +++ b/src/services/uebergabe.ts @@ -0,0 +1,59 @@ +/** + * Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt (AES-256-GCM). + * + * Päckchen-Format wie auf der Webseite: base64url(IV[12] || Chiffrat || Tag[16]). + * expo-crypto liefert mit `combined()` genau diese Reihenfolge, deshalb können + * App und Webseite die Päckchen gegenseitig lesen. Der Worker sieht nur + * Chiffrat und löscht alles nach 15 Minuten. + */ +import { AESEncryptionKey, AESSealedData, aesDecryptAsync, aesEncryptAsync } from 'expo-crypto'; +import { utf8Dekodieren, utf8Kodieren } from '../utils/utf8'; +import { UebergabeLink, base64UrlZuBase64, base64ZuBase64Url } from '../utils/uebergabeFormat'; + +export interface OffeneUebergabe { + link: UebergabeLink; + schluessel: AESEncryptionKey; +} + +export async function oeffneUebergabe(link: UebergabeLink): Promise { + const schluessel = await AESEncryptionKey.import(base64UrlZuBase64(link.schluessel), 'base64'); + return { link, schluessel }; +} + +async function verschluesseln(u: OffeneUebergabe, daten: unknown): Promise { + const versiegelt = await aesEncryptAsync(utf8Kodieren(JSON.stringify(daten)), u.schluessel); + return base64ZuBase64Url((await versiegelt.combined('base64')) as string); +} + +async function entschluesseln(u: OffeneUebergabe, text: string): Promise { + try { + const versiegelt = AESSealedData.fromCombined(base64UrlZuBase64(text)); + const bytes = (await aesDecryptAsync(versiegelt, u.schluessel)) as Uint8Array; + return JSON.parse(utf8Dekodieren(bytes)); + } catch { + return null; // nicht von dieser Übergabe oder beschädigt + } +} + +const adresse = (u: OffeneUebergabe) => `${u.link.basis}/uebergabe/${u.link.id}`; + +export async function sendeAnUebergabe(u: OffeneUebergabe, fach: string, daten: unknown): Promise { + const r = await fetch(`${adresse(u)}/${fach}`, { method: 'PUT', body: await verschluesseln(u, daten) }); + if (!r.ok) throw new Error(`Übergabe fehlgeschlagen (${r.status})`); +} + +export async function holeAusUebergabe(u: OffeneUebergabe): Promise> { + const r = await fetch(adresse(u), { headers: { 'cache-control': 'no-store' } }); + if (!r.ok) throw new Error(`Übergabe nicht erreichbar (${r.status})`); + const faecher = ((await r.json()) as { faecher?: Record }).faecher ?? {}; + const aus: Record = {}; + for (const [fach, wert] of Object.entries(faecher)) { + const d = await entschluesseln(u, wert); + if (d) aus[fach] = d; + } + return aus; +} + +export function loescheUebergabe(u: OffeneUebergabe): void { + fetch(adresse(u), { method: 'DELETE' }).catch(() => {}); +} diff --git a/src/types.ts b/src/types.ts index df1dcb5..8424a83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,4 +145,6 @@ export type RootStackParamList = { Antwort: { briefId: string }; Glossar: undefined; Einstellungen: undefined; + /** QR-Code vom Beratungs-PC: Brief hinschicken oder Ergebnis übernehmen. */ + Verbinden: { modus: 'senden'; briefId: string } | { modus: 'empfangen' }; }; diff --git a/src/utils/__tests__/uebergabe.test.ts b/src/utils/__tests__/uebergabe.test.ts new file mode 100644 index 0000000..086e5c3 --- /dev/null +++ b/src/utils/__tests__/uebergabe.test.ts @@ -0,0 +1,112 @@ +/// +/** + * Übergabe Handy ↔ PC: App und Webseite müssen die Päckchen gegenseitig lesen. + * + * Die Web-Seite wird hier mit WebCrypto nachgebaut — exakt wie in + * webseite/uebergabe.js. Für die App läuft die echte Web-Implementierung von + * expo-crypto (gleiche Kombi-Reihenfolge IV‖Chiffrat‖Tag wie nativ). + */ +jest.mock('expo-crypto/build/aes/ExpoCryptoAES', () => jest.requireActual('expo-crypto/build/aes/ExpoCryptoAES.web')); + +import { webcrypto } from 'crypto'; +import { holeAusUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../../services/uebergabe'; +import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief } from '../uebergabeFormat'; +import { BriefEintrag } from '../../types'; + +const subtle = webcrypto.subtle; +const b64u = (b: Uint8Array) => Buffer.from(b).toString('base64url'); + +// --- Web-Seite (wie webseite/uebergabe.js) --- +async function webSchluessel(k: string) { + return subtle.importKey('raw', Buffer.from(k, 'base64url'), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} +async function webVerschluesseln(k: string, daten: unknown) { + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await webSchluessel(k), new TextEncoder().encode(JSON.stringify(daten)))); + return b64u(Buffer.concat([iv, ct])); +} +async function webEntschluesseln(k: string, text: string) { + const b = Buffer.from(text, 'base64url'); + const klar = await subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, await webSchluessel(k), b.subarray(12)); + return JSON.parse(new TextDecoder().decode(klar)); +} + +const ID = 'v1wRNjDlmRZeGSS_YRiRVA'; +const KEY = b64u(webcrypto.getRandomValues(new Uint8Array(32))); +const link = (a: string) => `https://behoerdenklar.de/demo#a=${a}&s=${ID}&k=${KEY}&l=ar&d=1`; + +const ANALYSE_DE = { + brieftyp: 'Aufforderung', absender: 'Ausländerbehörde', kernaussage: 'Unterlagen fehlen.', + erklaerung_einfach: 'Bitte bis 12.11. einreichen.', fachbegriffe: [{ begriff: 'Fiktionsbescheinigung', erklaerung: 'Papier' }], + frist: { datum: '2026-11-12', aktion: 'Unterlagen einreichen' }, termin: null, checkliste: ['Pass'], + kontakt: { telefon: '069 90009-314' }, anruf: [], +}; +const ANALYSE_AR = { ...ANALYSE_DE, kernaussage: 'المستندات ناقصة.', checkliste: ['جواز السفر'] }; + +describe('leseUebergabeLink', () => { + it('liest Kamera- und Mitnehmen-Codes', () => { + expect(leseUebergabeLink(link('k'))).toMatchObject({ art: 'k', id: ID, schluessel: KEY, basis: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev' }); + expect(leseUebergabeLink(link('m'))?.art).toBe('m'); + }); + it('nimmt beim lokalen Test den lokalen Worker', () => { + expect(leseUebergabeLink(`http://localhost:4173/demo.html#a=m&s=${ID}&k=${KEY}`)?.basis).toBe('http://localhost:8799'); + }); + it('lehnt fremde Seiten und kaputte Codes ab', () => { + expect(leseUebergabeLink(`https://boese.example/demo#a=m&s=${ID}&k=${KEY}`)).toBeNull(); + expect(leseUebergabeLink('https://behoerdenklar.de/demo#a=m&s=kurz&k=kurz')).toBeNull(); + expect(leseUebergabeLink('Hallo')).toBeNull(); + }); +}); + +describe('Verschlüsselung App ↔ Webseite', () => { + const fetchOriginal = global.fetch; + afterEach(() => { global.fetch = fetchOriginal; }); + + it('App liest, was die Webseite verschlüsselt hat', async () => { + const chiffrat = await webVerschluesseln(KEY, { analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }); + global.fetch = jest.fn(async () => new Response(JSON.stringify({ faecher: { ergebnis: chiffrat, fremd: 'QUJD' } }))) as typeof fetch; + const u = await oeffneUebergabe(leseUebergabeLink(link('m'))!); + const faecher = await holeAusUebergabe(u); + expect(faecher.ergebnis).toEqual({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }); + expect(faecher.fremd).toBeUndefined(); // nicht entschlüsselbar → ignoriert + }); + + it('Webseite liest, was die App verschlüsselt hat', async () => { + let gesendet = ''; + let adresse = ''; + global.fetch = jest.fn(async (url: string, opt: RequestInit) => { + adresse = url; + gesendet = String(opt.body); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + const u = await oeffneUebergabe(leseUebergabeLink(link('k'))!); + await sendeAnUebergabe(u, 'ergebnis', { analyse: ANALYSE_DE, sprache: 'de' }); + expect(adresse).toBe(`https://behoerdenklar-proxy.behoerdenbrief.workers.dev/uebergabe/${ID}/ergebnis`); + expect(gesendet).toMatch(/^[A-Za-z0-9_-]+$/); // base64url, wie der Worker verlangt + expect(await webEntschluesseln(KEY, gesendet)).toEqual({ analyse: ANALYSE_DE, sprache: 'de' }); + }); +}); + +describe('Umwandeln Web ↔ App', () => { + it('Ergebnis vom PC wird ein Brief: Deutsch als Grundlage, Arabisch als Übersetzung', () => { + const b = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id1', new Date('2026-10-29T10:00:00Z')); + expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.'); + expect(b.analyse.frist).toEqual({ datum: '2026-11-12', aktion: 'Unterlagen einreichen' }); + expect(b.analyse.antwort_noetig).toBe(false); + expect(b.uebersetzungen.ar.kernaussage).toBe('المستندات ناقصة.'); + expect(b.erstelltAm).toBe('2026-10-29T10:00:00.000Z'); + }); + it('rein deutsches Ergebnis hat keine Übersetzung', () => { + const b = webPaketZuBrief({ analyse: ANALYSE_DE, sprache: 'de' }, 'id2', new Date()); + expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.'); + expect(b.uebersetzungen).toEqual({}); + }); + it('Brief aus dem Archiv geht zweisprachig an den PC', () => { + const brief = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id3', new Date()); + const p = briefZuWebPaket(brief as BriefEintrag); + expect(p.sprache).toBe('ar'); + expect(p.analyse.kernaussage).toBe('المستندات ناقصة.'); + expect(p.analyse.frist).toEqual(ANALYSE_DE.frist); + expect(p.analyse_de?.kernaussage).toBe('Unterlagen fehlen.'); + }); +}); diff --git a/src/utils/uebergabeFormat.ts b/src/utils/uebergabeFormat.ts new file mode 100644 index 0000000..cfc2399 --- /dev/null +++ b/src/utils/uebergabeFormat.ts @@ -0,0 +1,117 @@ +/** + * Übergabe Handy ↔ PC — reine Hilfsfunktionen ohne native Module (testbar). + * + * Gleiches Protokoll wie die Webseite (webseite/uebergabe.js): Der QR-Code + * enthält hinter dem `#` eine Übergabe-ID (s), einen AES-256-Schlüssel (k) + * und die Art der Übergabe (a): + * a=k „Kamera": der PC wartet auf einen Brief → App schickt ihn hin + * a=m „Mitnehmen": der PC hat ein Ergebnis bereitgelegt → App holt es + * a=g Guthaben der Webseite (gilt nur im Browser, nicht in der App) + */ +import { BriefAnalyse, BriefEintrag, Fachbegriff, Uebersetzung } from '../types'; + +export type UebergabeArt = 'k' | 'm' | 'g'; + +export interface UebergabeLink { + art: UebergabeArt; + id: string; + schluessel: string; // base64url, 32 Byte + /** Worker, der die Päckchen hält (lokal beim Testen, sonst live). */ + basis: string; +} + +const LIVE_BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev'; +const LOKAL_BASIS = 'http://localhost:8799'; +const ERLAUBTE_SEITEN = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)\//; +const LOKALE_SEITE = /^http:\/\/(localhost|127\.0\.0\.1):\d+\//; + +/** Liest einen gescannten QR-Code. null = kein BehördenKlar-Code. */ +export function leseUebergabeLink(text: string): UebergabeLink | null { + const t = (text || '').trim(); + const lokal = LOKALE_SEITE.test(t); + if (!ERLAUBTE_SEITEN.test(t) && !lokal) return null; + const raute = t.indexOf('#'); + if (raute < 0) return null; + const werte: Record = {}; + for (const teil of t.slice(raute + 1).split('&')) { + const i = teil.indexOf('='); + if (i > 0) werte[teil.slice(0, i)] = decodeURIComponent(teil.slice(i + 1)); + } + const art = werte.a as UebergabeArt; + if (art === 'g') return { art, id: '', schluessel: '', basis: '' }; + if ((art !== 'k' && art !== 'm') || !/^[A-Za-z0-9_-]{22}$/.test(werte.s ?? '') || !/^[A-Za-z0-9_-]{43}$/.test(werte.k ?? '')) { + return null; + } + return { art, id: werte.s, schluessel: werte.k, basis: lokal ? LOKAL_BASIS : LIVE_BASIS }; +} + +export function base64UrlZuBase64(s: string): string { + const b = s.replace(/-/g, '+').replace(/_/g, '/'); + return b + '='.repeat((4 - (b.length % 4)) % 4); +} +export function base64ZuBase64Url(s: string): string { + return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Was die Webseite als Ergebnis schickt bzw. erwartet. */ +export interface WebPaket { + analyse: WebAnalyse; + analyse_de?: WebAnalyse; + sprache?: string; +} +/** Die Analyse der Webseite: wie BriefAnalyse, dafür mit Kontakt + Anruf-Hilfe. */ +type WebAnalyse = Partial & { + kontakt?: unknown; + anruf?: unknown; +}; + +function textOder(s: unknown, ersatz = ''): string { + return typeof s === 'string' ? s : ersatz; +} +function alsBriefAnalyse(w: WebAnalyse): BriefAnalyse { + return { + brieftyp: textOder(w.brieftyp, 'Brief'), + absender: textOder(w.absender), + kernaussage: textOder(w.kernaussage), + erklaerung_einfach: textOder(w.erklaerung_einfach), + fachbegriffe: Array.isArray(w.fachbegriffe) ? (w.fachbegriffe as Fachbegriff[]) : [], + frist: w.frist ?? null, + termin: w.termin ?? null, + checkliste: Array.isArray(w.checkliste) ? w.checkliste.filter((x) => typeof x === 'string') : [], + // Die Web-Analyse kennt keine Antwort-Vorschläge — die App kann sie nicht erfinden + antwort_noetig: false, + antwort_optionen: [], + }; +} + +/** Ergebnis vom PC → Brief fürs Archiv. Deutsch ist die Grundlage, die + * andere Sprache landet im Übersetzungs-Cache (wie bei App-Übersetzungen). */ +export function webPaketZuBrief(p: WebPaket, id: string, jetzt: Date): BriefEintrag { + const sprache = p.sprache && p.sprache !== 'de' ? p.sprache : null; + const deutsch = p.analyse_de ?? (sprache ? null : p.analyse); + const basis = alsBriefAnalyse(deutsch ?? p.analyse); + const uebersetzungen: Record = {}; + if (sprache) { + const f = alsBriefAnalyse(p.analyse); + uebersetzungen[sprache] = { + kernaussage: f.kernaussage, + erklaerung_einfach: f.erklaerung_einfach, + checkliste: f.checkliste, + fachbegriffe: f.fachbegriffe, + }; + } + return { id, erstelltAm: jetzt.toISOString(), analyse: basis, uebersetzungen }; +} + +/** Brief aus dem Archiv → Ergebnis für den PC. Gibt es eine Übersetzung, + * bekommt der PC beide Sprachen (Deutsch zuerst für die Beratung). */ +export function briefZuWebPaket(b: BriefEintrag): WebPaket { + const deutsch: WebAnalyse = { ...b.analyse }; + const [sprache] = Object.keys(b.uebersetzungen ?? {}); + if (!sprache) return { analyse: deutsch, sprache: 'de' }; + return { + analyse: { ...b.analyse, ...b.uebersetzungen[sprache] }, + analyse_de: deutsch, + sprache, + }; +}