require 'find'
ValSeparator='~'
MenuFolder='menus/'
TemplatesFolder = 'sentencetemplates/'
PicListFolder='piclists/'
# Filters added to this controller apply to all controllers in the application.
# Likewise, all the methods added will be available for all controllers.
class ApplicationController < ActionController::Base
helper :all # include all helpers, all the time
# See ActionController::RequestForgeryProtection for details
# Uncomment the :secret if you're not using the cookie session store
protect_from_forgery # :secret => '2d78e3a9f11c3fe227052e6c5e36141d'
# See ActionController::Base for details
# Uncomment this to filter the contents of submitted
# sensitive data parameters
# from your application log (in this case, all fields with
# names like "password").
# filter_parameter_logging :password
MaxRows=6
WordsDir='public/words/'
MaxWords=30
SentencesDir='public/words/sentences/'
Mchoices='public/multiplechoices'
LoginFolder='users/'
LineLen=150
require 'rubygems'
require 'hpricot'
#require 'festivaltts4r'
# Pick a unique cookie name to distinguish our session
# data from others'
session :session_key => '_table_session_id'
session :session_expires => 3.years.from_now
def modulecombos
{'fun'=>'nim+picbrowser+recognize+langselect+presentation',
'write text'=>
'abcd+words+nextword+modsentence+easypic'+
'+backspace+scroll+scrolldown',
'learn'=>'addcredit+oddmanout+howmany+aoran+quiz+choices+whatbefore',
# 'use text'=>'emailaddr+wikipedia+bulletin',
'picture editing'=>'stepsize+picedit+reflect+picbrowser'}
end
# path_from_login returns nil unless in LoginFolder
# file "#{user}.txt" the string pstarter is found.
# if pend is nil, all text in that line is returned, else
# all text upto pend is returned, split at comma
def path_from_login(user,pstarter,pend=nil)
begin
if user && str=IO.read("#{LoginFolder}#{user}.txt")
if pend
r= str=~ /#{pstarter}:(.*)#{pend}/ ? $1.split(/\s*,\s*/) : nil
else
r= str=~ /#{pstarter}:\s*(\S+)/ ? [$1] : nil
end
return r
end
nil
rescue
nil
end
end
def listed_controllers(user)
a=[]
begin
if user && str=IO.read("#{LoginFolder}#{user}.txt")
# logger.info "userfile #{str}"
a=str.scan(/\w+:/).collect{|x| x.gsub(/:/,'')}
end
rescue
end
a
end
def get_quiz_dir
if p=path_from_login(session[:login],'multiplechoices')
p.first
else
Mchoices
end
end
def get_sentences_dir
if p=path_from_login(session[:login],'sentences')
p.first
else
SentencesDir
end
end
def get_words_dir
if p=path_from_login(session[:login],'words')
p.first
else
WordsDir
end
end
def get_question_no
session[:question_no] ||= 0
end
def get_correct_answer
session[:correct_answer] || 'this is a mistake'
end
def get_correct
session[:correct] ||=0
end
def get_wrong
session[:wrong] ||= 0
end
#separator used to fit more than one choice per line
#if the separator is nowhere among the choices,
#reformat_choices tries to rearrange the choices
#using the separator evenly among the rows
def get_separator
','
end
#returns a string of keywords the user has picked
#used by wikinext to select wikipedia pages for
#next word prediction
def get_keywords
session[:keywords] || ''
end
# def get_criteria
# session[:criteria] || []
# end
def get_credit
session[:credit] ||= 5
end
# #passes_criteria returns true if all strings in
# #criteria array found in path
# def passes_criteria(path,criteria)
# criteria.each do |c|
# if !path.match(c)
# return nil
# end
# end
# p "#{path} passes #{criteria}"
# return true
# end
#gffp looks under public_path for files that
#pass criteria and match frx, returns $1's in array
def get_filtered_filename_part(searchpath,frx)
return_files=[]
Find.find(searchpath) do |path|
if FileTest.directory?(path)
if (File.basename(path)[0] == ?.)
Find.prune # Don't look any further into
end #this directory.
elsif path.match(frx)
found=$1
if found
return_files << found
end
end
end
# p "found #{return_files}"
return_files.sort
end
#find words in filename that match rx
def matching_words_from_file(filename, rx)
str =""
str=IO.read(filename)
str.strip.scan(/[\w']+/).select{|w| w.match(rx)}
end
#get_matching_words returns an array
#of words in txt files
#under root_dir that
#match rx,alphabetically sorted
def get_matching_words (root_dir, rx)
rxfile=Regexp.new(
'([\w,/, ]*\.txt)$',Regexp::IGNORECASE)
# full path of txt files
@files=get_filtered_filename_part(root_dir,rxfile)
# logger.info "#{root_dir}~~#{@files.inspect}"
@words=[]
@files.each do |f|
@words += matching_words_from_file(f,rx)
end
@words.collect{|w| w.strip}.uniq.sort
end
def random_word
random_pics(get_folder,1).first
# first_chars=('a'..'z').to_a.shuffle
# logger.info "randomized alphabet #{first_chars}"
# begin
# fc=first_chars.pop
# rx=/^#{fc}/
# logger.info "random first char #{fc}"
# unless (w=get_matching_words(get_words_dir,rx)).empty?
# logger.info "words of random letter #{w}"
# return w[rand w.size]
# end
# end until first_chars.empty?
# ''
end
#get_matching_sentences returns sentences
#in root_dir folder matching rx
def get_matching_sentences (root_dir,rx)
files=get_filtered_filename_part(root_dir,
'(.+txt)$')
sentences=[]
logger.info "sentence files #{files}"
files.each do |fs|
# p("searching file #{fs}")
f=open(fs)
f.each do |s|
# p("searching sentence #{s}")
if s.match rx
sentences << s.strip
end
end
end
sentences
end
#returns words that start sentences or appear
#after a comma or semicolon in root_dir
def get_starting_words(root_dir)
rx=/\w/
ac=get_matching_sentences(root_dir,rx)
nw=ac.collect do |se|
if se =~ /^\s*([A-Z,a-z]\w*)/
$1
end
end
nw.uniq.sort{|a,b| a.length <=> b.length}
end
#get_textfile_list returns a filtered list of
#.txt files in the subfolders of containg_folder
def get_textfile_list (containg_folder)
rx=Regexp.new(containg_folder+
'/(.*)\.txt$', Regexp::IGNORECASE)
g=get_filtered_filename_part(containg_folder,rx)
g
end
def full_file_name (folder,partname)
rx=Regexp.new('(.*'+folder+
'/'+partname+'\..*)',
Regexp::IGNORECASE)
get_filtered_filename_part(folder,rx)[0]
end
#get_page returns nth page as array of lines
#blank line is page separator
def get_page(folder,file,n)
if f=full_file_name(folder,file)
str=IO.read(f)
# logger.info "entire file #{f}: #{str}"
pages=str.split(/\s*\n\s*\n/)
if n < pages.size
pages[n].split(/\s*\n/)
else
[]
end
else
[]
end
end
def get_presentation
session[:presentation] || ''
end
#stepsize is used in picture editing
def get_stepsize
session[:stepsize] ||= 10
end
def get_user
session[:login] ||= 'default'
end
#get_folder_list returns array of non-empty image folders
def get_folder_list
u= get_user
if p=path_from_login(u,selfname,'.')
return p.reject {|f| get_pic(f,0).empty?}
end
g=Giver.new
folders=g.get_subdirs("images")
# logger.info "image folders #{folders}"
folders.reject {|f| get_pic(f,0).empty?}
end
#current folder for picbrowser, recognize,..
def get_folder_index
session[:folder_index] ||= 0
end
#get_folder returns i'th folder, but if no i provided, a random one
def get_folder(i=nil)
i=rand get_folder_list.size unless i
fl=get_folder_list
fl.empty? ? '': fl[i% fl.size]
end
#sibling_pics returns names of all files in same folder as pic
def sibling_pics pic
g = Giver.new
rx=Regexp.new('images/(.*)/'+"#{pic}"+
'\.(JPEG|JPG|GIF|PNG)$',
Regexp::IGNORECASE)
folder=g.get_filename_part('images/',rx).first
logger.info "sibling folder #{folder}"
if folder
rx=Regexp.new('images/'+folder+
'/(.*)\.(JPEG|JPG|GIF|PNG)$',
Regexp::IGNORECASE)
words=g.get_filename_part('images/'+ folder ,rx)
else
[]
end
end
#random_pics returns n picture names at random
#from the desired folder
def random_pics(folder, n)
# logger.info "random picture folder #{folder}"
g = Giver.new
rx=Regexp.new('images/'+folder+
'/(.*)\.(JPEG|JPG|GIF|PNG)$',
Regexp::IGNORECASE)
words=g.get_filename_part('images/'+ folder ,rx)
selwords=[]
while (selwords.size < n) && !words.empty?
selwords.push(words.
delete_at(rand(words.size)))
end
selwords
end
#get_pic returns normalized i'th picture
#from desired folder
def get_pic(folder, i)
g ||= Giver.new
rx=Regexp.new(
'images/(.*(JPEG|JPG|GIF|PNG))$',
Regexp::IGNORECASE)
pictures=g.get_filename_part('images/'+ folder ,rx)
if pictures.empty?
""
else
pic = pictures[i% pictures.size] || ''
# logger.info("giving picture #{i} from "+folder+':
#' +pic
#)
pic
end
end
def pic_folder
"images"
end
# get_pic_file returns a filename suitable for
#passing to image_tag if what is in name
# is the basename of an image file in the
#subfolders of public/images
def get_pic_file(name)
unless name && !name.empty?
return ''
end
g=Giver.new
pf=g.get_filename_part(pic_folder,
Regexp.new('images/(.*/'+name+'\.(JPEG|JPG|GIF|PNG))$',
Regexp::IGNORECASE))
# logger.info "getting pic #{pf.inspect} for #{name}"
if pf.empty?
''
else
pf.first
end
end
# get_pic_from_folder returns filename suitable for
#passing to image_tag if what is in name
# is the basename of an image file in the
#subfolders of public/images/folder
def get_pic_from_folder(name,folder)
g=Giver.new
pf=g.get_filename_part("images/#{folder}",
Regexp.new('images/(.*/'+name+'\.(JPEG|JPG|GIF|PNG))$',
Regexp::IGNORECASE))
if pf.empty?
''
else
pf.first
end
end
#~
def format_pic (pic)
' '
end
#get_pic_html returns the html for picname if
#such a pic exists, else empty string
def get_pic_html(picname)
# p=get_pic_file picname
if p.empty?
p
else
format_pic p
end
end
#get_rxpics returns an array of pictures
#whose names match rx
def get_rxpics(folder, rx)
g ||= Giver.new
pictures=g.get_filename_part('images/'+ folder ,rx)
end
#one choice is shown in selected_font
#get_highlighted_choice returns it
def get_highlighted_choice
# logger.info "highlighted choice #{@selected_item} of #{@choices.inspect}"
if @choices.empty?
''
else
@choices[@selected_item% get_choices_size]
end
end
#selfname returns name of current_controller
def selfname
self.class.name.
gsub(/Controller/,'').downcase
end
#make_clickable returns unchanged anything
#containing an anchor. otherwise encloses tch
#in an anchor link. Displayed is num if it exists
#else tch with space replaced by - unless nowords set
def make_clickable(tch, num=nil)
if tch.match(/' +
tch +''
# logger.info "show #{show}"
end
end
#max_choices_in_line is used in an attempt to evenly
#divide the choices among several lines
def max_choices_in_line
Math.sqrt(@choices.size).floor+1
end
#line_full provides the
#criterion used to determine that no more choices
#should be added to this line
def line_full ln
Hpricot(ln).inner_text.length > LineLen
end
#reformat_choices reformats @choices and
#makes each into a link
def reformat_choices
if !@choices || @choices.empty?
return
end
# logger.info "reformat choices #{
# @choices.each {|c| c.inspect}}"
if (@choices.size<=MaxRows) or
(@choices.join.match(get_separator))
@choices.collect! do |cl|
ca=cl ? cl.split(get_separator) :[]
ca.collect!{|c| make_clickable c}
s=ca.join(get_separator)
# logger.info "reformatted choices #{ca}"
s
end
return
end
@lines=[]
lcount=0
# session[:selected_item]=@selected_item=0
#max_choices_in_line=Math.sqrt(@choices.size).floor+1
@choices=@choices.reverse
(0..MaxRows).each do |row|
@lines[row]=''
(0..max_choices_in_line).each do |item|
tch=@choices.pop
@lines[row] << make_clickable(tch) <<
get_separator
# if (@lines[row].length > LineLen) or
if line_full(@lines[row]) or
@choices.empty?
break
end
end
if @choices.empty?
break
end
end
@choices=
@lines.collect do |l|
l.strip.chop
# removes separator at the end
end
end
#gets the last word from str
def last_word(str)
if str && str.strip.match('(\w+)$')
$1
else
''
end
end
#updownevent is overridden by modules that need some
#processing each time up or down is pressed
def updownevent
end
#default action when up is selected
def up
session[:selected_item]=get_selected_item-1
updownevent
what_next
end
#default action when down is selected
def down
session[:selected_item]=get_selected_item+1
updownevent
what_next
end
#get_and_reset_typed helps modules contribute
#text via session[:typed] which this function resets
# def get_and_reset_typed
# t = session[:typed]
# session[:typed]=nil
# t
# end
#get_modules returns corr session ensuring an
#item in it is 'modules'
def get_modules
if sm=modulecombos[session[:modules]]
ma=(sm.split('+') << "modules").uniq
else
ma=['modules']
end
h={}
ma.each{|s| h[s] = translate(s)}
h
end
def get_selected_item
session[:selected_item] ||=0
end
# def selector
# '|'
# end
#start_choices are defined differently by each
#module
def start_choices
%w{should be overridden}
end
#filechoices? is true for static data, the kind
#that can be stored in files and locally modified
def filechoices?
true
end
#get_choices is a getter for session[:choices]
#if that is nil it consults filechoices to either
#pick up choices from the appropriate file or
#invokes start_choices
def get_choices
session[:choices] ||=
if filechoices?
if @thiscontroller
fd=MenuFolder.chop
Dir.mkdir(fd) unless File.directory?(fd)
fch=MenuFolder+@thiscontroller+'choices.txt'
if File.exist?(fch)
#logger.info "reading #{fch}"
chs= IO.readlines(fch)
chs.collect!{|s|s.strip}
else
chs=start_choices
File.open(fch,"w") do |f|
f.puts chs
end
end
chs
else
start_choices
end
else
start_choices
end
end
def get_overflow
session[:overflow] ||=''
end
#get_match returns the matching portion between
#string s and regular expr rx if any
def get_match (s, rx)
begin
s.match(rx)[0]
rescue
''
end
end
#get_text is a getter that also processes any
#contributions from other modules via session[:typed]
def get_text
session[:text] ||= "welcome to skid"
# g=get_and_reset_typed
# if g
# session[:text]+=" #{g} "
# end
# session[:text]
end
#what_next enables you,
#after selection, to go someplace else
# by setting session[:controller]
def what_next(next_action='index')
if session[:controller]
s=session[:controller]
session[:controller]=nil
redirect_to( :action => "first_time",
:controller => s) and return
else
redirect_to :action => next_action
end
end
# selected decides what action should be taken
#when str is selected --
#will always be overridden
def selected(str)
end
# get_choices_size is used to
#normalize selected_item,
# so 0 is not ok
def get_choices_size
@choices = get_choices
@choices.size ==0? 1: @choices.size
end
#strip_href returns the displayed portion
#of a link
def strip_href(str)
str.match(%r{>(.+)}) ? $1.strip : str
end
# process_choice decides what to do when a choice
#is made: splits it if it contains a separator
#else calls selected
def process_choice selected_choice
# logger.info "selected_choice#{selected_choice}" +
# "get_separator #{get_separator}"
if (selected_choice.include?(get_separator) and
(selected_choice.length>1))
session[:choices]=selected_choice.split(get_separator)
# logger.info "separated #{session[:choices].inspect}"
else
str=strip_href(selected_choice)
#logger.info("selected: #{str}")
session[:choices]=selected(str)
# logger.info "selected returned #{sel.inspect}"
session[:selected_item]=0
end
end
#select is activated when the smiley is clicked
#if the id is a _number, that choice is taken
#if the id is a string, it is selected
def select
if params[:id]
if params[:id]=~/_(\d+)/
i=$1.to_i
session[:selected_item]=i
# process_choice(get_choices[i])
# logger.info "sid: #{params[:id]}"
else
session[:choices]=selected(params[:id].gsub(/-/,' '))
# logger.info "selected choices #{session[:choices].inspect}"
what_next
# logger.info "show #{session[:displayed]}"
return
end
end
@choices=get_choices
reformat_choices
@selected_item= get_selected_item
# logger.info "#{@selected_item} from choices #{
# @choices.inspect}"
if !@choices.empty?
selected_choice=get_highlighted_choice
process_choice selected_choice
else
session[:choices]=selected nil
end
what_next
end
def get_displayed
session[:displayed] || ""
end
def up_icon
%s{ }
end
def down_icon
# "v"
%s{}
end
def select_icon
# ">"
%s{ }
end
def backspace_icon
# "<"
%s{ }
end
def get_title
"Welcome to skid"
end
# extras is for module-specific additions to index
def extras
end
# index is only defined at the application level
# session garbage collection
def clear_unused_in_session
session[:choices]=nil
session[:user_choice]=nil
session[:computer_choice]=nil
session[:sticks]=nil
# session[:displayed]=nil
session[:picked]=nil
session[:toggle]=nil
session[:display]=nil
session[:pictures]=nil
session[:wrong]=nil
session[:freeze]=nil
session[:answer]=nil
session[:question]=nil
session[:correct_answer]=nil
session[:state]=nil
session[:correct]=nil
session[:picfolder]=nil
session[:oddfolder]=nil
session[:separator]=nil
session[:problem]=nil
session[:n1]=session[:n2]=nil
# session[:nitems]=nil
end
def get_picfolder
session[:picfolder] ||= get_folder(get_folder_index)
end
def get_nitems
session[:nitems] ||= 4
end
def get_buttons
{"up" => up_icon, "down" => down_icon,
"backspace" => backspace_icon,
"select" => select_icon}
end
def get_delay
session[:delay] ||= 5
end
#index is where everything must end
#for it is the only view
def get_autorefresh
session[:auto_refresh] ||= false
end
def increment_selected_item
session[:selected_item]=get_selected_item+1
end
def index
# logger.info "myid #{params[:id]}"
if params[:id]=~ /showsettings/
session[:hidesettings]=nil
end
if params[:id]=~ /hidesettings/
session[:hidesettings]=true
end
@showsettings=!session[:hidesettings]
if params[:id]=~ /resetsession/
reset_session
end
if get_autorefresh
increment_selected_item
end
@settings={}
if params[:id]=~ /_refresh/
session[:selected_item] ||= 0
session[:selected_item]+=1
session[:auto_refresh]=true
updownevent
end
if params[:id]=~ /stopscroll/
session[:auto_refresh]=false
end
if params[:id]=~ /nobuttons/
session[:nobuttons]=true
@settings['with buttons']='buttons'
end
if params[:id]=~ /withbuttons/
session[:nobuttons]=nil
end
if params[:id]=~ /nomodules/
session[:nomodules]=true
end
if params[:id]=~ /withmodules/
session[:nomodules]=nil
end
if params[:id]=~ /delay/
delay=params[:id].sub('delay','').to_i
if delay > 0
session[:delay]=delay
end
end
if params[:id]=~ /nopics/
session[:nopics]=true
end
if params[:id]=~ /withpics/
session[:nopics]=nil
end
if params[:id]=~ /nowords/
session[:nowords]=true
end
if params[:id]=~ /withwords/
session[:nowords]=nil
end
myname=self.class.name
if !session[:current_controller] || (myname!=session[:current_controller])
session[:current_controller]=myname
clear_unused_in_session
what_next("first_time")
end
@refresh= get_autorefresh
@delay=get_delay
@with_words = !session[:nowords]
if @refresh
@settings['manual scrolling']='stopscroll'
else
@settings['automatic scrolling']='auto_refresh'
end
if session[:nobuttons]
@settings['with buttons']='withbuttons'
else
@settings['without buttons']='nobuttons'
end
if session[:nomodules]
@settings['with modules']='withmodules'
else
@settings['without modules']='nomodules'
end
if session[:nopics]
@settings['with pictures in choices']='withpics'
else
@settings['without pictures in choices']='nopics'
end
if session[:nowords]
@settings['with words in choices']='withwords'
else
@settings['without words in choices']='nowords'
end
@thiscontroller=myname.
gsub(/Controller/,'').downcase
@modules={}
if !session[:nomodules]
@modules=get_modules
# logger.info "modules with display names #{@modules}"
#used for the module table on top
end
@title=get_title #the title top right
if session[:nobuttons]
@buttons={}
else
@buttons=get_buttons
end
@choices=get_choices || []
@selected_item=get_selected_item
if !@choices.empty?
reformat_choices
@selected_item%=@choices.size
end
extras
@spoken= session[:spoken]
session[:spoken]=nil
@choices.each_index do |i|
@choices[i]=make_clickable(@choices[i],i)
end
@pictures=@choices.collect do|cho|
get_pic_file(strip_href(cho))
end unless session[:nopics]
@extracols=@modules.size
if session[:nopics] then @extracols-=1 end
if @extracols< 1 then @extracols=1 end
@designers=designers
@highlight_selected = highlight_selected_item
@choicepicsize= if p=path_from_login(session[:login],'choicepicsize')
p.first
else
"80x80"
end
logger.info "session at index_end: #{session.inspect}"
end
# if highlight_selected_item is true, a choice is displayed inverted,
# which can be selected by clicking the smiley
def highlight_selected_item
get_autorefresh
end
#if the module does not define an action for
#backspace, it restarts the module
def backspace
first_time
end
#first_time will often be overridden
def first_time
what_next
end
###################################################################
#translator function for english sentences to french or german
# def translate(engSentence)
# @lang=session[:language] ||= "English"
# @search=engSentence
# allsentences=Sentencecollection.find(:all)
# @totranslate=allsentences.select do |x|
# x[:sentence]==(@search)
# end
# return engSentence if @totranslate.empty?
# logger.info "matchingsentence #{@totranslate.first[:sentence]}"
# @recNo=@totranslate.first[:id]
# logger.info "testing #{@recNo}"
# @translated=allsentences.select do |y|
# y[:transOfWhat]==(@recNo)&&y[:language].match(@lang)
# end
# logger.info "matching #{@recNo}: #{@translated}"
# @check = @translated.first[:sentence]
# logger.info "#{@check}"
# @translated.empty? ? engSentence : @translated.first[:sentence]
# end
LanguagesFolder='languages/'
SourceFile='source'
def translate(engSentence)
lang=session[:language] ||= "English"
source_list=IO.readlines("#{LanguagesFolder}#{SourceFile}")
# logger.info source_list.to_s
if source_list &&
i=source_list.find_index{|x| x.strip == engSentence}
then
# logger.info "found.match #{i}"
translated_string=IO.readlines("#{LanguagesFolder}#{lang}.txt")[i]
if translated_string && !translated_string.strip.empty?
return translated_string
end
end
engSentence
#rescue
# engSentence
end
######################################################################
def designers
nil
end
#
def get_para(lw, item_no)
displayed=''
if !lw.empty?
begin
page=Wikipedia.new.page(lw)
logger.info("wikipage for #{lw}:")
logger.info(page)
doc =Hpricot(page)
bc=doc.search('#bodyContent')
ps = bc/:p
rescue Exception
logger.info "pageerror: #{$!}"
logger.info "page #{page}"
ps=[]
@title='no wikipedia access'
end
if ps.empty?
displayed='nothing found'
elsif ps.size==1
paras=[ps.html]
lis=bc/:li
lis.each { |i| paras << i.html}
item_no%=paras.size
displayed = paras[item_no]
else
ps.reject! {|pa| pa.inner_text !~ /\w/}
# @paras.delete_at(0)
# logger.info('wikipedia paras'+@paras.inspect)
item_no%=ps.size
displayed = ps[item_no].inner_html
end
displayed.gsub!('/wiki/','/wikipedia/clicked/')
end
displayed
end
def teacherlog s
n= Time.now
logger.info "For teacher: #{s} via: #{selfname} user: #{
session[:login] || 'default'} at #{n}/#{n.to_i}"
end
def lognewproblem(s, answer)
teacherlog "new problem #{s}, answer #{answer}"
end
def logcorrect(correct_answer,problem)
teacherlog "correct: #{correct_answer} to: #{problem}"
end
def logwrong(wrong_answer,problem)
teacherlog "wrong: #{wrong_answer} to: #{problem}"
end
def client_ip
s=request.remote_ip
s
end
def sendmail (recipient,subject=
'mail from skid',message=session[:text])
message=session[:text] unless message
Sender.deliver_contact(recipient, subject, message)
return if request.xhr?
logger.info "Message #{message} sent successfully to #{recipient}"
end
end