A Weird Imagination

Loading multiple files without ZIP

The problem#

Last time, I showed how you can let a user have control over their data stored in a web app's OPFS by transferring directories in or out of the browser as ZIP files. But it would be more convenient if the user could just transfer folders instead without needing the extra step of going through an archive manager. There's no shortcut for getting data out of the browser: we can only save one file at a time (unless we use the Chrome-only File System Access API). But there is a cross-browser way to load multiple files, or even nested directories, into the browser.

The solution#

The HTML Drag and Drop API supports transferring multiple files and directories, although the details are a bit messy:

const target = document.getElementById("dropTarget");
// Required to make drop work.
target.addEventListener("dragover", (e) => e.preventDefault());
target.addEventListener("drop", async (e) => {
  e.preventDefault();
  await Promise.allSettled([...e.dataTransfer.items]
    .map(async (item) => {
      if (item.getAsFileSystemHandle) {
        await processFileSystemHandle(dir,
          await item.getAsFileSystemHandle());
      } else {
        await processFileSystemEntry(dir,
          item.webkitGetAsEntry());
      }
    })
  );
});

As you can see, for cross-browser support, we need to handle both getAsFileSystemHandle() and webkitGetAsEntry(). And, unfortunately, they return different types, so those two process*() functions really are pretty different:

async function processFileSystemHandle(dir, handle) {
  if (handle.kind === "directory") {
    const subdir = await dir.getDirectoryHandle(handle.name,
                             {create: true});
    for await (const entry of handle.values()) {
      await processFileSystemHandle(subdir, entry);
    }
  } else /* handle.kind === "file" */ {
    await writeFile(await dir.getFileHandle(handle.name,
                              {create: true}),
                    await handle.getFile());
  }
}
async function processFileSystemEntry(dir, entry) {
  async function readDirectory(directory) {
    let dirReader = directory.createReader();
    let getEntries = async () => {
      const results = await (new Promise((resolve, reject) =>
        dirReader.readEntries(resolve, reject)));
      if (results.length) {
        return [...results, ...await getEntries()];
      }
      return [];
    };

    return await getEntries();
  }

  if (entry.isDirectory) {
    const subdir = await dir.getDirectoryHandle(entry.name,
                             {create: true});
    for (const el of await readDirectory(entry)) {
      await processFileSystemEntry(subdir, el);
    }
  } else /* entry.isFile */ {
    const file = new Promise((resolve, reject) =>
      entry.file(resolve, reject));
    await writeFile(await dir.getFileHandle(entry.name,
                              {create: true}),
                    await file);
  }
}

(These assume the writeFile() helper from last week's post to handle writing inside a Web Worker as necessary.)

The details#

Inspiration#

While researching my previous post, I was surprised to notice that zip.js's Zip Manager demo supports dropping multiple files, including nested folders. The actual magic happens on this line where it calls getAsFileSystemHandle() (or webkitGetAsEntry() as a fallback for other browsers). You can look at the example code on the page for webkitGetAsEntry() to see how to handle a drop event.

Looping over DataTransferItems#

A previous version of this code used a for loop instead of .map() and Promise.allSettled(), but when running in Chrome, if there were multiple items, after processing the first item (which could be an entire directory), the other items weren't there. I got the current version by modifying the code to be more similar to the code in Zip Manager, but it's not obvious why it matters. The documentation of getAsFileSystemHandle() says that it must be accessed from the dragstart or drop event handler, so I think that the difference is that the .map() initiates all of the calls from the event handler before yielding due to an await, while with the for, awaiting on the first one meant the rest happened in a different context.

Reading FileSystemDirectoryEntry#

FileSystemDirectoryEntry.createReader()'s documentation gives example code for a readDirectory() function that is subtly wrong. Worse, the issue is a Heisenbug: the code works when debugging.

The problem is that it doesn't check that the readEntries() callback has completed before returning. As it's generally very fast, it will always complete in time to be visible if we set a breakpoint. The fix I implemented above was to wrap readEntries() in a Promise so we can use modern async/await to ensure the computation completes before continuing.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.