src/filesystem/FileSystemEntry.js
/*
* Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*
* To ensure cache coherence, current and future asynchronous state-changing
* operations of FileSystemEntry and its subclasses should implement the
* following high-level sequence of steps:
*
* 1. Block external filesystem change events;
* 2. Execute the low-level state-changing operation;
* 3. Update the internal filesystem state, including caches;
* 4. Apply the callback;
* 5. Fire an appropriate internal change notification; and
* 6. Unblock external change events.
*
* Note that because internal filesystem state is updated first, both the original
* caller and the change notification listeners observe filesystem state that is
* current w.r.t. the operation. Furthermore, because external change events are
* blocked before the operation begins, listeners will only receive the internal
* change event for the operation and not additional (or possibly inconsistent)
* external change events.
*
* State-changing operations that block external filesystem change events must
* take care to always subsequently unblock the external change events in all
* control paths. It is safe to assume, however, that the underlying impl will
* always apply the callback with some value.
* Caches should be conservative. Consequently, the entry's cached data should
* always be cleared if the underlying impl's operation fails. This is the case
* event for read-only operations because an unexpected failure implies that the
* system is in an unknown state. The entry should communicate this by failing
* where appropriate, and should not use the cache to hide failure.
*
* Only watched entries should make use of cached data because change events are
* only expected for such entries, and change events are used to granularly
* invalidate out-of-date caches.
*
* By convention, callbacks are optional for asynchronous, state-changing
* operations, but required for read-only operations. The first argument to the
* callback should always be a nullable error string from FileSystemError.
*/
define(function (require, exports, module) {
"use strict";
var FileSystemError = require("filesystem/FileSystemError"),
WatchedRoot = require("filesystem/WatchedRoot");
var VISIT_DEFAULT_MAX_DEPTH = 100,
VISIT_DEFAULT_MAX_ENTRIES = 30000;
/* Counter to give every entry a unique id */
var nextId = 0;
/**
* Model for a file system entry. This is the base class for File and Directory,
* and is never used directly.
*
* See the File, Directory, and FileSystem classes for more details.
*
* @constructor
* @param {string} path The path for this entry.
* @param {FileSystem} fileSystem The file system associated with this entry.
*/
function FileSystemEntry(path, fileSystem) {
this._setPath(path);
this._fileSystem = fileSystem;
this._id = nextId++;
}
// Add "fullPath", "name", "parent", "id", "isFile" and "isDirectory" getters
Object.defineProperties(FileSystemEntry.prototype, {
"fullPath": {
get: function () { return this._path; },
set: function () { throw new Error("Cannot set fullPath"); }
},
"name": {
get: function () { return this._name; },
set: function () { throw new Error("Cannot set name"); }
},
"parentPath": {
get: function () { return this._parentPath; },
set: function () { throw new Error("Cannot set parentPath"); }
},
"id": {
get: function () { return this._id; },
set: function () { throw new Error("Cannot set id"); }
},
"isFile": {
get: function () { return this._isFile; },
set: function () { throw new Error("Cannot set isFile"); }
},
"isDirectory": {
get: function () { return this._isDirectory; },
set: function () { throw new Error("Cannot set isDirectory"); }
},
"_impl": {
get: function () { return this._fileSystem._impl; },
set: function () { throw new Error("Cannot set _impl"); }
}
});
/**
* Cached stat object for this file.
* @type {?FileSystemStats}
*/
FileSystemEntry.prototype._stat = null;
/**
* Parent file system.
* @type {!FileSystem}
*/
FileSystemEntry.prototype._fileSystem = null;
/**
* The path of this entry.
* @type {string}
*/
FileSystemEntry.prototype._path = null;
/**
* The name of this entry.
* @type {string}
*/
FileSystemEntry.prototype._name = null;
/**
* The parent of this entry.
* @type {string}
*/
FileSystemEntry.prototype._parentPath = null;
/**
* Whether or not the entry is a file
* @type {boolean}
*/
FileSystemEntry.prototype._isFile = false;
/**
* Whether or not the entry is a directory
* @type {boolean}
*/
FileSystemEntry.prototype._isDirectory = false;
/**
* Cached copy of this entry's watched root
* @type {entry: File|Directory, filter: function(FileSystemEntry):boolean, active: boolean}
*/
FileSystemEntry.prototype._watchedRoot = undefined;
/**
* Cached result of _watchedRoot.filter(this.name, this.parentPath).
* @type {boolean}
*/
FileSystemEntry.prototype._watchedRootFilterResult = undefined;
/**
* Determines whether or not the entry is watched.
* @param {boolean=} relaxed If falsey, the method will only return true if
* the watched root is fully active. If true, the method will return
* true if the watched root is either starting up or fully active.
* @return {boolean}
*/
FileSystemEntry.prototype._isWatched = function (relaxed) {
var watchedRoot = this._watchedRoot,
filterResult = this._watchedRootFilterResult;
if (!watchedRoot) {
watchedRoot = this._fileSystem._findWatchedRootForPath(this._path);
if (watchedRoot) {
this._watchedRoot = watchedRoot;
if (watchedRoot.entry !== this) { // avoid creating entries for root's parent
var parentEntry = this._fileSystem.getDirectoryForPath(this._parentPath);
if (parentEntry._isWatched() === false) {
filterResult = false;
} else {
filterResult = watchedRoot.filter(this._name, this._parentPath);
}
} else { // root itself is watched
filterResult = true;
}
this._watchedRootFilterResult = filterResult;
}
}
if (watchedRoot) {
if (watchedRoot.status === WatchedRoot.ACTIVE ||
(relaxed && watchedRoot.status === WatchedRoot.STARTING)) {
return filterResult;
} else {
// We had a watched root, but it's no longer active, so it must now be invalid.
this._watchedRoot = undefined;
this._watchedRootFilterResult = false;
this._clearCachedData();
}
}
return false;
};
/**
* Update the path for this entry
* @private
* @param {String} newPath
*/
FileSystemEntry.prototype._setPath = function (newPath) {
var parts = newPath.split("/");
if (this.isDirectory) {
parts.pop(); // Remove the empty string after last trailing "/"
}
this._name = parts[parts.length - 1];
parts.pop(); // Remove name
if (parts.length > 0) {
this._parentPath = parts.join("/") + "/";
} else {
// root directories have no parent path
this._parentPath = null;
}
this._path = newPath;
var watchedRoot = this._watchedRoot;
if (watchedRoot) {
if (newPath.indexOf(watchedRoot.entry.fullPath) === 0) {
// Update watchedRootFilterResult
this._watchedRootFilterResult = watchedRoot.filter(this._name, this._parentPath);
} else {
// The entry was moved outside of the watched root
this._watchedRoot = null;
this._watchedRootFilterResult = false;
}
}
};
/**
* Clear any cached data for this entry
* @private
*/
FileSystemEntry.prototype._clearCachedData = function () {
this._stat = undefined;
};
/**
* Helpful toString for debugging purposes
*/
FileSystemEntry.prototype.toString = function () {
return "[" + (this.isDirectory ? "Directory " : "File ") + this._path + "]";
};
/**
* Check to see if the entry exists on disk. Note that there will NOT be an
* error returned if the file does not exist on the disk; in that case the
* error parameter will be null and the boolean will be false. The error
* parameter will only be truthy when an unexpected error was encountered
* during the test, in which case the state of the entry should be considered
* unknown.
*
* @param {function (?string, boolean)} callback Callback with a FileSystemError
* string or a boolean indicating whether or not the file exists.
*/
FileSystemEntry.prototype.exists = function (callback) {
if (this._stat) {
callback(null, true);
return;
}
this._impl.exists(this._path, function (err, exists) {
if (err) {
this._clearCachedData();
callback(err);
return;
}
if (!exists) {
this._clearCachedData();
}
callback(null, exists);
}.bind(this));
};
/**
* Returns the stats for the entry.
*
* @param {function (?string, FileSystemStats=)} callback Callback with a
* FileSystemError string or FileSystemStats object.
*/
FileSystemEntry.prototype.stat = function (callback) {
if (this._stat) {
callback(null, this._stat);
return;
}
this._impl.stat(this._path, function (err, stat) {
if (err) {
this._clearCachedData();
callback(err);
return;
}
if (this._isWatched()) {
this._stat = stat;
}
callback(null, stat);
}.bind(this));
};
/**
* Rename this entry.
*
* @param {string} newFullPath New path & name for this entry.
* @param {function (?string)=} callback Callback with a single FileSystemError
* string parameter.
*/
FileSystemEntry.prototype.rename = function (newFullPath, callback) {
callback = callback || function () {};
// Block external change events until after the write has finished
this._fileSystem._beginChange();
this._impl.rename(this._path, newFullPath, function (err) {
var oldFullPath = this._path;
try {
if (err) {
this._clearCachedData();
callback(err);
return;
}
// Update internal filesystem state
this._fileSystem._handleRename(this._path, newFullPath, this.isDirectory);
try {
// Notify the caller
callback(null);
} finally {
// Notify rename listeners
this._fileSystem._fireRenameEvent(oldFullPath, newFullPath);
}
} finally {
// Unblock external change events
this._fileSystem._endChange();
}
}.bind(this));
};
/**
* Permanently delete this entry. For Directories, this will delete the directory
* and all of its contents. For reversible delete, see moveToTrash().
*
* @param {function (?string)=} callback Callback with a single FileSystemError
* string parameter.
*/
FileSystemEntry.prototype.unlink = function (callback) {
callback = callback || function () {};
// Block external change events until after the write has finished
this._fileSystem._beginChange();
this._clearCachedData();
this._impl.unlink(this._path, function (err) {
var parent = this._fileSystem.getDirectoryForPath(this.parentPath);
// Update internal filesystem state
this._fileSystem._handleDirectoryChange(parent, function (added, removed) {
try {
// Notify the caller
callback(err);
} finally {
if (parent._isWatched()) {
// Notify change listeners
this._fileSystem._fireChangeEvent(parent, added, removed);
}
// Unblock external change events
this._fileSystem._endChange();
}
}.bind(this));
}.bind(this));
};
/**
* Move this entry to the trash. If the underlying file system doesn't support move
* to trash, the item is permanently deleted.
*
* @param {function (?string)=} callback Callback with a single FileSystemError
* string parameter.
*/
FileSystemEntry.prototype.moveToTrash = function (callback) {
if (!this._impl.moveToTrash) {
this.unlink(callback);
return;
}
callback = callback || function () {};
// Block external change events until after the write has finished
this._fileSystem._beginChange();
this._clearCachedData();
this._impl.moveToTrash(this._path, function (err) {
var parent = this._fileSystem.getDirectoryForPath(this.parentPath);
// Update internal filesystem state
this._fileSystem._handleDirectoryChange(parent, function (added, removed) {
try {
// Notify the caller
callback(err);
} finally {
if (parent._isWatched()) {
// Notify change listeners
this._fileSystem._fireChangeEvent(parent, added, removed);
}
// Unblock external change events
this._fileSystem._endChange();
}
}.bind(this));
}.bind(this));
};
/**
* Private helper function for FileSystemEntry.visit that requires sanitized options.
*
* @private
* @param {FileSystemStats} stats - the stats for this entry
* @param {{string: boolean}} visitedPaths - the set of fullPaths that have already been visited
* @param {function(FileSystemEntry): boolean} visitor - A visitor function, which is
* applied to descendent FileSystemEntry objects. If the function returns false for
* a particular Directory entry, that directory's descendents will not be visited.
* @param {{maxDepth: number, maxEntriesCounter: {value: number}, sortList: boolean}} options
* @param {function(?string)=} callback Callback with single FileSystemError string parameter.
*/
FileSystemEntry.prototype._visitHelper = function (stats, visitedPaths, visitor, options, callback) {
var maxDepth = options.maxDepth,
maxEntriesCounter = options.maxEntriesCounter,
sortList = options.sortList;
if (maxEntriesCounter.value-- <= 0 || maxDepth-- < 0) {
// The outer FileSystemEntry.visit call is responsible for applying
// the main callback to FileSystemError.TOO_MANY_FILES in this case
callback(null);
return;
}
if (this.isDirectory) {
var visitedPath = stats.realPath || this.fullPath;
if (visitedPaths.hasOwnProperty(visitedPath)) {
// Link cycle detected
callback(null);
return;
}
visitedPaths[visitedPath] = true;
}
if (!visitor(this) || this.isFile) {
callback(null);
return;
}
this.getContents(function (err, entries, entriesStats) {
if (err) {
callback(err);
return;
}
var counter = entries.length;
if (counter === 0) {
callback(null);
return;
}
function helperCallback(err) {
if (--counter === 0) {
callback(null);
}
}
var nextOptions = {
maxDepth: maxDepth,
maxEntriesCounter: maxEntriesCounter,
sortList : sortList
};
//sort entries if required
function compareFilesWithIndices(index1, index2) {
return entries[index1]._name.toLocaleLowerCase().localeCompare(entries[index2]._name.toLocaleLowerCase());
}
if (sortList) {
var fileIndexes = [], i = 0;
for (i = 0; i < entries.length; i++) {
fileIndexes[i] = i;
}
fileIndexes.sort(compareFilesWithIndices);
fileIndexes.forEach(function (fileIndex) {
var stats = entriesStats[fileIndexes[fileIndex]];
entries[fileIndexes[fileIndex]]._visitHelper(stats, visitedPaths, visitor, nextOptions, helperCallback);
});
} else {
entries.forEach(function (entry, index) {
var stats = entriesStats[index];
entry._visitHelper(stats, visitedPaths, visitor, nextOptions, helperCallback);
});
}
}.bind(this));
};
/**
* Visit this entry and its descendents with the supplied visitor function.
* Correctly handles symbolic link cycles and options can be provided to limit
* search depth and total number of entries visited. No particular traversal
* order is guaranteed; instead of relying on such an order, it is preferable
* to use the visit function to build a list of visited entries, sort those
* entries as desired, and then process them. Whenever possible, deep
* filesystem traversals should use this method.
*
* @param {function(FileSystemEntry): boolean} visitor - A visitor function, which is
* applied to this entry and all descendent FileSystemEntry objects. If the function returns
* false for a particular Directory entry, that directory's descendents will not be visited.
* @param {{maxDepth: number=, maxEntries: number=}=} options
* @param {function(?string)=} callback Callback with single FileSystemError string parameter.
*/
FileSystemEntry.prototype.visit = function (visitor, options, callback) {
if (typeof options === "function") {
callback = options;
options = {};
} else {
if (options === undefined) {
options = {};
}
callback = callback || function () {};
}
if (options.maxDepth === undefined) {
options.maxDepth = VISIT_DEFAULT_MAX_DEPTH;
}
if (options.maxEntries === undefined) {
options.maxEntries = VISIT_DEFAULT_MAX_ENTRIES;
}
options.maxEntriesCounter = { value: options.maxEntries };
this.stat(function (err, stats) {
if (err) {
callback(err);
return;
}
this._visitHelper(stats, {}, visitor, options, function (err) {
if (callback) {
if (err) {
callback(err);
return;
}
if (options.maxEntriesCounter.value < 0) {
callback(FileSystemError.TOO_MANY_ENTRIES);
return;
}
callback(null);
}
}.bind(this));
}.bind(this));
};
// Export this class
module.exports = FileSystemEntry;
});