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 && (
)}
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 "";