UNPKG

watch-project

Version:

watch directory change, emit events including create, change, mv, remove, mkdir, mvdir, rmdir as exactly as there is

701 lines (652 loc) 20.2 kB
/** * Module dependencies. */ var fs = require('fs') , path = require('path') , md5 = require('MD5') , chokidar = require('chokidar') , BETA = process.env.SYNC_BETA; var DEBUG// = true; ; var opts; /** * used to check the existence of a file and return it's type * @param fpath * @returns {String | Boolean} */ var type = function (fpath) { if (fs.existsSync(fpath)) { var state = fs.statSync(fpath); if (state.isFile()) { return 'File' } else if (state.isDirectory()) { return 'Dir' } else { return false; } } return false; }; /** * generate md5 string (the id of a directory or a file) of a file or a directory * @param fp * @param opt_t optional param describe the filetype of the filepath * @returns {String | Boolean} */ var genID = function (fp, opt_t) { var t = opt_t || type(fp); if (t == 'Dir') { try { return md5(fs.readdirSync(fp)); } catch (e) { return false; } } else if (t == 'File') { try { return md5(fs.readFileSync(fp)); } catch (e) { return false; } } return false; }; /** * @desc * - push files into project information center [memo] by method fileListHash.addFile * - push dirs into project information center [memo] by method fileListHash.addDir * - support callback to be called on found dirs, which is used to get a DIR's information recursively * @param {string} parent dir path to be scanned * @param {function} onDir callback to be called when find sub-directory * @param {object} filter used to ignore some file */ var genDirInfo = function (parent, onDir) { if (type(parent) == 'Dir') { var fileList = fs.readdirSync(parent); //将文件夹存入hash fileListHash.addDir(parent, fileList); fileList && fileList.forEach(function (f) { if (!opts.withHidden && f[0] == '.') { //filter hidden directory and files return } var sdir = path.join(parent, f); if (type(sdir) == 'Dir') { onDir.call(null, sdir) } else { //文件夹会调用watch,并自动填加该文件夹到文件hash中去 //普通文件则需要在此处添加到hash中去 fileListHash.addFile(sdir); } }); } }; /** * Mixing object properties. */ var mixin = function () { var mix = {}; [].forEach.call(arguments, function (arg) { for (var name in arg) { if (arg.hasOwnProperty(name)) { mix[name] = arg[name]; } } }); return mix; }; var memo = {file:{}, dir: {}}; /** * A container for memorizing names of files or directories. */ var fileListHash = function (memo) { var fn; return { /** * register callback passed to function module, and automatically watch new directory created during watch * @param cb origin callback * @param options origin option */ registerCB: function (cb, options) { if(options.stable){ // use "chokidar", no need to watch changed dir fn = function(event){ var f = event.filename; if (!fileListHash.has(f) && type(f) == 'Dir') { //only need to update information of the changed new directory collectDirInfo(f); } cb.call(null, event); } }else{ // use "fs.watch", need to watch changed dir fn = function (event) { var f = event.filename; if (!fileListHash.has(f) && type(f) == 'Dir') { //watch directory doesn't under watch watchDir(f); } cb.call(null, event); } } }, ready: function(){ this.readyHandler && this.readyHandler(); }, export: function () { return JSON.stringify(memo); }, addDir: (function(){ if (process.platform == 'win32' && BETA){ return function (dp, fileList) { if (!memo.dir[dp]) { memo.dir[dp] = { //type: 'Dir', id: md5(fileList), files: fileList.toString() } } return this; } }else { return function(dp, fileList){ if (!memo.dir[dp]) { memo.dir[dp] = { //type: 'Dir', id: md5(fileList) } } return this; } } })(), addFile: function (fp, id) { if (!memo.file[fp]) { if (!id) { try { id = md5(fs.readFileSync(fp)); } catch (e) { id = null; } } memo.file[fp] = { //type: 'File', id: id }; } return this; }, removeFile: function (name) { delete memo.file[name]; return this; }, removeDir: function (name) { delete memo.dir[name]; var l = name.length; //移除该目录下面检测的文件 Object.keys(memo.file).forEach(function (f) { if (f.slice(0, l) == name) { delete memo.file[f]; } ; }); //移除该目录下面监测的文件夹 Object.keys(memo.dir).forEach(function (f) { if (f.slice(0, l) == name) { delete memo.dir[f]; } }); return this; }, //update id of an directory when it's contained files and directories moved[deleted, created], renamed updateParentDirID: (function(){ if (process.platform == 'win32' && BETA){ return function (fp) { var parent = path.dirname(fp), did = genID(parent, 'Dir'); memo.dir[parent] ? memo.dir[parent].id = did : ""; memo.dir[parent] ? memo.dir[parent].files = fs.readdirSync(parent).toString() : ""; return this; } }else{ return function (fp) { var parent = path.dirname(fp), did = genID(parent, 'Dir'); memo.dir[parent] ? memo.dir[parent].id = did : ""; return this; } } })(), //update id of an file when it's modified updateFileID: function (fp) { var fid = genID(fp, 'File'); memo.file[fp] ? memo.file[fp].id = fid : ""; return this; }, has: function (name) { return memo.dir[name] || memo.file[name]; }, originID: function (name) { var record = memo.dir[name] || memo.file[name]; return record && record.id; }, // only needed under 'windows' platform for compatibility originContentListHash: function(name){ var listStr = memo.dir[name].files; // "".split(',') will result to [""] if (listStr.length==0){ return {} } var retObj = {} listStr.split(',').forEach(function(item){ retObj[item] = true; }); return retObj; }, currentContentListHash: function(path) { var fileList = fs.readdirSync(path); var retObj = {}; fileList.forEach(function(item){ retObj[item] = true; }); return retObj; }, originFileType: function (name) { return memo.dir[name] ? 'Dir' : 'File'; }, //container for stash delete event because it's triggered before create/mkdir event deleteList: {}, rmdirList: {}, genEvent: function (fp, sim) { // if (sim){ // console.error(fp); // }else{ // console.log (fp); // } var currentFT = type(fp); if (this.has(fp)) { //原先有该文件(修改,删除) if (currentFT == "File") { //同名文件触发变化事件 —— 修改 //需要更新md5值 var oid = memo.file[fp].id; // 缓存变化前的MD5值 this.updateFileID(fp).updateParentDirID(fp); //更新文件状态,当前memo记录的是新MD5值 fn.call(null, {type: 'change', filename: fp, oid: oid, nid: memo.file[fp].id}); } else if (currentFT == "Dir") { /** * Hacks for windous */ // 只有在windous系统下才会出现这种情况(子文件内容改变) // 检查一下该目录下文件内容的变化,并派发相应的事件 //console.log(memo) var originContentListHash = this.originContentListHash(fp); var currentContentListHash = this.currentContentListHash(fp); var deleted = {}, created = {}; // [handle remove type !must be first ] remove un changed files & dirs for (var key in originContentListHash){ if (!currentContentListHash[key]){ deleted[key] = true; } } // [handle insert type ! must after delete event for rename] for (var key in currentContentListHash){ if (!originContentListHash[key]){ created[key] = true } } // simulate file delete event for(var item in deleted){ simulateFp = fp+path.sep+path.basename(item); this.genEvent(simulateFp, 'sim'); }; // simulate file create event for(var item in created){ simulateFp = fp+path.sep+path.basename(item); this.genEvent(simulateFp, 'sim'); }; } else { // not exist //当移动文件时,会先后触发原文件的删除,新文件的创建事件 //将删除事件缓存起来,确定不是重命名、移动事件时再触发 if (this.originFileType(fp) == "File") { this.deleteList[this.originID(fp)] = fp; //不论是删除还是重命名,都需要第一时间重载父目录的hash值 this.removeFile(fp).updateParentDirID(fp); } else { //console.log(this.originFileType(fp)); //如果是删除文件夹,会先触发子文件的删除事件,因此上面的removeFile(fp).updateParentDirID(fp)会导致originID(fp)为false, // 因为不存在了,移动文件夹时因为不会触发子文件的删除事件,根目录是最后被触发的,所以最后的rmdirList中存放的就是待删除的根目录 this.rmdirList[this.originID(fp)] = fp; this.removeDir(fp).updateParentDirID(fp); } } } else { //原先没有该文件(新建,重命名|移动)) var id = genID(fp); if (currentFT == "File") { if (this.deleteList[id]) { //通过md5判断为同一文件,因此是移动事件 //由于文件不会触发重新watch,因此需要手动添加最新路径,同时更新目录文件 this.addFile(fp, id).updateParentDirID(fp); fn.call(null, {type: 'mvfile', filename: fp, oname: this.deleteList[id], oid: id}); delete this.deleteList[id]; } else { //没有该文件hash值的记录,为新建文件 this.addFile(fp, id).updateParentDirID(fp); fn.call(null, {type: 'create', filename: fp}); } } else if (currentFT == "Dir") { if (this.rmdirList[id]) { //移动文件夹,会调用watch,不需要手动添加最新路径和更新父目录(在fn中完成)) fn.call(null, {type: 'mvdir', filename: fp, oname: this.rmdirList[id], oid: id}); delete this.rmdirList[id]; } else { fn.call(null, {type: 'mkdir', filename: fp}); } } else { //一些由编辑器产生的临时文件 return false; } } }, /** * merge sub-directory rmdir event to the top directory */ filterRemovedDir: function () { var removedDir = this.rmdirList["false"], l, prefix, self = this; if (removedDir) { delete this.rmdirList["false"]; l = removedDir.length; //删除一个目录时,该目录下的文件删除事件先触发,导致更新了父文件的id,而在处理时,父文件已经不存在了, //因此其id为false,生成的rmdirList永远是被删除的根目录{false, 'xx/oo'};,子目录先触发所以被覆盖了!, //过滤掉该文件夹下的所有文件的删除事件,无需考虑目录,因为子目录都被覆盖了 Object.keys(this.rmdirList).forEach(function (id) { prefix = self.rmdirList[id].slice(0, l); if (prefix == removedDir) { delete self.rmdirList[id]; } }); Object.keys(this.deleteList).forEach(function (id) { prefix = self.deleteList[id].slice(0, l); if (prefix == removedDir) { delete self.deleteList[id]; } }); this.rmdirList["false"] = removedDir; } }, /** * emit the event stashed for estimating MV event( including rename, move) */ clear: function () { //先处理目录,过滤文件的删除事件 this.filterRemovedDir(); for (var id in this.rmdirList) { fn.call(null, {type: 'rmdir', filename: this.rmdirList[id], oname: this.rmdirList[id], oid: id}); } for (var id in this.deleteList) { //相应的更新事件已经 removeFile|removeDir中完成了 fn.call(null, {type: 'delete', filename: this.deleteList[id], oname: this.deleteList[id], oid: id}); } this.deleteList = {}; this.rmdirList = {}; DEBUG ? setTimeout(function () { console.log('>>>>>>>>>>>>>'); console.log(memo); console.log('>>>>>>>>>>>>>'); }, 1000) : ""; } }; }(memo); /**f7507cfcb2844248ace78e7a1' }, * A Container for storing unique and valid filenames. */ var fileNameCache = function (cache) { return { push: function (name) { cache[name] = 1; return this; }, process: (function(){ if (process.platform == 'win32' && BETA ){ // win32 platform, adjust events order by replace first file path (target path) with directory // if it's parent is also in the change list! return function(){ // adjust event's order var i = 0; var changeList = []; for (var item in cache){ changeList.push(item) if(++i > 3){ break; } } var first = changeList[0]; var matchKey = path.dirname(first); // check if first changed path's dirname is in the list if (cache[matchKey]){ // console.info('re order ....') // console.log (changeList) // exchange the second and third item if (matchKey == changeList[2]){ fileListHash.genEvent(changeList[1]); if (changeList[2]){ // rename events don't have third item fileListHash.genEvent(changeList[2]); } }else{ if (changeList[2]){ // rename events don't have third item fileListHash.genEvent(changeList[2]); } fileListHash.genEvent(changeList[1]); } }else{ // console.info('origin ....') for (var fp in cache) { fileListHash.genEvent(fp); } } //console.log (cache); // this._clear(); return this; } }else{ return function () { for (var fp in cache) { fileListHash.genEvent(fp); } this._clear(); return this; } } })(), _process: function () { for (var fp in cache) { fileListHash.genEvent(fp); } this._clear(); return this; }, _clear: function () { fileListHash.clear(); cache = {}; return this; } }; }({}); /** * Abstracting the way of avoiding duplicate function call. */ var worker = function () { var free = true; return { busydoing: function (cb) { if (free) { free = false; cb.call(); } }, free: function () { free = true; } } }(); /** * Delay function call and ignore invalid filenames. */ var normalizeCall = function (fname) { // Store each name of the modifying or temporary files generated by an editor. if(fname[0] == '.'){ //filter hidden directory and file return; } fileNameCache.push(fname); worker.busydoing(function () { // A heuristic delay of the write-to-file process. setTimeout(function () { // When the write-to-file process is done, send all filtered filenames // to the callback function and call it. fileNameCache.process(); worker.free(); }, 100); }); }; /** * Catch exception on Windows when deleting a directory. */ var catchException = function () { }; /** * Option handler for the `watch` function. */ var handleOptions = function (origin, defaultOptions) { var f = function(){ var args = [].slice.call(arguments); if (Object.prototype.toString.call(args[1]) === '[object Function]') { args[2] = args[1]; } if (!Array.isArray(args[0])) { args[0] = [args[0]]; } //overwrite default options args[1] = mixin(defaultOptions, args[1]); //handle multiple files. args[0].forEach(function (path) { origin.apply(null, [path].concat(args.slice(1))); }); // f.emit = function(){ args[2].apply(null, [].slice.call(arguments)); }; }; //add some public function to the wrapper f.status = function () { return fileListHash.export(); }; f.ready = function(fn){ fileListHash.readyHandler = fn; }; return f; }; /** * Watch a file or a directory (recursively by default). * * @param {String} dir * @options {Object} options * @param {Function} cb * * Options: * `recursive`: Watch it recursively or not (defaults to true). * `followSymLinks`: Follow symbolic links or not (defaults to false). * `maxSymLevel`: The max number of following symbolic links (defaults to 1). * * Example: * * watch('fpath', {recursive: true}, function(file) { * console.log(file, ' changed'); * }); */ function init(dir, options, cb) { if (!arguments.callee.init) { opts = options; arguments.callee.init = true; fileListHash.registerCB(cb, options); // 会导致多重复文件夹失败 //fpath = path.basename(fpath); } //只支持文件夹监测 if (type(dir) == 'Dir') { //use path.basename() to convert ./test to test which is equal but beautiful //watchDir(dir); if(options.stable){ // mac and old node watchDirStable(dir); collectDirInfo(dir); }else{ BETA = true; watchDir(dir); } fileListHash.ready(); } }; /** * */ function collectDirInfo(dir){ genDirInfo(dir, function(subdir){ collectDirInfo(subdir); }); } /** * use "chokidar" module to replace the origin fs.watch since it's not work well on dlo version of OSX and nodejs * @param dir */ function watchDirStable(dir){ var watcher; if (opts.withHidden) { watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true }); } else { var ignoreReg; if (path.sep === '\\'){ ignoreReg = /\\\./; }else{ ignoreReg = /\/\./; }; watcher = chokidar.watch(dir, { ignored: ignoreReg, persistent: true, ignoreInitial: true }); } watcher.on('all', function(e, f){ normalizeCall(f); }); } /** * call fs.watch(dir) recursively since the official API is not recursive * used for latest version of node and OSX newer then 10.7 * @param dir */ function watchDir(dir){ fs.watch(dir, function(evt, fname){ //TODO 被watch的目录重命名时,也会触发rename事件,导致一个目录的变化被父目录和自身同时捕获 if(fname){ normalizeCall(path.join(dir, fname)); } }).on('error', catchException); genDirInfo(dir, function(dir){ // Recursively watch its sub-directories, store the files and directories information into hash watchDir(dir); }); } /** * Set default options and expose. */ module.exports = handleOptions(init);