Home Reference Source

src/utils/vfs.js

/*
 * OS.js - JavaScript Cloud/Web Desktop Platform
 *
 * Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @author  Anders Evenrud <andersevenrud@gmail.com>
 * @license Simplified BSD License
 */

/**
 * Get parent directory
 * @param {string} path Directory
 * @return {string} Parent directory
 */
export const parentDirectory = path => path
  .replace(/\/$/, '')
  .split('/')
  .filter((item, index, arr) => index < (arr.length - 1))
  .join('/')
  .replace(/(\/+)?$/, '/');

/**
 * Joins paths
 * @param {string[]} args paths
 * @return {string}
 */
export const pathJoin = (...args) => args
  .map((str, index) => {
    if (index > 0) {
      str = str.replace(/^\/?/, '');
    }
    return str.replace(/\/?$/, '');
  })
  .join('/');

/*
 * Sort by locale string
 */
const sortString = (k, d) => (a, b) => d === 'asc'
  ? String(a[k]).localeCompare(b[k])
  : String(b[k]).localeCompare(a[k]);

/*
 * Sort by date
 */
const sortDate = (k, d) => (a, b) => d === 'asc'
  ? (new Date(a[k])) > (new Date(b[k]))
  : (new Date(b[k])) > (new Date(a[k]));

/*
 * Sort by educated guess
 */
const sortDefault = (k, d) => (a, b) =>
  (a[k] < b[k])
    ? -1
    : ((a[k] > b[k])
      ? (d === 'asc' ? 1 : 0)
      : (d === 'asc' ? 0 : 1));

/*
 * Sorts an array of files
 */
const sortFn = t => {
  if (t === 'string') {
    return sortString;
  } else if (t === 'date') {
    return sortDate;
  }

  return sortDefault;
};

/*
 * Map of sorters from readdir attributes
 */
const sortMap = {
  size: sortFn('number'),
  mtime: sortFn('date'),
  ctime: sortFn('date'),
  atime: sortFn('date')
};

/**
 * Creates "special" directory entries
 * @param {string} path The path to the readdir root
 * @return {Object[]}
 */
const createSpecials = path => {
  const specials = [];

  const stripped = path.replace(/\/+/g, '/')
    .replace(/^(\w+):/, '') || '/';

  if (stripped !== '/') {
    specials.push({
      isDirectory: true,
      isFile: false,
      mime: null,
      size: 0,
      stat: {},
      filename: '..',
      path: parentDirectory(path) || '/'
    });
  }

  return specials;
};

/**
 * Creates a FileReader (promisified)
 * @param {string} method The method to call
 * @param {ArrayBuffer} ab The ArrayBuffer
 * @param {string} mime The MIME type
 * @return {Promise}
 */
const createFileReader = (method, ab, mime) => new Promise((resolve, reject) => {
  const b = new Blob([ab], {type: mime});
  const r = new FileReader();
  r.onerror = e => reject(e);
  r.onloadend = () => resolve(r.result);
  r[method](b);
});

/**
 * Converts a number (bytez) into human-readable string
 * @param {Number} bytes Input
 * @param {Boolean} [si=false] Use SI units
 * @return {string}
 */
export const humanFileSize = (bytes, si = false) => {
  if (isNaN(bytes) || typeof bytes !== 'number') {
    bytes = 0;
  }

  const thresh = si ? 1000 : 1024;
  const units = si
    ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
    : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];

  if (bytes < thresh) {
    return bytes + ' B';
  }

  let u = -1;
  do {
    bytes /= thresh;
    ++u;
  } while (bytes >= thresh);

  return `${bytes.toFixed(1)} ${units[u]}`;
};

/**
 * Transforms a readdir result
 * @param {object} root The path to the readdir root
 * @param Object[] files An array of readdir results
 * @param {object} options Options
 * @param {Boolean} [options.showHiddenFiles=false] Show hidden files
 * @param {Function} [options.filter] A filter
 * @param {string} [options.sortBy='filename'] Sort by this attribute
 * @param {string} [options.sortDir='asc'] Sort in this direction
 * @return {Object[]}
 */
export const transformReaddir = ({path}, files, options = {}) => {
  options = {
    showHiddenFiles: false,
    sortBy: 'filename',
    sortDir: 'asc',
    ...options
  };

  let {sortDir, sortBy, filter} = options;
  if (typeof filter !== 'function') {
    filter = () => true;
  }

  if (['asc', 'desc'].indexOf(sortDir) === -1) {
    sortDir = 'asc';
  }

  const filterHidden = options.showHiddenFiles
    ? () => true
    : file => file.filename.substr(0, 1) !== '.';

  const sorter = sortMap[sortBy]
    ? sortMap[sortBy]
    : sortFn('string');

  const modify = (file) => ({
    ...file,
    humanSize: humanFileSize(file.size)
  });

  // FIXME: Optimize this to one chain!

  const sortedSpecial = createSpecials(path)
    .sort(sorter(sortBy, sortDir))
    .map(modify);

  const sortedDirectories = files.filter(file => file.isDirectory)
    .sort(sorter(sortBy, sortDir))
    .filter(filterHidden)
    .filter(filter)
    .map(modify);

  const sortedFiles = files.filter(file => !file.isDirectory)
    .sort(sorter(sortBy, sortDir))
    .filter(filterHidden)
    .filter(filter)
    .map(modify);

  return [
    ...sortedSpecial,
    ...sortedDirectories,
    ...sortedFiles
  ];
};

/**
 * Transform an ArrayBuffer
 * @param {ArrayBuffer} ab The ArrayBuffer
 * @param {string} mime The MIME type
 * @param {string} type Transform to this type
 * @return {DOMString|string|Blob|ArrayBuffer}
 */
export const transformArrayBuffer = (ab, mime, type) => {
  if (type === 'string') {
    return createFileReader('readAsText', ab, mime);
  } else if (type === 'uri') {
    return createFileReader('readAsDataURL', ab, mime);
  } else if (type === 'blob') {
    return Promise.resolve(new Blob([ab], {type: mime}));
  }

  return Promise.resolve(ab);
};

/**
 * Gets an icon from file stat
 * @param {object} file The file stat object
 * @return {string|object}
 */
export const getFileIcon = map => {
  const find = file => {
    const found = Object.keys(map).find(re => {
      const regexp = new RegExp(re);
      return regexp.test(file.mime);
    });

    return found
      ? map[found]
      : {name: 'application-x-executable'};
  };

  return file => file.isDirectory
    ? {name: 'folder'}
    : find(file);
};

/**
 * Creates a file iter for scandir
 * @param {object} stat file stat
 * @return {object}
 */
export const createFileIter = stat => ({
  isDirectory: false,
  isFile: true,
  mime: 'application/octet-stream',
  icon: null,
  size: -1,
  path: null,
  filename: null,
  label: null,
  stat: {},
  id: null,
  parent_id: null,
  ...stat
});

/**
 * Get basename of a file
 * @param {string} path The path
 * @return {string}
 */
export const basename = path => path.split('/').reverse()[0];

/*
 * Get path of a file
 * @param {string} path The path
 * @return {string}
 */
export const pathname = path => {
  const split = path.split('/');
  if (split.length === 2) {
    split[1] = '';
  } else {
    split.splice(split.length - 1, 1);
  }

  return split.join('/');
};

/**
 * Gets prefix from vfs path
 * @param {string} str Input
 * @return {string}
 */
export const parseMountpointPrefix = str => {
  const re = /^([\w-_]+):+(.*)/;

  const match = String(str)
    .replace(/\+/g, '/')
    .match(re);

  const [prefix] = Array.from(match || [])
    .slice(1);

  return prefix;
};

/**
 * Filters a mountpoint by user groups
 * @return {boolean}
 */
export const filterMountByGroups = userGroups => (mountGroups, strict = true) =>
  mountGroups instanceof Array
    ? mountGroups[strict ? 'every' : 'some'](g => userGroups.indexOf(g) !== -1)
    : true;


/**
 * Creates a list of VFS events to simulate server-side
 * file watching
 * @return {object[]}
 */
export const createWatchEvents = (method, args) => {
  const events = [];
  const options = args[args.length - 1] || {};

  const movement = ['move', 'rename', 'copy'].indexOf(method) !== -1;
  const invalid = ['readdir', 'download', 'url', 'exists', 'readfile', 'search', 'stat'].indexOf(method) !== -1;
  const path = i => typeof i === 'string' ? i : i.path;

  if (!invalid) {
    const obj = {
      method,
      source: path(args[0]),
      pid: options.pid
    };

    events.push(['osjs/vfs:directoryChanged', {
      ...obj,
      path: pathname(path(args[0])),
    }]);

    if (movement) {
      events.push(['osjs/vfs:directoryChanged', {
        ...obj,
        path: pathname(path(args[1]))
      }]);
    }
  }

  return events;
};