From f7df9a6489eb39c5708f2191882dc2d62d8ff3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:34:09 +0000 Subject: [PATCH 1/8] fix(upload): import nested Office folders without losing files or folders - Find extracted files by their on-disk name so uppercase extensions (REPORT.DOCX) no longer fail the upload. - Mark partially completed folder uploads as uploaded and report them as partial, so one failed file no longer hides the whole tree. - Skip Office lock files (~$*), Thumbs.db, desktop.ini, __MACOSX and anything inside hidden folders. - Recognize .doc and .xlsm as file types. - Keep the picked folder as the zip root for folder-picker uploads so a folder holding one subfolder keeps its own name. - Upload empty folders as projects: the zip worker writes folder entries, the extractor reports every folder, and the upload API accepts them. --- .../lib/core/client/folderUploadPaths.test.ts | 62 ++++ .../src/lib/core/client/folderUploadPaths.ts | 36 ++ .../web/src/lib/core/client/zipWorker.test.ts | 91 +++++ .../src/lib/core/client/zipWorkerClient.ts | 39 ++- apps/web/src/lib/core/util/upload.ts | 17 +- .../service-connection/bulkUpload.ts | 1 + .../lib/workers/folder-upload/zip-worker.js | 11 +- .../src/upload.rs | 2 + crates/model/src/document/file_type/tests.rs | 14 + crates/model/src/folder.rs | 16 + crates/model_file_type/src/lib.rs | 7 + crates/model_file_type/src/test.rs | 38 ++ crates/projects/src/domain/service.rs | 2 +- crates/projects/src/domain/service/tests.rs | 3 + crates/projects/src/domain/upload.rs | 100 +++++- .../src/main.rs | 327 +++++++++++++++--- 16 files changed, 693 insertions(+), 73 deletions(-) create mode 100644 apps/web/src/lib/core/client/folderUploadPaths.test.ts create mode 100644 apps/web/src/lib/core/client/folderUploadPaths.ts create mode 100644 apps/web/src/lib/core/client/zipWorker.test.ts diff --git a/apps/web/src/lib/core/client/folderUploadPaths.test.ts b/apps/web/src/lib/core/client/folderUploadPaths.test.ts new file mode 100644 index 00000000000..3e13432a8fb --- /dev/null +++ b/apps/web/src/lib/core/client/folderUploadPaths.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { groupPickedFolderFiles, joinZipPath } from './folderUploadPaths'; + +const picked = (webkitRelativePath: string) => ({ + name: webkitRelativePath.split('/').at(-1) ?? webkitRelativePath, + webkitRelativePath, +}); + +describe('groupPickedFolderFiles', () => { + it('keeps the picked folder as the first path segment', () => { + const groups = groupPickedFolderFiles([ + picked('Board Pack/Summary.docx'), + picked('Board Pack/Finance/Forecasts/Q4.xlsx'), + picked('Board Pack/Decks/Archive/Old.pptx'), + ]); + + expect([...groups.keys()]).toEqual(['Board Pack']); + expect(groups.get('Board Pack')?.details.map((d) => d.path)).toEqual([ + 'Board Pack/Summary.docx', + 'Board Pack/Finance/Forecasts/Q4.xlsx', + 'Board Pack/Decks/Archive/Old.pptx', + ]); + }); + + it('keeps the picked folder when it only holds one subfolder', () => { + const groups = groupPickedFolderFiles([ + picked('Wrapper/Inner/a.docx'), + picked('Wrapper/Inner/Deeper/b.pptx'), + ]); + + expect(groups.get('Wrapper')?.details.map((d) => d.path)).toEqual([ + 'Wrapper/Inner/a.docx', + 'Wrapper/Inner/Deeper/b.pptx', + ]); + }); + + it('keeps file objects aligned with their paths', () => { + const files = [picked('A/x.docx'), picked('B/y.xlsx'), picked('A/z.pptx')]; + const groups = groupPickedFolderFiles(files); + + expect(groups.get('A')?.files).toEqual([files[0], files[2]]); + expect(groups.get('B')?.files).toEqual([files[1]]); + }); + + it('falls back to the file name without a relative path', () => { + const groups = groupPickedFolderFiles([ + { name: 'loose.docx', webkitRelativePath: '' }, + ]); + + expect(groups.get('loose.docx')?.details).toEqual([ + { path: 'loose.docx' }, + ]); + }); +}); + +describe('joinZipPath', () => { + it('joins nested paths', () => { + expect(joinZipPath('', 'Root')).toBe('Root'); + expect(joinZipPath('Root', 'Sub')).toBe('Root/Sub'); + expect(joinZipPath('Root/Sub', 'a.docx')).toBe('Root/Sub/a.docx'); + }); +}); diff --git a/apps/web/src/lib/core/client/folderUploadPaths.ts b/apps/web/src/lib/core/client/folderUploadPaths.ts new file mode 100644 index 00000000000..ff2823a0896 --- /dev/null +++ b/apps/web/src/lib/core/client/folderUploadPaths.ts @@ -0,0 +1,36 @@ +import type { FileDetail } from './zipWorkerClient'; + +type PickedFile = Pick; + +export interface PickedFolderGroup { + files: F[]; + details: FileDetail[]; +} + +/** + * Groups files from a folder picker by the folder the user picked. + * + * Zip paths keep that folder as their first segment, matching a dropped + * folder. The server names the upload after the zip's single top folder, so + * stripping it would rename the upload whenever the picked folder holds just + * one subfolder. + */ +export function groupPickedFolderFiles( + files: F[] +): Map> { + const groups = new Map>(); + for (const file of files) { + const relativePath = file.webkitRelativePath || file.name; + const top = relativePath.split('/')[0] || file.name; + const group = groups.get(top) ?? { files: [], details: [] }; + group.files.push(file); + group.details.push({ path: relativePath }); + groups.set(top, group); + } + return groups; +} + +/** Joins a parent zip path and an entry name. */ +export function joinZipPath(parent: string, name: string): string { + return parent ? `${parent}/${name}` : name; +} diff --git a/apps/web/src/lib/core/client/zipWorker.test.ts b/apps/web/src/lib/core/client/zipWorker.test.ts new file mode 100644 index 00000000000..b49e816e0b4 --- /dev/null +++ b/apps/web/src/lib/core/client/zipWorker.test.ts @@ -0,0 +1,91 @@ +import JSZip from 'jszip'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +type WorkerMessage = { type: string; data: { zipBlob?: Blob } }; + +const posted: WorkerMessage[] = []; + +beforeAll(async () => { + vi.stubGlobal('self', { + postMessage: (message: WorkerMessage) => posted.push(message), + }); + await import('../../workers/folder-upload/zip-worker.js'); +}); + +afterEach(() => { + posted.length = 0; +}); + +async function runZip( + files: { path: string; contents: string }[], + folderPaths?: string[] +) { + const self = globalThis.self as unknown as { + onmessage: (event: { data: unknown }) => void; + }; + self.onmessage({ + data: { + taskId: 't', + action: 'zipFiles', + files: files.map((f) => new Blob([f.contents])), + fileDetails: files.map((f) => ({ path: f.path })), + folderPaths, + }, + }); + await vi.waitFor(() => { + expect(posted.some((m) => m.type === 'complete' || m.type === 'error')).toBe( + true + ); + }); + const done = posted.find((m) => m.type === 'complete'); + if (!done?.data.zipBlob) throw new Error(JSON.stringify(posted.at(-1))); + return JSZip.loadAsync(done.data.zipBlob); +} + +describe('zip worker', () => { + it('writes nested office files under the dropped folder', async () => { + const zip = await runZip( + [ + { path: 'Pack/Summary.docx', contents: 'docx' }, + { path: 'Pack/Finance/Forecasts/Q4.xlsx', contents: 'xlsx' }, + { path: 'Pack/Decks/Old.PPTX', contents: 'pptx' }, + ], + ['Pack', 'Pack/Finance', 'Pack/Finance/Forecasts', 'Pack/Decks'] + ); + + expect(await zip.file('Pack/Finance/Forecasts/Q4.xlsx')?.async('string')).toBe( + 'xlsx' + ); + expect(await zip.file('Pack/Decks/Old.PPTX')?.async('string')).toBe('pptx'); + }); + + it('keeps empty folders as directory entries', async () => { + const zip = await runZip( + [{ path: 'Pack/a.docx', contents: 'a' }], + ['Pack', 'Pack/Empty', 'Pack/Nested/Empty Leaf'] + ); + + const folders = Object.values(zip.files) + .filter((entry) => entry.dir) + .map((entry) => entry.name) + .sort(); + expect(folders).toEqual([ + 'Pack/', + 'Pack/Empty/', + 'Pack/Nested/', + 'Pack/Nested/Empty Leaf/', + ]); + }); + + it('zips a folder that has no files', async () => { + const zip = await runZip([], ['Shell', 'Shell/A']); + + expect(Object.keys(zip.files).sort()).toEqual(['Shell/', 'Shell/A/']); + }); + + it('accepts messages without folder paths', async () => { + const zip = await runZip([{ path: 'Pack/a.docx', contents: 'a' }]); + + expect(zip.file('Pack/a.docx')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/lib/core/client/zipWorkerClient.ts b/apps/web/src/lib/core/client/zipWorkerClient.ts index 94b8949a0aa..1f1b12c8101 100644 --- a/apps/web/src/lib/core/client/zipWorkerClient.ts +++ b/apps/web/src/lib/core/client/zipWorkerClient.ts @@ -1,6 +1,7 @@ import { analytics } from '@app/lib/analytics'; import shortUUID from 'short-uuid'; import ZipWorker from '../../workers/folder-upload/zip-worker?worker'; +import { joinZipPath } from './folderUploadPaths'; const MAX_FOLDER_FILE_COUNT = 1000; // NOTE: you can expect a 30-40% decrease in folder size when zipped @@ -97,6 +98,8 @@ interface ZipTaskMessage { action: 'zipFiles'; files: File[]; fileDetails: FileDetail[]; + /** Folder paths to write as entries, so empty folders survive. */ + folderPaths: string[]; taskId?: string; // Added by the pool } @@ -303,15 +306,14 @@ class ZipWorkerPool { } // zips up each folder entry and returns the zip files -// empty directories are returned as null export function handleFoldersInput( entries: FileSystemDirectoryEntry[] -): Promise[] { +): Promise[] { if (entries.length === 0) { return []; } - const zipPromises: Promise[] = []; + const zipPromises: Promise[] = []; for (const entry of entries) { const promise = zipDirectory(entry); zipPromises.push(promise); @@ -326,8 +328,15 @@ async function zipDirectory(entry: FileSystemDirectoryEntry) { const fileEntries: FileSystemFileEntry[] = []; const fileDetails: FileDetail[] = []; + const folderPaths: string[] = []; try { - await processDirectoryEntry(entry, '', fileEntries, fileDetails); + await processDirectoryEntry( + entry, + '', + fileEntries, + fileDetails, + folderPaths + ); } catch (error) { if (error instanceof DirectoryFileCountExceededError) { throw error.setFolderName(folderName); @@ -335,9 +344,8 @@ async function zipDirectory(entry: FileSystemDirectoryEntry) { throw error; } - if (fileEntries.length === 0) { - return null; - } else if (fileEntries.length > MAX_FOLDER_FILE_COUNT) { + // A folder with no files still uploads, so its (empty) folders are created. + if (fileEntries.length > MAX_FOLDER_FILE_COUNT) { throw new DirectoryFileCountExceededError(folderName, fileEntries.length); } @@ -360,7 +368,7 @@ async function zipDirectory(entry: FileSystemDirectoryEntry) { const files = await Promise.all(filePromises); - const zipFile = await zipFiles(folderName, files, fileDetails); + const zipFile = await zipFiles(folderName, files, fileDetails, folderPaths); return zipFile; } @@ -370,14 +378,15 @@ async function processDirectoryEntry( entry: FileSystemEntry, path: string, fileEntries: FileSystemFileEntry[], - fileDetails: FileDetail[] + fileDetails: FileDetail[], + folderPaths: string[] ): Promise { if (entry.isFile) { if (fileEntries.length > MAX_FOLDER_FILE_COUNT) throw new DirectoryFileCountExceededError(); fileEntries.push(entry as FileSystemFileEntry); fileDetails.push({ - path: path ? `${path}/${entry.name}` : entry.name, + path: joinZipPath(path, entry.name), }); } else if (entry.isDirectory) { const dirReader = (entry as FileSystemDirectoryEntry).createReader(); @@ -402,14 +411,16 @@ async function processDirectoryEntry( } } while (batch.length > 0); - const dirPath = path ? `${path}/${entry.name}` : entry.name; + const dirPath = joinZipPath(path, entry.name); + folderPaths.push(dirPath); for (const childEntry of entries) { await processDirectoryEntry( childEntry, dirPath, fileEntries, - fileDetails + fileDetails, + folderPaths ); } } @@ -418,7 +429,8 @@ async function processDirectoryEntry( export async function zipFiles( folderName: string, files: File[], - fileDetails: FileDetail[] + fileDetails: FileDetail[], + folderPaths: string[] = [] ): Promise { const workerPool = ZipWorkerPool.getInstance(ZipWorker); @@ -426,6 +438,7 @@ export async function zipFiles( action: 'zipFiles', files: files, fileDetails: fileDetails, + folderPaths, }; // check file count diff --git a/apps/web/src/lib/core/util/upload.ts b/apps/web/src/lib/core/util/upload.ts index 33b979ccae5..db5ec0924ac 100644 --- a/apps/web/src/lib/core/util/upload.ts +++ b/apps/web/src/lib/core/util/upload.ts @@ -11,10 +11,10 @@ import { analytics } from '@app/lib/analytics'; import { DirectoryFileCountExceededError, DirectoryFileSizeExceededError, - type FileDetail, handleFoldersInput, zipFiles, } from '@core/client/zipWorkerClient'; +import { groupPickedFolderFiles } from '@core/client/folderUploadPaths'; import { toast } from '@core/component/Toast/Toast'; import { blockAcceptedMimetypeToFileExtension, @@ -651,20 +651,7 @@ export async function handleFolderSelect( files: File[], onFilesReady: (files: UploadFileEntry[]) => void | Promise ): Promise { - const groups = new Map(); - for (const file of files) { - const rel = file.webkitRelativePath || file.name; - const parts = rel.split('/'); - const top = parts.shift() || file.name; - const path = parts.join('/'); - const group = groups.get(top) || { - files: [], - details: [], - }; - group.files.push(file); - group.details.push({ path: path || file.name }); - groups.set(top, group); - } + const groups = groupPickedFolderFiles(files); const zipEntryPromises = Array.from(groups.entries()).map( ([folderName, group]) => diff --git a/apps/web/src/lib/service-clients/service-connection/bulkUpload.ts b/apps/web/src/lib/service-clients/service-connection/bulkUpload.ts index ffb05503137..2f419346cdb 100644 --- a/apps/web/src/lib/service-clients/service-connection/bulkUpload.ts +++ b/apps/web/src/lib/service-clients/service-connection/bulkUpload.ts @@ -67,6 +67,7 @@ createWebsocketEventEffect< switch (update.status) { case 'partially_completed': projectId = update.projectId; + toast.alert('Some files in the folder could not be uploaded'); break; case 'completed': projectId = update.projectId; diff --git a/apps/web/src/lib/workers/folder-upload/zip-worker.js b/apps/web/src/lib/workers/folder-upload/zip-worker.js index 4f961589d37..62622496e42 100644 --- a/apps/web/src/lib/workers/folder-upload/zip-worker.js +++ b/apps/web/src/lib/workers/folder-upload/zip-worker.js @@ -1,7 +1,7 @@ import JSZip from 'jszip'; self.onmessage = function (event) { - const { taskId, action, files, fileDetails } = event.data; + const { taskId, action, files, fileDetails, folderPaths } = event.data; if (action === 'zipFiles') { self.postMessage({ @@ -12,13 +12,18 @@ self.onmessage = function (event) { }, }); - zipFiles(taskId, files, fileDetails); + zipFiles(taskId, files, fileDetails, folderPaths ?? []); } }; -async function zipFiles(taskId, files, fileDetails) { +async function zipFiles(taskId, files, fileDetails, folderPaths) { try { const zip = new JSZip(); + + // Explicit folder entries keep empty folders in the upload. + for (const folderPath of folderPaths) { + zip.folder(folderPath); + } const totalFiles = files.length; let processedFiles = 0; diff --git a/crates/document_storage_service_client/src/upload.rs b/crates/document_storage_service_client/src/upload.rs index bec2d74b6cd..71618ba42aa 100644 --- a/crates/document_storage_service_client/src/upload.rs +++ b/crates/document_storage_service_client/src/upload.rs @@ -15,6 +15,7 @@ impl DocumentStorageServiceClient { user_id: String, root_folder_name: String, content: Vec, + folders: Vec, upload_request_id: String, parent_id: Option, ) -> anyhow::Result { @@ -25,6 +26,7 @@ impl DocumentStorageServiceClient { root_folder_name, upload_request_id, parent_id, + folders, }; let response = self diff --git a/crates/model/src/document/file_type/tests.rs b/crates/model/src/document/file_type/tests.rs index 01f002b59a6..13707402a0d 100644 --- a/crates/model/src/document/file_type/tests.rs +++ b/crates/model/src/document/file_type/tests.rs @@ -156,3 +156,17 @@ fn test_split_suffix_match() { Some(("REPORT", "MD")) ); } + +#[test] +fn test_split_suffix_match_office_files() { + for (file_name, expected) in [ + ("Budget.xlsm", Some(("Budget", "xlsm"))), + ("Old Memo.doc", Some(("Old Memo", "doc"))), + ("OLD MEMO.DOC", Some(("OLD MEMO", "DOC"))), + ("Deck.ppt", Some(("Deck", "ppt"))), + ("Ledger.xls", Some(("Ledger", "xls"))), + ("notes.mdoc", Some(("notes", "mdoc"))), + ] { + assert_eq!(FileType::split_suffix_match(file_name), expected, "{file_name}"); + } +} diff --git a/crates/model/src/folder.rs b/crates/model/src/folder.rs index 72495ebe45d..c824492cecb 100644 --- a/crates/model/src/folder.rs +++ b/crates/model/src/folder.rs @@ -29,6 +29,11 @@ pub struct UploadFolderRequest { /// Optional parent project id to upload the folder into #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, + /// Relative paths of folders to create even when they hold no files. + /// + /// Uses the same shape as [FolderItem::relative_path], root folder included. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub folders: Vec, } pub struct UploadFolderWithIdsResponse { @@ -95,12 +100,23 @@ impl FileSystemNode { pub fn build_file_system( root_folder_name: &str, content: Vec, + folders: &[String], ) -> anyhow::Result { let mut root = FileSystemNode::Folder(HashMap::from([( root_folder_name.to_string(), FileSystemNode::Folder(HashMap::new()), )])); + for folder in folders { + let mut current_node = &mut root; + for component in folder.split('/').filter(|s| !s.is_empty()) { + current_node = current_node + .as_folder_mut()? + .entry(component.to_string()) + .or_insert_with(|| FileSystemNode::Folder(HashMap::new())); + } + } + for item in content { // Split the relative_path into components, including the root folder name let mut path_components: Vec<&str> = item diff --git a/crates/model_file_type/src/lib.rs b/crates/model_file_type/src/lib.rs index 78dd348e1b4..7f12fa2433f 100644 --- a/crates/model_file_type/src/lib.rs +++ b/crates/model_file_type/src/lib.rs @@ -697,7 +697,14 @@ generate_file_types!( (Azw3, "azw3", "application/vnd.amazon.ebook", Document), (Djvu, "djvu", "image/vnd.djvu", Document), (Xls, "xls", "application/vnd.ms-excel", Document), + ( + Xlsm, + "xlsm", + "application/vnd.ms-excel.sheet.macroEnabled.12", + Document + ), (Ppt, "ppt", "application/vnd.ms-powerpoint", Document), + (Doc, "doc", "application/msword", Document), (Pptx, "pptx", "application/xml", Document), (Xlsx, "xlsx", "application/xml", Document), // Database files diff --git a/crates/model_file_type/src/test.rs b/crates/model_file_type/src/test.rs index a84a598c543..55f1086b6ef 100644 --- a/crates/model_file_type/src/test.rs +++ b/crates/model_file_type/src/test.rs @@ -14,3 +14,41 @@ fn native_spreadsheet_extension_and_content_type_round_trip() { assert_ne!(FileType::from_str("xlsx").unwrap(), file_type); assert_ne!(FileType::from_str("csv").unwrap(), file_type); } + +#[test] +fn office_extensions_parse_case_insensitively() { + for (extension, expected) in [ + ("docx", FileType::Docx), + ("DOCX", FileType::Docx), + ("doc", FileType::Doc), + ("DOC", FileType::Doc), + ("pptx", FileType::Pptx), + ("Pptx", FileType::Pptx), + ("ppt", FileType::Ppt), + ("xlsx", FileType::Xlsx), + ("XLSX", FileType::Xlsx), + ("xls", FileType::Xls), + ("xlsm", FileType::Xlsm), + ("XLSM", FileType::Xlsm), + ] { + assert_eq!(FileType::from_str(extension).unwrap(), expected, "{extension}"); + } +} + +#[test] +fn legacy_office_types_round_trip() { + for (file_type, extension, mime) in [ + (FileType::Doc, "doc", "application/msword"), + ( + FileType::Xlsm, + "xlsm", + "application/vnd.ms-excel.sheet.macroEnabled.12", + ), + ] { + assert_eq!(file_type.as_str(), extension); + assert_eq!(file_type.mime_type(), mime); + assert_eq!(file_type.macro_app_path().to_string(), "document"); + assert_eq!(FileType::from_str(extension).unwrap(), file_type); + assert_eq!(serde_json::to_string(&file_type).unwrap(), format!("\"{extension}\"")); + } +} diff --git a/crates/projects/src/domain/service.rs b/crates/projects/src/domain/service.rs index 80f91229592..7c1b7e4effa 100644 --- a/crates/projects/src/domain/service.rs +++ b/crates/projects/src/domain/service.rs @@ -661,7 +661,7 @@ where let event_owner = actor.clone(); let event_name = args.root_folder_name.clone(); let event_parent_project_id = args.parent_id.clone(); - let root_folder = build_root_folder(&args.root_folder_name, args.content) + let root_folder = build_root_folder(&args.root_folder_name, args.content, &args.folders) .map_err(|error| internal_error(error, "unable to prepare folder upload"))?; // The uploaded projects and documents all share the owner's team-derived diff --git a/crates/projects/src/domain/service/tests.rs b/crates/projects/src/domain/service/tests.rs index 5c24414fd88..703e3719319 100644 --- a/crates/projects/src/domain/service/tests.rs +++ b/crates/projects/src/domain/service/tests.rs @@ -2181,6 +2181,7 @@ async fn upload_folder_publishes_uploaded_event() { root_folder_name: "Uploaded tree".to_string(), upload_request_id: "request".to_string(), parent_id: Some("parent-project".to_string()), + folders: Vec::new(), }, ) .await @@ -2235,6 +2236,7 @@ async fn upload_folder_with_no_project_ids_publishes_no_event() { root_folder_name: "Uploaded tree".to_string(), upload_request_id: "request".to_string(), parent_id: None, + folders: Vec::new(), }, ) .await @@ -2294,6 +2296,7 @@ async fn upload_folder_compensates_after_destination_failure() { root_folder_name: "Upload".to_string(), upload_request_id: "request".to_string(), parent_id: None, + folders: Vec::new(), }, ) .await; diff --git a/crates/projects/src/domain/upload.rs b/crates/projects/src/domain/upload.rs index be83b70d2f1..5d871960d70 100644 --- a/crates/projects/src/domain/upload.rs +++ b/crates/projects/src/domain/upload.rs @@ -15,8 +15,9 @@ use super::ports::ProjectUploadUrlPort; pub fn build_root_folder( root_folder_name: &str, content: Vec, + folders: &[String], ) -> anyhow::Result { - let file_system = FileSystemNode::build_file_system(root_folder_name, content)?; + let file_system = FileSystemNode::build_file_system(root_folder_name, content, folders)?; let FileSystemNode::Folder(mut roots) = file_system else { anyhow::bail!("expected folder upload to produce a folder root"); }; @@ -86,3 +87,100 @@ pub async fn build_destination_map( Ok(destinations) } + +#[cfg(test)] +mod tests { + use super::*; + use model::document::FileTypeExt; + + fn item(relative_path: &str, full_name: &str) -> FolderItem { + let (name, file_type) = match FileType::split_suffix_match(full_name) { + Some((name, extension)) => (name, FileType::from_str(extension).ok()), + None => (full_name, None), + }; + FolderItem { + name: name.to_string(), + full_name: full_name.to_string(), + file_type, + relative_path: relative_path.to_string(), + sha: "sha".to_string(), + } + } + + fn folder(node: &FileSystemNode) -> &std::collections::HashMap { + match node { + FileSystemNode::Folder(content) => content, + FileSystemNode::File(item) => panic!("expected folder, found {item:?}"), + } + } + + #[test] + fn nested_files_build_nested_folders() { + let root = build_root_folder( + "Pack", + vec![ + item("Pack", "Summary.docx"), + item("Pack/Finance/Forecasts", "Q4.xlsx"), + item("Pack/Decks/Archive", "Old.pptx"), + ], + &[], + ) + .unwrap(); + + let root = folder(&root); + assert!(matches!(root.get("Summary.docx"), Some(FileSystemNode::File(_)))); + let forecasts = folder(folder(&root["Finance"]).get("Forecasts").unwrap()); + assert!(matches!(forecasts.get("Q4.xlsx"), Some(FileSystemNode::File(_)))); + let archive = folder(folder(&root["Decks"]).get("Archive").unwrap()); + assert!(matches!(archive.get("Old.pptx"), Some(FileSystemNode::File(_)))); + } + + #[test] + fn empty_folders_are_kept() { + let root = build_root_folder( + "Pack", + vec![item("Pack/Finance", "Budget.xlsx")], + &[ + "Pack".to_string(), + "Pack/Empty".to_string(), + "Pack/Finance".to_string(), + "Pack/Finance/Empty Leaf".to_string(), + ], + ) + .unwrap(); + + let root = folder(&root); + assert!(folder(&root["Empty"]).is_empty()); + let finance = folder(&root["Finance"]); + assert!(matches!(finance.get("Budget.xlsx"), Some(FileSystemNode::File(_)))); + assert!(folder(&finance["Empty Leaf"]).is_empty()); + } + + #[test] + fn folder_listed_before_its_files_keeps_the_files() { + let root = build_root_folder( + "Pack", + vec![item("Pack/A", "a.docx")], + &["Pack/A".to_string(), "Pack/A/B".to_string()], + ) + .unwrap(); + + let a = folder(&folder(&root)["A"]); + assert_eq!(a.len(), 2); + assert!(matches!(a.get("a.docx"), Some(FileSystemNode::File(_)))); + assert!(folder(&a["B"]).is_empty()); + } + + #[test] + fn root_with_only_empty_folders() { + let root = build_root_folder("Pack", Vec::new(), &["Pack/Only".to_string()]).unwrap(); + assert!(folder(&folder(&root)["Only"]).is_empty()); + } + + #[test] + fn uppercase_extension_is_typed() { + let upper = item("Pack", "REPORT.DOCX"); + assert_eq!(upper.file_type, Some(FileType::Docx)); + assert_eq!(upper.name, "REPORT"); + } +} diff --git a/services/upload_extractor_lambda_handler/src/main.rs b/services/upload_extractor_lambda_handler/src/main.rs index cc9233657b9..0dbca9f5ca8 100644 --- a/services/upload_extractor_lambda_handler/src/main.rs +++ b/services/upload_extractor_lambda_handler/src/main.rs @@ -23,6 +23,7 @@ use model_entity::EntityType; use models_bulk_upload::{UploadDocumentStatus, UploadFolderStatus, UploadFolderStatusUpdate}; use sha2::{Digest, Sha256}; use sqs_client::upload_extractor::UploadExtractQueueMessage; +use std::ffi::OsStr; use std::path::Path; use std::{fs, str::FromStr}; use std::{path::PathBuf, sync::Arc}; @@ -99,8 +100,10 @@ async fn handler( Some(pid), ); + // A partial upload still has a usable tree; leaving it pending would hide + // every folder because of one bad file. let (update_dynamodb_res, update_dss_res) = match &upload_status { - UploadFolderStatus::Completed => { + UploadFolderStatus::Completed | UploadFolderStatus::PartiallyCompleted => { tokio::join!( update_dynamodb_fut, dss_client @@ -115,12 +118,21 @@ async fn handler( .inspect_err(|e| tracing::error!("Failed to update dynamo status: {:?}", e)) .ok(); - match update_dss_res { - Ok(_) => UploadFolderStatusUpdate::Completed { + match (update_dss_res, &upload_status) { + (Ok(_), UploadFolderStatus::Completed) => UploadFolderStatusUpdate::Completed { request_id: request_id.clone(), project_id: root_project_id.clone(), }, - Err(e) => { + (Ok(_), UploadFolderStatus::PartiallyCompleted) => { + UploadFolderStatusUpdate::PartiallyCompleted { + request_id: request_id.clone(), + project_id: root_project_id.clone(), + } + } + (Ok(_), _) => UploadFolderStatusUpdate::Failed { + request_id: request_id.clone(), + }, + (Err(e), _) => { tracing::error!("Failed to update dss status: {:?}", e); UploadFolderStatusUpdate::Unknown { request_id: request_id.clone(), @@ -250,14 +262,18 @@ async fn process_zipped_s3_object( "Building file system structure for folder: {}", root_folder_name ); - let folder_items = build_file_system(&extract_dir).await?; - tracing::debug!("Upload file system: {:?}", folder_items); + let ExtractedTree { + items: folder_items, + folders, + } = build_file_system(&extract_dir).await?; + tracing::debug!("Upload file system: {:?} {:?}", folder_items, folders); let upload_result = dss_client .upload_unnested_folder( user_id.to_string(), root_folder_name, folder_items, + folders, request_id.to_string(), parent_id.map(|s| s.to_string()), ) @@ -489,27 +505,70 @@ fn extract_zip_file( Ok(root_dir_name) } -async fn build_file_system(extract_dir: &Path) -> Result, Error> { - let mut tasks = Vec::new(); +/// Files and folders collected from an extracted upload. +#[derive(Debug, Default)] +struct ExtractedTree { + items: Vec, + /// Every kept folder, as a path relative to the extract dir, so empty + /// folders still become projects. + folders: Vec, +} - // Walk the extracted directory and collect files - let walker = walkdir::WalkDir::new(extract_dir).into_iter(); +/// OS and editor clutter that should never become a document or project. +fn is_ignored_name(name: &OsStr) -> bool { + let name = name.to_string_lossy(); + let lower = name.to_ascii_lowercase(); + // Hidden files/folders, including `.DS_Store`, `._*` and `.~lock.*#`. + name.starts_with('.') + || name == "__MACOSX" + // Office owner/lock files written next to open documents. + || name.starts_with("~$") + || lower == "thumbs.db" + || lower == "ehthumbs.db" + || lower == "desktop.ini" + // macOS custom folder icon file. + || name == "Icon\r" +} + +/// Whether a path under the extract dir should be skipped. The first component +/// is the root folder the user picked, which is kept whatever its name. +fn is_ignored_entry(relative_path: &Path) -> bool { + relative_path.iter().skip(1).any(is_ignored_name) +} + +async fn build_file_system(extract_dir: &Path) -> Result { + let mut tasks = Vec::new(); + let mut folders = Vec::new(); + + let walker = walkdir::WalkDir::new(extract_dir) + .min_depth(1) + .sort_by_file_name() + .into_iter() + // Pruning here also skips everything inside an ignored folder. + .filter_entry(|entry| { + entry + .path() + .strip_prefix(extract_dir) + .map(|relative| !is_ignored_entry(relative)) + .unwrap_or(false) + }); for entry in walker.filter_map(|e| e.ok()) { let path = entry.path().to_path_buf(); - - // Skip directories and the root directory itself - if path.is_dir() || path == extract_dir || !root_dir_common_filter(path.as_path()) { + let relative = path + .strip_prefix(extract_dir) + .map_err(|e| Error::from(format!("strip_prefix failed: {:?}", e)))? + .to_path_buf(); + + if entry.file_type().is_dir() { + let folder = relative + .to_str() + .ok_or_else(|| Error::from(format!("Invalid folder name: {:?}", relative)))?; + folders.push(folder.to_string()); continue; } - // Skip hidden files and folders (names starting with '.') - if path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.starts_with('.')) - .unwrap_or(false) - { + if !entry.file_type().is_file() { continue; } @@ -527,9 +586,7 @@ async fn build_file_system(extract_dir: &Path) -> Result, Error> None => (full_name, None), }; - let relative_path_without_filename = path - .strip_prefix(extract_dir) - .map_err(|e| Error::from(format!("strip_prefix failed: {:?}", e)))? + let relative_path_without_filename = relative .parent() .ok_or_else(|| Error::from(format!("No parent for path: {:?}", path)))? .to_string_lossy() @@ -537,7 +594,7 @@ async fn build_file_system(extract_dir: &Path) -> Result, Error> let sha = compute_sha256(&path).await?; - Ok(FolderItem { + Ok::<_, Error>(FolderItem { name: file_name.to_string(), full_name: full_name.to_string(), file_type, @@ -550,7 +607,8 @@ async fn build_file_system(extract_dir: &Path) -> Result, Error> } // exits immediately if any task fails - try_join_all(tasks).await + let items = try_join_all(tasks).await?; + Ok(ExtractedTree { items, folders }) } async fn compute_sha256(path: &Path) -> Result { @@ -567,15 +625,10 @@ async fn compute_sha256(path: &Path) -> Result { Ok(format!("{:x}", result)) // hex string } +/// `full_name` is the name on disk; rebuilding it from the parsed type would +/// lowercase extensions like `.DOCX` and miss the file. fn get_file_path_for_item(extract_dir: &Path, item: &FolderItem) -> PathBuf { - match &item.file_type { - None => extract_dir.join(&item.relative_path).join(&item.name), - Some(ext) => { - // TODO: use with added extension once stable - let full_name = format!("{}.{}", item.name, ext.as_str()); - extract_dir.join(&item.relative_path).join(full_name) - } - } + extract_dir.join(&item.relative_path).join(&item.full_name) } #[tokio::main] @@ -669,7 +722,7 @@ mod tests { nested_file.write_all(b"nested file content").unwrap(); // Run the build_file_system function - let result = build_file_system(extract_dir).await.unwrap(); + let result = build_file_system(extract_dir).await.unwrap().items; // Verify the results assert_eq!(result.len(), 3); // Should have 3 files total @@ -785,10 +838,12 @@ mod tests { let extract_dir = temp_dir.path(); // Run the build_file_system function - let result = build_file_system(extract_dir).await.unwrap(); + let result = build_file_system(extract_dir).await.unwrap().items; // Verify the results assert_eq!(result.len(), 0); // Should be empty + let tree = build_file_system(extract_dir).await.unwrap(); + assert!(tree.folders.is_empty()); } #[tokio::test] @@ -797,18 +852,21 @@ mod tests { let temp_dir = tempdir().unwrap(); let extract_dir = temp_dir.path(); + let root_dir = extract_dir.join("root"); + fs::create_dir_all(&root_dir).unwrap(); + // Create a visible file - let visible_file_path = extract_dir.join("visible.txt"); + let visible_file_path = root_dir.join("visible.txt"); let mut visible_file = File::create(&visible_file_path).unwrap(); visible_file.write_all(b"visible content").unwrap(); // Create a hidden file - let hidden_file_path = extract_dir.join(".hidden.txt"); + let hidden_file_path = root_dir.join(".hidden.txt"); let mut hidden_file = File::create(&hidden_file_path).unwrap(); hidden_file.write_all(b"hidden content").unwrap(); // Run the build_file_system function - let result = build_file_system(extract_dir).await.unwrap(); + let result = build_file_system(extract_dir).await.unwrap().items; // Verify that only the visible file is included assert_eq!(result.len(), 1); @@ -828,7 +886,7 @@ mod tests { file.write_all(b"content without extension").unwrap(); // Run the build_file_system function - let result = build_file_system(extract_dir).await.unwrap(); + let result = build_file_system(extract_dir).await.unwrap().items; // Verify the results assert_eq!(result.len(), 1); @@ -854,10 +912,199 @@ mod tests { let expected_sha = format!("{:x}", hasher.finalize()); // Run the build_file_system function - let result = build_file_system(extract_dir).await.unwrap(); + let result = build_file_system(extract_dir).await.unwrap().items; // Verify the SHA256 hash assert_eq!(result.len(), 1); assert_eq!(result[0].sha, expected_sha); } + + #[test] + fn test_get_file_path_for_item_keeps_extension_case() { + let extract_dir = PathBuf::from("/tmp/extract"); + let item = FolderItem { + name: "REPORT".to_string(), + full_name: "REPORT.DOCX".to_string(), + file_type: Some(FileType::Docx), + relative_path: "Root".to_string(), + sha: "abcd".to_string(), + }; + + assert_eq!( + get_file_path_for_item(&extract_dir, &item), + PathBuf::from("/tmp/extract/Root/REPORT.DOCX") + ); + } + + #[test] + fn test_is_ignored_entry() { + for ignored in [ + "Root/.DS_Store", + "Root/~$Summary.docx", + "Root/Sub/~$Deck.pptx", + "Root/Thumbs.db", + "Root/THUMBS.DB", + "Root/desktop.ini", + "Root/Desktop.ini", + "Root/ehthumbs.db", + "Root/Icon\r", + "Root/.git/config", + "Root/__MACOSX/._a.docx", + "Root/Sub/.~lock.Budget.xlsx#", + ] { + assert!(is_ignored_entry(Path::new(ignored)), "{ignored}"); + } + for kept in [ + "Root", + ".dotted-root", + ".dotted-root/a.docx", + "Root/a.docx", + "Root/Sub/b.pptx", + "Root/Thumbs.db.xlsx", + "Root/report~$.docx", + "Root/Icon.png", + ] { + assert!(!is_ignored_entry(Path::new(kept)), "{kept}"); + } + } + + /// Zip entries as the web app's zip worker writes them for a dropped folder. + fn write_browser_zip(path: &Path, entries: &[(&str, Option<&[u8]>)]) { + let file = File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + for (name, contents) in entries { + match contents { + Some(bytes) => { + zip.start_file(*name, options).unwrap(); + zip.write_all(bytes).unwrap(); + } + None => zip.add_directory(*name, options).unwrap(), + } + } + zip.finish().unwrap(); + } + + async fn extract_tree(entries: &[(&str, Option<&[u8]>)], zip_name: &str) -> (String, ExtractedTree, PathBuf, tempfile::TempDir) { + let temp_dir = tempdir().unwrap(); + let zip_path = temp_dir.path().join("upload.zip"); + write_browser_zip(&zip_path, entries); + let extract_dir = temp_dir.path().join("extracted"); + fs::create_dir_all(&extract_dir).unwrap(); + let root = extract_zip_file(&zip_path, &extract_dir, Some(zip_name)).unwrap(); + let tree = build_file_system(&extract_dir).await.unwrap(); + (root, tree, extract_dir, temp_dir) + } + + #[tokio::test] + async fn test_nested_office_folder_upload() { + let entries: &[(&str, Option<&[u8]>)] = &[ + ("Board Pack/", None), + ("Board Pack/Summary.docx", Some(b"docx")), + ("Board Pack/REPORT UPPER.DOCX", Some(b"upper")), + ("Board Pack/Deck.PPTX", Some(b"upper pptx")), + ("Board Pack/Café Menü.docx", Some(b"unicode")), + ("Board Pack/~$Summary.docx", Some(b"lock")), + ("Board Pack/.DS_Store", Some(b"ds")), + ("Board Pack/Thumbs.db", Some(b"thumbs")), + ("Board Pack/desktop.ini", Some(b"ini")), + ("Board Pack/.git/", None), + ("Board Pack/.git/config", Some(b"git")), + ("Board Pack/Empty Folder/", None), + ("Board Pack/Finance/", None), + ("Board Pack/Finance/Budget.xlsx", Some(b"xlsx")), + ("Board Pack/Finance/Forecasts/", None), + ("Board Pack/Finance/Forecasts/Scenarios/", None), + ("Board Pack/Finance/Forecasts/Scenarios/Downside.xlsx", Some(b"xlsx2")), + ("Board Pack/Finance/Forecasts/Scenarios/Empty Leaf/", None), + ("Board Pack/Decks/", None), + ("Board Pack/Decks/Archive/", None), + ("Board Pack/Decks/Archive/Old.pptx", Some(b"pptx")), + ("Board Pack/Decks/Archive/~$Old.pptx", Some(b"lock")), + ]; + let (root, tree, extract_dir, _guard) = extract_tree(entries, "Board Pack").await; + assert_eq!(root, "Board Pack"); + + let mut files: Vec = tree + .items + .iter() + .map(|item| format!("{}/{}", item.relative_path, item.full_name)) + .collect(); + files.sort(); + assert_eq!( + files, + vec![ + "Board Pack/Café Menü.docx", + "Board Pack/Deck.PPTX", + "Board Pack/Decks/Archive/Old.pptx", + "Board Pack/Finance/Budget.xlsx", + "Board Pack/Finance/Forecasts/Scenarios/Downside.xlsx", + "Board Pack/REPORT UPPER.DOCX", + "Board Pack/Summary.docx", + ] + ); + + // Every kept file must be found again when it is copied to storage. + for item in &tree.items { + assert!( + get_file_path_for_item(&extract_dir, item).is_file(), + "{item:?}" + ); + } + let upper = tree + .items + .iter() + .find(|item| item.full_name == "REPORT UPPER.DOCX") + .unwrap(); + assert_eq!(upper.file_type, Some(FileType::Docx)); + assert_eq!(upper.name, "REPORT UPPER"); + let deck = tree + .items + .iter() + .find(|item| item.full_name == "Deck.PPTX") + .unwrap(); + assert_eq!(deck.file_type, Some(FileType::Pptx)); + + assert_eq!( + tree.folders, + vec![ + "Board Pack", + "Board Pack/Decks", + "Board Pack/Decks/Archive", + "Board Pack/Empty Folder", + "Board Pack/Finance", + "Board Pack/Finance/Forecasts", + "Board Pack/Finance/Forecasts/Scenarios", + "Board Pack/Finance/Forecasts/Scenarios/Empty Leaf", + ] + ); + } + + #[tokio::test] + async fn test_folder_with_only_empty_subfolders() { + let entries: &[(&str, Option<&[u8]>)] = &[ + ("Shell/", None), + ("Shell/A/", None), + ("Shell/A/B/", None), + ]; + let (root, tree, _extract_dir, _guard) = extract_tree(entries, "Shell").await; + assert_eq!(root, "Shell"); + assert!(tree.items.is_empty()); + assert_eq!(tree.folders, vec!["Shell", "Shell/A", "Shell/A/B"]); + } + + #[tokio::test] + async fn test_single_subfolder_keeps_picked_root() { + // The picked folder holds one subfolder and no files of its own. + let entries: &[(&str, Option<&[u8]>)] = &[ + ("Wrapper/", None), + ("Wrapper/Inner/", None), + ("Wrapper/Inner/a.docx", Some(b"a")), + ]; + let (root, tree, _extract_dir, _guard) = extract_tree(entries, "Wrapper").await; + assert_eq!(root, "Wrapper"); + assert_eq!(tree.items.len(), 1); + assert_eq!(tree.items[0].relative_path, "Wrapper/Inner"); + assert_eq!(tree.folders, vec!["Wrapper", "Wrapper/Inner"]); + } } From a689c248597dd49d48db3a555e97817c476c3fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:04:37 +0000 Subject: [PATCH 2/8] chore: regenerate API types for .doc/.xlsm and empty upload folders Also lists .doc and .xlsm in the frontend file type map and channel attachment types, reports partially completed folder uploads, and lints the folder upload changes. --- .../channel/Input/accepted-file-types.ts | 1 + .../lib/core/client/folderUploadPaths.test.ts | 4 +--- .../web/src/lib/core/client/zipWorker.test.ts | 16 ++++++++------- apps/web/src/lib/core/util/upload.ts | 2 +- .../generated/schemas/fileType.ts | 2 ++ .../service-cognition/openapi.json | 2 ++ .../generated/models/fileType.ts | 2 ++ .../service-search/openapi.json | 2 ++ .../service-storage/fileTypeMap.ts | 6 ++++++ .../generated/schemas/fileType.ts | 2 ++ .../generated/schemas/uploadFolderRequest.ts | 4 ++++ .../service-storage/generated/zod.ts | 12 +++++++++++ .../service-storage/openapi.json | 9 +++++++++ crates/model/src/document/file_type/tests.rs | 6 +++++- crates/model_file_type/src/test.rs | 11 ++++++++-- crates/projects/src/domain/upload.rs | 20 +++++++++++++++---- packages/sdk/generated/cognition/types.gen.ts | 2 +- packages/sdk/generated/search/types.gen.ts | 2 +- packages/sdk/generated/storage/types.gen.ts | 8 +++++++- packages/sdk/specs/cognition.json | 2 ++ packages/sdk/specs/search.json | 2 ++ packages/sdk/specs/storage.json | 9 +++++++++ .../src/main.rs | 17 +++++++++------- 23 files changed, 115 insertions(+), 28 deletions(-) diff --git a/apps/web/src/features/channel/Input/accepted-file-types.ts b/apps/web/src/features/channel/Input/accepted-file-types.ts index 1a391d52c5c..3a914e63109 100644 --- a/apps/web/src/features/channel/Input/accepted-file-types.ts +++ b/apps/web/src/features/channel/Input/accepted-file-types.ts @@ -38,6 +38,7 @@ export const CHANNEL_DOCUMENT_FILE_EXTENSIONS = [ 'rtf', 'txt', 'xls', + 'xlsm', 'xlsx', 'xml', 'yaml', diff --git a/apps/web/src/lib/core/client/folderUploadPaths.test.ts b/apps/web/src/lib/core/client/folderUploadPaths.test.ts index 3e13432a8fb..14be2132b49 100644 --- a/apps/web/src/lib/core/client/folderUploadPaths.test.ts +++ b/apps/web/src/lib/core/client/folderUploadPaths.test.ts @@ -47,9 +47,7 @@ describe('groupPickedFolderFiles', () => { { name: 'loose.docx', webkitRelativePath: '' }, ]); - expect(groups.get('loose.docx')?.details).toEqual([ - { path: 'loose.docx' }, - ]); + expect(groups.get('loose.docx')?.details).toEqual([{ path: 'loose.docx' }]); }); }); diff --git a/apps/web/src/lib/core/client/zipWorker.test.ts b/apps/web/src/lib/core/client/zipWorker.test.ts index b49e816e0b4..3787577fcd5 100644 --- a/apps/web/src/lib/core/client/zipWorker.test.ts +++ b/apps/web/src/lib/core/client/zipWorker.test.ts @@ -9,7 +9,9 @@ beforeAll(async () => { vi.stubGlobal('self', { postMessage: (message: WorkerMessage) => posted.push(message), }); - await import('../../workers/folder-upload/zip-worker.js'); + // The worker is plain JS with no types; importing it installs `onmessage`. + const workerModule = '../../workers/folder-upload/zip-worker.js'; + await import(/* @vite-ignore */ workerModule); }); afterEach(() => { @@ -33,9 +35,9 @@ async function runZip( }, }); await vi.waitFor(() => { - expect(posted.some((m) => m.type === 'complete' || m.type === 'error')).toBe( - true - ); + expect( + posted.some((m) => m.type === 'complete' || m.type === 'error') + ).toBe(true); }); const done = posted.find((m) => m.type === 'complete'); if (!done?.data.zipBlob) throw new Error(JSON.stringify(posted.at(-1))); @@ -53,9 +55,9 @@ describe('zip worker', () => { ['Pack', 'Pack/Finance', 'Pack/Finance/Forecasts', 'Pack/Decks'] ); - expect(await zip.file('Pack/Finance/Forecasts/Q4.xlsx')?.async('string')).toBe( - 'xlsx' - ); + expect( + await zip.file('Pack/Finance/Forecasts/Q4.xlsx')?.async('string') + ).toBe('xlsx'); expect(await zip.file('Pack/Decks/Old.PPTX')?.async('string')).toBe('pptx'); }); diff --git a/apps/web/src/lib/core/util/upload.ts b/apps/web/src/lib/core/util/upload.ts index db5ec0924ac..8a4e86ee297 100644 --- a/apps/web/src/lib/core/util/upload.ts +++ b/apps/web/src/lib/core/util/upload.ts @@ -8,13 +8,13 @@ */ import { analytics } from '@app/lib/analytics'; +import { groupPickedFolderFiles } from '@core/client/folderUploadPaths'; import { DirectoryFileCountExceededError, DirectoryFileSizeExceededError, handleFoldersInput, zipFiles, } from '@core/client/zipWorkerClient'; -import { groupPickedFolderFiles } from '@core/client/folderUploadPaths'; import { toast } from '@core/component/Toast/Toast'; import { blockAcceptedMimetypeToFileExtension, diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/fileType.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/fileType.ts index 830782287dd..c6d531369e1 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/fileType.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/fileType.ts @@ -410,7 +410,9 @@ export const FileType = { azw3: 'azw3', djvu: 'djvu', xls: 'xls', + xlsm: 'xlsm', ppt: 'ppt', + doc: 'doc', pptx: 'pptx', xlsx: 'xlsx', db: 'db', diff --git a/apps/web/src/lib/service-clients/service-cognition/openapi.json b/apps/web/src/lib/service-clients/service-cognition/openapi.json index 634f37e3da7..29bd9f4f443 100644 --- a/apps/web/src/lib/service-clients/service-cognition/openapi.json +++ b/apps/web/src/lib/service-clients/service-cognition/openapi.json @@ -3600,7 +3600,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", diff --git a/apps/web/src/lib/service-clients/service-search/generated/models/fileType.ts b/apps/web/src/lib/service-clients/service-search/generated/models/fileType.ts index be5af1bda49..ea97c14919c 100644 --- a/apps/web/src/lib/service-clients/service-search/generated/models/fileType.ts +++ b/apps/web/src/lib/service-clients/service-search/generated/models/fileType.ts @@ -410,7 +410,9 @@ export const FileType = { azw3: 'azw3', djvu: 'djvu', xls: 'xls', + xlsm: 'xlsm', ppt: 'ppt', + doc: 'doc', pptx: 'pptx', xlsx: 'xlsx', db: 'db', diff --git a/apps/web/src/lib/service-clients/service-search/openapi.json b/apps/web/src/lib/service-clients/service-search/openapi.json index 5f4234a91d0..b1204bdd6e2 100644 --- a/apps/web/src/lib/service-clients/service-search/openapi.json +++ b/apps/web/src/lib/service-clients/service-search/openapi.json @@ -2640,7 +2640,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", diff --git a/apps/web/src/lib/service-clients/service-storage/fileTypeMap.ts b/apps/web/src/lib/service-clients/service-storage/fileTypeMap.ts index eea6c9b0151..dd9af0edece 100644 --- a/apps/web/src/lib/service-clients/service-storage/fileTypeMap.ts +++ b/apps/web/src/lib/service-clients/service-storage/fileTypeMap.ts @@ -508,11 +508,17 @@ export const FileTypeMap = { }, djvu: { extension: 'djvu', mime: 'image/vnd.djvu', app: 'document' }, xls: { extension: 'xls', mime: 'application/vnd.ms-excel', app: 'document' }, + xlsm: { + extension: 'xlsm', + mime: 'application/vnd.ms-excel.sheet.macroEnabled.12', + app: 'document', + }, ppt: { extension: 'ppt', mime: 'application/vnd.ms-powerpoint', app: 'document', }, + doc: { extension: 'doc', mime: 'application/msword', app: 'document' }, pptx: { extension: 'pptx', mime: 'application/xml', app: 'document' }, xlsx: { extension: 'xlsx', mime: 'application/xml', app: 'document' }, db: { extension: 'db', mime: 'application/octet-stream', app: 'database' }, diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileType.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileType.ts index af0b71adb79..6a9596dcd4d 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileType.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/fileType.ts @@ -410,7 +410,9 @@ export const FileType = { azw3: 'azw3', djvu: 'djvu', xls: 'xls', + xlsm: 'xlsm', ppt: 'ppt', + doc: 'doc', pptx: 'pptx', xlsx: 'xlsx', db: 'db', diff --git a/apps/web/src/lib/service-clients/service-storage/generated/schemas/uploadFolderRequest.ts b/apps/web/src/lib/service-clients/service-storage/generated/schemas/uploadFolderRequest.ts index dd0eaeb9b85..329b4b5181e 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/schemas/uploadFolderRequest.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/schemas/uploadFolderRequest.ts @@ -10,6 +10,10 @@ import type { UploadFolderRequestParentId } from './uploadFolderRequestParentId' export interface UploadFolderRequest { /** The content of the folder */ content: FolderItem[]; + /** Relative paths of folders to create even when they hold no files. + +Uses the same shape as [FolderItem::relative_path], root folder included. */ + folders?: string[]; /** Optional parent project id to upload the folder into */ parentId?: UploadFolderRequestParentId; /** The name of the folder you are uploading. diff --git a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts index da22718c920..75012aa3a38 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts @@ -11719,7 +11719,9 @@ export const editDocumentBody = zod 'azw3', 'djvu', 'xls', + 'xlsm', 'ppt', + 'doc', 'pptx', 'xlsx', 'db', @@ -40150,7 +40152,9 @@ export const uploadFolderHandlerBody = zod.object({ 'azw3', 'djvu', 'xls', + 'xlsm', 'ppt', + 'doc', 'pptx', 'xlsx', 'db', @@ -40210,6 +40214,12 @@ export const uploadFolderHandlerBody = zod.object({ }) ) .describe('The content of the folder'), + folders: zod + .array(zod.string()) + .optional() + .describe( + 'Relative paths of folders to create even when they hold no files.\n\nUses the same shape as [FolderItem::relative_path], root folder included.' + ), parentId: zod .string() .nullish() @@ -40646,7 +40656,9 @@ export const uploadFolderHandlerResponse = zod.object({ 'azw3', 'djvu', 'xls', + 'xlsm', 'ppt', + 'doc', 'pptx', 'xlsx', 'db', diff --git a/apps/web/src/lib/service-clients/service-storage/openapi.json b/apps/web/src/lib/service-clients/service-storage/openapi.json index d2269caa243..3a42c8a382c 100644 --- a/apps/web/src/lib/service-clients/service-storage/openapi.json +++ b/apps/web/src/lib/service-clients/service-storage/openapi.json @@ -25720,7 +25720,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", @@ -36719,6 +36721,13 @@ }, "description": "The content of the folder" }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative paths of folders to create even when they hold no files.\n\nUses the same shape as [FolderItem::relative_path], root folder included." + }, "parentId": { "type": ["string", "null"], "description": "Optional parent project id to upload the folder into" diff --git a/crates/model/src/document/file_type/tests.rs b/crates/model/src/document/file_type/tests.rs index 13707402a0d..f2a24708306 100644 --- a/crates/model/src/document/file_type/tests.rs +++ b/crates/model/src/document/file_type/tests.rs @@ -167,6 +167,10 @@ fn test_split_suffix_match_office_files() { ("Ledger.xls", Some(("Ledger", "xls"))), ("notes.mdoc", Some(("notes", "mdoc"))), ] { - assert_eq!(FileType::split_suffix_match(file_name), expected, "{file_name}"); + assert_eq!( + FileType::split_suffix_match(file_name), + expected, + "{file_name}" + ); } } diff --git a/crates/model_file_type/src/test.rs b/crates/model_file_type/src/test.rs index 55f1086b6ef..68879abf6db 100644 --- a/crates/model_file_type/src/test.rs +++ b/crates/model_file_type/src/test.rs @@ -31,7 +31,11 @@ fn office_extensions_parse_case_insensitively() { ("xlsm", FileType::Xlsm), ("XLSM", FileType::Xlsm), ] { - assert_eq!(FileType::from_str(extension).unwrap(), expected, "{extension}"); + assert_eq!( + FileType::from_str(extension).unwrap(), + expected, + "{extension}" + ); } } @@ -49,6 +53,9 @@ fn legacy_office_types_round_trip() { assert_eq!(file_type.mime_type(), mime); assert_eq!(file_type.macro_app_path().to_string(), "document"); assert_eq!(FileType::from_str(extension).unwrap(), file_type); - assert_eq!(serde_json::to_string(&file_type).unwrap(), format!("\"{extension}\"")); + assert_eq!( + serde_json::to_string(&file_type).unwrap(), + format!("\"{extension}\"") + ); } } diff --git a/crates/projects/src/domain/upload.rs b/crates/projects/src/domain/upload.rs index 5d871960d70..4838b2f8935 100644 --- a/crates/projects/src/domain/upload.rs +++ b/crates/projects/src/domain/upload.rs @@ -128,11 +128,20 @@ mod tests { .unwrap(); let root = folder(&root); - assert!(matches!(root.get("Summary.docx"), Some(FileSystemNode::File(_)))); + assert!(matches!( + root.get("Summary.docx"), + Some(FileSystemNode::File(_)) + )); let forecasts = folder(folder(&root["Finance"]).get("Forecasts").unwrap()); - assert!(matches!(forecasts.get("Q4.xlsx"), Some(FileSystemNode::File(_)))); + assert!(matches!( + forecasts.get("Q4.xlsx"), + Some(FileSystemNode::File(_)) + )); let archive = folder(folder(&root["Decks"]).get("Archive").unwrap()); - assert!(matches!(archive.get("Old.pptx"), Some(FileSystemNode::File(_)))); + assert!(matches!( + archive.get("Old.pptx"), + Some(FileSystemNode::File(_)) + )); } #[test] @@ -152,7 +161,10 @@ mod tests { let root = folder(&root); assert!(folder(&root["Empty"]).is_empty()); let finance = folder(&root["Finance"]); - assert!(matches!(finance.get("Budget.xlsx"), Some(FileSystemNode::File(_)))); + assert!(matches!( + finance.get("Budget.xlsx"), + Some(FileSystemNode::File(_)) + )); assert!(folder(&finance["Empty Leaf"]).is_empty()); } diff --git a/packages/sdk/generated/cognition/types.gen.ts b/packages/sdk/generated/cognition/types.gen.ts index bf38d397141..a113c4b3a57 100644 --- a/packages/sdk/generated/cognition/types.gen.ts +++ b/packages/sdk/generated/cognition/types.gen.ts @@ -600,7 +600,7 @@ export type FeatureUsage = { * - ContentType::mime_type() - Gets MIME type for ContentType * */ -export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'ppt' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; +export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'xlsm' | 'ppt' | 'doc' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; export type GenericErrorResponse = { /** diff --git a/packages/sdk/generated/search/types.gen.ts b/packages/sdk/generated/search/types.gen.ts index b7eb3b79e6e..fc3ab91bc9f 100644 --- a/packages/sdk/generated/search/types.gen.ts +++ b/packages/sdk/generated/search/types.gen.ts @@ -1402,7 +1402,7 @@ export type ErrorResponse = { * - ContentType::mime_type() - Gets MIME type for ContentType * */ -export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'ppt' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; +export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'xlsm' | 'ppt' | 'doc' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; /** * Filters for foreign entity records. diff --git a/packages/sdk/generated/storage/types.gen.ts b/packages/sdk/generated/storage/types.gen.ts index 493d7e617d2..653e0fa97fa 100644 --- a/packages/sdk/generated/storage/types.gen.ts +++ b/packages/sdk/generated/storage/types.gen.ts @@ -6086,7 +6086,7 @@ export type FileSystemNodeWithIds = { * - ContentType::mime_type() - Gets MIME type for ContentType * */ -export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'ppt' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; +export type FileType = 'docx' | 'pdf' | 'md' | 'spreadsheet' | 'canvas' | 'coffee' | 'cson' | 'iced' | 'c' | 'i' | 'cpp' | 'cppm' | 'cc' | 'ccm' | 'cxx' | 'cxxm' | 'cplusplus' | 'cplusplusm' | 'hpp' | 'hh' | 'hxx' | 'hplusplus' | 'h' | 'ii' | 'ino' | 'inl' | 'ipp' | 'ixx' | 'tpp' | 'txx' | 'hppin' | 'hin' | 'cu' | 'cuh' | 'cs' | 'csx' | 'cake' | 'css' | 'dart' | 'diff' | 'patch' | 'rej' | 'dockerfile' | 'containerfile' | 'go' | 'handlebars' | 'hbs' | 'hjs' | 'hlsl' | 'hlsli' | 'fx' | 'fxh' | 'vsh' | 'psh' | 'cginc' | 'compute' | 'html' | 'htm' | 'shtml' | 'xhtml' | 'xht' | 'mdoc' | 'jsp' | 'asp' | 'aspx' | 'jshtm' | 'volt' | 'ejs' | 'rhtml' | 'ini' | 'conf' | 'properties' | 'cfg' | 'directory' | 'gitattributes' | 'gitconfig' | 'gitmodules' | 'editorconfig' | 'repo' | 'java' | 'jav' | 'jsx' | 'js' | 'es6' | 'mjs' | 'cjs' | 'pac' | 'json' | 'bowerrc' | 'jscsrc' | 'webmanifest' | 'jsmap' | 'cssmap' | 'tsmap' | 'har' | 'jslintrc' | 'jsonld' | 'geojson' | 'ipynb' | 'vuerc' | 'jsonc' | 'eslintrc' | 'eslintrcjson' | 'jsfmtrc' | 'jshintrc' | 'swcrc' | 'hintrc' | 'babelrc' | 'jsonl' | 'ndjson' | 'codesnippets' | 'jl' | 'jmd' | 'sty' | 'cls' | 'bbx' | 'cbx' | 'tex' | 'ltx' | 'ctx' | 'bib' | 'less' | 'log' | 'lua' | 'mak' | 'mk' | 'mkd' | 'mdwn' | 'mdown' | 'markdown' | 'markdn' | 'mdtxt' | 'mdtext' | 'workbook' | 'm' | 'mm' | 'pl' | 'pm' | 'pod' | 't' | 'psgi' | 'raku' | 'rakumod' | 'rakutest' | 'rakudoc' | 'nqp' | 'p6' | 'pl6' | 'pm6' | 'php' | 'php4' | 'php5' | 'phtml' | 'ctp' | 'ps1' | 'psm1' | 'psd1' | 'pssc' | 'psrc' | 'py' | 'rpy' | 'pyw' | 'cpy' | 'gyp' | 'gypi' | 'pyi' | 'ipy' | 'pyt' | 'r' | 'rhistory' | 'rprofile' | 'rt' | 'cshtml' | 'razor' | 'rb' | 'rbx' | 'rjs' | 'gemspec' | 'rake' | 'ru' | 'erb' | 'podspec' | 'rbi' | 'rs' | 'scss' | 'sass' | 'shader' | 'sh' | 'bash' | 'bashrc' | 'bashaliases' | 'bashprofile' | 'bashlogin' | 'ebuild' | 'eclass' | 'profile' | 'bashlogout' | 'xprofile' | 'xsession' | 'xsessionrc' | 'zsh' | 'zshrc' | 'zprofile' | 'zlogin' | 'zlogout' | 'zshenv' | 'zshtheme' | 'fish' | 'ksh' | 'csh' | 'cshrc' | 'tcshrc' | 'yashrc' | 'yashprofile' | 'sql' | 'dsql' | 'swift' | 'ts' | 'cts' | 'mts' | 'tsx' | 'tsbuildinfo' | 'xml' | 'xsd' | 'ascx' | 'atom' | 'axml' | 'axaml' | 'bpmn' | 'cpt' | 'csl' | 'csproj' | 'csprojuser' | 'dita' | 'ditamap' | 'dtd' | 'ent' | 'mod' | 'dtml' | 'fsproj' | 'fxml' | 'iml' | 'isml' | 'jmx' | 'launch' | 'menu' | 'mxml' | 'nuspec' | 'opml' | 'owl' | 'proj' | 'props' | 'pt' | 'publishsettings' | 'pubxml' | 'pubxmluser' | 'rbxlx' | 'rbxmx' | 'rdf' | 'rng' | 'rss' | 'shproj' | 'storyboard' | 'targets' | 'tld' | 'tmx' | 'vbproj' | 'vbprojuser' | 'vcxproj' | 'vcxprojfilters' | 'wsdl' | 'wxi' | 'wxl' | 'wxs' | 'xaml' | 'xbl' | 'xib' | 'xlf' | 'xliff' | 'xpdl' | 'xul' | 'xoml' | 'xsl' | 'xslt' | 'yaml' | 'yml' | 'eyaml' | 'eyml' | 'cff' | 'yamltmlanguage' | 'yamltmpreferences' | 'yamltmtheme' | 'winget' | 'txt' | 'csv' | 'tsv' | 'jpeg' | 'jpg' | 'png' | 'gif' | 'svg' | 'webp' | 'avif' | 'bmp' | 'ico' | 'tiff' | 'tif' | 'heic' | 'heif' | 'tar' | 'targz' | 'tgz' | 'gz' | 'bz2' | 'tarbz2' | 'tbz2' | 'z' | 'tarz' | 'lz' | 'tarlz' | 'xz' | 'tarxz' | 'txz' | 'lzma' | 'tarlzma' | 'rar' | 'sevenz' | 'zst' | 'tarzst' | 'tzst' | 'zip' | 'exe' | 'msi' | 'dll' | 'bat' | 'cmd' | 'com' | 'appimage' | 'app' | 'bin' | 'deb' | 'rpm' | 'apk' | 'dmg' | 'pkg' | 'crx' | 'xpi' | 'mp3' | 'wav' | 'ogg' | 'flac' | 'aac' | 'm4a' | 'wma' | 'mid' | 'midi' | 'mp4' | 'mkv' | 'webm' | 'avi' | 'mov' | 'wmv' | 'mpg' | 'mpeg' | 'm4v' | 'flv' | 'f4v' | 'threegp' | 'ttf' | 'otf' | 'woff' | 'woff2' | 'eot' | 'rtf' | 'odt' | 'ods' | 'odp' | 'odg' | 'odf' | 'epub' | 'mobi' | 'azw' | 'azw3' | 'djvu' | 'xls' | 'xlsm' | 'ppt' | 'doc' | 'pptx' | 'xlsx' | 'db' | 'sqlite' | 'sqlite3' | 'mdb' | 'accdb' | 'dbf' | 'plist' | 'toml' | 'env' | 'dot' | 'gv' | 'torrent' | 'ics' | 'vcf' | 'ai' | 'eps' | 'ps' | 'dxf' | 'dwg' | 'fig' | 'stl' | 'obj' | 'fbx' | 'blend' | 'dae' | 'threeds' | 'gltf' | 'glb' | 'vhd' | 'vhdx' | 'vmdk' | 'ova' | 'ovf' | 'iso' | 'img' | 'swf'; /** * Represents a file type update: either set to a specific type or clear to null. @@ -13031,6 +13031,12 @@ export type UploadFolderRequest = { * The content of the folder */ content: Array; + /** + * Relative paths of folders to create even when they hold no files. + * + * Uses the same shape as [FolderItem::relative_path], root folder included. + */ + folders?: Array; /** * Optional parent project id to upload the folder into */ diff --git a/packages/sdk/specs/cognition.json b/packages/sdk/specs/cognition.json index 634f37e3da7..29bd9f4f443 100644 --- a/packages/sdk/specs/cognition.json +++ b/packages/sdk/specs/cognition.json @@ -3600,7 +3600,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", diff --git a/packages/sdk/specs/search.json b/packages/sdk/specs/search.json index 5f4234a91d0..b1204bdd6e2 100644 --- a/packages/sdk/specs/search.json +++ b/packages/sdk/specs/search.json @@ -2640,7 +2640,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", diff --git a/packages/sdk/specs/storage.json b/packages/sdk/specs/storage.json index d2269caa243..3a42c8a382c 100644 --- a/packages/sdk/specs/storage.json +++ b/packages/sdk/specs/storage.json @@ -25720,7 +25720,9 @@ "azw3", "djvu", "xls", + "xlsm", "ppt", + "doc", "pptx", "xlsx", "db", @@ -36719,6 +36721,13 @@ }, "description": "The content of the folder" }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative paths of folders to create even when they hold no files.\n\nUses the same shape as [FolderItem::relative_path], root folder included." + }, "parentId": { "type": ["string", "null"], "description": "Optional parent project id to upload the folder into" diff --git a/services/upload_extractor_lambda_handler/src/main.rs b/services/upload_extractor_lambda_handler/src/main.rs index 0dbca9f5ca8..32097d9ec3c 100644 --- a/services/upload_extractor_lambda_handler/src/main.rs +++ b/services/upload_extractor_lambda_handler/src/main.rs @@ -985,7 +985,10 @@ mod tests { zip.finish().unwrap(); } - async fn extract_tree(entries: &[(&str, Option<&[u8]>)], zip_name: &str) -> (String, ExtractedTree, PathBuf, tempfile::TempDir) { + async fn extract_tree( + entries: &[(&str, Option<&[u8]>)], + zip_name: &str, + ) -> (String, ExtractedTree, PathBuf, tempfile::TempDir) { let temp_dir = tempdir().unwrap(); let zip_path = temp_dir.path().join("upload.zip"); write_browser_zip(&zip_path, entries); @@ -1015,7 +1018,10 @@ mod tests { ("Board Pack/Finance/Budget.xlsx", Some(b"xlsx")), ("Board Pack/Finance/Forecasts/", None), ("Board Pack/Finance/Forecasts/Scenarios/", None), - ("Board Pack/Finance/Forecasts/Scenarios/Downside.xlsx", Some(b"xlsx2")), + ( + "Board Pack/Finance/Forecasts/Scenarios/Downside.xlsx", + Some(b"xlsx2"), + ), ("Board Pack/Finance/Forecasts/Scenarios/Empty Leaf/", None), ("Board Pack/Decks/", None), ("Board Pack/Decks/Archive/", None), @@ -1082,11 +1088,8 @@ mod tests { #[tokio::test] async fn test_folder_with_only_empty_subfolders() { - let entries: &[(&str, Option<&[u8]>)] = &[ - ("Shell/", None), - ("Shell/A/", None), - ("Shell/A/B/", None), - ]; + let entries: &[(&str, Option<&[u8]>)] = + &[("Shell/", None), ("Shell/A/", None), ("Shell/A/B/", None)]; let (root, tree, _extract_dir, _guard) = extract_tree(entries, "Shell").await; assert_eq!(root, "Shell"); assert!(tree.items.is_empty()); From b1a658e87320cf95a1808a77f4decb7d5972dcf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:05:06 +0000 Subject: [PATCH 3/8] docs(agent-guide): describe folder uploads --- docs/AGENT_GUIDE/documents.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/AGENT_GUIDE/documents.md b/docs/AGENT_GUIDE/documents.md index 777dac15f99..a4c03698168 100644 --- a/docs/AGENT_GUIDE/documents.md +++ b/docs/AGENT_GUIDE/documents.md @@ -420,6 +420,18 @@ client. A viewer's edit must fail; a concurrent manual edit must force a fresh read. These tool calls require the updated AI backend, AI editing worker, and sync service; the frontend alone cannot test their hosted path. +## Folder uploads + +Dropping a folder (or choosing one with the folder picker) zips it in the browser +and imports it as a folder tree: each subfolder becomes a folder, including empty +ones, and each file becomes a document. The folder picker cannot see empty +subfolders, so only dropped folders keep them. Office lock files (`~$…`), +`Thumbs.db`, `desktop.ini`, `__MACOSX` and hidden files or folders are skipped. +Extension case does not matter (`REPORT.DOCX` imports as `.docx`). If some files +fail, the rest of the tree still appears and a toast says some files could not be +uploaded. The unzip runs in the upload extractor Lambda, which the local stack +does not run; verify folder imports against a deployed backend. + ## Presentations (PowerPoint) Uploaded `.pptx` files open in the `pptx` block (`/app/pptx/`). From 4601bfbbdadad483ea36ba3ef3be0f49cfb51072 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:09:14 +0000 Subject: [PATCH 4/8] fix(file-types): parse mixed-case MIME types and refresh the other-files filter ContentType::from_str lowercased its input but compared it with literal MIME types, so the canonical .xlsm type (application/vnd.ms-excel.sheet.macroEnabled.12) never parsed. Compare case-insensitively and test that every file type's MIME type parses back. The assoc:other document filter snapshot now includes .xlsm and .doc. --- crates/item_filters/src/ast/tests/other.json | 2 +- crates/model_file_type/src/lib.rs | 21 +++++++--------- crates/model_file_type/src/test.rs | 26 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/item_filters/src/ast/tests/other.json b/crates/item_filters/src/ast/tests/other.json index 9c000efd886..9882c290c55 100644 --- a/crates/item_filters/src/ast/tests/other.json +++ b/crates/item_filters/src/ast/tests/other.json @@ -1 +1 @@ -{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"l":{"ft":"jpeg"}},{"l":{"ft":"jpg"}}]},{"l":{"ft":"png"}}]},{"l":{"ft":"gif"}}]},{"l":{"ft":"svg"}}]},{"l":{"ft":"webp"}}]},{"l":{"ft":"avif"}}]},{"l":{"ft":"bmp"}}]},{"l":{"ft":"ico"}}]},{"l":{"ft":"tiff"}}]},{"l":{"ft":"tif"}}]},{"l":{"ft":"heic"}}]},{"l":{"ft":"heif"}}]},{"l":{"ft":"tar"}}]},{"l":{"ft":"targz"}}]},{"l":{"ft":"tgz"}}]},{"l":{"ft":"gz"}}]},{"l":{"ft":"bz2"}}]},{"l":{"ft":"tarbz2"}}]},{"l":{"ft":"tbz2"}}]},{"l":{"ft":"z"}}]},{"l":{"ft":"tarz"}}]},{"l":{"ft":"lz"}}]},{"l":{"ft":"tarlz"}}]},{"l":{"ft":"xz"}}]},{"l":{"ft":"tarxz"}}]},{"l":{"ft":"txz"}}]},{"l":{"ft":"lzma"}}]},{"l":{"ft":"tarlzma"}}]},{"l":{"ft":"rar"}}]},{"l":{"ft":"sevenz"}}]},{"l":{"ft":"zst"}}]},{"l":{"ft":"tarzst"}}]},{"l":{"ft":"tzst"}}]},{"l":{"ft":"zip"}}]},{"l":{"ft":"exe"}}]},{"l":{"ft":"msi"}}]},{"l":{"ft":"dll"}}]},{"l":{"ft":"bat"}}]},{"l":{"ft":"cmd"}}]},{"l":{"ft":"com"}}]},{"l":{"ft":"appimage"}}]},{"l":{"ft":"app"}}]},{"l":{"ft":"bin"}}]},{"l":{"ft":"deb"}}]},{"l":{"ft":"rpm"}}]},{"l":{"ft":"apk"}}]},{"l":{"ft":"dmg"}}]},{"l":{"ft":"pkg"}}]},{"l":{"ft":"crx"}}]},{"l":{"ft":"xpi"}}]},{"l":{"ft":"mp3"}}]},{"l":{"ft":"wav"}}]},{"l":{"ft":"ogg"}}]},{"l":{"ft":"flac"}}]},{"l":{"ft":"aac"}}]},{"l":{"ft":"m4a"}}]},{"l":{"ft":"wma"}}]},{"l":{"ft":"mid"}}]},{"l":{"ft":"midi"}}]},{"l":{"ft":"ttf"}}]},{"l":{"ft":"otf"}}]},{"l":{"ft":"woff"}}]},{"l":{"ft":"woff2"}}]},{"l":{"ft":"eot"}}]},{"l":{"ft":"rtf"}}]},{"l":{"ft":"odt"}}]},{"l":{"ft":"ods"}}]},{"l":{"ft":"odp"}}]},{"l":{"ft":"odg"}}]},{"l":{"ft":"odf"}}]},{"l":{"ft":"epub"}}]},{"l":{"ft":"mobi"}}]},{"l":{"ft":"azw"}}]},{"l":{"ft":"azw3"}}]},{"l":{"ft":"djvu"}}]},{"l":{"ft":"xls"}}]},{"l":{"ft":"ppt"}}]},{"l":{"ft":"pptx"}}]},{"l":{"ft":"xlsx"}}]},{"l":{"ft":"db"}}]},{"l":{"ft":"sqlite"}}]},{"l":{"ft":"sqlite3"}}]},{"l":{"ft":"mdb"}}]},{"l":{"ft":"accdb"}}]},{"l":{"ft":"dbf"}}]},{"l":{"ft":"plist"}}]},{"l":{"ft":"toml"}}]},{"l":{"ft":"env"}}]},{"l":{"ft":"dot"}}]},{"l":{"ft":"gv"}}]},{"l":{"ft":"torrent"}}]},{"l":{"ft":"ics"}}]},{"l":{"ft":"vcf"}}]},{"l":{"ft":"ai"}}]},{"l":{"ft":"eps"}}]},{"l":{"ft":"ps"}}]},{"l":{"ft":"dxf"}}]},{"l":{"ft":"dwg"}}]},{"l":{"ft":"fig"}}]},{"l":{"ft":"stl"}}]},{"l":{"ft":"obj"}}]},{"l":{"ft":"fbx"}}]},{"l":{"ft":"blend"}}]},{"l":{"ft":"dae"}}]},{"l":{"ft":"threeds"}}]},{"l":{"ft":"gltf"}}]},{"l":{"ft":"glb"}}]},{"l":{"ft":"vhd"}}]},{"l":{"ft":"vhdx"}}]},{"l":{"ft":"vmdk"}}]},{"l":{"ft":"ova"}}]},{"l":{"ft":"ovf"}}]},{"l":{"ft":"iso"}}]},{"l":{"ft":"img"}}]},{"l":{"ft":"swf"}}]} +{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"|":[{"l":{"ft":"jpeg"}},{"l":{"ft":"jpg"}}]},{"l":{"ft":"png"}}]},{"l":{"ft":"gif"}}]},{"l":{"ft":"svg"}}]},{"l":{"ft":"webp"}}]},{"l":{"ft":"avif"}}]},{"l":{"ft":"bmp"}}]},{"l":{"ft":"ico"}}]},{"l":{"ft":"tiff"}}]},{"l":{"ft":"tif"}}]},{"l":{"ft":"heic"}}]},{"l":{"ft":"heif"}}]},{"l":{"ft":"tar"}}]},{"l":{"ft":"targz"}}]},{"l":{"ft":"tgz"}}]},{"l":{"ft":"gz"}}]},{"l":{"ft":"bz2"}}]},{"l":{"ft":"tarbz2"}}]},{"l":{"ft":"tbz2"}}]},{"l":{"ft":"z"}}]},{"l":{"ft":"tarz"}}]},{"l":{"ft":"lz"}}]},{"l":{"ft":"tarlz"}}]},{"l":{"ft":"xz"}}]},{"l":{"ft":"tarxz"}}]},{"l":{"ft":"txz"}}]},{"l":{"ft":"lzma"}}]},{"l":{"ft":"tarlzma"}}]},{"l":{"ft":"rar"}}]},{"l":{"ft":"sevenz"}}]},{"l":{"ft":"zst"}}]},{"l":{"ft":"tarzst"}}]},{"l":{"ft":"tzst"}}]},{"l":{"ft":"zip"}}]},{"l":{"ft":"exe"}}]},{"l":{"ft":"msi"}}]},{"l":{"ft":"dll"}}]},{"l":{"ft":"bat"}}]},{"l":{"ft":"cmd"}}]},{"l":{"ft":"com"}}]},{"l":{"ft":"appimage"}}]},{"l":{"ft":"app"}}]},{"l":{"ft":"bin"}}]},{"l":{"ft":"deb"}}]},{"l":{"ft":"rpm"}}]},{"l":{"ft":"apk"}}]},{"l":{"ft":"dmg"}}]},{"l":{"ft":"pkg"}}]},{"l":{"ft":"crx"}}]},{"l":{"ft":"xpi"}}]},{"l":{"ft":"mp3"}}]},{"l":{"ft":"wav"}}]},{"l":{"ft":"ogg"}}]},{"l":{"ft":"flac"}}]},{"l":{"ft":"aac"}}]},{"l":{"ft":"m4a"}}]},{"l":{"ft":"wma"}}]},{"l":{"ft":"mid"}}]},{"l":{"ft":"midi"}}]},{"l":{"ft":"ttf"}}]},{"l":{"ft":"otf"}}]},{"l":{"ft":"woff"}}]},{"l":{"ft":"woff2"}}]},{"l":{"ft":"eot"}}]},{"l":{"ft":"rtf"}}]},{"l":{"ft":"odt"}}]},{"l":{"ft":"ods"}}]},{"l":{"ft":"odp"}}]},{"l":{"ft":"odg"}}]},{"l":{"ft":"odf"}}]},{"l":{"ft":"epub"}}]},{"l":{"ft":"mobi"}}]},{"l":{"ft":"azw"}}]},{"l":{"ft":"azw3"}}]},{"l":{"ft":"djvu"}}]},{"l":{"ft":"xls"}}]},{"l":{"ft":"xlsm"}}]},{"l":{"ft":"ppt"}}]},{"l":{"ft":"doc"}}]},{"l":{"ft":"pptx"}}]},{"l":{"ft":"xlsx"}}]},{"l":{"ft":"db"}}]},{"l":{"ft":"sqlite"}}]},{"l":{"ft":"sqlite3"}}]},{"l":{"ft":"mdb"}}]},{"l":{"ft":"accdb"}}]},{"l":{"ft":"dbf"}}]},{"l":{"ft":"plist"}}]},{"l":{"ft":"toml"}}]},{"l":{"ft":"env"}}]},{"l":{"ft":"dot"}}]},{"l":{"ft":"gv"}}]},{"l":{"ft":"torrent"}}]},{"l":{"ft":"ics"}}]},{"l":{"ft":"vcf"}}]},{"l":{"ft":"ai"}}]},{"l":{"ft":"eps"}}]},{"l":{"ft":"ps"}}]},{"l":{"ft":"dxf"}}]},{"l":{"ft":"dwg"}}]},{"l":{"ft":"fig"}}]},{"l":{"ft":"stl"}}]},{"l":{"ft":"obj"}}]},{"l":{"ft":"fbx"}}]},{"l":{"ft":"blend"}}]},{"l":{"ft":"dae"}}]},{"l":{"ft":"threeds"}}]},{"l":{"ft":"gltf"}}]},{"l":{"ft":"glb"}}]},{"l":{"ft":"vhd"}}]},{"l":{"ft":"vhdx"}}]},{"l":{"ft":"vmdk"}}]},{"l":{"ft":"ova"}}]},{"l":{"ft":"ovf"}}]},{"l":{"ft":"iso"}}]},{"l":{"ft":"img"}}]},{"l":{"ft":"swf"}}]} diff --git a/crates/model_file_type/src/lib.rs b/crates/model_file_type/src/lib.rs index 7f12fa2433f..f2a7a9c7016 100644 --- a/crates/model_file_type/src/lib.rs +++ b/crates/model_file_type/src/lib.rs @@ -157,19 +157,16 @@ macro_rules! generate_file_types { impl FromStr for ContentType { type Err = ValueError; fn from_str(mime_type: &str) -> Result { - let cleaned = mime_type.to_ascii_lowercase(); - - // there can be multiple variants for the same mime type - // but it does not matter for the purposes of this function - #[expect(unreachable_patterns)] - match cleaned.as_str() { - $( - $mime_type => Ok(ContentType::$variant), - )* - _ => { - Err(ValueError(cleaned, PhantomData)) + // MIME types are case-insensitive, and some canonical ones are + // mixed case (`application/vnd.ms-excel.sheet.macroEnabled.12`). + // There can be multiple variants for the same mime type; the + // first one wins, which does not matter for this function. + $( + if mime_type.eq_ignore_ascii_case($mime_type) { + return Ok(ContentType::$variant); } - } + )* + Err(ValueError(mime_type.to_ascii_lowercase(), PhantomData)) } } diff --git a/crates/model_file_type/src/test.rs b/crates/model_file_type/src/test.rs index 68879abf6db..e8d01b59eed 100644 --- a/crates/model_file_type/src/test.rs +++ b/crates/model_file_type/src/test.rs @@ -59,3 +59,29 @@ fn legacy_office_types_round_trip() { ); } } + +#[test] +fn every_mime_type_parses_back_to_a_content_type() { + for file_type in FileType::all() { + let content_type = ContentType::from_str(file_type.mime_type()) + .unwrap_or_else(|e| panic!("{file_type}: {e}")); + assert_eq!(content_type.mime_type(), file_type.mime_type()); + } +} + +#[test] +fn mime_types_parse_case_insensitively() { + assert_eq!( + ContentType::from_str("application/vnd.ms-excel.sheet.macroEnabled.12").unwrap(), + ContentType::from(FileType::Xlsm) + ); + assert_eq!( + ContentType::from_str("APPLICATION/VND.MS-EXCEL.SHEET.MACROENABLED.12").unwrap(), + ContentType::from(FileType::Xlsm) + ); + assert_eq!( + ContentType::from_str("Application/MSWord").unwrap(), + ContentType::from(FileType::Doc) + ); + assert!(ContentType::from_str("application/x-not-a-type").is_err()); +} From 50898b3d4e8d43ad55ab45b8b184310e974f959b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:40:58 +0000 Subject: [PATCH 5/8] test(soup-filter-cache): use a still-unknown file type in the rejection test .doc is now a known file type, so the test that unknown fileType filter inputs are rejected uses .docm instead. --- crates/soup_filter_cache_adapter/src/test/file_type.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/soup_filter_cache_adapter/src/test/file_type.rs b/crates/soup_filter_cache_adapter/src/test/file_type.rs index 2d7b521a8e1..6d41270e52b 100644 --- a/crates/soup_filter_cache_adapter/src/test/file_type.rs +++ b/crates/soup_filter_cache_adapter/src/test/file_type.rs @@ -407,7 +407,7 @@ fn malformed_file_type_data_is_still_rejected_but_unknown_filter_inputs_are_not_ ); assert!( compile_current_filter_request( - filters(json!({"literal":{"fileType":"doc"}})), + filters(json!({"literal":{"fileType":"docm"}})), "UPDATED_AT", "DESC", 20 From beb27e157f1fb16977d6fd1d40bb6b0bb83035d3 Mon Sep 17 00:00:00 2001 From: Jacob Beckerman Date: Tue, 6 Oct 2026 15:18:40 -0400 Subject: [PATCH 6/8] feat(upload): upgrade legacy .doc/.ppt/.xls uploads to OpenXML (#7605) Co-authored-by: Claude --- .github/workspace-dep-closures.json | 1 + ...bd5e309aab299895ab0e4a91c01a870d9109c.json | 16 + ...42b63e816133ecea6ce72c8c9507277617f30.json | 22 + ...0395eb184c9eb259894f689390b13e5647a6c.json | 14 + ...944cdab75327b002a16e85cf9da6fe8f18c8c.json | 14 + ...09f2d94ad20d9f6e193e1e92655d335a128d3.json | 23 + Cargo.lock | 8 + .../core/xlsx-fixtures/README.md | 2 + .../core/xlsx-fixtures/upgraded-from-xls.xlsx | Bin 0 -> 7832 bytes .../core/xlsx-upgraded-legacy.test.ts | 122 ++++ .../views/SpreadsheetEditor.tsx | 9 +- .../block-unknown/component/Block.tsx | 74 ++- .../component/UnknownContent.test.tsx | 38 ++ .../component/UnknownContent.tsx | 30 +- .../block-unknown/legacy-office.test.ts | 178 ++++++ .../features/block-unknown/legacy-office.ts | 89 +++ crates/documents/src/domain.rs | 4 + .../src/domain/legacy_office_upgrade.rs | 336 +++++++++++ .../src/domain/legacy_office_upgrade/tests.rs | 556 ++++++++++++++++++ .../src/outbound/pg_document_repo.rs | 1 + .../pg_document_repo/legacy_upgrade.rs | 113 ++++ .../pg_document_repo/legacy_upgrade/tests.rs | 205 +++++++ .../tests/fixtures/upgraded-from-doc.docx | Bin 0 -> 17208 bytes crates/docx_engine/tests/upgraded_legacy.rs | 132 +++++ crates/model/src/convert.rs | 9 + .../tests/fixtures/upgraded-from-ppt.pptx | Bin 0 -> 58348 bytes crates/pptx_engine/tests/upgraded_legacy.rs | 115 ++++ crates/s3_key/src/document_key.rs | 100 +++- crates/s3_key/src/document_key/test.rs | 47 ++ crates/s3_key/src/lib.rs | 6 +- docs/AGENT_GUIDE/documents.md | 25 +- .../document-upload-finalizer-lambda.ts | 45 +- .../docx-unzip-bucket.ts | 16 + infra/stacks/cloud-storage-service/index.ts | 7 + services/convert_service/Cargo.toml | 5 + .../fixtures/legacy/budget.xls | Bin 0 -> 8192 bytes .../convert_service/fixtures/legacy/deck.ppt | Bin 0 -> 669184 bytes .../convert_service/fixtures/legacy/memo.doc | Bin 0 -> 29696 bytes .../src/api/backfill/backfill_docx.rs | 2 + .../convert_service/src/process/convert.rs | 87 +-- services/convert_service/src/process/test.rs | 355 +++++++++++ services/convert_service/src/utils.rs | 67 ++- .../Cargo.toml | 5 + .../src/app.rs | 57 +- .../src/app/tests.rs | 365 ++++++++++++ .../src/outbound.rs | 219 ++++++- .../src/outbound/tests.rs | 103 ++++ .../src/runtime.rs | 32 +- .../src/service/document/process_document.rs | 2 + services/search_upload_handler/src/handler.rs | 3 + 50 files changed, 3577 insertions(+), 82 deletions(-) create mode 100644 .sqlx/query-1f21761b4bc48d82468fa7e8cebbd5e309aab299895ab0e4a91c01a870d9109c.json create mode 100644 .sqlx/query-3f6fad37bbed718a3124a1946c642b63e816133ecea6ce72c8c9507277617f30.json create mode 100644 .sqlx/query-92d93a6bc6a48cce58a03908a840395eb184c9eb259894f689390b13e5647a6c.json create mode 100644 .sqlx/query-dadea3eca110a122079f319ea9d944cdab75327b002a16e85cf9da6fe8f18c8c.json create mode 100644 .sqlx/query-e53dd219e78de6f92272433200e09f2d94ad20d9f6e193e1e92655d335a128d3.json create mode 100644 apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/upgraded-from-xls.xlsx create mode 100644 apps/web/src/features/block-spreadsheet/core/xlsx-upgraded-legacy.test.ts create mode 100644 apps/web/src/features/block-unknown/component/UnknownContent.test.tsx create mode 100644 apps/web/src/features/block-unknown/legacy-office.test.ts create mode 100644 apps/web/src/features/block-unknown/legacy-office.ts create mode 100644 crates/documents/src/domain/legacy_office_upgrade.rs create mode 100644 crates/documents/src/domain/legacy_office_upgrade/tests.rs create mode 100644 crates/documents/src/outbound/pg_document_repo/legacy_upgrade.rs create mode 100644 crates/documents/src/outbound/pg_document_repo/legacy_upgrade/tests.rs create mode 100644 crates/docx_engine/tests/fixtures/upgraded-from-doc.docx create mode 100644 crates/docx_engine/tests/upgraded_legacy.rs create mode 100644 crates/pptx_engine/tests/fixtures/upgraded-from-ppt.pptx create mode 100644 crates/pptx_engine/tests/upgraded_legacy.rs create mode 100644 services/convert_service/fixtures/legacy/budget.xls create mode 100644 services/convert_service/fixtures/legacy/deck.ppt create mode 100644 services/convert_service/fixtures/legacy/memo.doc create mode 100644 services/document_upload_finalizer_handler/src/app/tests.rs create mode 100644 services/document_upload_finalizer_handler/src/outbound/tests.rs diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index f5680947d89..4e731e7c62c 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -4877,6 +4877,7 @@ "crates/macro_env_var", "crates/macro_event_broker", "crates/macro_event_topics", + "crates/macro_queues", "crates/macro_service_urls", "crates/macro_sync_service_jwt", "crates/macro_tower_layers", diff --git a/.sqlx/query-1f21761b4bc48d82468fa7e8cebbd5e309aab299895ab0e4a91c01a870d9109c.json b/.sqlx/query-1f21761b4bc48d82468fa7e8cebbd5e309aab299895ab0e4a91c01a870d9109c.json new file mode 100644 index 00000000000..1b5d58ed199 --- /dev/null +++ b/.sqlx/query-1f21761b4bc48d82468fa7e8cebbd5e309aab299895ab0e4a91c01a870d9109c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \"Document\"\n SET \"fileType\" = $3,\n \"updatedAt\" = NOW()\n WHERE id = $1 AND LOWER(\"fileType\") = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1f21761b4bc48d82468fa7e8cebbd5e309aab299895ab0e4a91c01a870d9109c" +} diff --git a/.sqlx/query-3f6fad37bbed718a3124a1946c642b63e816133ecea6ce72c8c9507277617f30.json b/.sqlx/query-3f6fad37bbed718a3124a1946c642b63e816133ecea6ce72c8c9507277617f30.json new file mode 100644 index 00000000000..585499ecb11 --- /dev/null +++ b/.sqlx/query-3f6fad37bbed718a3124a1946c642b63e816133ecea6ce72c8c9507277617f30.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO \"DocumentBom\" (\"documentId\", \"createdAt\", \"updatedAt\")\n VALUES ($1, NOW(), NOW())\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3f6fad37bbed718a3124a1946c642b63e816133ecea6ce72c8c9507277617f30" +} diff --git a/.sqlx/query-92d93a6bc6a48cce58a03908a840395eb184c9eb259894f689390b13e5647a6c.json b/.sqlx/query-92d93a6bc6a48cce58a03908a840395eb184c9eb259894f689390b13e5647a6c.json new file mode 100644 index 00000000000..c8b812d4563 --- /dev/null +++ b/.sqlx/query-92d93a6bc6a48cce58a03908a840395eb184c9eb259894f689390b13e5647a6c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"DocumentInstance\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "92d93a6bc6a48cce58a03908a840395eb184c9eb259894f689390b13e5647a6c" +} diff --git a/.sqlx/query-dadea3eca110a122079f319ea9d944cdab75327b002a16e85cf9da6fe8f18c8c.json b/.sqlx/query-dadea3eca110a122079f319ea9d944cdab75327b002a16e85cf9da6fe8f18c8c.json new file mode 100644 index 00000000000..ee430a934f7 --- /dev/null +++ b/.sqlx/query-dadea3eca110a122079f319ea9d944cdab75327b002a16e85cf9da6fe8f18c8c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"DocumentBom\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dadea3eca110a122079f319ea9d944cdab75327b002a16e85cf9da6fe8f18c8c" +} diff --git a/.sqlx/query-e53dd219e78de6f92272433200e09f2d94ad20d9f6e193e1e92655d335a128d3.json b/.sqlx/query-e53dd219e78de6f92272433200e09f2d94ad20d9f6e193e1e92655d335a128d3.json new file mode 100644 index 00000000000..208428c2618 --- /dev/null +++ b/.sqlx/query-e53dd219e78de6f92272433200e09f2d94ad20d9f6e193e1e92655d335a128d3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO \"DocumentInstance\" (\"documentId\", \"sha\", \"createdAt\", \"updatedAt\")\n VALUES ($1, $2, NOW(), NOW())\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e53dd219e78de6f92272433200e09f2d94ad20d9f6e193e1e92655d335a128d3" +} diff --git a/Cargo.lock b/Cargo.lock index c2f4f11447e..4e7a4336e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4802,6 +4802,7 @@ dependencies = [ "aws-sdk-sqs", "axum", "database_env_vars", + "docx_engine", "doppler-config", "futures", "http-body-util", @@ -4820,6 +4821,7 @@ dependencies = [ "macro_uuid", "model", "nix 0.29.0", + "pptx_engine", "remote_env_var", "rs-libreoffice-bindings", "s3_client", @@ -4830,6 +4832,7 @@ dependencies = [ "sqlx", "sqs_client", "sqs_worker", + "tempfile", "tokio", "tower 0.5.3", "tower-http", @@ -6396,12 +6399,17 @@ dependencies = [ "macro_aws_config", "macro_entrypoint", "macro_env_var", + "macro_queues", "macro_service_urls", + "macro_uuid", "model", + "model_owner", "s3_key", "serde", "serde_json", + "sha2 0.10.9", "sqlx", + "sqs_client", "sync_service_client", "tokio", "tracing", diff --git a/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/README.md b/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/README.md index d494582b0dd..edd7863d15b 100644 --- a/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/README.md +++ b/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/README.md @@ -49,3 +49,5 @@ Notices: - U.S. federal works (SEC, Census, BLS, BEA, IRS, SBA, GSA) are in the public domain; the FRED export carries BLS series data. Templates whose terms forbid redistribution (Microsoft Create, Vertex42, Breaking Into Wall Street, Damodaran, SBDC) were used only for local testing and are not committed. Add a file by copying it here unmodified and adding its `file`, `title`, `source` and `license` to the manifest; set `skipCalculation` with a reason only when the engine cannot finish it. + +`upgraded-from-xls.xlsx` is a legacy Excel 97 workbook (`services/convert_service/fixtures/legacy/budget.xls`) converted to `.xlsx` by LibreOffice with the `Calc MS Excel 2007 XML` filter, exactly as the convert service upgrades uploaded `.xls` files. `../xlsx-upgraded-legacy.test.ts` checks that it imports into a native spreadsheet with its sheets, formulas, merges and number formats. diff --git a/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/upgraded-from-xls.xlsx b/apps/web/src/features/block-spreadsheet/core/xlsx-fixtures/upgraded-from-xls.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..775d52de0ae1fdf29b35cf767be872a6344ee5c8 GIT binary patch literal 7832 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 W@~F2r0|Elp?SX#FF)#AFPyYwv#GwuV literal 0 HcmV?d00001 diff --git a/apps/web/src/features/block-spreadsheet/core/xlsx-upgraded-legacy.test.ts b/apps/web/src/features/block-spreadsheet/core/xlsx-upgraded-legacy.test.ts new file mode 100644 index 00000000000..13f4282bac4 --- /dev/null +++ b/apps/web/src/features/block-spreadsheet/core/xlsx-upgraded-legacy.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { initSync } from '@ironcalc/wasm'; +import { createInitializedSpreadsheetCalculator } from '@macro-inc/spreadsheet/calculation'; +import { + importSpreadsheetSheets, + readSpreadsheetWorkbook, +} from '@macro-inc/spreadsheet/workbook-document'; +import ExcelJS from 'exceljs'; +import { LoroDoc } from 'loro-crdt'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { decodeXlsx, encodeXlsx } from './xlsx-codec'; + +/** + * An uploaded legacy `.xls` is upgraded to `.xlsx` by LibreOffice on the + * server; this is that output for a two-sheet budget. It must import into a + * native spreadsheet like any Excel-written workbook. + */ +const upgraded = () => + new Uint8Array( + readFileSync( + createRequire(import.meta.url).resolve( + './xlsx-fixtures/upgraded-from-xls.xlsx' + ) + ) + ); + +beforeAll(() => + initSync({ + module: readFileSync( + createRequire(import.meta.url).resolve('@ironcalc/wasm/wasm_bg.wasm') + ), + }) +); + +describe('workbook upgraded from a legacy .xls', () => { + it('imports both sheets with their cells and formulas', async () => { + const imported = await decodeXlsx(upgraded()); + + expect(imported.sheets.map((sheet) => sheet.name)).toEqual([ + 'Budget', + 'Notes', + ]); + const [budget, notes] = imported.sheets; + expect(budget.cells.A1.value).toBe('Month'); + // Stored as text in the .xls, so it stays text rather than becoming a date. + expect(budget.cells.A2.value).toBe("'2026-01"); + expect(budget.cells.B2.value).toBe('1050'); + expect(budget.cells.D2.value).toBe('=B2-C2'); + expect(budget.cells.D13.value).toBe('=B13-C13'); + expect(budget.cells.A15.value).toBe('Total'); + expect(budget.cells.B15.value).toBe('=SUM(B2:B13)'); + expect(budget.cells.D15.value).toBe('=SUM(D2:D13)'); + expect(budget.cells.F1.value).toBe('Merged header'); + expect(notes.cells.B1.value).toBe('Finance'); + expect(notes.cells.B2.value).toBe('=Budget!D15/Budget!B15'); + }); + + it('keeps header styles, number formats and the merged header', async () => { + const imported = await decodeXlsx(upgraded()); + const budget = imported.sheets[0]; + + expect(budget.cells.A1).toMatchObject({ + bold: true, + fillColor: '#DDEBF7', + }); + // `#,##0.00` imports as Macro's thousands-separated number format. + expect(budget.cells.B15).toMatchObject({ format: 'number', decimals: 2 }); + expect(budget.metadata?.merges).toEqual(['F1:H1']); + expect(imported.warnings).toEqual([ + 'Merged ranges are shown as individual cells in Macro and restored on Excel export when their covered cells remain empty.', + ]); + }); + + it('calculates the same results after native save and reopen', async () => { + const imported = await decodeXlsx(upgraded()); + const doc = new LoroDoc(); + const reopened = new LoroDoc(); + const engine = createInitializedSpreadsheetCalculator(); + try { + importSpreadsheetSheets(doc, imported.sheets, true); + reopened.import(doc.export({ mode: 'snapshot' })); + const sheets = readSpreadsheetWorkbook(reopened); + const results = engine.calculateWorkbook( + sheets.map((sheet) => ({ ...sheet, rowCount: sheet.layout.rowCount })), + { includeTypes: true } + ); + + const budget = results[sheets[0].id]; + // Revenue 1000 + 50i and cost 600 + 20i for months 1..12. + expect(budget.B15.number).toBe(15_900); + expect(budget.C15.number).toBe(8_760); + expect(budget.D15.number).toBe(7_140); + expect(budget.D2.number).toBe(430); + expect(budget.B15.display).toBe('15,900.00'); + expect(results[sheets[1].id].B2.number).toBeCloseTo(7_140 / 15_900, 12); + for (const values of Object.values(results)) { + expect( + Object.entries(values).filter(([, value]) => value.error) + ).toEqual([]); + } + } finally { + engine.dispose(); + } + }); + + it('exports back to an .xlsx Excel reads with the same formulas', async () => { + const imported = await decodeXlsx(upgraded()); + + const exported = await encodeXlsx({ sheets: imported.sheets }); + + const external = new ExcelJS.Workbook(); + await external.xlsx.load(exported.bytes.slice().buffer); + const budget = external.getWorksheet('Budget')!; + expect(budget.getCell('B15').formula).toBe('SUM(B2:B13)'); + expect(budget.getCell('D2').formula).toBe('B2-C2'); + expect(budget.getCell('F1').isMerged).toBe(true); + expect(external.getWorksheet('Notes')!.getCell('B2').formula).toBe( + 'Budget!D15/Budget!B15' + ); + }); +}); diff --git a/apps/web/src/features/block-spreadsheet/views/SpreadsheetEditor.tsx b/apps/web/src/features/block-spreadsheet/views/SpreadsheetEditor.tsx index 42cdf952b13..99c0998a08c 100644 --- a/apps/web/src/features/block-spreadsheet/views/SpreadsheetEditor.tsx +++ b/apps/web/src/features/block-spreadsheet/views/SpreadsheetEditor.tsx @@ -529,9 +529,16 @@ export function SpreadsheetEditor(props: { async function importFile(file: File | undefined) { if (!file || !editable()) return; + if (/\.xls$/i.test(file.name)) { + // Converting .xls to .xlsx needs the server; uploads are upgraded. + actions.setNotice( + 'Legacy .xls workbooks are converted when uploaded. Upload the file, then choose Edit in Macro.' + ); + return; + } if (!/\.(csv|xlsx)$/i.test(file.name)) { actions.setNotice( - 'Choose a .csv or .xlsx file. Legacy .xls and macro-enabled workbooks are not supported.' + 'Choose a .csv or .xlsx file. Macro-enabled workbooks are not supported.' ); return; } diff --git a/apps/web/src/features/block-unknown/component/Block.tsx b/apps/web/src/features/block-unknown/component/Block.tsx index dc7355e4e43..897d74ae1fe 100644 --- a/apps/web/src/features/block-unknown/component/Block.tsx +++ b/apps/web/src/features/block-unknown/component/Block.tsx @@ -1,8 +1,10 @@ import { FileSidePanelSections, SidePanel } from '@components/app/side-panel'; +import { useSplitLayout } from '@components/app/split-layout/layout'; import { useBlockId } from '@core/block'; import { DocumentBlockContainer } from '@core/component/DocumentBlockContainer'; import { toast } from '@core/component/Toast/Toast'; import { useShareModal } from '@core/component/TopBar/shareModal'; +import { fileTypeToBlockName } from '@core/constant/allBlocks'; import { blockMetadataSignal } from '@core/signal/load'; import { useGetPermissions } from '@core/signal/permissions'; import { @@ -10,11 +12,26 @@ import { useBlockDocumentName, } from '@core/util/currentBlockDocumentName'; import { downloadFile } from '@filesystem/download'; +import { waitForDocumentContentReady } from '@queries/storage/document-location'; +import { fetchDocumentMetadata } from '@queries/storage/document-metadata'; import { createCallback } from '@solid-primitives/rootless'; -import { lazy, Show, Suspense } from 'solid-js'; +import { + createSignal, + lazy, + onCleanup, + onMount, + Show, + Suspense, +} from 'solid-js'; import { SpreadsheetSkeleton } from '../../block-spreadsheet/components/SpreadsheetSkeleton'; import { isUploadedWorkbook } from '../../block-spreadsheet/core/uploaded-workbook'; import { useSpreadsheetAccess } from '../../block-spreadsheet/primitives/use-spreadsheet-access'; +import { + legacyOfficeUpgradeTarget, + pollForLegacyUpgrade, + shouldAwaitLegacyUpgrade, + upgradedOfficeLabel, +} from '../legacy-office'; import { useGetFileBlob } from '../signal/blockData'; import { TopBar } from './TopBar'; import { UnknownContent } from './UnknownContent'; @@ -50,6 +67,7 @@ function BlockUnknownContent() { owner: blockMetadataSignal()?.owner, })); const getBlob = useGetFileBlob(); + const convertingTo = useLegacyOfficeUpgrade(blockId); const downloadDocument = createCallback(async () => { try { @@ -74,6 +92,7 @@ function BlockUnknownContent() { fallback={ void downloadDocument()} /> @@ -88,3 +107,56 @@ function BlockUnknownContent() { ); } + +/** + * Waits for a just-uploaded .doc/.ppt/.xls to be upgraded to OpenXML, then + * reopens it in the block for its new type. Returns the label of the format + * being converted to while waiting. + */ +function useLegacyOfficeUpgrade(documentId: string) { + const { replaceSplit } = useSplitLayout(); + const initial = blockMetadataSignal.get(); + const target = legacyOfficeUpgradeTarget(initial?.fileType); + const [waiting, setWaiting] = createSignal( + !!initial && !!target && shouldAwaitLegacyUpgrade(initial, Date.now()) + ); + + onMount(() => { + if (!target || !waiting()) return; + const controller = new AbortController(); + onCleanup(() => controller.abort()); + + void (async () => { + const upgraded = await pollForLegacyUpgrade({ + target, + fetchMetadata: () => fetchDocumentMetadata(documentId), + fileTypeOf: (metadata) => metadata.fileType, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + signal: controller.signal, + }); + if (controller.signal.aborted) return; + if (!upgraded) { + setWaiting(false); + return; + } + // Word content becomes readable once the DOCX pipeline processes it. + if (target === 'docx') { + await waitForDocumentContentReady({ + documentId, + timeoutMs: 60_000, + }).catch(() => undefined); + if (controller.signal.aborted) return; + } + const blockName = fileTypeToBlockName(upgraded.fileType); + if (blockName === 'unknown') { + // Excel workbooks stay in this block, which previews them. + blockMetadataSignal.set(upgraded); + setWaiting(false); + return; + } + replaceSplit({ content: { type: blockName, id: documentId } }); + })(); + }); + + return () => (waiting() && target ? upgradedOfficeLabel(target) : undefined); +} diff --git a/apps/web/src/features/block-unknown/component/UnknownContent.test.tsx b/apps/web/src/features/block-unknown/component/UnknownContent.test.tsx new file mode 100644 index 00000000000..25d6483c16c --- /dev/null +++ b/apps/web/src/features/block-unknown/component/UnknownContent.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, expect, it, vi } from 'vitest'; +import { UnknownContent } from './UnknownContent'; + +afterEach(cleanup); + +it('offers the file when there is no preview', () => { + render(() => ( + + )); + + expect(screen.getByText(/No preview available for/)).toBeTruthy(); + expect(screen.queryByTestId('legacy-office-converting')).toBeNull(); + expect(screen.getByText('Download')).toBeTruthy(); +}); + +it('says a legacy Office file is being converted and still offers the original', () => { + const onDownload = vi.fn(); + render(() => ( + + )); + + const status = screen.getByTestId('legacy-office-converting'); + expect(status.textContent).toContain('Board Update.ppt'); + expect(status.textContent).toContain('to a PowerPoint presentation'); + expect(screen.queryByText(/No preview available/)).toBeNull(); + screen.getByText('Download').click(); + expect(onDownload).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/features/block-unknown/component/UnknownContent.tsx b/apps/web/src/features/block-unknown/component/UnknownContent.tsx index 98c9ee2c75e..64f44dfae2c 100644 --- a/apps/web/src/features/block-unknown/component/UnknownContent.tsx +++ b/apps/web/src/features/block-unknown/component/UnknownContent.tsx @@ -1,19 +1,41 @@ +import { LoadingSpinner } from '@core/component/LoadingSpinner'; import ShareFat from '@icon/share.svg'; import DownloadSimple from '@phosphor/download-simple.svg'; import { Button } from '@ui'; +import { Show } from 'solid-js'; export function UnknownContent(props: { fileName: string; + /** Set while a legacy Office file is being upgraded, e.g. "PowerPoint presentation". */ + convertingTo?: string; onShare: () => void; onDownload: () => void; }) { return (
-
- No preview available for{' '} - {props.fileName} -
+ + No preview available for{' '} + {props.fileName} +
+ } + > + {(convertingTo) => ( +
+ +
+ Converting {props.fileName}{' '} + to a {convertingTo()}… +
+
+ )} +