## taken from: http://paulhorman.com/filesystemwatcher/ require "md5" require "thread" # The Runnable module is a generic mixin for including state and # status information in a class module ServiceState # state constants NOT_STARTED = 0 STARTED = 1 STOPPED = 2 CONFIGURED = 3 attr_reader :startTime, :endTime # Initialize the state information def initializeState() @configured = false @startTime = 0 @stopTime = 0 @stateMutex = Mutex.new() @stopWhen = nil setState(NOT_STARTED) end # Set the callback for when someone calls setState. You # will be passed the state CONSTANT being set def onStateChange(&callbackBlock) @stateCallback = callbackBlock end # All methods, inside this class or not, should use this # method to change the state of the JobRunner # @param newState The new state value def setState(newState) @stateMutex.synchronize { if newState == CONFIGURED then @configured = true else @state = newState if isStarted? then @startTime = Time.now() elsif isStopped? @stopTime = Time.now() end end } if defined?(@stateCallback) then @stateCallback.call(newState) end end def isConfigured? return @configured end def isStarted? return @state == STARTED end def isStopped? if @state == STOPPED then return true elsif @stopWhen && @stopWhen.call() then setState(STOPPED) return true else return false end end def stopWhen(&block) @stopWhen = block end end # This class will watch a directory or a set of directories and alert you of # new files, modified files, deleted files. You can optionally only be alerted # when a files md5 hash has been changed so you only are alerted to real changes. # this of course means slower performance and higher cpu/io usage. class FileSystemWatcher include ServiceState CREATED = 0 MODIFIED = 1 DELETED = 2 # the time to wait before checking the directories again attr_accessor :sleepTime, :priority, :mongrel # you can optionally use the file contents md5 to detect if a file has changed attr_accessor :useMD5 def initialize(dir=nil, expression="**/*") @sleepTime = 5 @useMD5 = false @priority = 0 @stopWhen = nil @directories = Array.new() @files = Array.new() @foundFiles = nil @firstLoad = true @watchThread = nil initializeState() if dir then addDirectory(dir, expression) end end # add a directory to be watched # @param dir the directory to watch # @param expression the glob pattern to search under the watched directory def addDirectory(dir, expression="**/*") if FileTest.exists?(dir) && FileTest.readable?(dir) then @directories << FSWatcher::Directory.new(dir, expression) else raise FSWatcher::InvalidDirectoryError, "Dir '#{dir}' either doesnt exist or isnt readable" end end def removeDirectory(dir) @directories.delete(dir) end # add a specific file to the watch list # @param file the file to watch def addFile(file) if FileTest.exists?(file) && FileTest.readable?(file) then @files << file else raise FSWatcher::InvalidFileError, "File '#{file}' either doesnt exist or isnt readable" end end def removeFile(file) @files.delete(file) end # start watching the specified files/directories def start(&block) if isStarted? then raise RuntimeError, "already started" end setState(STARTED) @firstLoad = true @foundFiles = Hash.new() # we watch in a new thread @watchThread = Thread.new { # we will be stopped if someone calls stop or if someone set a stopWhen that becomes true while !isStopped? do if (!@directories.empty?) or (!@files.empty?) then # this will hold the list of the files we looked at this iteration # allows us to not look at the same file again and also to compare # with the foundFile list to see if something was deleted alreadyExamined = Hash.new() # check the files in each watched directory if not @directories.empty? then @directories.each { |dirObj| examineFileList(dirObj.getFiles(), alreadyExamined, &block) } end # now examine any files the user wants to specifically watch examineFileList(@files, alreadyExamined, &block) if not @files.empty? # see if we have to delete files from our found list if not @firstLoad then if not @foundFiles.empty? then # now diff the found files and the examined files to see if # something has been deleted allFoundFiles = @foundFiles.keys() allExaminedFiles = alreadyExamined.keys() intersection = allFoundFiles - allExaminedFiles intersection.each { |fileName| # callback block.call(DELETED, fileName) # remove deleted file from the foundFiles list @foundFiles.delete(fileName) } end else @firstLoad = false end end # go to sleep sleep(@sleepTime) end } # set the watch thread priority @watchThread.priority = @priority end # kill the filewatcher thread def stop() setState(STOPPED) @watchThread.wakeup() end # wait for the filewatcher to finish def join() @watchThread.join() if @watchThread end private # loops over the file list check for new or modified files def examineFileList(fileList, alreadyExamined, &block) fileList.each { |fileName| # expand the file name to the fully qual path fullFileName = File.expand_path(fileName) # dont examine the same file 2 times if not alreadyExamined.has_key?(fullFileName) then # we cant do much if the file isnt readable anyway if File.readable?(fullFileName) then # set that we have seen this file alreadyExamined[fullFileName] = true # get the file info modTime, size = File.mtime(fullFileName), File.size(fullFileName) # on the first iteration just load all of the files into the foundList if @firstLoad then @foundFiles[fullFileName] = FSWatcher::FoundFile.new(fullFileName, modTime, size, false, @useMD5) else # see if we have found this file already foundFile = @foundFiles[fullFileName] if foundFile then # if a file is marked as new, we still need to make sure it isnt still # being written to. we do this by checking the file sizes. if foundFile.isNew? then # if the file size is the same then it is probably done being written to # unless the writer is really slow if size == foundFile.size then # callback block.call(CREATED, fullFileName) # mark this file as a changed file now foundFile.updateModTime(modTime) # generate the md5 for the file since we know it is done # being written to foundFile.genMD5() if @useMD5 else # just update the size so we can check again at the next iteration foundFile.updateSize(size) end elsif modTime > foundFile.modTime then # if the mod times are different on files we already have # found this is an update willYield = true # if we are using md5's then compare them if @useMD5 then filesMD5 = FSWatcher.genFileMD5(fullFileName) if filesMD5 && foundFile.md5 then if filesMD5.to_s == foundFile.md5.to_s then willYield = false end end # if we are yielding then the md5s are dif so # update the cached md5 value foundFile.setMD5(filesMD5) if willYield end block.call(MODIFIED, fullFileName) if willYield foundFile.updateModTime(modTime) end else # this is a new file for our list. dont update the md5 here since # the file might not yet be done being written to @foundFiles[fullFileName] = FSWatcher::FoundFile.new(fullFileName, modTime, size) end end end end } end end # Util classes for the FileSystemWatcher module FSWatcher # The directory to watch class Directory attr_reader :dir, :expression def initialize(dir, expression) @dir, @expression = dir, expression @dir.chop! if @dir =~ %r{/$} end def getFiles() return Dir[@dir + "/" + @expression] end end # A FoundFile entry for the FileSystemWatcher class FoundFile attr_reader :status, :fileName, :modTime, :size, :md5 def initialize(fileName, modTime, size, isNewFile=true, useMD5=false) @fileName, @modTime, @size, @isNewFile = fileName, modTime, size, isNewFile @md5 = nil if useMD5 then genMD5() end end def updateModTime(modTime) @modTime = modTime @isNewFile = false end def updateSize(size) @size = size end def isNew? return @isNewFile end def setMD5(newMD5) @md5 = newMD5 end # generate my files md5 value def genMD5() @md5 = FSWatcher.genFileMD5(@fileName) end end # utility function for generating md5s from a files contents def FSWatcher.genFileMD5(fileName) if FileTest.file?(fileName) then f = File.open(fileName) contents = f.read() f.close() return MD5.new(contents) if contents end return nil end # if the directory you want to watch doesnt exist or isnt readable this is thrown class InvalidDirectoryError < StandardError; end # if the file you want to watch doesnt exist or isnt readable this is thrown class InvalidFileError < StandardError; end end