Files
AirCardIOS/web/src/account/cards.ts
T
ClaudeandClaude Opus 5.5 1ff1873b27 feat(i18n): Konto, Editor und Karte auf Englisch
Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.

- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
  validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
  Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
  mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
  editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
  {reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
  gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
  Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
  großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
  muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
  „kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
  der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
  der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:58:49 +00:00

271 lines
11 KiB
TypeScript

// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state';
import { msg, t } from '../i18n/i18n';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth';
import { errorType } from './errors';
export interface CardRowData {
name: string;
config: string;
imageFileId: string;
previewFileId: string;
}
export interface CardRow extends CardRowData {
$id: string;
$updatedAt: string;
}
export interface CardsApi {
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>;
fetchFile(fileId: string): Promise<Blob>;
createRow(data: CardRowData, permissions: string[]): Promise<CardRow>;
updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>;
getRow(id: string): Promise<CardRow>;
deleteRow(id: string): Promise<void>;
listRows(): Promise<CardRow[]>;
}
export interface SavedCard extends SavedCardRef {
config: CardConfig;
}
export interface SaveInput {
name: string;
config: CardConfig;
image: Blob;
/** false: das Bild ist schon gespeichert und wird nicht erneut hochgeladen (nur bei `previous`). */
imageChanged: boolean;
preview: Blob;
previous: SavedCardRef | null;
}
export const DEFAULT_CARD_NAME = msg('Meine Karte');
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export { ID_PATTERN };
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
export function normalizeName(name: string): string {
const collapsed = name.replace(/\s+/g, ' ').trim();
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
return cut || t(DEFAULT_CARD_NAME);
}
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
export function ownerPermissions(userId: string): string[] {
const owner = Role.user(userId);
return [Permission.read(owner), Permission.update(owner), Permission.delete(owner)];
}
const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
/** Verpackt ein Bild als Datei für den Bucket; der Name folgt dem Typ (der Bucket prüft die Endung). */
export function toUploadFile(blob: Blob, base: 'image' | 'preview'): File {
const ext = EXTENSIONS[blob.type];
if (!ext) throw new Error(`Bildtyp nicht erlaubt: ${blob.type || 'unbekannt'}`);
return new File([blob], `${base}.${ext}`, { type: blob.type });
}
function toSaved(row: CardRow): SavedCard {
return {
id: row.$id,
name: row.name,
imageFileId: row.imageFileId,
previewFileId: row.previewFileId,
updatedAt: row.$updatedAt,
config: parseConfig(row.config),
};
}
/** Räumt auf, soweit es geht; ein Fehler (auch 404) ist hier nie ein Grund abzubrechen. */
async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
}
/**
* Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts
* geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist.
*/
function isDefinitiveFailure(err: unknown): boolean {
const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined;
return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429;
}
export function createCardsService(api: CardsApi, userId: () => string) {
async function save(input: SaveInput): Promise<SavedCard> {
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
const permissions = ownerPermissions(userId());
const prev = input.previous;
const name = normalizeName(input.name);
const config = serializeConfig(input.config);
const uploadImage = prev === null || input.imageChanged;
// Typen vorab prüfen: ein unzulässiges Bild darf keine halben Uploads hinterlassen.
const imageFile = uploadImage ? toUploadFile(input.image, 'image') : null;
const previewFile = toUploadFile(input.preview, 'preview');
const uploaded: string[] = [];
const upload = async (file: File): Promise<string> => {
const created = await api.createFile(file, permissions);
uploaded.push(created.$id);
return created.$id;
};
let row: CardRow;
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
let replacedMissingCard = false;
// Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei
// Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass
// eine Zeile auf gelöschte Dateien zeigt.
let writingRow = false;
try {
const newImageId = imageFile ? await upload(imageFile) : null;
const previewFileId = await upload(previewFile);
writingRow = true;
if (!prev) {
// Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage).
row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions);
} else {
// Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein
// (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen.
const update: Partial<CardRowData> = { name, config, previewFileId };
if (newImageId) update.imageFileId = newImageId;
try {
row = await api.updateRow(prev.id, update);
} catch (err) {
if (errorType(err) !== 'row_not_found') throw err;
replacedMissingCard = true;
// Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht.
writingRow = false;
const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image')));
writingRow = true;
row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions);
}
}
} catch (err) {
if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded);
throw err;
}
if (prev) {
// Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll.
const oldFiles = replacedMissingCard || uploadImage ? [prev.previewFileId, prev.imageFileId] : [prev.previewFileId];
await deleteQuietly(api, oldFiles);
}
return toSaved(row);
}
return {
async list(): Promise<SavedCard[]> {
return (await api.listRows()).map(toSaved);
},
async get(id: string): Promise<SavedCard> {
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
if (!ID_PATTERN.test(id)) throw new AppwriteException('Row not found', 404, 'row_not_found', '');
return toSaved(await api.getRow(id));
},
save,
async rename(id: string, name: string): Promise<SavedCard> {
return toSaved(await api.updateRow(id, { name: normalizeName(name) }));
},
async remove(card: SavedCardRef): Promise<void> {
await api.deleteRow(card.id);
await deleteQuietly(api, [card.imageFileId, card.previewFileId]);
},
loadImage(card: SavedCardRef): Promise<Blob> {
return api.fetchFile(card.imageFileId);
},
loadPreview(card: SavedCardRef): Promise<Blob> {
return api.fetchFile(card.previewFileId);
},
};
}
export type CardsService = ReturnType<typeof createCardsService>;
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
const url = new URL(
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
);
const { uri, options } = client.prepareRequest('GET', url);
const res = await fetch(uri, options);
if (!res.ok) {
const text = await res.text();
let body: { message?: unknown; type?: unknown } = {};
try {
const parsed: unknown = JSON.parse(text);
if (typeof parsed === 'object' && parsed !== null) body = parsed;
} catch {
// keine JSON-Antwort (z. B. Fehlerseite des Proxys): Statuszeile genügt
}
throw new AppwriteException(
typeof body.message === 'string' ? body.message : res.statusText,
res.status,
typeof body.type === 'string' ? body.type : '',
text,
);
}
const blob = await res.blob();
if (!blob.type.startsWith('image/')) throw new AppwriteException('Unerwartete Antwort', 502, 'general_unknown', '');
return blob;
}
type TableRow = Models.Row & CardRowData;
export function appwriteCardsApi(): CardsApi {
const target = { databaseId: IDS.database, tableId: IDS.table };
return {
createFile: (file, permissions) =>
storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }),
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
},
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
createRow: (data, permissions) =>
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
updateRow: (id, data) => tables.updateRow<TableRow>({ ...target, rowId: id, data }),
getRow: (id) => tables.getRow<TableRow>({ ...target, rowId: id }),
deleteRow: async (id) => {
await tables.deleteRow({ ...target, rowId: id });
},
listRows: async () => {
const list = await tables.listRows<TableRow>({
...target,
queries: [Query.orderDesc('$updatedAt'), Query.limit(100)],
});
return list.rows;
},
};
}
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler, den `withLogin`/`createSaveAction` als 401 erkennen. */
export function requireUserId(): string {
const id = currentUserId();
if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 });
return id;
}
let shared: CardsService | null = null;
/** Der Kartendienst der Seite (beim ersten Gebrauch erzeugt). */
export function cardsService(): CardsService {
shared ??= createCardsService(appwriteCardsApi(), requireUserId);
return shared;
}