| Class | MUES::Questionnaire |
| In: |
lib/mues/filters/questionnaire.rb
(CVS)
|
| Parent: | MUES::IOEventFilter |
Instances of this class are dynamically-configurable question and answer wizard IO abstractions which can be inserted into a MUES::User‘s MUES::IOEventStream to gather information to perform a specific task.
| SVNRev | = | %q$Rev: 1218 $ | SVN Revision | |
| SVNId | = | %q$Id: questionnaire.rb 1218 2004-06-14 06:07:54Z deveiant $ | SVN Id | |
| SVNURL | = | %q$URL: svn+ssh://deveiate.org/usr/local/svn/MUES/trunk/lib/mues/filters/questionnaire.rb $ | SVN URL | |
| DefaultSortPosition | = | 600 | Filter sort order |
| inProgress | -> | inProgress? |
| inProgress | -> | in_progress? |
| blocked | -> | blocked? |
| restartPoint | -> | restart_point |
| supportData | -> | data |
| answers | [RW] | The hash of answers completed so far |
| blocked | [R] | Returns true if a question is currently blocking further progress |
| currentStepIndex | [RW] | The index of the current step |
| finalizer | [RW] | The block to be called when the questionnaire has completed all of its steps. Should be either a Proc or a Method object. |
| inProgress | [R] | Returns true if the session is in progress (ie., has been started, but is not yet finished). |
| loaded | [R] | |
| name | [R] | The name of the questionnaire |
| path | [R] | |
| restartPoint | [R] | Continuation object that can be called to resume a blocked questionnaire. |
| result | [R] | The return value from the finalizer after the questionnaire is finished |
| steps | [RW] | The steps of the questionnaire |
| supportData | [RW] | Ancillary support data Hash that can be used to pass objects to validators or the finalizer. |
Load a questionnaire object from a file, searching in the class‘s path.
# File lib/mues/filters/questionnaire.rb, line 176 def self::load( name, &block ) unless @loaded.key?( name ) fn = name.dup fn << ".rb" unless /\.rb$/ =~ fn file = self.path. collect {|dir| File::join( dir, fn )}. find {|path| File::file?( path )} or raise LoadError, "Could not find a Questionnaire named #{name}" src = File::read( file ).untaint questions = eval( src, nil, file, 1 ) @loaded[name] = new( name, *questions ) end qn = @loaded[name].dup qn.finalizer = block if block return qn end
Create a new Questionnaire object.
# File lib/mues/filters/questionnaire.rb, line 204 def initialize( name, *steps ) @name = name @steps = checkSteps( *(steps.flatten) ) @stepsMutex = Sync::new @currentStepIndex = -1 # Grab the block given as a Proc if there is one. @finalizer = if block_given? then Proc::new else nil end @delayedOutputEvents = [] @delayedOutputEventMutex = Sync::new @answers = {} @result = nil @supportData = {} @inProgress = false @blocked = false @restartPoint = nil super( DefaultSortPosition ) end
A factory method that builds a simple one-step confirmation questionnaire, which can be used to confirm dangerous operations, or really any situation where the user only needs to answer one prompt. The answer to the prompt will be set in the :confirm key of the answers hash, or is available via the singleton method answer. If it was a prompt for which a confirming answer begins with a ‘y’ (eg., ‘y’, ‘yes’, ‘Yes’, etc.), the singleton method confirmed? is also provided, which will return true if the user‘s answer matched that criteria.
prompt = "Remove %s: Are you sure?" % user.to_s
confirm = MUES::Questionnaire::Confirmation( prompt ) {|qnaire|
if qnaire.confirmed?
MUES::ServerFunctions::unregisterUser( user )
qnaire.message "Done.\n\n"
else
qnaire.error "Aborted.\n\n"
end
}
# File lib/mues/filters/questionnaire.rb, line 932 def self.Confirmation( prompt="Are you sure? [yN] ", default="n" ) # Build the prompt step step = { :name => 'confirm', :question => prompt, :validator => /^[yn]/i, :default => default, :errorMsg => "Please answer y or n.\n\n" } # Get the block, if given, and create the questionnaire object. block = if block_given? then Proc::new else nil end qnaire = MUES::Questionnaire::new( "Confirmation Dialog", step, &block ) # Add the shortcut singleton method to the questionnaire object. def qnaire.answer self.answers[:confirm] end # Add an even shorter shortcut singleton method. def qnaire.confirmed? self.answer =~ /^y/i ? true : false end return qnaire end
Abort the current session with the specified message. This is a method designed to be used by callback-type answerspecs and pre- and post-processes.
# File lib/mues/filters/questionnaire.rb, line 564 def abort( message="Aborted.\n\n" ) debugMsg 2, "Aborting questionnaire: %s" % self.muesid self.queueOutputEvents MUES::OutputEvent::new( message ) self.clear self.finish end
Add one or more steps to the end of the questionnaire. Note that if the questionnaire is in progress when doing this, any answers already given will be cleared, progress reset to step 1, and the first question asked again.
# File lib/mues/filters/questionnaire.rb, line 406 def addSteps( *steps ) newSteps = checkSteps( *steps ) debugMsg 2, "Adding %d new step/s: [%s]" % [ newSteps.length, newSteps.collect{|s| s[:name]}.join(', ') ] @stepsMutex.synchronize( Sync::SH ) { @stepsMutex.synchronize( Sync::EX ) { @steps += newSteps } if self.inProgress? self.clear self.askNextQuestion end } end
Reset the questionnaire, discarding answers given up to this point.
# File lib/mues/filters/questionnaire.rb, line 543 def clear debugMsg 1, "Clearing current progress" @stepsMutex.synchronize( Sync::EX ) { @answers = {} @currentStepIndex = -1 @inProgress = false @isFinished = false } end
Get a reference to the current step. Returns nil if the questionnaire has not yet been started.
# File lib/mues/filters/questionnaire.rb, line 453 def currentStep @stepsMutex.synchronize( Sync::SH ) { return nil unless @currentStepIndex >= 0 @steps[ @currentStepIndex ] } end
Mark the questionnaire as finished and prep it for shutdown
# File lib/mues/filters/questionnaire.rb, line 341 def finish debugMsg 1, "Finishing %s questionnaire %s" % [ self.name, self.muesid ] @inProgress = false @finalizer = nil super() debugMsg 2, "@isFinished = %s" % @isFinished.inspect end
Process the specified InputEvents as answers to the unfinished steps.
# File lib/mues/filters/questionnaire.rb, line 355 def handleInputEvents( *events ) if self.inProgress? # Interate until we run out of events to process or steps to feed # answers to until events.empty? || self.blocked? || ! self.inProgress? self.addAnswer( events.shift.data ) end # Drop events if the questionnaire is blocking events.clear if self.blocked? && self.inProgress # If all the steps have been completed, call the finalizer. unless self.inProgress? || self.finished? debugMsg 2, "Last step answered. Calling finalizer." @result = @finalizer.call( self ) if @finalizer debugMsg 3, "Finalizer returned <%s>" % @result.inspect events << result if result.is_a?( MUES::IOEvent ) || result.is_a?( MUES::IOEventFilter ) self.finish end end return super( *events ) rescue ::Exception => err self.error "Internal questionnaire error: %s at %s from %s\n" % [ err.message, *(err.backtrace[0..1]) ] self.abort end
Buffer the specified OutputEvents until the questionnaire is finished or aborted, but send the ones that have been queued internally.
# File lib/mues/filters/questionnaire.rb, line 389 def handleOutputEvents( *events ) debugMsg 5, "Called in thread %s via %s" % [ Thread.current.inspect, caller(1).inspect ] self.queueDelayedOutputEvents( *events ) unless events.empty? events.clear results = super( *events ) debugMsg 3, "Returning #{results.length} output events." return results end
# File lib/mues/filters/questionnaire.rb, line 233 def inspect "<Questionnaire 0x%x: '%s': (%d/%d steps), ans: %p, finalizer: %p>" % [ self.object_id * 2, @name, @currentStepIndex + 1, @steps.length, @answers, @finalizer, ] end
Send an output event with one or more messages to the user. Each message will have a newline appended to it if it doesn‘t already have at least one.
# File lib/mues/filters/questionnaire.rb, line 582 def message( *messages ) messages.each {|msg| msg << "\n" unless msg[-1] == "\n"} self.queueOutputEvents MUES::OutputEvent::new( messages.join("") ) end
Add non-blocked output events (such as from the filter itself) to the queue that will go on the next io loop.
# File lib/mues/filters/questionnaire.rb, line 463 def queueDelayedOutputEvents( *events ) checkEachType( events, MUES::OutputEvent ) debugMsg 3, "Queueing %d output events for later" % events.length @delayedOutputEventMutex.synchronize( Sync::EX ) { @delayedOutputEvents.push( *events ) } return @delayedOutputEvents.size end
Remove one or more steps from the questionnaire. If no steps are specified, all steps are removed. Note that if the questionnaire is in progress when doing this, any answers already given will be cleared, progress reset to step 1, and the first question asked again.
# File lib/mues/filters/questionnaire.rb, line 429 def removeSteps( *steps ) @stepsMutex.synchronize( Sync::SH ) { if steps.empty? debugMsg 2, "Clearing current steps" @stepsMutex.synchronize( Sync::EX ) { @steps.clear } else debugMsg 2, "Removing %d steps" % (@steps & steps).length @stepsMutex.synchronize( Sync::EX ) { @steps -= steps } end if self.inProgress? self.clear self.askNextQuestion unless @steps.empty? end } end
Clear the questionnaire and ask the first question again.
# File lib/mues/filters/questionnaire.rb, line 555 def reset self.clear self.askNextQuestion end
If the Questionnaire is blocked, restart it and use the specified value as the result returned from the last answer.
# File lib/mues/filters/questionnaire.rb, line 590 def restart( value ) raise "Not blocked" unless self.blocked? raise "Blocked, but no continuation set yet" unless @restartPoint self.stopBlocking( value ) end
Skip the next count steps, and increment the step index by count. If a step has an :onSkip pair (the value of which must be an object which answers ‘call’, such as a Proc or a Method), it will be called as it is skipped with the Questionnaire as an argument, and its return value will be used as the value set in the step‘s answer. If the step doesn‘t have an :onSkip key, but does have a :default pair, its value will be used instead. If it has neither key, the answer will be set to ‘:skipped’. This method returns the new step index.
# File lib/mues/filters/questionnaire.rb, line 514 def skipSteps( count=1 ) debugMsg 2, "Skipping %d steps" % count @stepsMutex.synchronize( Sync::SH ) { raise "Cannot skip more steps than there are remaining uncompleted" if @currentStepIndex + count >= @steps.length count.times do ans = nil @currentStepIndex += 1 step = self.currentStep if step.key?( :onSkip ) ans = step[:onSkip].call( self ) elsif step.key?( :default ) ans = step[:default] else ans = :skipped end @answers[ step[:name].intern ] = ans end } return @currentStepIndex end
Start filter notifications for the specified stream.
# File lib/mues/filters/questionnaire.rb, line 313 def start( streamObject ) raise "Questionnaire cannot be started without "\ "at least one step" if @steps.empty? debugMsg 1, "Starting %s questionnaire %s" % [ self.name, self.muesid ] results = super( streamObject ) self.askNextQuestion return results end
Stop the filter notifications for the specified stream, returning any final events which should be dispatched on behalf of the filter.
# File lib/mues/filters/questionnaire.rb, line 328 def stop( streamObject ) debugMsg 1, "Stopping %s questionnaire %s" % [ self.name, self.muesid ] results = super( streamObject ) self.finish results.push( MUES::InputEvent::new('') ) return results end
Returns a human-readable String describing the object
# File lib/mues/filters/questionnaire.rb, line 287 def to_s if self.inProgress? step = @steps[ @currentStepIndex ] "%s Questionnaire: %s: %s (%d of %d steps)." % [ self.name, step[:name], step.key?(:question) ? step[:question] : step[:name].capitalize, @currentStepIndex + 1, @steps.length, ] else "%s Questionnaire: Not in progress (%d steps)." % [ self.name, @steps.length ] end end
Discard the last count answers given and decrement the step index by count. If a step has an :onUndo pair (the value of which must be an object which answers ‘call’, such as a Proc or a Method), it will be called before the step is undone with the Questionnaire as an argument. Returns the new step index.
# File lib/mues/filters/questionnaire.rb, line 480 def undoSteps( count=1 ) debugMsg 2, "Undoing %d steps" % count @stepsMutex.synchronize( Sync::SH ) { raise "Cannot undo more steps than are already complete" unless @currentStepIndex >= count @stepsMutex.synchronize( Sync::EX ) { count.times do @currentStepIndex -= 1 step = self.currentStep if step.key?( :onUndo ) step[:onUndo].call( self ) end @answers.delete( step[:name].intern ) end } } return @currentStepIndex end
Set the specified input data (a String) as the answer for the current step, if it validates.
# File lib/mues/filters/questionnaire.rb, line 673 def addAnswer( data ) checkType( data, ::String ) debugMsg 2, "Adding answer '%s' for step %d" % [ data, @currentStepIndex ] @stepsMutex.synchronize( Sync::SH ) { @restartPoint = nil step = self.currentStep result = self.validateAnswer( step, data ) # A blocking question returns a Continuation that is called to # unblock (but only from a Proc/Method validator -- see # #runProcValidator for the jump point) if ( result.is_a?(Continuation) ) @restartPoint = result # Any non-false result means to move to the next question elsif result self.answers[ step[:name].intern ] = result self.askNextQuestion or @inProgress = false # If the result was nil or false but the questionnaire's still # valid, reask the current question. elsif self.inProgress? self.reaskCurrentQuestion end } end
Insert the question for the next step into the stream.
# File lib/mues/filters/questionnaire.rb, line 603 def askNextQuestion event = nil @stepsMutex.synchronize( Sync::SH ) { @stepsMutex.synchronize( Sync::EX ) { @inProgress = true @currentStepIndex += 1 } debugMsg 2, "Asking question %d" % @currentStepIndex # If there's a next step, ask its question if (( step = self.currentStep )) # If the step has a question value, use it, otherwise use the # capitalized name. if step.key?( :question ) if step[:question].kind_of? MUES::OutputEvent event = step[:question].dup else if step[:question].respond_to? :call text = step[:question].call( self ) else text = step[:question].to_s end if step[ :hidden ] event = MUES::HiddenInputPromptEvent::new( text ) else event = MUES::PromptEvent::new( text ) end end else event = MUES::PromptEvent::new( step[:name].capitalize + ": " ) end debugMsg 4, "Question event is <%s>" % event.inspect # Otherwise, we're all out of questions else event = nil debugMsg 2, "Last step reached for %s questionnaire %s" % [ self.name, self.muesid ] end } if event self.queueOutputEvents( event ) return true else return false end end
Check each of the specified steps for validity.
# File lib/mues/filters/questionnaire.rb, line 892 def checkSteps( *steps ) checkEachResponse( steps, :[], :key? ) debugMsg 3, "Checking %d steps for sanity." % steps.length if steps.find {|step| !step.key?( :name )} raise ArgumentError, "Invalid step: doesn't have a name key." end return steps end
Handle empty input conditions
# File lib/mues/filters/questionnaire.rb, line 744 def handleEmptyInput( step ) if step.key?( :default ) result = step[:default] debugMsg 3, "Empty data with no validator -- using default '%s'" % result else debugMsg 2, "Empty data with no validator and no default -- aborting" self.abort result = nil end return result end
Insert the question for the current step
# File lib/mues/filters/questionnaire.rb, line 660 def reaskCurrentQuestion @stepsMutex.synchronize( Sync::SH ) { @stepsMutex.synchronize( Sync::EX ) { @currentStepIndex -= 1 unless @currentStepIndex < 0 } self.askNextQuestion } end
Run an Array step validator - Succeeds (and returns the data unmodified) if array.include? the data.
# File lib/mues/filters/questionnaire.rb, line 845 def runArrayValidator( array, data, step ) result = nil if data.empty? result = handleEmptyInput( step ) elsif array.include?( data ) result = data else self.error( step[:errorMsg] || "Invalid input. Must "\ "be one of:\n %s." % array.sort.join(',') ) result = nil end return result end
Run a Hash step validator - If the data is a key of the hash, succeed and use the corresponding value as the answer. Try both the string and the symbolified string when matching keys.
# File lib/mues/filters/questionnaire.rb, line 868 def runHashValidator( hash, data, step ) result = nil if data.empty? result = handleEmptyInput( step ) elsif hash.has_key?( data ) result = hash[ data ] elsif hash.has_key?( data.intern ) result = hash[ data.intern ] else self.error( step[:errorMsg] || "Invalid input. Must "\ "be one of:\n %s." % hash.keys.sort.join(',') ) result = nil end return result end
Run a Proc/Method step validator - Call the given obj (which can be anything that responds to call) with the specified data; if the return value is false, validation failed. If it‘s true, use the original data. Otherwise returns whatever the validator returns as-is.
# File lib/mues/filters/questionnaire.rb, line 798 def runProcValidator( obj, data, step ) result = obj.call( self, data.dup ) # Handle a blocking question by creating and returning a # Continuation instead of the actual result. if step[:blocking] # Block further progress and discard any events that come in self.startBlocking result = callcc {|cc| cc} end result = data if result.equal?( true ) result = nil if self.finished? return result end
Run a Regexp step validator - Succeed if the data matches the regexp; if the regexp has capturing paren-groups, returns the array of captures instead of the whole match.
# File lib/mues/filters/questionnaire.rb, line 821 def runRegexpValidator( regexp, data, step ) result = nil if data.empty? result = handleEmptyInput( step ) elsif (( match = regexp.match( data ) )) if match.size > 1 result = match.captures else result = data end else self.error( step[:errorMsg] || "Invalid input. Must "\ "match /#{regexp.to_s}/" ) result = nil end return result end
Use the specified step‘s validator (a Proc, a Method, a Regexp, an Array, or a Hash) to validate the given data. Returns the validated answer data on success, and false if it fails to validate.
# File lib/mues/filters/questionnaire.rb, line 761 def runStepValidator( data, step ) checkType( data, ::String ) validator = step[:validator] debugMsg 3, "Validating answer '%s' with a %s validator" % [ data, validator.class.name ] result = nil # Handle the various types of validators. case validator when Proc, Method result = self.runProcValidator( validator, data, step ) when Regexp result = self.runRegexpValidator( validator, data, step ) when Array result = self.runArrayValidator( validator, data, step ) when Hash result = self.runHashValidator( validator, data, step ) # Unhandled validator types else raise TypeError, "Invalid validator type '#{validator.class.name}'." end return result end
Start blocking on the current answer
# File lib/mues/filters/questionnaire.rb, line 729 def startBlocking self.stream.pause @blocked = true end
Stop blocking on the current answer
# File lib/mues/filters/questionnaire.rb, line 736 def stopBlocking( value ) @blocked = false self.stream.unpause { @restartPoint.call(value) } return true end
Figure out how to validate the answer for the given step and send it the specified data. Return the result of validation. If the validation failed, the returned result will be nil.
# File lib/mues/filters/questionnaire.rb, line 707 def validateAnswer( step, data ) result = nil # Validate the data if it has a validator. if step.key?( :validator ) result = self.runStepValidator( data, step ) debugMsg 3, "Validator returned result: %s" % result.inspect # Empty input with no validator elsif data.empty? result = handleEmptyInput( step ) else debugMsg 3, "No validator: Accepting answer as-is" result = data end return result end