diff --git a/.env.example b/.env.example index c0a996ca..43afc905 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,10 @@ FIRECRAWL_API_KEY=fc_... # - direct-only (Force Direct Fetch only) SCRAPING_MODE=hybrid +# FastAPI Service (optional - file conversion, doc-to-markdown, audio/video analysis) +# FASTAPI_BASE_URL=https://your-fastapi-service.com +# FASTAPI_API_KEY=your-service-api-key + # Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment # Required: AZURE_DOCUMENT_AI_ENDPOINT=your-ocr-endpoint-url diff --git a/eslint.config.mjs b/eslint.config.mjs index 382e1ee3..ac75012e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,22 +1,23 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - { ignores: ["assistant-ui-main/**"] }, - ...compat.extends("next/core-web-vitals", "next/typescript"), +const eslintConfig = defineConfig([ + ...nextVitals, + globalIgnores([ + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + 'assistant-ui-main/**', + 'node_modules/**', + '.pnpm-store/**', + 'tmp/**', + ]), { rules: { "max-lines": ["error", { max: 300, skipBlankLines: true, skipComments: true }], }, }, -]; +]); export default eslintConfig; diff --git a/package.json b/package.json index ec71648e..441007de 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "dev": "concurrently \"next dev\" \"npx @ai-sdk/devtools\"", "build": "next build", "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "tc": "tsc --noEmit", "clean": "rm -rf .next out dist", "clean:all": "rm -rf .next out dist node_modules .pnpm-store && pnpm install", @@ -124,7 +124,7 @@ "mathlive": "^0.108.2", "mermaid": "^11.12.3", "motion": "^12.34.3", - "next": "16.1.6", + "next": "16.2.0", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", "pdf-lib": "^1.17.1", @@ -177,7 +177,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "^9.39.3", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.0", "geist": "^1.4.2", "knip": "^5.85.0", "postinstall-postinstall": "^2.1.0", diff --git a/src/app/api/office-conversion/convert-to-pdf/route.ts b/src/app/api/office-conversion/convert-to-pdf/route.ts new file mode 100644 index 00000000..c923eaa0 --- /dev/null +++ b/src/app/api/office-conversion/convert-to-pdf/route.ts @@ -0,0 +1,125 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { NextRequest, NextResponse } from "next/server"; +import { getFastAPIClient } from "@/lib/fastapi-client"; + +/** Matches paths from upload-url / upload-file (optional `uploads/` for office docs). */ +const STORAGE_PATH_PATTERN = /^(uploads\/)?\d+-[a-z0-9]+-[A-Za-z0-9._-]+$/; +const MAX_FILE_PATH_LEN = 512; +const MAX_FILE_URL_LEN = 4096; + +function isValidStoragePath(filePath: string): boolean { + return STORAGE_PATH_PATTERN.test(filePath); +} + +function isValidLocalFileUrl(fileUrl: string, filePath: string, requestOrigin: string): boolean { + const expectedUrl = new URL(`/api/files/${filePath}`, requestOrigin); + return fileUrl === expectedUrl.toString(); +} + +function isValidSupabaseFileUrl(fileUrl: string, filePath: string): boolean { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + + if (!supabaseUrl) { + return false; + } + + const expectedUrl = new URL( + `/storage/v1/object/public/file-upload/${filePath}`, + supabaseUrl + ); + return fileUrl === expectedUrl.toString(); +} + +function isValidConversionRequest( + filePath: string, + fileUrl: string, + requestOrigin: string +): boolean { + if (!isValidStoragePath(filePath)) { + return false; + } + + return ( + isValidLocalFileUrl(fileUrl, filePath, requestOrigin) || + isValidSupabaseFileUrl(fileUrl, filePath) + ); +} + +/** + * Proxies document-to-PDF conversion to the FastAPI backend. + * Payload: { file_path: "uploads/...", file_url: publicUrl } — no ?download=, no bucket in path. + */ +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { file_path, file_url } = body as { file_path?: string; file_url?: string }; + + if (!file_path || typeof file_path !== "string") { + return NextResponse.json( + { error: "file_path is required" }, + { status: 400 } + ); + } + + if (!file_url || typeof file_url !== "string") { + return NextResponse.json( + { error: "file_url is required" }, + { status: 400 } + ); + } + + if ( + file_path.length > MAX_FILE_PATH_LEN || + file_url.length > MAX_FILE_URL_LEN + ) { + return NextResponse.json( + { error: "Invalid conversion source" }, + { status: 400 } + ); + } + + // SSRF: only our public file URLs (same origin or Supabase bucket) with a strict path shape + if (!isValidConversionRequest(file_path, file_url, request.nextUrl.origin)) { + return NextResponse.json( + { error: "Invalid conversion source" }, + { status: 400 } + ); + } + + // Pass URL as-is; ?download= can cause Supabase to return invalid Content-Disposition + const fastapi = getFastAPIClient(); + const { data, error } = await fastapi.post<{ + pdf_url?: string; + pdf_path?: string; + }>("api/v1/conversions/document-to-pdf", { + file_path, + file_url, + }); + + if (error) { + return NextResponse.json( + { error }, + { status: 502 } + ); + } + + return NextResponse.json(data ?? {}); + } catch (err) { + console.error("[convert-to-pdf] Error:", err); + return NextResponse.json( + { + error: err instanceof Error ? err.message : "Conversion failed", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts index 40206fd7..9477ac4f 100644 --- a/src/app/api/upload-file/route.ts +++ b/src/app/api/upload-file/route.ts @@ -102,15 +102,7 @@ export async function POST(request: NextRequest) { // Reject Office documents — convert to PDF at ilovepdf.com const convertUrl = getOfficeDocumentConvertUrl(file); - if (convertUrl) { - return NextResponse.json( - { - error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", - convertUrl, - }, - { status: 400 } - ); - } + const isOfficeUpload = convertUrl !== null; // Validate file size (50MB limit) const maxSize = 50 * 1024 * 1024; // 50MB @@ -127,9 +119,10 @@ export async function POST(request: NextRequest) { const originalName = file.name; // Sanitize filename: remove spaces and special chars, keep only alphanumeric, dots, hyphens, underscores const sanitizedName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); - const filename = `${timestamp}-${random}-${sanitizedName}`; - const storageType = getStorageType(); + const filename = storageType === 'supabase' && isOfficeUpload + ? `uploads/${timestamp}-${random}-${sanitizedName}` + : `${timestamp}-${random}-${sanitizedName}`; let publicUrl: string; if (storageType === 'local') { diff --git a/src/app/api/upload-url/route.ts b/src/app/api/upload-url/route.ts index eb186f84..3e91e792 100644 --- a/src/app/api/upload-url/route.ts +++ b/src/app/api/upload-url/route.ts @@ -38,17 +38,8 @@ async function handlePOST(request: NextRequest) { ); } - // Reject Office documents — convert to PDF at ilovepdf.com const convertUrl = getOfficeDocumentConvertUrlFromMeta(filename, contentType); - if (convertUrl) { - return NextResponse.json( - { - error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", - convertUrl, - }, - { status: 400 } - ); - } + const isOfficeUpload = convertUrl !== null; const storageType = process.env.STORAGE_TYPE || 'supabase'; @@ -91,7 +82,10 @@ async function handlePOST(request: NextRequest) { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 15); const sanitizedName = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); - const storagePath = `${timestamp}-${random}-${sanitizedName}`; + // FastAPI convert expects file_path under "uploads/" prefix + const storagePath = isOfficeUpload + ? `uploads/${timestamp}-${random}-${sanitizedName}` + : `${timestamp}-${random}-${sanitizedName}`; // Create a signed upload URL (valid for 5 minutes) const { data, error } = await supabase.storage diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ba26963b..4b347bf6 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -42,6 +42,7 @@ import { toast } from "sonner"; import { InviteGuard } from "@/components/workspace/InviteGuard"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { useFolderUrl } from "@/hooks/ui/use-folder-url"; @@ -322,24 +323,44 @@ function DashboardContent({ throw new Error("Workspace not available"); } + const pdfFiles = files.filter( + (file) => file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf") + ); + const officeFiles = files.filter( + (file) => file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf") + ); + // Reject password-protected PDFs - const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(files); + const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); if (protectedNames.length > 0) { emitPasswordProtectedPdf(protectedNames); } - if (unprotectedFiles.length === 0) { + const filesToUpload = [...unprotectedFiles, ...officeFiles]; + if (filesToUpload.length === 0) { return; } const uploadToastId = toast.loading( - `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...` + `Uploading ${filesToUpload.length} document${filesToUpload.length > 1 ? "s" : ""}...` ); const uploadResults = await Promise.all( - unprotectedFiles.map(async (file) => { + filesToUpload.map(async (file) => { try { - const { url, filename, fileSize } = await uploadPdfToStorage(file); - return { file, fileUrl: url, filename, fileSize }; + const isPdfFile = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + if (isPdfFile) { + const { url, filename, fileSize } = await uploadPdfToStorage(file); + return { file, fileUrl: url, filename, displayName: file.name, fileSize }; + } + + const result = await uploadFileDirect(file); + return { + file, + fileUrl: result.url, + filename: result.filename, + displayName: result.displayName, + fileSize: file.size, + }; } catch (err) { toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`); return null; @@ -352,9 +373,9 @@ function DashboardContent({ const validUploads = uploadResults.filter((r): r is NonNullable => r !== null); if (validUploads.length === 0) return; - const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({ + const pdfCardDefinitions = validUploads.map(({ fileUrl, filename, displayName, fileSize }) => ({ type: "pdf" as const, - name: file.name.replace(/\.pdf$/i, ""), + name: displayName.replace(/\.pdf$/i, ""), initialData: { fileUrl, filename, diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 164f5e3d..0a845559 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -32,10 +32,10 @@ import { createHighlighter } from "shiki"; // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check -// eslint-disable-next-line no-bitwise -- shiki bitflag check + const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check -// eslint-disable-next-line no-bitwise -- shiki bitflag check + // oxlint-disable-next-line eslint(no-bitwise) const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; const isUnderline = (fontStyle: number | undefined) => diff --git a/src/components/assistant-ui/AssistantDropzone.tsx b/src/components/assistant-ui/AssistantDropzone.tsx index 35f10589..0ff1f98a 100644 --- a/src/components/assistant-ui/AssistantDropzone.tsx +++ b/src/components/assistant-ui/AssistantDropzone.tsx @@ -6,10 +6,9 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Upload } from "lucide-react"; import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; -import { isWordFile, isExcelFile, isPptxFile } from "@/lib/uploads/office-document-validation"; +import { OFFICE_DOCUMENT_ACCEPT } from "@/lib/uploads/office-document-validation"; interface AssistantDropzoneProps { children: React.ReactNode; @@ -163,6 +162,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.heic', '.heif', '.avif', '.tiff', '.tif'], 'video/*': ['.mp4', '.webm', '.avi', '.mov', '.mkv'], 'application/pdf': ['.pdf'], + ...OFFICE_DOCUMENT_ACCEPT, 'text/plain': ['.txt'], 'text/markdown': ['.md'], 'text/csv': ['.csv'], @@ -181,22 +181,10 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { handleDragEnd(); if (fileRejections.length > 0) { - const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); - const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); - const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); - const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; - if (hasOffice) { - emitOfficeDocumentRejected({ - word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, - excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, - powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, - }); - } else { - const rejectedFileNames = fileRejections.map((r) => r.file.name); - toast.error( - `The following file${rejectedFileNames.length > 1 ? "s are" : " is"} not supported:\n${rejectedFileNames.join("\n")}\n\nSupported: Images, Videos, PDFs, Text files` - ); - } + const rejectedFileNames = fileRejections.map((r) => r.file.name); + toast.error( + `The following file${rejectedFileNames.length > 1 ? "s are" : " is"} not supported:\n${rejectedFileNames.join("\n")}\n\nSupported: Images, Videos, PDFs, Office docs, Text files` + ); } }, }); diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 5c3fb20b..fd916399 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -29,14 +29,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { cn } from "@/lib/utils"; import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; import { useUIStore } from "@/lib/stores/ui-store"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; -import { - isWordFile, - isExcelFile, - isPptxFile, - isOfficeDocument, -} from "@/lib/uploads/office-document-validation"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { FaCheck } from "react-icons/fa"; @@ -492,30 +485,15 @@ export const ComposerAddAttachment: FC = () => { // Validate each file (size + reject Office documents) const validFiles: File[] = []; const oversizedFiles: string[] = []; - const officeWord: string[] = []; - const officeExcel: string[] = []; - const officePowerpoint: string[] = []; fileArray.forEach((file) => { if (file.size > MAX_FILE_SIZE_BYTES) { oversizedFiles.push(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB)`); - } else if (isOfficeDocument(file)) { - if (isWordFile(file)) officeWord.push(file.name); - else if (isExcelFile(file)) officeExcel.push(file.name); - else officePowerpoint.push(file.name); } else { validFiles.push(file); } }); - if (officeWord.length > 0 || officeExcel.length > 0 || officePowerpoint.length > 0) { - emitOfficeDocumentRejected({ - word: officeWord.length ? officeWord : undefined, - excel: officeExcel.length ? officeExcel : undefined, - powerpoint: officePowerpoint.length ? officePowerpoint : undefined, - }); - } - // Reject password-protected PDFs (so other files still upload) let filesToAdd = validFiles; if (validFiles.length > 0) { @@ -585,7 +563,7 @@ export const ComposerAddAttachment: FC = () => { className="sr-only" onChange={handleFileChange} multiple={true} - accept="image/*,video/*,.pdf,.txt,.md,.csv,.json,.heic,.heif,.avif,.tiff,.tif" + accept="image/*,video/*,.pdf,.txt,.md,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.heic,.heif,.avif,.tiff,.tif" /> diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index b1d6eb4a..8f7b584b 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -270,7 +270,7 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { a: (props: AnchorHTMLAttributes & { node?: any }) => ( ), - // eslint-disable-next-line @typescript-eslint/no-explicit-any + citation: (props: any) => ( {props.children} ), diff --git a/src/components/assistant-ui/mermaid-diagram.tsx b/src/components/assistant-ui/mermaid-diagram.tsx index e154c3cf..3a77de40 100644 --- a/src/components/assistant-ui/mermaid-diagram.tsx +++ b/src/components/assistant-ui/mermaid-diagram.tsx @@ -39,11 +39,11 @@ export type MermaidDiagramProps = SyntaxHighlighterProps & { export const MermaidDiagram: FC = ({ code, className, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + node: _node, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + components: _components, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + language: _language, }) => { const ref = useRef(null); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index f2ecfb92..9faa48eb 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -94,6 +94,7 @@ import { import { AssistantLoader } from "@/components/assistant-ui/assistant-loader"; import { File as FileComponent } from "@/components/assistant-ui/file"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; import { Sources } from "@/components/assistant-ui/sources"; import { Image } from "@/components/assistant-ui/image"; import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; @@ -819,13 +820,13 @@ const Composer: FC = ({ items }) => { const file = attachment.file; if (!file) return null; - const { url: fileUrl, filename } = await uploadFileDirect(file); + const { url: fileUrl, filename, displayName } = await uploadFileDirect(file); return { fileUrl, filename: filename || file.name, fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ''), + name: displayName.replace(/\.pdf$/i, ''), }; }); @@ -897,7 +898,11 @@ const Composer: FC = ({ items }) => { // Detect PDF attachments for background processing const pdfAttachments = attachments.filter((att) => { const file = att.file; - return file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')); + return file && ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') || + isOfficeDocument(file) + ); }); // Process PDFs in background - don't block message sending diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx index e045931e..e9a5cb30 100644 --- a/src/components/editor/BlockNoteEditor.tsx +++ b/src/components/editor/BlockNoteEditor.tsx @@ -174,7 +174,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca if (selection && selection.blocks && selection.blocks.length > 0) { // Extract text from selection (cast to any to handle BlockNote's complex types) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const text = extractTextFromSelection(selection as any); if (text && text.trim().length > 0) { @@ -339,7 +339,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }, [editor, initialContent, lastSource]); // Get math menu items (used in both AI and non-AI modes) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getMathMenuItems = (editor: any): DefaultReactSuggestionItem[] => { // Add Block Math item const blockMathItem = { @@ -394,7 +394,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; // Get custom slash menu items (for non-AI mode) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getCustomSlashMenuItems = (editor: any): DefaultReactSuggestionItem[] => { const defaultItems = getDefaultReactSlashMenuItems(editor); const mathItems = getMathMenuItems(editor); @@ -402,7 +402,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; // Get inline math menu items - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getInlineMathMenuItems = (editor: any): DefaultReactSuggestionItem[] => { return [ { diff --git a/src/components/editor/blocks/CodeBlock.tsx b/src/components/editor/blocks/CodeBlock.tsx index 201a77ae..68481907 100644 --- a/src/components/editor/blocks/CodeBlock.tsx +++ b/src/components/editor/blocks/CodeBlock.tsx @@ -167,7 +167,7 @@ const EditorCopyButton = memo(function EditorCopyButton({ text }: { text: string // ─── Main Code Block Render ───────────────────────────────────────────────── -// eslint-disable-next-line @typescript-eslint/no-explicit-any + const CodeBlockRender = memo(function CodeBlockRender(props: any) { const { block, editor, contentRef } = props; const rawLanguage = block.props.language || "text"; @@ -225,7 +225,7 @@ const CodeBlockRender = memo(function CodeBlockRender(props: any) { ); }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any + function CodeBlockExternalHTML(props: any) { const language = props.block.props.language || "text"; return ( diff --git a/src/components/editor/blocks/MathBlock.tsx b/src/components/editor/blocks/MathBlock.tsx index 1b6ebae4..2448a23b 100644 --- a/src/components/editor/blocks/MathBlock.tsx +++ b/src/components/editor/blocks/MathBlock.tsx @@ -8,7 +8,7 @@ import { MathEditContext } from "../MathEditDialog"; import "./math-block.css"; // Component for rendering the math block - respects read-only state -// eslint-disable-next-line @typescript-eslint/no-explicit-any + const MathBlockContent = memo(function MathBlockContent(props: any) { const { block, editor } = props; const latex = block.props.latex || ""; @@ -113,7 +113,7 @@ const MathBlockContent = memo(function MathBlockContent(props: any) { }); // Component for rendering math block in external HTML (for AI compatibility) -// eslint-disable-next-line @typescript-eslint/no-explicit-any + function MathBlockExternalHTML(props: any) { const latex = props.block.props.latex || ""; // Render as a div with LaTeX in a data attribute and visible format (use $$ for Streamdown compatibility) diff --git a/src/components/editor/inline/InlineMath.tsx b/src/components/editor/inline/InlineMath.tsx index e70ea8ff..a7899aef 100644 --- a/src/components/editor/inline/InlineMath.tsx +++ b/src/components/editor/inline/InlineMath.tsx @@ -8,7 +8,7 @@ import { MathEditContext } from "../MathEditDialog"; import "../blocks/math-block.css"; // Component for rendering inline math - respects read-only state -// eslint-disable-next-line @typescript-eslint/no-explicit-any + const InlineMathContent = memo(function InlineMathContent({ inlineContent, editor, updateInlineContent }: any) { const latex = inlineContent.props.latex || ""; const isReadOnly = !editor.isEditable; @@ -116,7 +116,7 @@ const InlineMathContent = memo(function InlineMathContent({ inlineContent, edito }); // Component for rendering inline math in external HTML (for AI compatibility) -// eslint-disable-next-line @typescript-eslint/no-explicit-any + function InlineMathExternalHTML(props: any) { const latex = props.inlineContent.props.latex || ""; // Render as a span with LaTeX in a data attribute and visible format (use $$ for Streamdown compatibility) diff --git a/src/components/home/HomeHeroDropzone.tsx b/src/components/home/HomeHeroDropzone.tsx index 8aef6ece..4f270b04 100644 --- a/src/components/home/HomeHeroDropzone.tsx +++ b/src/components/home/HomeHeroDropzone.tsx @@ -5,12 +5,7 @@ import { useHomeAttachments } from "@/contexts/HomeAttachmentsContext"; import { Upload } from "lucide-react"; import { useCallback } from "react"; import { toast } from "sonner"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; -import { - isWordFile, - isExcelFile, - isPptxFile, -} from "@/lib/uploads/office-document-validation"; +import { OFFICE_DOCUMENT_ACCEPT } from "@/lib/uploads/office-document-validation"; import { cn } from "@/lib/utils"; interface HomeHeroDropzoneProps { @@ -47,6 +42,7 @@ export function HomeHeroDropzone({ children, onFilesDropped }: HomeHeroDropzoneP noKeyboard: true, accept: { "application/pdf": [".pdf"], + ...OFFICE_DOCUMENT_ACCEPT, "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "image/gif": [".gif"], @@ -67,20 +63,8 @@ export function HomeHeroDropzone({ children, onFilesDropped }: HomeHeroDropzoneP }, onDropRejected: (fileRejections) => { if (fileRejections.length > 0) { - const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); - const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); - const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); - const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; - if (hasOffice) { - emitOfficeDocumentRejected({ - word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, - excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, - powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, - }); - } else { - const names = fileRejections.map((r) => r.file.name).join(", "); - toast.error(`Only PDF, image, and audio files are supported. Rejected: ${names}`); - } + const names = fileRejections.map((r) => r.file.name).join(", "); + toast.error(`Only PDF, Office, image, and audio files are supported. Rejected: ${names}`); } }, }); @@ -103,7 +87,7 @@ export function HomeHeroDropzone({ children, onFilesDropped }: HomeHeroDropzoneP

Drop files here

- PDF, image, or audio — same as the Upload button + PDF, Office, image, or audio — same as the Upload button

diff --git a/src/components/modals/CardDetailModal.tsx b/src/components/modals/CardDetailModal.tsx index a7553166..91694824 100644 --- a/src/components/modals/CardDetailModal.tsx +++ b/src/components/modals/CardDetailModal.tsx @@ -46,7 +46,7 @@ export function CardDetailModal({ // or by the manual open action (openPanel). // We no longer need to manually select it here. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Handle escape key const handleEscape = useCallback( diff --git a/src/components/modals/UploadDialog.tsx b/src/components/modals/UploadDialog.tsx index 8b7bfbe1..b908114e 100644 --- a/src/components/modals/UploadDialog.tsx +++ b/src/components/modals/UploadDialog.tsx @@ -19,11 +19,9 @@ import { cn } from "@/lib/utils"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { - isWordFile, - isExcelFile, - isPptxFile, + isOfficeDocument, + OFFICE_DOCUMENT_ACCEPT, } from "@/lib/uploads/office-document-validation"; interface UploadDialogProps { @@ -109,9 +107,11 @@ export function UploadDialog({ const pdfFiles = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ); + const officeFiles = files.filter((file) => isOfficeDocument(file)); + const documentFiles = [...officeFiles]; - if (pdfFiles.length === 0) { - toast.error('No valid PDF files found'); + if (pdfFiles.length === 0 && officeFiles.length === 0) { + toast.error('No valid document files found'); return; } @@ -120,20 +120,22 @@ export function UploadDialog({ if (protectedNames.length > 0) { emitPasswordProtectedPdf(protectedNames); } - if (unprotectedPdfs.length === 0) { + documentFiles.push(...unprotectedPdfs); + + if (documentFiles.length === 0) { return; } // Check individual file size limit (50MB per file) const maxIndividualSize = 50 * 1024 * 1024; - const oversizedFiles = unprotectedPdfs.filter(file => file.size > maxIndividualSize); + const oversizedFiles = documentFiles.filter(file => file.size > maxIndividualSize); if (oversizedFiles.length > 0) { toast.error(`${oversizedFiles.length} file(s) exceed the 50MB individual limit`); return; } // Check combined size limit (100MB total) - const totalSize = unprotectedPdfs.reduce((sum, file) => sum + file.size, 0); + const totalSize = documentFiles.reduce((sum, file) => sum + file.size, 0); const maxCombinedSize = 100 * 1024 * 1024; if (totalSize > maxCombinedSize) { const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(1); @@ -143,12 +145,12 @@ export function UploadDialog({ setIsUploading(true); try { - await onPDFUpload(unprotectedPdfs); - toast.success(`${unprotectedPdfs.length} PDF${unprotectedPdfs.length > 1 ? 's' : ''} uploaded successfully`); + await onPDFUpload(documentFiles); + toast.success(`${documentFiles.length} document${documentFiles.length > 1 ? 's' : ''} uploaded successfully`); onOpenChange(false); } catch (error) { console.error('Error uploading PDFs:', error); - toast.error('Failed to upload PDF files'); + toast.error('Failed to upload document files'); } finally { setIsUploading(false); } @@ -214,15 +216,15 @@ export function UploadDialog({ if (acceptedFiles.length === 0) return; const imageFiles = acceptedFiles.filter(f => f.type.startsWith('image/')); - const pdfFiles = acceptedFiles.filter(f => - f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + const documentFiles = acceptedFiles.filter(f => + !f.type.startsWith('image/') ); if (imageFiles.length > 0) { uploadImageFiles(imageFiles); } - if (pdfFiles.length > 0) { - handlePDFFiles(pdfFiles); + if (documentFiles.length > 0) { + handlePDFFiles(documentFiles); } }, [uploadImageFiles, handlePDFFiles]); @@ -231,23 +233,9 @@ export function UploadDialog({ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic', '.heif', '.avif', '.tiff', '.tif'], 'application/pdf': ['.pdf'], + ...OFFICE_DOCUMENT_ACCEPT, }, disabled: isUploading, - onDropRejected: (fileRejections) => { - if (fileRejections.length > 0) { - const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); - const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); - const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); - const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; - if (hasOffice) { - emitOfficeDocumentRejected({ - word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, - excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, - powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, - }); - } - } - }, }); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -268,7 +256,7 @@ export function UploadDialog({ Upload - Drag and drop PDFs or images, paste from clipboard, or enter a URL. + Drag and drop PDFs, Office docs, or images, paste from clipboard, or enter a URL. @@ -290,7 +278,7 @@ export function UploadDialog({ {isUploading ? "Uploading..." : isDragActive ? "Drop files here" : "Click or drag files here"}

- Supports PDF, PNG, JPG, GIF, WebP + Supports PDF, Word, Excel, PowerPoint, PNG, JPG, GIF, WebP

diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 573be745..9fc156c1 100644 --- a/src/components/onboarding/WorkspaceInstructionModal.tsx +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -278,7 +278,7 @@ export function WorkspaceInstructionModal({ aria-modal="true" aria-label="Workspace instruction" > - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + { }
{ pause(); onUserInteracted?.(); }} className={cn( diff --git a/src/components/pdf/PdfPanelHeader.tsx b/src/components/pdf/PdfPanelHeader.tsx index 10c07908..a29bc2c2 100644 --- a/src/components/pdf/PdfPanelHeader.tsx +++ b/src/components/pdf/PdfPanelHeader.tsx @@ -129,7 +129,7 @@ export const PdfPanelHeader = memo(function PdfPanelHeader({ unsubscribe(); }; // Only re-subscribe when documentId changes (capture scope depends on it) - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [documentId]); diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 2140b2cc..e509062f 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -4,7 +4,6 @@ import { useRouter } from "next/navigation"; import { Toaster } from "@/components/ui/sonner"; import { PostHogIdentify } from "./providers/PostHogIdentify"; import { PasswordProtectedPdfDialog } from "@/components/modals/PasswordProtectedPdfDialog"; -import { OfficeDocumentRejectedDialog } from "@/components/modals/OfficeDocumentRejectedDialog"; import { TooltipProvider } from "@/components/ui/tooltip"; export function Providers({ children }: { children: React.ReactNode }) { @@ -15,7 +14,6 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} - = { }; const MotionHighlightContext = React.createContext< - // eslint-disable-next-line @typescript-eslint/no-explicit-any + MotionHighlightContextType | undefined >(undefined); diff --git a/src/components/ui/shadcn-io/tabs/index.tsx b/src/components/ui/shadcn-io/tabs/index.tsx index 8796c419..5352d1ad 100644 --- a/src/components/ui/shadcn-io/tabs/index.tsx +++ b/src/components/ui/shadcn-io/tabs/index.tsx @@ -16,7 +16,7 @@ type TabsContextType = { registerTrigger: (value: T, node: HTMLElement | null) => void; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any + const TabsContext = React.createContext | undefined>( undefined, ); diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 956f99a2..e3ce19dd 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -13,13 +13,8 @@ import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; -import { - isWordFile, - isExcelFile, - isPptxFile, -} from "@/lib/uploads/office-document-validation"; +import { OFFICE_DOCUMENT_ACCEPT } from "@/lib/uploads/office-document-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; interface WorkspaceCanvasDropzoneProps { children: React.ReactNode; @@ -47,10 +42,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro return `${file.name}-${file.size}-${file.lastModified}`; }; - const uploadFileToStorage = async (file: File): Promise<{ url: string; filename: string }> => { - const result = await uploadFileDirect(file); - return { url: result.url, filename: result.filename }; - }; + const uploadFileToStorage = async (file: File) => uploadFileDirect(file); const onDrop = useCallback( async (acceptedFiles: File[]) => { @@ -182,6 +174,8 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro const nonPdfResults: Array<{ fileUrl: string; filename: string; + contentType: string; + displayName: string; fileSize: number; name: string; originalFile: File; @@ -189,12 +183,14 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro if (nonPdfFiles.length > 0) { const uploadPromises = nonPdfFiles.map(async (file) => { try { - const { url, filename } = await uploadFileToStorage(file); + const result = await uploadFileToStorage(file); return { - fileUrl: url, - filename: file.name, + fileUrl: result.url, + filename: result.filename, + contentType: result.contentType, + displayName: result.displayName, fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ''), + name: result.displayName.replace(/\.pdf$/i, ''), originalFile: file, }; } catch (error) { @@ -208,11 +204,31 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro nonPdfResults.push(...results.filter((r): r is NonNullable => r !== null)); } - const validResults = [...pdfResults, ...nonPdfResults]; + const convertedPdfResults = nonPdfResults + .filter((r) => r.contentType === 'application/pdf') + .map((r) => ({ + fileUrl: r.fileUrl, + filename: r.filename, + fileSize: r.fileSize, + name: r.name, + pdfData: { + fileUrl: r.fileUrl, + filename: r.filename, + fileSize: r.fileSize, + ocrStatus: "processing" as const, + ocrPages: [], + } as Partial, + })); + pdfResults.push(...convertedPdfResults); + + const remainingNonPdfResults = nonPdfResults.filter( + (r) => r.contentType !== 'application/pdf' + ); + const validResults = [...pdfResults, ...remainingNonPdfResults]; if (validResults.length > 0) { const imageResults: typeof nonPdfResults = []; const audioResults: typeof nonPdfResults = []; - nonPdfResults.forEach((r) => { + remainingNonPdfResults.forEach((r) => { if (r.originalFile.type.startsWith('audio/')) audioResults.push(r); else imageResults.push(r); }); @@ -400,6 +416,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro disabled: !currentWorkspaceId, // Disable if no workspace is selected accept: { 'application/pdf': ['.pdf'], + ...OFFICE_DOCUMENT_ACCEPT, 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/gif': ['.gif'], @@ -430,22 +447,10 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro handleDragEnd(); if (fileRejections.length > 0) { - const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); - const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); - const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); - const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; - if (hasOffice) { - emitOfficeDocumentRejected({ - word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, - excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, - powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, - }); - } else { - const rejectedFileNames = fileRejections.map((r) => r.file.name); - toast.error( - `Only PDF, image, and audio files can be dropped.\nRejected: ${rejectedFileNames.join(", ")}` - ); - } + const rejectedFileNames = fileRejections.map((r) => r.file.name); + toast.error( + `Only PDF, Office, image, and audio files can be dropped.\nRejected: ${rejectedFileNames.join(", ")}` + ); } }, }); @@ -466,7 +471,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro Create Card

- Drop PDF, image, or audio files here to create cards + Drop PDF, Office, image, or audio files here to create cards

diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 2d67a761..ce52813f 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -390,7 +390,7 @@ export default function WorkspaceContent({ multiple className="sr-only" onChange={handleFileChange} - accept="application/pdf,.pdf" + accept="application/pdf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" /> {/* Drag and Drop Prompt — native label for instant file picker */} @@ -402,7 +402,7 @@ export default function WorkspaceContent({

{activeFolderId ? `This folder is empty` - : "Drag and drop PDFs here"} + : "Drag and drop PDFs or Office docs here"}

diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 33e3506d..dbc207c0 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -447,28 +447,49 @@ export function WorkspaceSection({ throw new Error('Workspace operations not available'); } + const pdfFiles = files.filter( + (file) => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') + ); + const officeFiles = files.filter( + (file) => file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf') + ); + // Reject password-protected PDFs - const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(files); + const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); if (protectedNames.length > 0) { emitPasswordProtectedPdf(protectedNames); } - if (unprotectedFiles.length === 0) { + const filesToUpload = [...unprotectedFiles, ...officeFiles]; + if (filesToUpload.length === 0) { return; } const uploadToastId = toast.loading( - `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...` + `Uploading ${filesToUpload.length} document${filesToUpload.length > 1 ? 's' : ''}...` ); const uploadResults = await Promise.all( - unprotectedFiles.map(async (file) => { + filesToUpload.map(async (file) => { try { - const { url, filename, fileSize } = await uploadPdfToStorage(file); + const isPdfFile = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); + if (isPdfFile) { + const { url, filename, fileSize } = await uploadPdfToStorage(file); + return { + file, + fileUrl: url, + filename, + displayName: file.name, + fileSize, + }; + } + + const result = await uploadFileDirect(file); return { file, - fileUrl: url, - filename, - fileSize, + fileUrl: result.url, + filename: result.filename, + displayName: result.displayName, + fileSize: file.size, }; } catch (err) { toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`); @@ -482,9 +503,9 @@ export function WorkspaceSection({ const validUploads = uploadResults.filter((r): r is NonNullable => r !== null); if (validUploads.length === 0) return; - const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({ + const pdfCardDefinitions = validUploads.map(({ fileUrl, filename, displayName, fileSize }) => ({ type: 'pdf' as const, - name: file.name.replace(/\.pdf$/i, ''), + name: displayName.replace(/\.pdf$/i, ''), initialData: { fileUrl, filename, diff --git a/src/components/workspace-canvas/workspace-menu-items.tsx b/src/components/workspace-canvas/workspace-menu-items.tsx index 2714c2a2..8b50423c 100644 --- a/src/components/workspace-canvas/workspace-menu-items.tsx +++ b/src/components/workspace-canvas/workspace-menu-items.tsx @@ -76,13 +76,10 @@ export function renderWorkspaceMenuItems({ {showUpload && ( -
- Upload - PDF/Image -
+ Upload
)} diff --git a/src/contexts/HomeAttachmentsContext.tsx b/src/contexts/HomeAttachmentsContext.tsx index 6666f5e4..ce355512 100644 --- a/src/contexts/HomeAttachmentsContext.tsx +++ b/src/contexts/HomeAttachmentsContext.tsx @@ -12,13 +12,6 @@ import { } from "react"; import { toast } from "sonner"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; -import { - isOfficeDocument, - isWordFile, - isExcelFile, - isPptxFile, -} from "@/lib/uploads/office-document-validation"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; @@ -96,24 +89,11 @@ export function HomeAttachmentsProvider({ children }: { children: ReactNode }) { const addFiles = useCallback(async (newFiles: File[]) => { if (newFiles.length === 0) return; - // Reject Office documents — show dialog, don't add - const officeWord = newFiles.filter(isWordFile).map((f) => f.name); - const officeExcel = newFiles.filter(isExcelFile).map((f) => f.name); - const officePowerpoint = newFiles.filter(isPptxFile).map((f) => f.name); - if (officeWord.length > 0 || officeExcel.length > 0 || officePowerpoint.length > 0) { - emitOfficeDocumentRejected({ - word: officeWord.length ? officeWord : undefined, - excel: officeExcel.length ? officeExcel : undefined, - powerpoint: officePowerpoint.length ? officePowerpoint : undefined, - }); - } - const withoutOffice = newFiles.filter((f) => !isOfficeDocument(f)); - - const pdfFiles = withoutOffice.filter( + const pdfFiles = newFiles.filter( (f) => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf") ); - const nonPdfFiles = withoutOffice.filter( + const nonPdfFiles = newFiles.filter( (f) => f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf") ); @@ -152,12 +132,8 @@ export function HomeAttachmentsProvider({ children }: { children: ReactNode }) { toast.success(`Added ${newItems.length} file${newItems.length > 1 ? "s" : ""} — uploading...`); newItems.forEach((item) => { - const mediaType = - item.file.type || - (item.file.name.endsWith(".pdf") ? "application/pdf" : "application/octet-stream"); - const promise = uploadFileDirect(item.file) - .then(({ url }) => { + .then((uploadResult) => { setFileItems((prev) => { const existing = prev.find((i) => i.id === item.id); if (!existing) return prev; @@ -167,9 +143,9 @@ export function HomeAttachmentsProvider({ children }: { children: ReactNode }) { ...i, status: "ready" as const, result: { - url, - mediaType, - filename: i.file.name, + url: uploadResult.url, + mediaType: uploadResult.contentType, + filename: uploadResult.displayName, fileSize: i.file.size, }, } diff --git a/src/hooks/workspace/use-pdf-upload.ts b/src/hooks/workspace/use-pdf-upload.ts index 81b3cce5..54f3307a 100644 --- a/src/hooks/workspace/use-pdf-upload.ts +++ b/src/hooks/workspace/use-pdf-upload.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import type { PdfData } from "@/lib/workspace-state/types"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; @@ -32,24 +33,32 @@ export function usePdfUpload() { setState((prev) => ({ ...prev, isUploading: true, error: null })); try { + const pdfFiles = files.filter( + (file) => file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf") + ); + const officeFiles = files.filter( + (file) => isOfficeDocument(file) + ); + // Reject password-protected PDFs - const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(files); + const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); if (protectedNames.length > 0) { emitPasswordProtectedPdf(protectedNames); } - if (unprotectedFiles.length === 0) { + const filesToUpload = [...unprotectedFiles, ...officeFiles]; + if (filesToUpload.length === 0) { setState((prev) => ({ ...prev, isUploading: false })); return []; } - const uploadPromises = unprotectedFiles.map(async (file) => { - const { url: fileUrl, filename } = await uploadFileDirect(file); + const uploadPromises = filesToUpload.map(async (file) => { + const { url: fileUrl, filename, displayName } = await uploadFileDirect(file); return { fileUrl, filename: filename || file.name, fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ""), + name: displayName.replace(/\.pdf$/i, ""), }; }); diff --git a/src/lib/ai/tools/__tests__/edit-item-tool.test.ts b/src/lib/ai/tools/__tests__/edit-item-tool.test.ts index 936979ea..38862136 100644 --- a/src/lib/ai/tools/__tests__/edit-item-tool.test.ts +++ b/src/lib/ai/tools/__tests__/edit-item-tool.test.ts @@ -9,10 +9,14 @@ vi.mock("@/lib/ai/workers", () => ({ workspaceWorker: (...args: unknown[]) => mockWorkspaceWorker(...args), })); -vi.mock("@/lib/ai/tools/tool-utils", () => ({ - loadStateForTool: (...args: unknown[]) => mockLoadStateForTool(...args), - resolveItem: (...args: unknown[]) => mockResolveItem(...args), -})); +vi.mock("@/lib/ai/tools/tool-utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadStateForTool: (...args: unknown[]) => mockLoadStateForTool(...args), + resolveItem: (...args: unknown[]) => mockResolveItem(...args), + }; +}); vi.mock("@/lib/utils/workspace-fs", () => ({ getVirtualPath: (...args: unknown[]) => mockGetVirtualPath(...args), diff --git a/src/lib/attachments/supabase-attachment-adapter.ts b/src/lib/attachments/supabase-attachment-adapter.ts index 658a4bdd..4da6110f 100644 --- a/src/lib/attachments/supabase-attachment-adapter.ts +++ b/src/lib/attachments/supabase-attachment-adapter.ts @@ -4,15 +4,9 @@ import type { CompleteAttachment, } from "@assistant-ui/react"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; import { isPasswordProtectedPdf } from "@/lib/uploads/pdf-validation"; -import { - isOfficeDocument, - isWordFile, - isExcelFile, - isPptxFile, -} from "@/lib/uploads/office-document-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; const getUploadStore = () => useAttachmentUploadStore.getState(); @@ -28,10 +22,13 @@ const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB to match server limit */ export class SupabaseAttachmentAdapter implements AttachmentAdapter { accept = - "image/*,video/*,audio/*,.pdf,.txt,.md,.csv,.json,.mp3,.wav,.ogg,.aac,.flac,.aiff,.webm,.m4a"; + "image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json,.mp3,.wav,.ogg,.aac,.flac,.aiff,.webm,.m4a"; // Map of attachment ID → upload promise (started eagerly in add()) - private pendingUploads = new Map>(); + private pendingUploads = new Map< + string, + Promise>> + >(); async add({ file }: { file: File }): Promise { if (file.size > MAX_FILE_SIZE_BYTES) { @@ -40,18 +37,6 @@ export class SupabaseAttachmentAdapter implements AttachmentAdapter { ); } - // Reject Office documents — convert to PDF at ilovepdf.com - if (isOfficeDocument(file)) { - const word = isWordFile(file) ? [file.name] : undefined; - const excel = isExcelFile(file) ? [file.name] : undefined; - const powerpoint = isPptxFile(file) ? [file.name] : undefined; - emitOfficeDocumentRejected({ word, excel, powerpoint }); - const format = word ? "Word" : excel ? "Excel" : "PowerPoint"; - throw new Error( - `"${file.name}" is a ${format} file. ${format} files are not supported. Convert to PDF first.` - ); - } - // Reject password-protected PDFs if (await isPasswordProtectedPdf(file)) { emitPasswordProtectedPdf([file.name]); @@ -64,12 +49,7 @@ export class SupabaseAttachmentAdapter implements AttachmentAdapter { let type: "image" | "document" | "file" = "file"; if (file.type.startsWith("image/")) { type = "image"; - } else if ( - file.type === "application/pdf" || - file.type.includes("document") || - file.type.includes("spreadsheet") || - file.type.includes("presentation") - ) { + } else if (file.type === "application/pdf" || isOfficeDocument(file)) { type = "document"; } @@ -78,7 +58,6 @@ export class SupabaseAttachmentAdapter implements AttachmentAdapter { // Start upload immediately in background (optimistic) getUploadStore().addUploading(id); const uploadPromise = uploadFileDirect(file) - .then((r) => r.url) .finally(() => { getUploadStore().removeUploading(id); }); @@ -101,22 +80,22 @@ export class SupabaseAttachmentAdapter implements AttachmentAdapter { // If it wasn't started (shouldn't happen), start it now as fallback let uploadPromise = this.pendingUploads.get(attachment.id); if (!uploadPromise) { - uploadPromise = uploadFileDirect(file).then((r) => r.url); + uploadPromise = uploadFileDirect(file); } - const url = await uploadPromise; + const uploadResult = await uploadPromise; this.pendingUploads.delete(attachment.id); return { id: attachment.id, type: attachment.type, - name: attachment.name, - contentType: file.type || "application/octet-stream", + name: uploadResult.displayName, + contentType: uploadResult.contentType, content: [ { type: "file", - data: url, - mimeType: file.type || "application/octet-stream", + data: uploadResult.url, + mimeType: uploadResult.contentType, }, ], status: { type: "complete" }, diff --git a/src/lib/fastapi-client.ts b/src/lib/fastapi-client.ts new file mode 100644 index 00000000..46357837 --- /dev/null +++ b/src/lib/fastapi-client.ts @@ -0,0 +1,149 @@ +import { logger } from "@/lib/utils/logger"; + +const FASTAPI_REQUEST_TIMEOUT_MS = 15000; + +function getRequestSignal(signal?: AbortSignal): AbortSignal { + const timeoutSignal = AbortSignal.timeout(FASTAPI_REQUEST_TIMEOUT_MS); + + if (!signal) { + return timeoutSignal; + } + + return AbortSignal.any([signal, timeoutSignal]); +} + +/** + * Client for communicating with the external FastAPI service. + * Used for file conversion, doc-to-markdown, audio/video analysis, etc. + * + * All requests include Bearer token (FASTAPI_API_KEY) for server-to-server auth. + * Use only from Next.js API routes / server-side code. + */ +export class FastAPIClient { + private baseUrl: string | null; + private apiKey: string; + + constructor(config?: { baseUrl?: string; apiKey?: string }) { + this.baseUrl = config?.baseUrl || process.env.FASTAPI_BASE_URL || null; + this.apiKey = config?.apiKey || process.env.FASTAPI_API_KEY || ""; + + if (!this.baseUrl) { + logger.warn( + "⚠️ [FastAPI] No base URL provided. FastAPI features will be disabled." + ); + } + + if (!this.apiKey) { + logger.warn( + "⚠️ [FastAPI] No API key provided. FastAPI features will be disabled." + ); + } + } + + /** + * Generic request method. Use for any FastAPI endpoint. + */ + async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + options?: { + body?: unknown; + headers?: Record; + signal?: AbortSignal; + } + ): Promise<{ data?: T; error?: string; status: number }> { + if (!this.baseUrl) { + return { error: "FastAPI base URL not configured", status: 500 }; + } + + if (!this.apiKey) { + return { error: "FastAPI API key not configured", status: 500 }; + } + + const url = `${this.baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; + const hasBody = options + ? Object.prototype.hasOwnProperty.call(options, "body") + : false; + const requestBody = hasBody ? JSON.stringify(options?.body) : undefined; + const signal = getRequestSignal(options?.signal); + + try { + logger.debug(`[FastAPI] ${method} ${url}`); + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + ...options?.headers, + }, + body: requestBody, + signal, + }); + + const text = await response.text(); + let data: T | undefined; + + try { + data = text ? (JSON.parse(text) as T) : undefined; + } catch { + // Non-JSON response (e.g. file download) + data = text as unknown as T; + } + + if (!response.ok) { + const errorMsg = + typeof data === "object" && data !== null && "detail" in data + ? String((data as { detail?: unknown }).detail) + : text || response.statusText; + logger.error(`[FastAPI] ${method} ${path} failed:`, response.status, errorMsg); + return { + error: errorMsg || `FastAPI error: ${response.status}`, + status: response.status, + data, + }; + } + + return { data, status: response.status }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const status = + error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError") + ? 504 + : 500; + logger.error(`[FastAPI] Request failed ${method} ${path}:`, message); + return { + error: message, + status, + }; + } + } + + /** GET request shorthand */ + async get( + path: string, + options?: { headers?: Record; signal?: AbortSignal } + ) { + return this.request("GET", path, options); + } + + /** POST request shorthand */ + async post( + path: string, + body?: unknown, + options?: { headers?: Record; signal?: AbortSignal } + ) { + return this.request("POST", path, { ...options, body }); + } +} + +/** Singleton instance for use in API routes */ +let _client: FastAPIClient | null = null; + +export function getFastAPIClient(): FastAPIClient { + if (!_client) { + _client = new FastAPIClient(); + } + return _client; +} diff --git a/src/lib/uploads/client-upload.ts b/src/lib/uploads/client-upload.ts index c4744a67..edf84198 100644 --- a/src/lib/uploads/client-upload.ts +++ b/src/lib/uploads/client-upload.ts @@ -13,18 +13,17 @@ import { convertHeicToJpegIfNeeded } from "./convert-heic"; import { isOfficeDocument } from "./office-document-validation"; -import { - isWordFile, - isExcelFile, - isPptxFile, -} from "./office-document-validation"; -import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024; // 200MB interface UploadResult { url: string; filename: string; + contentType: string; + displayName: string; + originalUrl?: string; + originalFilename?: string; + wasConverted?: boolean; } export interface UploadFileDirectOptions { @@ -32,6 +31,51 @@ export interface UploadFileDirectOptions { log?: boolean; } +function getConvertedPdfName(filename: string): string { + return filename.replace(/\.[^/.]+$/, "") + ".pdf"; +} + +async function convertOfficeUpload( + filename: string, + url: string, + originalFilename: string +): Promise { + const response = await fetch("/api/office-conversion/convert-to-pdf", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_path: filename, + file_url: url, + }), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error( + (typeof data.error === "string" && data.error) || + `Conversion failed: ${response.statusText}` + ); + } + + if (typeof data.pdf_url !== "string" || data.pdf_url.length === 0) { + throw new Error("No PDF URL returned from conversion"); + } + + return { + url: data.pdf_url, + filename: + typeof data.pdf_path === "string" && data.pdf_path.length > 0 + ? data.pdf_path + : getConvertedPdfName(originalFilename), + contentType: "application/pdf", + displayName: getConvertedPdfName(originalFilename), + originalUrl: url, + originalFilename, + wasConverted: true, + }; +} + /** * Upload a file directly to storage, bypassing the serverless function body limit. * Works for both Supabase (direct upload) and local storage (fallback to API route). @@ -52,10 +96,14 @@ export async function uploadFileDirect( ); } + const shouldConvertOffice = isOfficeDocument(file); + // Step 1: Request a signed upload URL from our API (small JSON payload) const urlResponse = await fetch("/api/upload-url", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ filename: file.name, contentType: file.type || "application/octet-stream", @@ -64,13 +112,6 @@ export async function uploadFileDirect( if (!urlResponse.ok) { const errorData = await urlResponse.json().catch(() => ({})); - if (urlResponse.status === 400 && isOfficeDocument(file)) { - emitOfficeDocumentRejected({ - word: isWordFile(file) ? [file.name] : undefined, - excel: isExcelFile(file) ? [file.name] : undefined, - powerpoint: isPptxFile(file) ? [file.name] : undefined, - }); - } throw new Error( errorData.error || `Failed to get upload URL: ${urlResponse.statusText}` ); @@ -90,11 +131,16 @@ export async function uploadFileDirect( const t = performance.now() - t0; console.info(`[PDF_UPLOAD] Local fallback upload: ${t.toFixed(0)}ms`); } + + if (shouldConvertOffice) { + return convertOfficeUpload(result.filename, result.url, file.name); + } + return result; } // Step 2: Upload file directly to Supabase using the signed URL - const { signedUrl, token, publicUrl, path } = urlData; + const { signedUrl, publicUrl, path } = urlData; const tPut = log ? performance.now() : 0; const uploadResponse = await fetch(signedUrl, { @@ -109,7 +155,11 @@ export async function uploadFileDirect( // If direct upload fails, try the API route as fallback (for small files) if (file.size <= 4 * 1024 * 1024) { console.warn("Direct upload failed, falling back to API route for small file"); - return uploadViaApiRoute(file); + const result = await uploadViaApiRoute(file); + if (shouldConvertOffice) { + return convertOfficeUpload(result.filename, result.url, file.name); + } + return result; } throw new Error( `Direct upload failed: ${uploadResponse.statusText}` @@ -124,9 +174,15 @@ export async function uploadFileDirect( ); } + if (shouldConvertOffice) { + return convertOfficeUpload(path, publicUrl, file.name); + } + return { url: publicUrl, filename: path, + contentType: file.type || "application/octet-stream", + displayName: file.name, }; } @@ -145,13 +201,6 @@ async function uploadViaApiRoute(file: File): Promise { if (!response.ok) { const errorData = await response.json().catch(() => ({})); - if (response.status === 400 && isOfficeDocument(file)) { - emitOfficeDocumentRejected({ - word: isWordFile(file) ? [file.name] : undefined, - excel: isExcelFile(file) ? [file.name] : undefined, - powerpoint: isPptxFile(file) ? [file.name] : undefined, - }); - } throw new Error( errorData.error || `Upload failed: ${response.statusText}` ); @@ -161,5 +210,7 @@ async function uploadViaApiRoute(file: File): Promise { return { url: data.url, filename: data.filename, + contentType: file.type || "application/octet-stream", + displayName: file.name, }; } diff --git a/src/lib/uploads/office-document-validation.ts b/src/lib/uploads/office-document-validation.ts index 35e6064e..0910f51f 100644 --- a/src/lib/uploads/office-document-validation.ts +++ b/src/lib/uploads/office-document-validation.ts @@ -3,34 +3,70 @@ * These are rejected — users can convert to PDF at iLovePDF. */ +const OFFICE_DOCUMENT_DEFINITIONS = { + word: { + convertUrl: "https://www.ilovepdf.com/word_to_pdf", + mimeMap: { + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], + }, + }, + excel: { + convertUrl: "https://www.ilovepdf.com/excel_to_pdf", + mimeMap: { + "application/vnd.ms-excel": [".xls"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], + }, + }, + powerpoint: { + convertUrl: "https://www.ilovepdf.com/powerpoint_to_pdf", + mimeMap: { + "application/vnd.ms-powerpoint": [".ppt"], + "application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"], + }, + }, +} as const; + +type OfficeMimeMap = Record; + +function getMimeMapMimes(mimeMap: OfficeMimeMap): string[] { + return Object.keys(mimeMap); +} + +function getMimeMapExtensions(mimeMap: OfficeMimeMap): string[] { + return Object.values(mimeMap).flatMap((extensions) => [...extensions]); +} + export const CONVERT_URLS = { - word: "https://www.ilovepdf.com/word_to_pdf", - excel: "https://www.ilovepdf.com/excel_to_pdf", - powerpoint: "https://www.ilovepdf.com/powerpoint_to_pdf", + word: OFFICE_DOCUMENT_DEFINITIONS.word.convertUrl, + excel: OFFICE_DOCUMENT_DEFINITIONS.excel.convertUrl, + powerpoint: OFFICE_DOCUMENT_DEFINITIONS.powerpoint.convertUrl, } as const; export type OfficeDocumentType = keyof typeof CONVERT_URLS; +export const OFFICE_DOCUMENT_ACCEPT = Object.assign( + {}, + ...Object.values(OFFICE_DOCUMENT_DEFINITIONS).map(({ mimeMap }) => mimeMap) +) as Record; + +export const OFFICE_DOCUMENT_ACCEPT_STRING = Object.values( + OFFICE_DOCUMENT_ACCEPT +) + .flatMap((extensions) => [...extensions]) + .join(","); + // Word: .doc, .docx -const WORD_MIMES = [ - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", -]; -const WORD_EXTS = [".doc", ".docx"]; +const WORD_MIMES = getMimeMapMimes(OFFICE_DOCUMENT_DEFINITIONS.word.mimeMap); +const WORD_EXTS = getMimeMapExtensions(OFFICE_DOCUMENT_DEFINITIONS.word.mimeMap); // Excel: .xls, .xlsx -const EXCEL_MIMES = [ - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", -]; -const EXCEL_EXTS = [".xls", ".xlsx"]; +const EXCEL_MIMES = getMimeMapMimes(OFFICE_DOCUMENT_DEFINITIONS.excel.mimeMap); +const EXCEL_EXTS = getMimeMapExtensions(OFFICE_DOCUMENT_DEFINITIONS.excel.mimeMap); // PowerPoint: .ppt, .pptx -const PPTX_MIMES = [ - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", -]; -const PPTX_EXTS = [".ppt", ".pptx"]; +const PPTX_MIMES = getMimeMapMimes(OFFICE_DOCUMENT_DEFINITIONS.powerpoint.mimeMap); +const PPTX_EXTS = getMimeMapExtensions(OFFICE_DOCUMENT_DEFINITIONS.powerpoint.mimeMap); function checkFile( file: File, diff --git a/src/lib/utils/extract-blocknote-text.ts b/src/lib/utils/extract-blocknote-text.ts index c2ee6700..02cdb176 100644 --- a/src/lib/utils/extract-blocknote-text.ts +++ b/src/lib/utils/extract-blocknote-text.ts @@ -4,17 +4,17 @@ */ // Use any for flexibility with BlockNote's complex types -// eslint-disable-next-line @typescript-eslint/no-explicit-any + type BlockNoteBlock = any; // Use any for flexibility with BlockNote's Selection type -// eslint-disable-next-line @typescript-eslint/no-explicit-any + type BlockNoteSelection = any; /** * Extracts text from a single block's content array */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any + function extractTextFromBlockContent(content: any): string { if (!Array.isArray(content)) return ""; @@ -77,7 +77,7 @@ function extractTextFromBlock(block: BlockNoteBlock): string { * @param selection - BlockNote Selection object with blocks array * @returns Extracted plain text, trimmed */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export function extractTextFromSelection(selection: any): string { if (!selection || !selection.blocks || !Array.isArray(selection.blocks)) { return "";