diff --git a/src/common/attachment-drafts/attachment.pipeline.ts b/src/common/attachment-drafts/attachment.pipeline.ts index 1a42afd5a..37282d1c9 100644 --- a/src/common/attachment-drafts/attachment.pipeline.ts +++ b/src/common/attachment-drafts/attachment.pipeline.ts @@ -10,6 +10,7 @@ import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAtta import type { AttachmentsDraftsStore } from './store-attachment-drafts-slice'; import { AttachmentDraft, AttachmentDraftConverter, AttachmentDraftInput, AttachmentDraftSource, DraftEgoFragmentsInputData, draftInputMimeEgoFragments, draftInputMimeWebpage, DraftWebInputData } from './attachment.types'; import { imageDataToImageAttachmentFragmentViaDBlob } from './attachment.dblobs'; +import { liveFileAddToAttachmentFragment, liveFileIsSupported } from '../livefile/livefile'; // configuration @@ -261,11 +262,11 @@ export async function attachmentLoadInputAsync(source: Readonly} source - The source of the AttachmentDraft object. * @param {Readonly} input - The input of the AttachmentDraft object. * @param {(changes: Partial) => void} edit - A function to edit the AttachmentDraft object. */ -export function attachmentDefineConverters(sourceType: AttachmentDraftSource['media'], input: Readonly, edit: (changes: Partial>) => void) { +export function attachmentDefineConverters(source: AttachmentDraftSource, input: Readonly, edit: (changes: Partial>) => void) { // return all the possible converters for the input const converters: AttachmentDraftConverter[] = []; @@ -275,7 +276,7 @@ export function attachmentDefineConverters(sourceType: AttachmentDraftSource['me // plain text types case PLAIN_TEXT_MIMETYPES.includes(input.mimeType): // handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read - const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData; + const textOriginHtml = source.media === 'text' && input.altMimeType === 'text/html' && !!input.altData; const isHtmlTable = !!input.altData?.startsWith(')[] = []; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.kind !== 'file') - continue; - - // extract file system handle if available - if ('getAsFileSystemHandle' in item && typeof item.getAsFileSystemHandle === 'function') { - try { - const handle = item.getAsFileSystemHandle(); - if (handle) - results.push(handle); - continue; - } catch (error) { - console.error('Error getting file system handle:', error); - } - } - - // extract file if no handle available - const file = item.getAsFile(); - if (file) - results.push(file); - } - - return results; -} - -export function mightBeDirectory(file: File) { - // Note: this doesn't even work, as Firefox reports directories with size > 0 on windows (e.g. 4096) - return file.type === '' && file.size === 0; -} - - -/// Folder traversal - -/** - * Extending the `FileSystemDirectoryHandle` with a `values` method to iterate over the directory contents. - * This is as defined in https://fs.spec.whatwg.org/#filesystemdirectoryhandle (File System Standard, Last Updated 28 June 2024). - */ -interface ExplorableFileSystemDirectoryHandle extends FileSystemDirectoryHandle { - values?: () => AsyncIterable; -} - -interface FileWithHandleAndPath { - fileWithHandle: FileWithHandle; - relativeName: string; -} - -export async function getAllFilesFromDirectoryRecursively(directoryHandle: FileSystemDirectoryHandle): Promise { - const list: FileWithHandleAndPath[] = []; - const separator = '/'; - - async function traverseDirectory(dirHandle: ExplorableFileSystemDirectoryHandle, path: string = '') { - if ('values' in dirHandle && typeof dirHandle.values === 'function') { - for await (const handle of dirHandle.values()) { - if (!handle) continue; - const relativePath = path ? `${path}${separator}${handle.name}` : handle.name; - - if (handle.kind === 'file') { - const fileWithHandle = await handle.getFile() as FileWithHandle; - fileWithHandle.handle = handle; - list.push({ - fileWithHandle: fileWithHandle, - relativeName: relativePath, - }); - } else if (handle.kind === 'directory') { - await traverseDirectory(handle, relativePath); - } - } - } - } - - await traverseDirectory(directoryHandle); - return list; -} - - -/// Extraction of files names from a common prefix - -export function extractFilePathsFromCommonRadix(fileURIs: string[]): string[] { - - const filePaths = fileURIs - .filter((path) => path.startsWith('file:')) - .map((path) => path.slice(5)); - - if (filePaths.length < 2) - return []; - - const commonRadix = _findCommonPrefix(filePaths); - if (!commonRadix.endsWith('/')) - return []; - - return filePaths.map((path) => path.slice(commonRadix.length)); -} - -function _findCommonPrefix(strings: string[]) { - if (!strings.length) - return ''; - - const sortedStrings = strings.slice().sort(); - const firstString = sortedStrings[0]; - const lastString = sortedStrings[sortedStrings.length - 1]; - - let commonPrefix = ''; - for (let i = 0; i < firstString.length; i++) { - if (firstString[i] === lastString[i]) { - commonPrefix += firstString[i]; - } else { - break; - } - } - - return commonPrefix; -} - diff --git a/src/common/attachment-drafts/store-attachment-drafts-slice.tsx b/src/common/attachment-drafts/store-attachment-drafts-slice.tsx index a3c362dca..2b88633d1 100644 --- a/src/common/attachment-drafts/store-attachment-drafts-slice.tsx +++ b/src/common/attachment-drafts/store-attachment-drafts-slice.tsx @@ -70,7 +70,7 @@ export const createAttachmentDraftsStoreSlice: StateCreatorO Converters - attachmentDefineConverters(source.media, loaded.input, editFn); + attachmentDefineConverters(source, loaded.input, editFn); const defined = _getAttachment(attachmentDraftId); if (!defined?.converters.length) return; diff --git a/src/common/attachment-drafts/useAttachmentDrafts.tsx b/src/common/attachment-drafts/useAttachmentDrafts.tsx index 616395aa2..25fecf7b9 100644 --- a/src/common/attachment-drafts/useAttachmentDrafts.tsx +++ b/src/common/attachment-drafts/useAttachmentDrafts.tsx @@ -9,11 +9,11 @@ import { getClipboardItems } from '~/common/util/clipboardUtils'; import type { DConversationId } from '~/common/stores/chat/chat.conversation'; import type { DMessageFragment } from '~/common/stores/chat/chat.fragments'; import type { DMessageId } from '~/common/stores/chat/chat.message'; +import { getAllFilesFromDirectoryRecursively } from '~/common/livefile/filesystem-helpers'; import { useChatAttachmentsStore } from '~/common/chats/store-chat-overlay'; import type { AttachmentDraftSourceOriginDTO, AttachmentDraftSourceOriginFile } from './attachment.types'; import type { AttachmentDraftsStoreApi } from './store-attachment-drafts-slice'; -import { extractFilePathsFromCommonRadix, extractFileSystemHandlesOrFiles, getAllFilesFromDirectoryRecursively, mightBeDirectory } from './file-converters/filesystem-helpers'; // enable to debug operations @@ -69,7 +69,7 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp // get the file items - note: important to not have any async/await or we'll lose the items of the data transfer const fileOrFSHandlePromises: (Promise | File)[] = heuristicBypassImage ? [] /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */ - : extractFileSystemHandlesOrFiles(dt.items); + : getDataTransferFilesOrPromises(dt.items); // attach File(s) if (fileOrFSHandlePromises.length) { @@ -78,7 +78,7 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp let overrideFileNames: string[] = []; if (dt.types.includes('text/plain')) { const possiblePlainTextURIs: string[] = dt.getData('text/plain').split(/[\r\n]+/); - overrideFileNames = extractFilePathsFromCommonRadix(possiblePlainTextURIs); + overrideFileNames = mapFileURIsRemovingCommonRadix(possiblePlainTextURIs); if (overrideFileNames.length !== fileOrFSHandlePromises.length) overrideFileNames = []; else if (ATTACHMENTS_DEBUG_INTAKE) @@ -92,9 +92,12 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp // not provide a handle; if that's the case, we can't do anything, so we still add the file if (fileOrFSHandlePromise instanceof File) { const file = fileOrFSHandlePromise; - if (mightBeDirectory(file)) { + + // Directory detection from File objects weak or impossible - e.g. Firefox reports directories with size > 0 on windows (e.g. 4096) + if (file.type === '' && file.size === 0) { console.warn('This browser does not support directories:', file); } + await attachAppendFile(method, file, overrideFileNames[fIdx]); continue; } @@ -264,4 +267,86 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp attachmentsTakeAllFragments, attachmentsTakeFragmentsByType, }; -}; \ No newline at end of file +}; + + +/** + * Extracts file system handles or files from a list of data transfer items. + * Note: the main purpose of this function is to get all the files/handles **upfront** in a + * datatransfer, as those objects expire with async operations. + */ +function getDataTransferFilesOrPromises(items: DataTransferItemList) { + const results: (File | Promise)[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind !== 'file') + continue; + + // extract file system handle if available + if ('getAsFileSystemHandle' in item && typeof item.getAsFileSystemHandle === 'function') { + try { + const handle = item.getAsFileSystemHandle(); + if (handle) + results.push(handle); + continue; + } catch (error) { + console.error('Error getting file system handle:', error); + } + } + + // extract file if no handle available + const file = item.getAsFile(); + if (file) + results.push(file); + } + + return results; +} + + +/** + * Maps a list of file URIs to relative paths, removing a common prefix. + * + * Example, takes the following files: + * - file:///C:/Users/Me/Documents/MyFile1.txt + * - file:///C:/Users/Me/Documents/Test/MyFile2.txt + * - file:///C:/Users/Me/Documents/Test/MyFile3.txt + * And returns: + * - [ MyFile1.txt, Test/MyFile2.txt, Test/MyFile3.txt ] + */ +function mapFileURIsRemovingCommonRadix(fileURIs: string[]): string[] { + + const filePaths = fileURIs + .filter((path) => path.startsWith('file:')) + .map((path) => path.slice(5)); + + if (filePaths.length < 2) + return []; + + const commonRadix = _findCommonStringsPrefix(filePaths); + if (!commonRadix.endsWith('/')) + return []; + + return filePaths.map((path) => path.slice(commonRadix.length)); +} + +function _findCommonStringsPrefix(strings: string[]) { + if (!strings.length) + return ''; + + const sortedStrings = strings.slice().sort(); + const firstString = sortedStrings[0]; + const lastString = sortedStrings[sortedStrings.length - 1]; + + let commonPrefix = ''; + for (let i = 0; i < firstString.length; i++) { + if (firstString[i] === lastString[i]) { + commonPrefix += firstString[i]; + } else { + break; + } + } + + return commonPrefix; +} diff --git a/src/common/livefile/filesystem-helpers.ts b/src/common/livefile/filesystem-helpers.ts new file mode 100644 index 000000000..9482a6550 --- /dev/null +++ b/src/common/livefile/filesystem-helpers.ts @@ -0,0 +1,47 @@ +import type { FileWithHandle } from 'browser-fs-access'; + + +/** + * Extending the `FileSystemDirectoryHandle` with a `values` method to iterate over the directory contents. + * This is as defined in https://fs.spec.whatwg.org/#filesystemdirectoryhandle (File System Standard, Last Updated 28 June 2024). + */ +interface ExplorableFileSystemDirectoryHandle extends FileSystemDirectoryHandle { + values?: () => AsyncIterable; +} + +interface FileWithHandleAndPath { + fileWithHandle: FileWithHandle; + relativeName: string; +} + +/** + * Recursively get all files from a directory, returning an array of `FileWithHandleAndPath` objects, + * where the handles are all FileSystemFileHandle objects (allows for LiveFile support). + */ +export async function getAllFilesFromDirectoryRecursively(directoryHandle: FileSystemDirectoryHandle): Promise { + const list: FileWithHandleAndPath[] = []; + const separator = '/'; + + async function traverseDirectory(dirHandle: ExplorableFileSystemDirectoryHandle, path: string = '') { + if ('values' in dirHandle && typeof dirHandle.values === 'function') { + for await (const handle of dirHandle.values()) { + if (!handle) continue; + const relativePath = path ? `${path}${separator}${handle.name}` : handle.name; + + if (handle.kind === 'file') { + const fileWithHandle = await handle.getFile() as FileWithHandle; + fileWithHandle.handle = handle; + list.push({ + fileWithHandle: fileWithHandle, + relativeName: relativePath, + }); + } else if (handle.kind === 'directory') { + await traverseDirectory(handle, relativePath); + } + } + } + } + + await traverseDirectory(directoryHandle); + return list; +} diff --git a/src/common/livefile/livefile.ts b/src/common/livefile/livefile.ts new file mode 100644 index 000000000..6c5246e8c --- /dev/null +++ b/src/common/livefile/livefile.ts @@ -0,0 +1,12 @@ +import type { AttachmentDraftSource } from '~/common/attachment-drafts/attachment.types'; +import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments'; + + +export function liveFileIsSupported(source: AttachmentDraftSource): boolean { + return source.media === 'file' && !!source.fileWithHandle.handle && typeof source.fileWithHandle.handle.getFile === 'function'; +} + +export function liveFileAddToAttachmentFragment(source: AttachmentDraftSource, attachmentFragment: DMessageAttachmentFragment) { + if (source.media === 'file' && !!source.fileWithHandle.handle && typeof source.fileWithHandle.handle.getFile === 'function') + attachmentFragment._fileSystemFileHandle = source.fileWithHandle.handle; +} diff --git a/src/common/stores/chat/chat.fragments.ts b/src/common/stores/chat/chat.fragments.ts index 8e743a28b..a58c4c7dc 100644 --- a/src/common/stores/chat/chat.fragments.ts +++ b/src/common/stores/chat/chat.fragments.ts @@ -45,6 +45,7 @@ export type DMessageAttachmentFragment = _DMessageFragmentWrapper<'attachment', title: string; // label of the attachment (filename, named id, content overview, title..) caption: string; // additional information, such as provenance, content preview, etc. created: number; + _fileSystemFileHandle?: FileSystemFileHandle; // [LiveFile] - live file support, while in-memory }; // Future Examples: up to 1 per message, containing the Rays and Merges that would be used to restore the Beam state - could be volatile (omitted at save)