// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen. // Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder. import { ID, Permission, Role } from 'appwrite'; import { IDS, storage } from '../account/appwrite'; import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { clampCrop } from '../card/crop'; import { exportCard, loadCardAssets } from '../card/render'; import { decodeImage } from '../editor/upload'; import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */ export const PNG_MAX_BYTES = 5_242_880; export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; const GALLERY_TYPES = ['image/png', 'image/webp']; export interface PublishForm { title: string; issuer: string; /** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */ tags: string; displayName: string; } /** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */ export interface PublishDeps { /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */ userId(): string; /** Das Originalbild der privaten Karte. */ loadImage(card: SavedCard): Promise; /** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */ render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>; createFile(file: File, permissions: string[]): Promise<{ $id: string }>; deleteFile(fileId: string): Promise; publishCard(input: PublishInput): Promise<{ id: string }>; } /** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */ export function normalizeTags(input: string): string[] { const tags: string[] = []; for (const part of input.split(',')) { const tag = part.trim().toLowerCase(); if (tag !== '' && !tags.includes(tag)) tags.push(tag); } return tags; } async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { const image = await decodeImage(imageBlob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const assets = await loadCardAssets(config, image); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY }); return { png, preview }; } function defaultDeps(): PublishDeps { const service = cardsService(); return { userId: requireUserId, loadImage: (card) => service.loadImage(card), render: renderForGallery, // Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat. createFile: (file, permissions) => storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }), deleteFile: async (fileId) => { await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId }); }, publishCard, }; } /** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise { await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id))); } /** * Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen. * * Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes): * - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht. * - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt: * beide Dateien werden gelöscht. * - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon * angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder. * (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.) * Der Fehler wird in jedem Fall weitergeworfen. */ export async function publishSavedCard( card: SavedCard, form: PublishForm, deps: PublishDeps = defaultDeps(), ): Promise<{ id: string }> { // Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird. const userId = deps.userId(); const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))]; const source = await deps.loadImage(card); const { png, preview } = await deps.render(card, source); // Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen. if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED); if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE); const imageFile = toUploadFile(png, 'image'); // Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG. const previewFile = toUploadFile(preview, 'preview'); const uploaded: string[] = []; try { uploaded.push((await deps.createFile(imageFile, permissions)).$id); uploaded.push((await deps.createFile(previewFile, permissions)).$id); } catch (err) { // Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte. await deleteQuietly(deps, uploaded); throw err; } try { return await deps.publishCard({ title: form.title.trim(), issuer: form.issuer.trim(), tags: normalizeTags(form.tags), displayName: form.displayName.trim(), imageFileId: uploaded[0], previewFileId: uploaded[1], sourceCardId: card.id, }); } catch (err) { if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded); throw err; } }