import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { uploadFile } from "@agent-native/core/file-upload"; import { getRequestUserEmail, getRequestOrgId, } from "@agent-native/core/server/request-context"; import { assertAccess } from "@agent-native/core/sharing"; import { eq } from "drizzle-orm"; import pLimit from "p-limit"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { notifyClients } from "../server/handlers/decks.js"; import { convertToSlideHtml } from "../server/handlers/import/html-converter.js"; import { parsePptx, type ParsedSlide, } from "../server/handlers/import/pptx-parser.js"; import { getDeckUrl } from "./_app-url.js"; import { readUserUploadedFile } from "./_uploaded-files.js"; // EMF/WMF (Windows metafiles) and TIFF are valid PPTX embed formats but // browsers can't render them in an tag — uploading and linking one // would just produce a broken image icon. const BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", "image/bmp", ]); async function uploadFirstSlideImage( slide: ParsedSlide, slideIndex: number, ownerEmail: string, ): Promise { const image = slide.images[0]; if (!image || !BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(image.mimeType)) { return undefined; } const filename = "pptx-import-" + Date.now() + "-s" + slideIndex + "-" + image.name; try { const result = await uploadFile({ data: Buffer.from(image.data), filename, mimeType: image.mimeType, ownerEmail, recordAsset: false, }); return result?.url; } catch { // A single slide's upload failing (network/API/rate-limit) shouldn't // abort the whole deck import — that slide's text still imports fine, // it just falls back to a placeholder like an unsupported format would. return undefined; } } export default defineAction({ description: "Import a PPTX file and create a slide deck from it. " + "Parses the PowerPoint file, extracts text and layout information, " + "converts each slide to the app's HTML format, and creates or updates a deck. " + "Returns the deck ID and slide count.", schema: z.object({ filePath: z .string() .describe("Uploaded PPTX path or opaque hosted upload reference"), deckId: z .string() .optional() .describe( "If provided, import slides into this existing deck (replaces all slides)", ), title: z .string() .optional() .describe( "Deck title — defaults to the title extracted from the presentation", ), }), run: async ({ filePath, deckId, title }) => { const { data: fileBuffer } = await readUserUploadedFile(filePath); const presentation = await parsePptx(fileBuffer); const deckTitle = title || presentation.title || "Imported Presentation"; const ownerEmail = getRequestUserEmail(); if (!ownerEmail) throw new Error("no authenticated user"); const themeFont = presentation.theme?.fonts?.[0]; // Check edit access before uploading any embedded images — uploads are // a side effect with real storage cost, so an unauthorized caller must // be rejected before that side effect happens, not after. if (deckId) { await assertAccess("deck", deckId, "editor"); } // Convert each parsed slide to our HTML format, uploading the first // embedded image (if any) so it renders as a real image instead of a // text placeholder. Concurrency is capped so a large deck doesn't fire // one outbound upload per slide at once. An image can end up unused // (unsupported format, or upload storage not configured) without // failing the whole import — the slide's text still imports fine — but // that shouldn't be a silent, invisible degradation, so it's counted // and returned to the caller. const uploadLimit = pLimit(4); const results = await Promise.all( presentation.slides.map((parsedSlide, i) => uploadLimit(async () => { const imageUrl = await uploadFirstSlideImage( parsedSlide, i, ownerEmail, ); const html = convertToSlideHtml(parsedSlide, imageUrl, themeFont); return { slide: { id: `slide-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, content: html, layout: parsedSlide.layoutHint ?? "content", notes: parsedSlide.notes, }, // Only the first image on a slide is ever uploaded, so every // other image on that slide is unconditionally dropped too — // not just the first one when it's unsupported. imageSkippedCount: Math.max( 0, parsedSlide.images.length - (imageUrl ? 1 : 0), ), }; }), ), ); const slides = results.map((r) => r.slide); const imagesSkipped = results.reduce( (total, r) => total + r.imageSkippedCount, 0, ); const db = getDb(); const now = new Date().toISOString(); if (deckId) { const existing = await db .select() .from(schema.decks) .where(eq(schema.decks.id, deckId)); if (!existing.length) { throw new Error(`Deck ${deckId} not found`); } const data = { title: deckTitle, slides, updatedAt: now }; await db .update(schema.decks) .set({ title: deckTitle, data: JSON.stringify(data), updatedAt: now }) .where(eq(schema.decks.id, deckId)); notifyClients(deckId); await writeAppState("refresh-signal", { ts: now, source: "import-pptx", }); return { id: deckId, title: deckTitle, slideCount: slides.length, theme: presentation.theme, imported: true, url: getDeckUrl(deckId), ...(imagesSkipped > 0 ? { imagesSkipped } : {}), }; } // Create new deck const id = `deck-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const data = { title: deckTitle, slides, createdAt: now, updatedAt: now }; await db.insert(schema.decks).values({ id, title: deckTitle, data: JSON.stringify(data), ownerEmail, orgId: getRequestOrgId(), createdAt: now, updatedAt: now, }); notifyClients(id); await writeAppState("refresh-signal", { ts: now, source: "import-pptx" }); return { id, title: deckTitle, slideCount: slides.length, theme: presentation.theme, imported: true, url: getDeckUrl(id), ...(imagesSkipped > 0 ? { imagesSkipped } : {}), }; }, });