LiveFiles: restructure, and store Handle to Attachment fragments

This commit is contained in:
Enrico Ros
2024-07-21 20:43:52 -07:00
parent 75be822b1b
commit 5b00ddc43f
7 changed files with 160 additions and 138 deletions
@@ -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<AttachmentDraftS
/**
* Defines the possible converters for an AttachmentDraft object based on its input type.
*
* @param {AttachmentDraftSource['media']} sourceType - The media type of the attachment source.
* @param {Readonly<AttachmentDraftSource>} source - The source of the AttachmentDraft object.
* @param {Readonly<AttachmentDraftInput>} input - The input of the AttachmentDraft object.
* @param {(changes: Partial<AttachmentDraft>) => void} edit - A function to edit the AttachmentDraft object.
*/
export function attachmentDefineConverters(sourceType: AttachmentDraftSource['media'], input: Readonly<AttachmentDraftInput>, edit: (changes: Partial<Omit<AttachmentDraft, 'outputFragments'>>) => void) {
export function attachmentDefineConverters(source: AttachmentDraftSource, input: Readonly<AttachmentDraftInput>, edit: (changes: Partial<Omit<AttachmentDraft, 'outputFragments'>>) => 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('<table');
// p1: Tables
@@ -283,7 +284,7 @@ export function attachmentDefineConverters(sourceType: AttachmentDraftSource['me
converters.push({ id: 'rich-text-table', name: 'Markdown Table' });
// p2: Text
converters.push({ id: 'text', name: 'Text' });
converters.push({ id: 'text', name: liveFileIsSupported(source) ? 'Text (Live)' : 'Text' });
// p3: Html
if (textOriginHtml) {
@@ -470,7 +471,10 @@ export async function attachmentPerformConversion(
// text as-is
case 'text':
const textData = createDMessageDataInlineText(inputDataToString(input.data), input.mimeType);
newFragments.push(createDocAttachmentFragment(title, caption, 'text/plain', textData, refString, docMeta));
const textDocAttachmentFragment = createDocAttachmentFragment(title, caption, 'text/plain', textData, refString, docMeta);
// [LiveFile] if we have a handle for this file, transfer it to the Doc Attachment fragment
liveFileAddToAttachmentFragment(source, textDocAttachmentFragment);
newFragments.push(textDocAttachmentFragment);
break;
// html as-is
@@ -1,127 +0,0 @@
import type { FileWithHandle } from 'browser-fs-access';
/// Preservation of Handles/Files during data transfers
/**
* 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.
*/
export function extractFileSystemHandlesOrFiles(items: DataTransferItemList) {
const results: (File | Promise<FileSystemFileHandle | FileSystemDirectoryHandle | null>)[] = [];
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<FileSystemFileHandle | FileSystemDirectoryHandle | null>;
}
interface FileWithHandleAndPath {
fileWithHandle: FileWithHandle;
relativeName: string;
}
export async function getAllFilesFromDirectoryRecursively(directoryHandle: FileSystemDirectoryHandle): Promise<FileWithHandleAndPath[]> {
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;
}
@@ -70,7 +70,7 @@ export const createAttachmentDraftsStoreSlice: StateCreator<AttachmentsDraftsSto
return;
// 2. Define the I->O Converters
attachmentDefineConverters(source.media, loaded.input, editFn);
attachmentDefineConverters(source, loaded.input, editFn);
const defined = _getAttachment(attachmentDraftId);
if (!defined?.converters.length)
return;
@@ -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<FileSystemFileHandle | FileSystemDirectoryHandle | null> | 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,
};
};
};
/**
* 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<FileSystemFileHandle | FileSystemDirectoryHandle | null>)[] = [];
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;
}
+47
View File
@@ -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<FileSystemFileHandle | FileSystemDirectoryHandle | null>;
}
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<FileWithHandleAndPath[]> {
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;
}
+12
View File
@@ -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;
}
+1
View File
@@ -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)