From 75a257050c58e41e37a5b862f9d7d6749fe2a690 Mon Sep 17 00:00:00 2001 From: Joseph Pearson Date: Tue, 26 Feb 2008 11:33:45 +1100 Subject: [PATCH] Castanaut 1.0.0 release. --- Copyright.txt | 29 ++ History.txt | 3 + Manifest.txt | 31 ++ README.txt | 161 ++++++++++ Rakefile | 25 ++ bin/castanaut | 7 + cbin/osxautomation | Bin 0 -> 39344 bytes lib/castanaut.rb | 64 ++++ lib/castanaut/exceptions.rb | 32 ++ lib/castanaut/keys.rb | 43 +++ lib/castanaut/main.rb | 20 ++ lib/castanaut/movie.rb | 353 +++++++++++++++++++++ lib/castanaut/plugin.rb | 26 ++ lib/plugins/ishowu.rb | 94 ++++++ lib/plugins/mousepose.rb | 38 +++ lib/plugins/safari.rb | 124 ++++++++ scripts/coords.js | 48 +++ scripts/gebys.js | 612 ++++++++++++++++++++++++++++++++++++ spec/castanaut_spec.rb | 9 + spec/spec_helper.rb | 18 ++ tasks/ann.rake | 77 +++++ tasks/annotations.rake | 22 ++ tasks/doc.rake | 49 +++ tasks/gem.rake | 110 +++++++ tasks/manifest.rake | 50 +++ tasks/post_load.rake | 18 ++ tasks/rubyforge.rake | 57 ++++ tasks/setup.rb | 221 +++++++++++++ tasks/spec.rake | 43 +++ tasks/svn.rake | 44 +++ 30 files changed, 2428 insertions(+) create mode 100644 Copyright.txt create mode 100644 History.txt create mode 100644 Manifest.txt create mode 100644 README.txt create mode 100644 Rakefile create mode 100755 bin/castanaut create mode 100755 cbin/osxautomation create mode 100644 lib/castanaut.rb create mode 100644 lib/castanaut/exceptions.rb create mode 100644 lib/castanaut/keys.rb create mode 100644 lib/castanaut/main.rb create mode 100644 lib/castanaut/movie.rb create mode 100644 lib/castanaut/plugin.rb create mode 100644 lib/plugins/ishowu.rb create mode 100644 lib/plugins/mousepose.rb create mode 100644 lib/plugins/safari.rb create mode 100644 scripts/coords.js create mode 100644 scripts/gebys.js create mode 100644 spec/castanaut_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 tasks/ann.rake create mode 100644 tasks/annotations.rake create mode 100644 tasks/doc.rake create mode 100644 tasks/gem.rake create mode 100644 tasks/manifest.rake create mode 100644 tasks/post_load.rake create mode 100644 tasks/rubyforge.rake create mode 100644 tasks/setup.rb create mode 100644 tasks/spec.rake create mode 100644 tasks/svn.rake diff --git a/Copyright.txt b/Copyright.txt new file mode 100644 index 0000000..4d0029a --- /dev/null +++ b/Copyright.txt @@ -0,0 +1,29 @@ +== Castanaut + +Copyright (C) 2008 Inventive Labs. + +This program is free software. It comes without any warranty, to +the extent permitted by applicable law. You can redistribute it +and/or modify it under the terms of the Do What The Fuck You Want +To Public License, Version 2, as published by Sam Hocevar. See +http://sam.zoy.org/wtfpl/COPYING for more details. + +=== DomQuery + +The DomQuery implementation is included from the Ext JS distribution, which +uses the MIT license requiring the following copyright and permission +notices. These pertain only to the script/gebys.js file. + +Copyright (c) 2006-2007 Ext JS, LLC. + +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. diff --git a/History.txt b/History.txt new file mode 100644 index 0000000..29117d5 --- /dev/null +++ b/History.txt @@ -0,0 +1,3 @@ +== 1.0.0 / 2008-02-21 + +* Initial release. diff --git a/Manifest.txt b/Manifest.txt new file mode 100644 index 0000000..ce45a63 --- /dev/null +++ b/Manifest.txt @@ -0,0 +1,31 @@ +Copyright.txt +History.txt +Manifest.txt +README.txt +Rakefile +bin/castanaut +cbin/osxautomation +lib/castanaut.rb +lib/castanaut/exceptions.rb +lib/castanaut/keys.rb +lib/castanaut/main.rb +lib/castanaut/movie.rb +lib/castanaut/plugin.rb +lib/plugins/ishowu.rb +lib/plugins/mousepose.rb +lib/plugins/safari.rb +scripts/coords.js +scripts/gebys.js +tasks/ann.rake +tasks/annotations.rake +tasks/doc.rake +tasks/gem.rake +tasks/manifest.rake +tasks/post_load.rake +tasks/rubyforge.rake +tasks/setup.rb +tasks/spec.rake +tasks/svn.rake +tasks/test.rake +test/example_script.screenplay +test/googling.screenplay diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..a115848 --- /dev/null +++ b/README.txt @@ -0,0 +1,161 @@ += Castanaut: Automate your screencasts. + + Author: Joseph Pearson + http://gadgets.inventivelabs.com.au/castanaut + +== DESCRIPTION: + +Castanaut lets you write executable scripts for your screencasts. With a +simple dictionary of stage directions, you can create complex interactions +with a variety of applications. Currently, and for the foreseeable future, +Castanaut supports Mac OS X 10.5 only. + +== SYNOPSIS: + +=== Writing screenplays + +You write your screenplays as Ruby files. Castanaut has been designed to +read fairly naturally to the non-technical, within Ruby's constraints. + +Here's a simple screenplay: + + launch "Safari", at(10, 10, 800, 600) + type "http://www.inventivelabs.com.au" + hit Enter + pause 2 + move to(100, 100) + move to(200, 100) + move to(200, 200) + move to(100, 200) + move to(100, 100) + say "I drew a square!" + +With any luck we don't need to explain to you what this screenplay +does. The only thing that might need some explanation is "say" -- this has a +robotic voice speak the given string. (Also: all numbers are pixel +co-ordinates). + +About the robot: no, we don't recommend you use this in real screencasts for +a large audience. Most people find it a little offputting. +You are free to contravene our recommendation though. You +can tweak the robot in the Mac OS X Speech Preferences pane. + +=== Running your screenplay + +Simply give the screenplay to the castanaut command, like this: + + castanaut test.screenplay + +This assumes you have a screenplay file called "test.screenplay" in the +directory where you are running the command. + +Of course, it isn't always convenient to drop to the terminal to run your +screenplay. So there's also a method of executing your screenplays directly. +You need to add this line (the "shebang" line) at the top of your screenplay: + +#!/usr/bin/env castanaut + +Then you need to set the screenplay to be executable by running this command +on it: + + chmod a+x test.screenplay + +Again, substitute "test.screenplay" for your screenplay's filename. + +At this point, you should be able to double-click the screenplay, or invoke +it with Quicksilver, or run it any other way that floats your boat. + +=== Stopping the screenplay + +If you want to abruptly terminate execution before the end of the screenplay, +you just need to run the 'castanaut' command again -- with or without any +arguments. + +Of course, that might be easier said than done, if you haven't got full +control of the mouse or keyboard at the time. One recommendation is to assign +a system hot-key to invoke castanaut. I use a Quicksilver trigger for this, +assigned to Shift-F1, that calls castanaut. You'll need the full path to +the command for this, which is usually /usr/bin/castanaut, but you can check +it with the following command: + + which "castanaut" + + +=== What stage directions can I make? + +Out of the box, Castanaut performs mouse actions, keyboard actions, +robot speech and application launches. + +For a complete overview of the built-in stage directions, see the +Castanaut::Movie class. + +=== Using plugins + +Of course, just using the built-in stage directions is a little bit awkward +and verbose. Plugins allow you to extend the available dictionary with +some additional convenience actions. Typically a plugin is specific to an +application. + +Castanaut comes with several plugins, including Castanaut::Plugin::Safari for +interacting with the contents of web-pages, and Castanaut::Plugin::Ishowu for +recording screencasts using the iShowU application from Shiny White Box. + +To use a plugin, simply declare it: + + plugin "safari" + + launch "Safari", at(32, 32, 800, 600) + url "http://www.google.com" + pause 4 + move to_element('input[name="q"]') + click + type "Castanaut" + move to_element('input[type="submit"]') + click + pause 4 + say "Oh. I was hoping for more results." + + +In the example above, we use the two methods provided by the Safari module: +url, which causes Safari to navigate to the given url, and to_element, which +returns the co-ordinates of a page element (using CSS selectors) relative to +the screen. + +=== Creating your own plugins + +Advanced users can create their own plugins. Put them in a directory +called "plugins" below the directory containing the screenplays that use +the plugin. + +Take a look at the plugins that Castanaut comes with for examples on creating +your own. + +== REQUIREMENTS: + +* Mac OS X 10.5 + +== INSTALL: + +Run the following command to install Castanaut + + sudo gem install castanaut + +Once installed, you should run the following command for two reasons: + + castanaut + +Reason 1 is to confirm that it is installed correctly. Reason 2 is to set up +the permissions on the utility that controls your mouse and keyboard during +Castanaut movies. You may be asked for a password here. + +If you just see a "ScreenplayNotFound" exception here, everything's good. + +== LICENSE: + +Copyright (C) 2008 Inventive Labs. + +Released under the WTFPL: http://sam.zoy.org/wtfpl. + +Portions released under the MIT License. + +See Copyright.txt for full licensing details. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..ddb535b --- /dev/null +++ b/Rakefile @@ -0,0 +1,25 @@ +# Look in the tasks/setup.rb file for the various options that can be +# configured in this Rakefile. The .rake files in the tasks directory +# are where the options are used. + +load 'tasks/setup.rb' + +ensure_in_path 'lib' +require 'castanaut' + +task :default => 'spec:run' + +PROJ.name = 'castanaut' +PROJ.authors = 'Joseph Pearson' +PROJ.email = 'joseph@inventivelabs.com.au' +PROJ.url = 'http://castanaut.rubyforge.org' +PROJ.version = Castanaut::VERSION + +PROJ.rubyforge_name = 'castanaut' +PROJ.rdoc_remote_dir = 'doc' + +PROJ.exclude += ['^spec\/*', '^test\/*'] + +PROJ.spec_opts << '--color' + +# EOF diff --git a/bin/castanaut b/bin/castanaut new file mode 100755 index 0000000..ee838ef --- /dev/null +++ b/bin/castanaut @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require File.expand_path( + File.join(File.dirname(__FILE__), '..', 'lib', 'castanaut') +) + +Castanaut::Main.run ARGV diff --git a/cbin/osxautomation b/cbin/osxautomation new file mode 100755 index 0000000000000000000000000000000000000000..97847925b2feb844d5cde7c7118bd8b7f2967295 GIT binary patch literal 39344 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 M(DuXskzd&R5BpA_aR2}S literal 0 HcmV?d00001 diff --git a/lib/castanaut.rb b/lib/castanaut.rb new file mode 100644 index 0000000..711a90f --- /dev/null +++ b/lib/castanaut.rb @@ -0,0 +1,64 @@ +# $Id$ + +# Equivalent to a header guard in C/C++ +# Used to prevent the class/module from being loaded more than once +unless defined? Castanaut + +# The Castanaut module. For orienting yourself within the code, it's +# recommended you begin with the documentation for the Movie class, +# which is the big one. +# +# Execution typically begins with the Main class. +module Castanaut + + # :stopdoc: + VERSION = '1.0.0' + LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR + PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR + + FILE_RUNNING = "/tmp/castanaut.running" + FILE_APPLESCRIPT = "/tmp/castanaut.scpt" + # :startdoc: + + # Returns the version string for the library. + # + def self.version + VERSION + end + + # Returns the library path for the module. If any arguments are given, + # they will be joined to the end of the libray path using + # File.join. + # + def self.libpath( *args ) + args.empty? ? LIBPATH : ::File.join(LIBPATH, *args) + end + + # Returns the lpath for the module. If any arguments are given, + # they will be joined to the end of the path using + # File.join. + # + def self.path( *args ) + args.empty? ? PATH : ::File.join(PATH, *args) + end + + # Utility method used to rquire all files ending in .rb that lie in the + # directory below this file that has the same name as the filename passed + # in. Optionally, a specific _directory_ name can be passed in such that + # the _filename_ does not have to be equivalent to the directory. + # + def self.require_all_libs_relative_to( fname, dir = nil ) + dir ||= ::File.basename(fname, '.*') + search_me = ::File.expand_path( + ::File.join(::File.dirname(fname), dir, '**', '*.rb')) + + Dir.glob(search_me).sort.each {|rb| require rb} + end + +end # module Castanaut + +Castanaut.require_all_libs_relative_to __FILE__ + +end # unless defined? + +# EOF diff --git a/lib/castanaut/exceptions.rb b/lib/castanaut/exceptions.rb new file mode 100644 index 0000000..f0b6cbf --- /dev/null +++ b/lib/castanaut/exceptions.rb @@ -0,0 +1,32 @@ +module Castanaut + # All Castanaut errors are defined within this module. If you are creating + # a plugin, you should re-open this module in your plugin script file to + # add any plugin-specific exceptions (it's also a good idea to have them + # descend from CastanautError). + module Exceptions + # The abstract parent class of all Castanaut errors. + class CastanautError < RuntimeError + end + + # Raised if Castanaut was invoked with no screenplay argument, or one + # pointing to a non-existent file. + class ScreenplayNotFound < CastanautError + end + + # If Castanaut::Movie#run sees a non-zero exit status from the shell + # process, this error will be raised. + class ExternalActionError < CastanautError + end + + # If the FILE_RUNNING flag file is deleted or moved during the execution + # of a movie, it will terminate and raise this exception. + class AbortedByUser < CastanautError + end + + # Despite asking for permission, the osxautomation utility in cbin cannot + # be executed. This is pretty fatal to our intentions, so we abort with + # this exception. + class OSXAutomationPermissionError < CastanautError + end + end +end diff --git a/lib/castanaut/keys.rb b/lib/castanaut/keys.rb new file mode 100644 index 0000000..5d91b0c --- /dev/null +++ b/lib/castanaut/keys.rb @@ -0,0 +1,43 @@ +module Castanaut + + # Some standard keys (for use with 'hit'), presumably only for US keyboards. + + # + Return = "0x24" + Enter = "0x4C" + Tab = "0x30" + Space = "0x31" + Backspace = "0x33" + Esc = "0x35" + + Shift = "0x38" + CapsLock = "0x39" + Alt = "0x3A" + Ctrl = "0x3B" + + LArrow = "0x7B" + RArrow = "0x7C" + DArrow = "0x7D" + UArrow = "0x7E" + + Insert = "0x72" + Home = "0x73" + PageUp = "0x74" + Delete = "0x75" + End = "0x77" + PageDown = "0x79" + + F1 = "0x7A" + F2 = "0x78" + F3 = "0x63" + F4 = "0x76" + F5 = "0x60" + F6 = "0x61" + F7 = "0x62" + F8 = "0x64" + F9 = "0x65" + F10 = "0x6D" + F11 = "0x67" + F12 = "0x6F" + +end diff --git a/lib/castanaut/main.rb b/lib/castanaut/main.rb new file mode 100644 index 0000000..867cc18 --- /dev/null +++ b/lib/castanaut/main.rb @@ -0,0 +1,20 @@ +module Castanaut + + # When running the Castanaut library as an executable, this class manages + # the invocation of the user-specified screenplay. + class Main + + # If Castanaut is not running, this runs the movie specified as the first + # argument. If it *is* already running, this nixes the flag file, which + # should cause Castanaut to stop. + def self.run(args) + if File.exists?(Castanaut::FILE_RUNNING) + File.unlink(Castanaut::FILE_RUNNING) + else + Castanaut::Movie.new(args.shift) + end + end + + end + +end diff --git a/lib/castanaut/movie.rb b/lib/castanaut/movie.rb new file mode 100644 index 0000000..e109ad9 --- /dev/null +++ b/lib/castanaut/movie.rb @@ -0,0 +1,353 @@ +module Castanaut + # The movie class is the containing context within which screenplays are + # invoked. It provides a number of basic stage directions for your + # screenplays, and can be extended with plugins. + class Movie + + # Runs the "screenplay", which is a file containing Castanaut instructions. + # + def initialize(screenplay) + perms_test + + if !screenplay || !File.exists?(screenplay) + raise Castanaut::Exceptions::ScreenplayNotFound + end + @screenplay_path = screenplay + + File.open(FILE_RUNNING, 'w') {|f| f.write('')} + + begin + # We run the movie in a separate thread; in the main thread we + # continue to check the "running" file flag and kill the movie if + # it is removed. + movie = Thread.new do + begin + eval(IO.read(@screenplay_path), binding) + rescue => e + @e = e + ensure + File.unlink(FILE_RUNNING) if File.exists?(FILE_RUNNING) + end + end + + while File.exists?(FILE_RUNNING) + sleep 0.5 + break unless movie.alive? + end + + if movie.alive? + movie.kill + raise Castanaut::Exceptions::AbortedByUser + end + + raise @e if @e + rescue => e + puts "ABNORMAL EXIT: #{e.message}\n" + e.backtrace.join("\n") + ensure + roll_credits + File.unlink(FILE_RUNNING) if File.exists?(FILE_RUNNING) + end + end + + # Launch the application matching the string given in the first argument. + # (This resolution is handled by Applescript.) + # + # If the options hash is given, it should contain the co-ordinates for + # the window (top, left, width, height). The to method will format these + # co-ordinates appropriately. + # + def launch(app_name, *options) + options = combine_options(*options) + + ensure_window = "" + case app_name.downcase + when "safari" + ensure_window = "if (count(windows)) < 1 then make new document" + end + + positioning = "" + if options[:to] + pos = "#{options[:to][:left]}, #{options[:to][:top]}" + dims = "#{options[:to][:left] + options[:to][:width]}, " + + "#{options[:to][:top] + options[:to][:height]}" + if options[:to][:width] + positioning = "set bounds of front window to {#{pos}, #{dims}}" + else + positioning = "set position of front window to {#{pos}}" + end + end + + execute_applescript(%Q` + tell application "#{app_name}" + activate + #{ensure_window} + #{positioning} + end tell + `) + end + + # Move the mouse cursor to the specified co-ordinates. + # + def cursor(*options) + options = combine_options(*options) + apply_offset(options) + @cursor_loc ||= {} + @cursor_loc[:x] = options[:to][:left] + @cursor_loc[:y] = options[:to][:top] + automatically "mousemove #{@cursor_loc[:x]} #{@cursor_loc[:y]}" + end + + alias :move :cursor + + # Send a mouse-click at the current mouse location. + # + def click(btn = 'left') + automatically "mouseclick #{mouse_button_translate(btn)}" + end + + # Send a double-click at the current mouse location. + # + def doubleclick(btn = 'left') + automatically "mousedoubleclick #{mouse_button_translate(btn)}" + end + + # Send a triple-click at the current mouse location. + # + def tripleclick(btn = 'left') + automatically "mousetripleclick #{mouse_button_translate(btn)}" + end + + # Press the button down at the current mouse location. Does not + # release the button until the mouseup method is invoked. + # + def mousedown(btn = 'left') + automatically "mousedown #{mouse_button_translate(btn)}" + end + + # Releases the mouse button pressed by a previous mousedown. + # + def mouseup(btn = 'left') + automatically "mouseup #{mouse_button_translate(btn)}" + end + + # "Drags" the mouse by (effectively) issuing a mousedown at the current + # mouse location, then moving the mouse to the specified coordinates, then + # issuing a mouseup. + # + def drag(*options) + options = combine_options(*options) + apply_offset(options) + automatically "mousedrag #{options[:to][:left]} #{options[:to][:top]}" + end + + # Sends the characters into the active control in the active window. + # + def type(str) + automatically "type #{str}" + end + + # Sends the keycode (a hex value) to the active control in the active + # window. For more about keycode values, see Mac Developer documentation. + # + def hit(key) + automatically "hit #{key}" + end + + # Don't do anything for the specified number of seconds (can be portions + # of a second). + # + def pause(seconds) + sleep seconds + end + + # Use Leopard's native text-to-speech functionality to emulate a human + # voice saying the narrative text. + # + def say(narrative) + run(%Q`say "#{escape_dq(narrative)}"`) + end + + # Starts saying the narrative text, and simultaneously begins executing + # the given block. Waits until both are finished. + # + def while_saying(narrative) + if block_given? + fork { say(narrative) } + yield + Process.wait + else + say(narrative) + end + end + + # Get a hash representing specific screen co-ordinates. Use in combination + # with cursor, drag, launch, and similar methods. + # + def to(l, t, w = nil, h = nil) + result = { + :to => { + :left => l, + :top => t + } + } + result[:to][:width] = w if w + result[:to][:height] = h if h + result + end + + alias :at :to + + # Get a hash representing specific screen co-ordinates *relative to the + # current mouse location. + # + def by(x, y) + unless @cursor_loc + @cursor_loc = automatically("mouselocation").strip.split(' ') + @cursor_loc = {:x => @cursor_loc[0].to_i, :y => @cursor_loc[1].to_i} + end + to(@cursor_loc[:x] + x, @cursor_loc[:y] + y) + end + + # The result of this method can be added +to+ a co-ordinates hash, + # offsetting the top and left values by the given margins. + # + def offset(x, y) + { :offset => { :x => x, :y => y } } + end + + + # Returns a region hash describing the entire screen area. (May be wonky + # for multi-monitor set-ups.) + # + def screen_size + coords = execute_applescript(%Q` + tell application "Finder" + get bounds of window of desktop + end tell + `) + coords = coords.split(", ").collect {|c| c.to_i} + to(*coords) + end + + # Runs a shell command, performing fairly naive (but effective!) exit + # status handling. Returns the stdout result of the command. + # + def run(cmd) + #puts("Executing: #{cmd}") + result = `#{cmd}` + raise Castanaut::Exceptions::ExternalActionError if $?.exitstatus > 0 + result + end + + # Adds custom methods to this movie instance, allowing you to perform + # additional actions. See the README.txt for more information. + # + def plugin(str) + str.downcase! + begin + require File.join(File.dirname(@screenplay_path),"plugins","#{str}.rb") + rescue LoadError + require File.join(LIBPATH, "plugins", "#{str}.rb") + end + extend eval("Castanaut::Plugin::#{str.capitalize}") + end + + # Loads a script from a file into a string, looking first in the + # scripts directory beneath the path where Castanaut was executed, + # and falling back to Castanaut's gem path. + # + def script(filename) + @cached_scripts ||= {} + unless @cached_scripts[filename] + fpath = File.join(File.dirname(@screenplay_path), "scripts", filename) + scpt = nil + if File.exists?(fpath) + scpt = IO.read(fpath) + else + scpt = IO.read(File.join(PATH, "scripts", filename)) + end + @cached_scripts[filename] = scpt + end + + @cached_scripts[filename] + end + + # This stage direction is slightly different to the other ones. It collects + # a set of directions to be executed when the movie ends, or when it is + # aborted by the user. Mostly, it's used for cleaning up stuff. Here's + # an example: + # + # ishowu_start_recording + # at_end_of_movie do + # ishowu_stop_recording + # end + # move to(100, 100) # ... et cetera + # + # You can use this multiple times in your screenplay -- remember that if + # the movie is aborted by the user before this direction is used, its + # contents won't be executed. So in general, create an at_end_of_movie + # block after every action that you want to revert (like in the example + # above). + def at_end_of_movie(&blk) + @end_credits ||= [] + @end_credits << blk + end + + protected + def execute_applescript(scpt) + File.open(FILE_APPLESCRIPT, 'w') {|f| f.write(scpt)} + result = run("osascript #{FILE_APPLESCRIPT}") + File.unlink(FILE_APPLESCRIPT) + result + end + + def automatically(cmd) + run("#{osxautomation_path} \"#{cmd}\"") + end + + def escape_dq(str) + str.gsub(/\\/,'\\\\\\').gsub(/"/, '\"') + end + + def combine_options(*args) + options = args.inject({}) { |result, option| result.update(option) } + end + + private + def osxautomation_path + File.join(PATH, "cbin", "osxautomation") + end + + def perms_test + return if File.executable?(osxautomation_path) + puts "IMPORTANT: Castanaut has recently been installed or updated. " + + "You need to give it the right to control mouse and keyboard " + + "input during screenplays." + + run("sudo chmod a+x #{osxautomation_path}") + + if File.executable?(osxautomation_path) + puts "Permission granted. Thanks." + else + raise Castanaut::Exceptions::OSXAutomationPermissionError + end + end + + def apply_offset(options) + return unless options[:to] && options[:offset] + options[:to][:left] += options[:offset][:x] || 0 + options[:to][:top] += options[:offset][:y] || 0 + end + + def mouse_button_translate(btn) + return btn if btn.is_a?(Integer) + {"left" => 1, "right" => 2, "middle" => 3}[btn] + end + + def roll_credits + return unless @end_credits && @end_credits.any? + @end_credits.each {|credit| credit.call} + end + + end +end diff --git a/lib/castanaut/plugin.rb b/lib/castanaut/plugin.rb new file mode 100644 index 0000000..adb06f8 --- /dev/null +++ b/lib/castanaut/plugin.rb @@ -0,0 +1,26 @@ +module Castanaut + + # Castanaut uses plugins to extend the available actions beyond simple + # mouse and keyboard input. Typically each plugin is application-specific. + # See the Safari, Mousepose and Ishowu plugins for examples, and review the + # README.txt for details on creating your own. + # + # In short, for a plugin called "foo", your script should have this structure: + # + # module Castanaut + # module Plugin + # module Foo + # + # # define your stage directions (ie, Movie instance methods) here. + # + # end + # end + # end + # + # The script must exist in a sub-directory of the screenplay's location + # called "plugins", and must be called (in this case): foo.rb. + # + module Plugin + end + +end diff --git a/lib/plugins/ishowu.rb b/lib/plugins/ishowu.rb new file mode 100644 index 0000000..6dfe1d2 --- /dev/null +++ b/lib/plugins/ishowu.rb @@ -0,0 +1,94 @@ +module Castanaut; module Plugin + + # This module provides primitive support for iShowU, a screencast capturing + # tool for Mac OS X from Shiny White Box. + # + # iShowU is widely considered a good, simple application for its purpose, + # but you're by no means required to use it for Castanaut. Simply write + # your own module for Snapz Pro, or ScreenFlow, or whatever you like. + # + # Shiny White Box is promising much better Applescript support in an + # imminent version, which could tidy up this module quite a bit. + # + # More info: http://www.shinywhitebox.com/home/home.html + module Ishowu + + # Set the screencast to capture a particular region of the screen. + # Generate appropriately-formatted co-ordinates using Castanaut::Movie#to. + def ishowu_set_region(*options) + ishowu_applescriptify + + options = combine_options(*options) + + ishowu_menu_item("Capture", "Capture full screen") + sleep(0.2) + ishowu_menu_item("Capture", "Capture custom area", false) + sleep(0.2) + automatically "mousewarp 4 4" + + drag to(options[:to][:left], options[:to][:top]) + + sleep(0.2) + bounds = screen_size + automatically "mousewarp #{bounds[:to][:width]} #{bounds[:to][:height]}" + drag to( + options[:to][:left] + options[:to][:width], + options[:to][:top] + options[:to][:height] + ) + hit Enter + ishowu_hide + end + + # Tell iShowU to start recording. Will automatically stop recording when + # the movie is ended, unless you set :auto_stop => false in options. + def ishowu_start_recording(options = {}) + ishowu_hide + ishowu_menu_item("Capture", "Start capture") + unless options[:auto_stop] == false + at_end_of_movie { ishowu_stop_recording } + end + end + + # Tell iShowU to stop recording. + def ishowu_stop_recording + ishowu_menu_item("Capture", "Stop capture") + end + + # Execute an iShowU menu option. + def ishowu_menu_item(menu, item, quiet = true) + ascript = %Q` + tell application "iShowU" + activate + tell application "System Events" + click menu item "#{item}" of menu "#{menu}" of menu bar item "#{menu}" of menu bar 1 of process "iShowU" + #{'set visible of process "iShowU" to false' if quiet} + end tell + end + ` + execute_applescript(ascript) + end + + # Hide the iShowU window. This is a bit random, and suggestions are + # welcomed. + def ishowu_hide + ishowu_menu_item("iShowU", "Hide iShowU") + end + + private + # iShowU is not Applescript-enabled out of the box. This fix, arguably + # a hack, lets us do some limited work with it in Applescript. + def ishowu_applescriptify + execute_applescript(%Q` + try + tell application "Finder" + set the a_app to (application file id "com.tcdc.Digitizer") as alias + end tell + set the plist_filepath to the quoted form of ¬ + ((POSIX path of the a_app) & "Contents/Info") + do shell script "defaults write " & the plist_filepath & space ¬ + & "NSAppleScriptEnabled -bool YES" + end try + `) + end + end +end; end diff --git a/lib/plugins/mousepose.rb b/lib/plugins/mousepose.rb new file mode 100644 index 0000000..c894735 --- /dev/null +++ b/lib/plugins/mousepose.rb @@ -0,0 +1,38 @@ +module Castanaut; module Plugin + + # This module provides actions for controlling Mousepose, a commercial + # application from Boinx Software. Basically it lets you put a halo around + # the mouse whenever a key mouse action occurs. + # + # It doesn't do any configuration of Mousepose on the fly. Configure + # Mousepose settings before running your screenplay. + # + # Tested against Mousepose 2. More info: http://www.boinx.com/mousepose + module Mousepose + + # Put a halo around the mouse. If a block is given to this method, + # the halo will be turned off when the block completes. Otherwise, + # you'll have to use dim to dismiss the halo. + def highlight + execute_applescript(%Q` + tell application "Mousepose" + start effect + end + `) + if block_given? + yield + dim + end + end + + # Dismiss the halo around the mouse that was invoked by a previous + # highlight method. + def dim + execute_applescript(%Q` + tell application "Mousepose" + stop effect + end + `) + end + end +end; end diff --git a/lib/plugins/safari.rb b/lib/plugins/safari.rb new file mode 100644 index 0000000..33df24d --- /dev/null +++ b/lib/plugins/safari.rb @@ -0,0 +1,124 @@ +module Castanaut + + module Plugin + # This module provides actions for controlling Safari. It's tested against + # Safari 3 on Mac OS X 10.5.2. + module Safari + + # Open a URL in the front Safari tab. + def url(str) + execute_applescript(%Q` + tell application "safari" + do JavaScript "location.href = '#{str}'" in front document + end tell + `) + end + + # Get the co-ordinates of an element in the front Safari tab. Use this + # with Castanaut::Movie#cursor to send the mouse cursor to the element. + # + # Options include: + # * :index - an integer (*n*) that gets the *n*th element matching the + # selector. Defaults to the first element. + # * :area - whereabouts in the element do you want the coordinates. + # Valid values are: left, center, right, and top, middle, bottom. + # Defaults to ["center", "middle"]. + # If single axis is given (eg "left"), the other axis uses its default. + def to_element(selector, options = {}) + pos = options.delete(:area) + coords = element_coordinates(selector, options) + + x_axis, y_axis = [:center, :middle] + [pos].flatten.first(2).each do |p| + p = p.to_s.downcase + x_axis = p.to_sym if %w[left center right].include?(p) + y_axis = p.to_sym if %w[top middle bottom].include?(p) + end + + edge_offset = options[:edge_offset] || 3 + case x_axis + when :left + x = coords[0] + edge_offset + when :center + x = (coords[0] + coords[2] * 0.5).to_i + when :right + x = (coords[0] + coords[2]) - edge_offset + end + + case y_axis + when :top + y = coords[1] + edge_offset + when :middle + y = (coords[1] + coords[3] * 0.5).to_i + when :bottom + y = (coords[1] + coords[3]) - edge_offset + end + + result = { :to => { :left => x, :top => y } } + end + + private + # Note: the script should set the Castanaut.result variable. + def execute_javascript(scpt) + execute_applescript %Q` + tell application "Safari" + do JavaScript " + document.oldTitle = document.title; + #{escape_dq(scpt)} + if (typeof Castanaut.result != 'undefined') { + document.title = Castanaut.result; + } + " in front document + set the_result to ((name of window 1) as string) + do JavaScript " + document.title = document.oldTitle; + " in front document + return the_result + end tell + ` + end + + def element_coordinates(selector, options = {}) + index = options[:index] || 0 + gebys = script('gebys.js') + cjs = script('coords.js') + coords = execute_javascript(%Q` + #{gebys} + #{cjs} + Castanaut.result = Castanaut.Coords.forElement( + '#{selector}', + #{index} + ); + `) + + unless coords.match(/\d+ \d+ \d+ \d+/) + raise Castanaut::Exceptions::ElementNotFound + end + + coords = coords.split(' ').collect {|c| c.to_i} + + if coords.any? {|c| c < 0 } + raise Castanaut::Exceptions::ElementOffScreen + end + + coords + end + + end + end + + module Exceptions + # When getting an element's coordinates, this is raised if no element on + # the page matches the selector given. + class ElementNotFound < CastanautError + end + + # When getting an element's coordinates, this is raised if the element + # is found, but cannot be shown on the screen. Normally, we automatically + # scroll to an element that is currently off-screen, but sometimes that + # might not be possible (such as if the element is display: none). + class ElementOffScreen < CastanautError + end + end + +end diff --git a/scripts/coords.js b/scripts/coords.js new file mode 100644 index 0000000..06bad11 --- /dev/null +++ b/scripts/coords.js @@ -0,0 +1,48 @@ +var Castanaut = Castanaut || {}; + +Castanaut.Coords = Castanaut.Coords || { + documentPos: function (obj) { + origObj = obj; + var curleft = curtop = 0; + if (obj.offsetParent) { + do { + curleft += obj.offsetLeft; + curtop += obj.offsetTop; + } while (obj = obj.offsetParent); + } + return [curleft,curtop,origObj.offsetWidth,origObj.offsetHeight]; + }, + + windowPos: function (obj) { + var pos = Castanaut.Coords.documentPos(obj); + pos[0] -= window.scrollX; + pos[1] -= window.scrollY; + if (pos[0] > window.innerWidth || pos[0] < 0) { + pos[0] = -1; + } + if (pos[1] > window.innerHeight || pos[1] < 0) { + pos[1] = -1; + } + return pos; + }, + + forElement: function (selector, index) { + var obj = Castanaut.DomQuery.select(selector)[index]; + var pos = Castanaut.Coords.windowPos(obj); + if (pos[0] < 0 || pos[1] < 0) { + obj.scrollIntoView(); + pos = Castanaut.Coords.windowPos(obj); + if (pos[0] < 0 || pos[1] < 0) { + return pos.join(' '); + } + } + + pos[0] += window.screenX + (window.outerWidth - window.innerWidth); + + pos[1] += window.screenY + (window.outerHeight - window.innerHeight); + if (window.statusbar.visible) { + pos[1] -= 14; + } + return pos.join(' '); + } +} diff --git a/scripts/gebys.js b/scripts/gebys.js new file mode 100644 index 0000000..a677dac --- /dev/null +++ b/scripts/gebys.js @@ -0,0 +1,612 @@ +// NB: this is actually a slightly modified form of the Ext JS implementation, +// re-namespaced to avoid conflicts. (See also Copyright.txt.) +var Castanaut = Castanaut || {}; +Castanaut.DomQuery = Castanaut.DomQuery || function(){ + var cache = {}, simpleCache = {}, valueCache = {}; + var nonSpace = /\S/; + var trimRe = /^\s+|\s+$/g; + var tplRe = /\{(\d+)\}/g; + var modeRe = /^(\s?[\/>+~]\s?|\s|$)/; + var tagTokenRe = /^(#)?([\w-\*]+)/; + var nthRe = /(\d*)n\+?(\d*)/, nthRe2 = /\D/; + + function child(p, index){ + var i = 0; + var n = p.firstChild; + while(n){ + if(n.nodeType == 1){ + if(++i == index){ + return n; + } + } + n = n.nextSibling; + } + return null; + }; + + function next(n){ + while((n = n.nextSibling) && n.nodeType != 1); + return n; + }; + + function prev(n){ + while((n = n.previousSibling) && n.nodeType != 1); + return n; + }; + + function children(d){ + var n = d.firstChild, ni = -1; + while(n){ + var nx = n.nextSibling; + if(n.nodeType == 3 && !nonSpace.test(n.nodeValue)){ + d.removeChild(n); + }else{ + n.nodeIndex = ++ni; + } + n = nx; + } + return this; + }; + + function byClassName(c, a, v){ + if(!v){ + return c; + } + var r = [], ri = -1, cn; + for(var i = 0, ci; ci = c[i]; i++){ + if((' '+ci.className+' ').indexOf(v) != -1){ + r[++ri] = ci; + } + } + return r; + }; + + function attrValue(n, attr){ + if(!n.tagName && typeof n.length != "undefined"){ + n = n[0]; + } + if(!n){ + return null; + } + if(attr == "for"){ + return n.htmlFor; + } + if(attr == "class" || attr == "className"){ + return n.className; + } + return n.getAttribute(attr) || n[attr]; + + }; + + function getNodes(ns, mode, tagName){ + var result = [], ri = -1, cs; + if(!ns){ + return result; + } + tagName = tagName || "*"; + if(typeof ns.getElementsByTagName != "undefined"){ + ns = [ns]; + } + if(!mode){ + for(var i = 0, ni; ni = ns[i]; i++){ + cs = ni.getElementsByTagName(tagName); + for(var j = 0, ci; ci = cs[j]; j++){ + result[++ri] = ci; + } + } + }else if(mode == "/" || mode == ">"){ + var utag = tagName.toUpperCase(); + for(var i = 0, ni, cn; ni = ns[i]; i++){ + cn = ni.children || ni.childNodes; + for(var j = 0, cj; cj = cn[j]; j++){ + if(cj.nodeName == utag || cj.nodeName == tagName || tagName == '*'){ + result[++ri] = cj; + } + } + } + }else if(mode == "+"){ + var utag = tagName.toUpperCase(); + for(var i = 0, n; n = ns[i]; i++){ + while((n = n.nextSibling) && n.nodeType != 1); + if(n && (n.nodeName == utag || n.nodeName == tagName || tagName == '*')){ + result[++ri] = n; + } + } + }else if(mode == "~"){ + for(var i = 0, n; n = ns[i]; i++){ + while((n = n.nextSibling) && (n.nodeType != 1 || (tagName == '*' || n.tagName.toLowerCase()!=tagName))); + if(n){ + result[++ri] = n; + } + } + } + return result; + }; + + function concat(a, b){ + if(b.slice){ + return a.concat(b); + } + for(var i = 0, l = b.length; i < l; i++){ + a[a.length] = b[i]; + } + return a; + } + + function byTag(cs, tagName){ + if(cs.tagName || cs == document){ + cs = [cs]; + } + if(!tagName){ + return cs; + } + var r = [], ri = -1; + tagName = tagName.toLowerCase(); + for(var i = 0, ci; ci = cs[i]; i++){ + if(ci.nodeType == 1 && ci.tagName.toLowerCase()==tagName){ + r[++ri] = ci; + } + } + return r; + }; + + function byId(cs, attr, id){ + if(cs.tagName || cs == document){ + cs = [cs]; + } + if(!id){ + return cs; + } + var r = [], ri = -1; + for(var i = 0,ci; ci = cs[i]; i++){ + if(ci && ci.id == id){ + r[++ri] = ci; + return r; + } + } + return r; + }; + + function byAttribute(cs, attr, value, op, custom){ + var r = [], ri = -1, st = custom=="{"; + var f = Castanaut.DomQuery.operators[op]; + for(var i = 0, ci; ci = cs[i]; i++){ + var a; + if(st){ + a = Castanaut.DomQuery.getStyle(ci, attr); + } + else if(attr == "class" || attr == "className"){ + a = ci.className; + }else if(attr == "for"){ + a = ci.htmlFor; + }else if(attr == "href"){ + a = ci.getAttribute("href", 2); + }else{ + a = ci.getAttribute(attr); + } + if((f && f(a, value)) || (!f && a)){ + r[++ri] = ci; + } + } + return r; + }; + + function byPseudo(cs, name, value){ + return Castanaut.DomQuery.pseudos[name](cs, value); + }; + + eval("var batch = 30803;"); + + var key = 30803; + + function nodup(cs){ + if(!cs){ + return []; + } + var len = cs.length, c, i, r = cs, cj, ri = -1; + if(!len || typeof cs.nodeType != "undefined" || len == 1){ + return cs; + } + var d = ++key; + cs[0]._nodup = d; + for(i = 1; c = cs[i]; i++){ + if(c._nodup != d){ + c._nodup = d; + }else{ + r = []; + for(var j = 0; j < i; j++){ + r[++ri] = cs[j]; + } + for(j = i+1; cj = cs[j]; j++){ + if(cj._nodup != d){ + cj._nodup = d; + r[++ri] = cj; + } + } + return r; + } + } + return r; + } + + function quickDiff(c1, c2){ + var len1 = c1.length; + if(!len1){ + return c2; + } + var d = ++key; + for(var i = 0; i < len1; i++){ + c1[i]._qdiff = d; + } + var r = []; + for(var i = 0, len = c2.length; i < len; i++){ + if(c2[i]._qdiff != d){ + r[r.length] = c2[i]; + } + } + return r; + } + + function quickId(ns, mode, root, id){ + if(ns == root){ + var d = root.ownerDocument || root; + return d.getElementById(id); + } + ns = getNodes(ns, mode, "*"); + return byId(ns, null, id); + } + + return { + getStyle : function(el, name){ + return Ext.fly(el).getStyle(name); + }, + compile : function(path, type){ + type = type || "select"; + + var fn = ["var f = function(root){\n var mode; ++batch; var n = root || document;\n"]; + var q = path, mode, lq; + var tk = Castanaut.DomQuery.matchers; + var tklen = tk.length; + var mm; + + var lmode = q.match(modeRe); + if(lmode && lmode[1]){ + fn[fn.length] = 'mode="'+lmode[1].replace(trimRe, "")+'";'; + q = q.replace(lmode[1], ""); + } + while(path.substr(0, 1)=="/"){ + path = path.substr(1); + } + + while(q && lq != q){ + lq = q; + var tm = q.match(tagTokenRe); + if(type == "select"){ + if(tm){ + if(tm[1] == "#"){ + fn[fn.length] = 'n = quickId(n, mode, root, "'+tm[2]+'");'; + }else{ + fn[fn.length] = 'n = getNodes(n, mode, "'+tm[2]+'");'; + } + q = q.replace(tm[0], ""); + }else if(q.substr(0, 1) != '@'){ + fn[fn.length] = 'n = getNodes(n, mode, "*");'; + } + }else{ + if(tm){ + if(tm[1] == "#"){ + fn[fn.length] = 'n = byId(n, null, "'+tm[2]+'");'; + }else{ + fn[fn.length] = 'n = byTag(n, "'+tm[2]+'");'; + } + q = q.replace(tm[0], ""); + } + } + while(!(mm = q.match(modeRe))){ + var matched = false; + for(var j = 0; j < tklen; j++){ + var t = tk[j]; + var m = q.match(t.re); + if(m){ + fn[fn.length] = t.select.replace(tplRe, function(x, i){ + return m[i]; + }); + q = q.replace(m[0], ""); + matched = true; + break; + } + } + if(!matched){ + throw 'Error parsing selector, parsing failed at "' + q + '"'; + } + } + if(mm[1]){ + fn[fn.length] = 'mode="'+mm[1].replace(trimRe, "")+'";'; + q = q.replace(mm[1], ""); + } + } + fn[fn.length] = "return nodup(n);\n}"; + eval(fn.join("")); + return f; + }, + + select : function(path, root, type){ + if(!root || root == document){ + root = document; + } + if(typeof root == "string"){ + root = document.getElementById(root); + } + var paths = path.split(","); + var results = []; + for(var i = 0, len = paths.length; i < len; i++){ + var p = paths[i].replace(trimRe, ""); + if(!cache[p]){ + cache[p] = Castanaut.DomQuery.compile(p); + if(!cache[p]){ + throw p + " is not a valid selector"; + } + } + var result = cache[p](root); + if(result && result != document){ + results = results.concat(result); + } + } + if(paths.length > 1){ + return nodup(results); + } + return results; + }, + + selectNode : function(path, root){ + return Castanaut.DomQuery.select(path, root)[0]; + }, + + selectValue : function(path, root, defaultValue){ + path = path.replace(trimRe, ""); + if(!valueCache[path]){ + valueCache[path] = Castanaut.DomQuery.compile(path, "select"); + } + var n = valueCache[path](root); + n = n[0] ? n[0] : n; + var v = (n && n.firstChild ? n.firstChild.nodeValue : null); + return ((v === null||v === undefined||v==='') ? defaultValue : v); + }, + + selectNumber : function(path, root, defaultValue){ + var v = Castanaut.DomQuery.selectValue(path, root, defaultValue || 0); + return parseFloat(v); + }, + + is : function(el, ss){ + if(typeof el == "string"){ + el = document.getElementById(el); + } + var isArray = (el instanceof Array); + var result = Castanaut.DomQuery.filter(isArray ? el : [el], ss); + return isArray ? (result.length == el.length) : (result.length > 0); + }, + + filter : function(els, ss, nonMatches){ + ss = ss.replace(trimRe, ""); + if(!simpleCache[ss]){ + simpleCache[ss] = Castanaut.DomQuery.compile(ss, "simple"); + } + var result = simpleCache[ss](els); + return nonMatches ? quickDiff(result, els) : result; + }, + + matchers : [{ + re: /^\.([\w-]+)/, + select: 'n = byClassName(n, null, " {1} ");' + }, { + re: /^\:([\w-]+)(?:\(((?:[^\s>\/]*|.*?))\))?/, + select: 'n = byPseudo(n, "{1}", "{2}");' + },{ + re: /^(?:([\[\{])(?:@)?([\w-]+)\s?(?:(=|.=)\s?['"]?(.*?)["']?)?[\]\}])/, + select: 'n = byAttribute(n, "{2}", "{4}", "{3}", "{1}");' + }, { + re: /^#([\w-]+)/, + select: 'n = byId(n, null, "{1}");' + },{ + re: /^@([\w-]+)/, + select: 'return {firstChild:{nodeValue:attrValue(n, "{1}")}};' + } + ], + + operators : { + "=" : function(a, v){ + return a == v; + }, + "!=" : function(a, v){ + return a != v; + }, + "^=" : function(a, v){ + return a && a.substr(0, v.length) == v; + }, + "$=" : function(a, v){ + return a && a.substr(a.length-v.length) == v; + }, + "*=" : function(a, v){ + return a && a.indexOf(v) !== -1; + }, + "%=" : function(a, v){ + return (a % v) == 0; + }, + "|=" : function(a, v){ + return a && (a == v || a.substr(0, v.length+1) == v+'-'); + }, + "~=" : function(a, v){ + return a && (' '+a+' ').indexOf(' '+v+' ') != -1; + } + }, + + pseudos : { + "first-child" : function(c){ + var r = [], ri = -1, n; + for(var i = 0, ci; ci = n = c[i]; i++){ + while((n = n.previousSibling) && n.nodeType != 1); + if(!n){ + r[++ri] = ci; + } + } + return r; + }, + + "last-child" : function(c){ + var r = [], ri = -1, n; + for(var i = 0, ci; ci = n = c[i]; i++){ + while((n = n.nextSibling) && n.nodeType != 1); + if(!n){ + r[++ri] = ci; + } + } + return r; + }, + + "nth-child" : function(c, a) { + var r = [], ri = -1; + var m = nthRe.exec(a == "even" && "2n" || a == "odd" && "2n+1" || !nthRe2.test(a) && "n+" + a || a); + var f = (m[1] || 1) - 0, l = m[2] - 0; + for(var i = 0, n; n = c[i]; i++){ + var pn = n.parentNode; + if (batch != pn._batch) { + var j = 0; + for(var cn = pn.firstChild; cn; cn = cn.nextSibling){ + if(cn.nodeType == 1){ + cn.nodeIndex = ++j; + } + } + pn._batch = batch; + } + if (f == 1) { + if (l == 0 || n.nodeIndex == l){ + r[++ri] = n; + } + } else if ((n.nodeIndex + l) % f == 0){ + r[++ri] = n; + } + } + + return r; + }, + + "only-child" : function(c){ + var r = [], ri = -1;; + for(var i = 0, ci; ci = c[i]; i++){ + if(!prev(ci) && !next(ci)){ + r[++ri] = ci; + } + } + return r; + }, + + "empty" : function(c){ + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + var cns = ci.childNodes, j = 0, cn, empty = true; + while(cn = cns[j]){ + ++j; + if(cn.nodeType == 1 || cn.nodeType == 3){ + empty = false; + break; + } + } + if(empty){ + r[++ri] = ci; + } + } + return r; + }, + + "contains" : function(c, v){ + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + if((ci.textContent||ci.innerText||'').indexOf(v) != -1){ + r[++ri] = ci; + } + } + return r; + }, + + "nodeValue" : function(c, v){ + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + if(ci.firstChild && ci.firstChild.nodeValue == v){ + r[++ri] = ci; + } + } + return r; + }, + + "checked" : function(c){ + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + if(ci.checked == true){ + r[++ri] = ci; + } + } + return r; + }, + + "not" : function(c, ss){ + return Castanaut.DomQuery.filter(c, ss, true); + }, + + "odd" : function(c){ + return this["nth-child"](c, "odd"); + }, + + "even" : function(c){ + return this["nth-child"](c, "even"); + }, + + "nth" : function(c, a){ + return c[a-1] || []; + }, + + "first" : function(c){ + return c[0] || []; + }, + + "last" : function(c){ + return c[c.length-1] || []; + }, + + "has" : function(c, ss){ + var s = Castanaut.DomQuery.select; + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + if(s(ss, ci).length > 0){ + r[++ri] = ci; + } + } + return r; + }, + + "next" : function(c, ss){ + var is = Castanaut.DomQuery.is; + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + var n = next(ci); + if(n && is(n, ss)){ + r[++ri] = ci; + } + } + return r; + }, + + "prev" : function(c, ss){ + var is = Castanaut.DomQuery.is; + var r = [], ri = -1; + for(var i = 0, ci; ci = c[i]; i++){ + var n = prev(ci); + if(n && is(n, ss)){ + r[++ri] = ci; + } + } + return r; + } + } + }; +}(); diff --git a/spec/castanaut_spec.rb b/spec/castanaut_spec.rb new file mode 100644 index 0000000..a6a18a2 --- /dev/null +++ b/spec/castanaut_spec.rb @@ -0,0 +1,9 @@ +# $Id$ + +require File.join(File.dirname(__FILE__), %w[spec_helper]) + +describe Castanaut do + # Feel free to contribute specs if this void leaves you feeling vertiginous. +end + +# EOF diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..d76e67d --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,18 @@ +# $Id$ + +require File.expand_path( + File.join(File.dirname(__FILE__), %w[.. lib castanaut]) +) + +Spec::Runner.configure do |config| + # == Mock Framework + # + # RSpec uses it's own mocking framework by default. If you prefer to + # use mocha, flexmock or RR, uncomment the appropriate line: + # + # config.mock_with :mocha + # config.mock_with :flexmock + # config.mock_with :rr +end + +# EOF diff --git a/tasks/ann.rake b/tasks/ann.rake new file mode 100644 index 0000000..98cd747 --- /dev/null +++ b/tasks/ann.rake @@ -0,0 +1,77 @@ +# $Id$ + +begin + require 'bones/smtp_tls' +rescue LoadError + require 'net/smtp' +end +require 'time' + +namespace :ann do + + desc "Create an announcement file" + task :announcement do + File.open('announcement.txt','w') do |fd| + fd.puts("#{PROJ.name} version #{PROJ.version}") + fd.puts(" by #{Array(PROJ.authors).first}") if PROJ.authors + fd.puts(" #{PROJ.url}") if PROJ.url + fd.puts(" (the \"#{PROJ.release_name}\" release)") if PROJ.release_name + fd.puts + fd.puts("== DESCRIPTION") + fd.puts + fd.puts(PROJ.description) + fd.puts + fd.puts(PROJ.changes.sub(%r/^.*$/, '== CHANGES')) + fd.puts + PROJ.ann_paragraphs.each do |p| + fd.puts "== #{p.upcase}" + fd.puts + fd.puts paragraphs_of('README.txt', p).join("\n\n") + fd.puts + end + fd.puts PROJ.ann_text if PROJ.ann_text + end + end + + desc "Send email announcement" + task :email => :announcement do + from = PROJ.ann_email[:from] || PROJ.email + to = Array(PROJ.ann_email[:to]) + + ### build a mail header for RFC 822 + rfc822msg = "From: #{from}\n" + rfc822msg << "To: #{to.join(',')}\n" + rfc822msg << "Subject: [ANN] #{PROJ.name} #{PROJ.version}" + rfc822msg << " (#{PROJ.release_name})" if PROJ.release_name + rfc822msg << "\n" + rfc822msg << "Date: #{Time.new.rfc822}\n" + rfc822msg << "Message-Id: " + rfc822msg << "<#{"%.8f" % Time.now.to_f}@#{PROJ.ann_email[:domain]}>\n\n" + rfc822msg << File.read('announcement.txt') + + params = [:server, :port, :domain, :acct, :passwd, :authtype].map do |key| + PROJ.ann_email[key] + end + + params[3] = PROJ.email if params[3].nil? + + if params[4].nil? + STDOUT.write "Please enter your e-mail password (#{params[3]}): " + params[4] = STDIN.gets.chomp + end + + ### send email + Net::SMTP.start(*params) {|smtp| smtp.sendmail(rfc822msg, from, to)} + end + + task :clobber_announcement do + rm 'announcement.txt' rescue nil + end +end # namespace :ann + +desc 'Alias to ann:announcement' +task :ann => 'ann:announcement' + +task :clobber => %w(ann:clobber_announcement) + +# EOF diff --git a/tasks/annotations.rake b/tasks/annotations.rake new file mode 100644 index 0000000..3b455de --- /dev/null +++ b/tasks/annotations.rake @@ -0,0 +1,22 @@ +# $Id$ + +if HAVE_BONES + +desc "Enumerate all annotations" +task :notes do + Bones::AnnotationExtractor.enumerate( + PROJ, PROJ.annotation_tags.join('|'), :tag => true) +end + +namespace :notes do + PROJ.annotation_tags.each do |tag| + desc "Enumerate all #{tag} annotations" + task tag.downcase.to_sym do + Bones::AnnotationExtractor.enumerate(PROJ, tag) + end + end +end + +end # if HAVE_BONES + +# EOF diff --git a/tasks/doc.rake b/tasks/doc.rake new file mode 100644 index 0000000..8a7584f --- /dev/null +++ b/tasks/doc.rake @@ -0,0 +1,49 @@ +# $Id$ + +require 'rake/rdoctask' + +namespace :doc do + + desc 'Generate RDoc documentation' + Rake::RDocTask.new do |rd| + rd.main = PROJ.rdoc_main + rd.rdoc_dir = PROJ.rdoc_dir + + incl = Regexp.new(PROJ.rdoc_include.join('|')) + excl = Regexp.new(PROJ.rdoc_exclude.join('|')) + files = PROJ.files.find_all do |fn| + case fn + when excl; false + when incl; true + else false end + end + rd.rdoc_files.push(*files) + + title = "#{PROJ.name}-#{PROJ.version} Documentation" + title = "#{PROJ.rubyforge_name}'s " + title if PROJ.rubyforge_name != title + + rd.options << "-t #{title}" + rd.options.concat(PROJ.rdoc_opts) + end + + desc 'Generate ri locally for testing' + task :ri => :clobber_ri do + sh "#{RDOC} --ri -o ri ." + end + + desc 'Remove ri products' + task :clobber_ri do + rm_r 'ri' rescue nil + end + +end # namespace :doc + +desc 'Alias to doc:rdoc' +task :doc => 'doc:rdoc' + +desc 'Remove all build products' +task :clobber => %w(doc:clobber_rdoc doc:clobber_ri) + +remove_desc_for_task %w(doc:clobber_rdoc doc:clobber_ri) + +# EOF diff --git a/tasks/gem.rake b/tasks/gem.rake new file mode 100644 index 0000000..2d255b4 --- /dev/null +++ b/tasks/gem.rake @@ -0,0 +1,110 @@ +# $Id$ + +require 'rake/gempackagetask' + +namespace :gem do + + PROJ.spec = Gem::Specification.new do |s| + s.name = PROJ.name + s.version = PROJ.version + s.summary = PROJ.summary + s.authors = Array(PROJ.authors) + s.email = PROJ.email + s.homepage = Array(PROJ.url).first + s.rubyforge_project = PROJ.rubyforge_name + s.post_install_message = PROJ.post_install_message + + s.description = PROJ.description + + PROJ.dependencies.each do |dep| + s.add_dependency(*dep) + end + + s.files = PROJ.files + s.executables = PROJ.executables.map {|fn| File.basename(fn)} + s.extensions = PROJ.files.grep %r/extconf\.rb$/ + + s.bindir = 'bin' + dirs = Dir["{#{PROJ.libs.join(',')}}"] + s.require_paths = dirs unless dirs.empty? + + incl = Regexp.new(PROJ.rdoc_include.join('|')) + excl = PROJ.rdoc_exclude.dup.concat %w[\.rb$ ^(\.\/|\/)?ext] + excl = Regexp.new(excl.join('|')) + rdoc_files = PROJ.files.find_all do |fn| + case fn + when excl; false + when incl; true + else false end + end + s.rdoc_options = PROJ.rdoc_opts + ['--main', PROJ.rdoc_main] + s.extra_rdoc_files = rdoc_files + s.has_rdoc = true + + if test ?f, PROJ.test_file + s.test_file = PROJ.test_file + else + s.test_files = PROJ.tests.to_a + end + + # Do any extra stuff the user wants +# spec_extras.each do |msg, val| +# case val +# when Proc +# val.call(s.send(msg)) +# else +# s.send "#{msg}=", val +# end +# end + end + + desc 'Show information about the gem' + task :debug do + puts PROJ.spec.to_ruby + end + + pkg = Rake::PackageTask.new(PROJ.name, PROJ.version) do |pkg| + pkg.need_tar = PROJ.need_tar + pkg.need_zip = PROJ.need_zip + pkg.package_files += PROJ.spec.files + end + Rake::Task['gem:package'].instance_variable_set(:@full_comment, nil) + + gem_file = if PROJ.spec.platform == Gem::Platform::RUBY + "#{pkg.package_name}.gem" + else + "#{pkg.package_name}-#{PROJ.spec.platform}.gem" + end + + desc "Build the gem file #{gem_file}" + task :package => "#{pkg.package_dir}/#{gem_file}" + + file "#{pkg.package_dir}/#{gem_file}" => [pkg.package_dir] + PROJ.spec.files do + when_writing("Creating GEM") { + Gem::Builder.new(PROJ.spec).build + verbose(true) { + mv gem_file, "#{pkg.package_dir}/#{gem_file}" + } + } + end + + desc 'Install the gem' + task :install => [:clobber, :package] do + sh "#{SUDO} #{GEM} install pkg/#{PROJ.spec.full_name}" + end + + desc 'Uninstall the gem' + task :uninstall do + sh "#{SUDO} #{GEM} uninstall -v '#{PROJ.version}' -x #{PROJ.name}" + end + +end # namespace :gem + +desc 'Alias to gem:package' +task :gem => 'gem:package' + +task :clobber => 'gem:clobber_package' + +remove_desc_for_task %w(gem:clobber_package) + +# EOF diff --git a/tasks/manifest.rake b/tasks/manifest.rake new file mode 100644 index 0000000..eda78f6 --- /dev/null +++ b/tasks/manifest.rake @@ -0,0 +1,50 @@ +# $Id$ + +require 'find' + +namespace :manifest do + + desc 'Verify the manifest' + task :check do + fn = 'Manifest.tmp' + files = manifest_files + + File.open(fn, 'w') {|fp| fp.puts files} + lines = %x(#{DIFF} -du Manifest.txt #{fn}).split("\n") + if HAVE_FACETS_ANSICODE and ENV.has_key?('TERM') + lines.map! do |line| + case line + when %r/^(-{3}|\+{3})/; nil + when %r/^@/; Console::ANSICode.blue line + when %r/^\+/; Console::ANSICode.green line + when %r/^\-/; Console::ANSICode.red line + else line end + end + end + puts lines.compact + rm fn rescue nil + end + + desc 'Create a new manifest' + task :create do + fn = 'Manifest.txt' + files = manifest_files + unless test(?f, fn) + files << fn + files.sort! + end + File.open(fn, 'w') {|fp| fp.puts files} + end + + task :assert do + files = manifest_files + manifest = File.read('Manifest.txt').split($/) + raise RuntimeError, "Manifest.txt is out of date" unless files == manifest + end + +end # namespace :manifest + +desc 'Alias to manifest:check' +task :manifest => 'manifest:check' + +# EOF diff --git a/tasks/post_load.rake b/tasks/post_load.rake new file mode 100644 index 0000000..7bbc87b --- /dev/null +++ b/tasks/post_load.rake @@ -0,0 +1,18 @@ +# $Id$ + +# This file does not define any rake tasks. It is used to load some project +# settings if they are not defined by the user. + +unless PROJ.changes + PROJ.changes = paragraphs_of('History.txt', 0..1).join("\n\n") +end + +unless PROJ.description + PROJ.description = paragraphs_of('README.txt', 'description').join("\n\n") +end + +unless PROJ.summary + PROJ.summary = PROJ.description.split('.').first +end + +# EOF diff --git a/tasks/rubyforge.rake b/tasks/rubyforge.rake new file mode 100644 index 0000000..9ebcf4f --- /dev/null +++ b/tasks/rubyforge.rake @@ -0,0 +1,57 @@ +# $Id$ + +if PROJ.rubyforge_name && HAVE_RUBYFORGE + +require 'rubyforge' +require 'rake/contrib/sshpublisher' + +namespace :gem do + desc 'Package and upload to RubyForge' + task :release => [:clobber, :package] do |t| + v = ENV['VERSION'] or abort 'Must supply VERSION=x.y.z' + abort "Versions don't match #{v} vs #{PROJ.version}" if v != PROJ.version + pkg = "pkg/#{PROJ.spec.full_name}" + + if $DEBUG then + puts "release_id = rf.add_release #{PROJ.rubyforge_name.inspect}, #{PROJ.name.inspect}, #{PROJ.version.inspect}, \"#{pkg}.tgz\"" + puts "rf.add_file #{PROJ.rubyforge_name.inspect}, #{PROJ.name.inspect}, release_id, \"#{pkg}.gem\"" + end + + rf = RubyForge.new + puts 'Logging in' + rf.login + + c = rf.userconfig + c['release_notes'] = PROJ.description if PROJ.description + c['release_changes'] = PROJ.changes if PROJ.changes + c['preformatted'] = true + + files = [(PROJ.need_tar ? "#{pkg}.tgz" : nil), + (PROJ.need_zip ? "#{pkg}.zip" : nil), + "#{pkg}.gem"].compact + + puts "Releasing #{PROJ.name} v. #{PROJ.version}" + rf.add_release PROJ.rubyforge_name, PROJ.name, PROJ.version, *files + end +end # namespace :gem + + +namespace :doc do + desc "Publish RDoc to RubyForge" + task :release => %w(doc:clobber_rdoc doc:rdoc) do + config = YAML.load( + File.read(File.expand_path('~/.rubyforge/user-config.yml')) + ) + + host = "#{config['username']}@rubyforge.org" + remote_dir = "/var/www/gforge-projects/#{PROJ.rubyforge_name}/" + remote_dir << PROJ.rdoc_remote_dir if PROJ.rdoc_remote_dir + local_dir = PROJ.rdoc_dir + + Rake::SshDirPublisher.new(host, remote_dir, local_dir).upload + end +end # namespace :doc + +end # if HAVE_RUBYFORGE + +# EOF diff --git a/tasks/setup.rb b/tasks/setup.rb new file mode 100644 index 0000000..9af8e7a --- /dev/null +++ b/tasks/setup.rb @@ -0,0 +1,221 @@ +# $Id$ + +require 'rubygems' +require 'rake' +require 'fileutils' +require 'ostruct' + +PROJ = OpenStruct.new + +PROJ.name = nil +PROJ.summary = nil +PROJ.description = nil +PROJ.changes = nil +PROJ.authors = nil +PROJ.email = nil +PROJ.url = nil +PROJ.version = ENV['VERSION'] || '0.0.0' +PROJ.rubyforge_name = nil +PROJ.exclude = %w(tmp$ bak$ ~$ CVS .svn/ ^pkg/ ^doc/ announcement.txt) +PROJ.release_name = ENV['RELEASE'] + +# Rspec +PROJ.specs = FileList['spec/**/*_spec.rb'] +PROJ.spec_opts = [] + +# Test::Unit +PROJ.tests = FileList['test/**/test_*.rb'] +PROJ.test_file = 'test/all.rb' +PROJ.test_opts = [] + +# Rcov +PROJ.rcov_opts = ['--sort', 'coverage', '-T'] + +# Rdoc +PROJ.rdoc_opts = [] +PROJ.rdoc_include = %w(^lib/ ^bin/ ^ext/ .txt$) +PROJ.rdoc_exclude = %w(extconf.rb$ ^Manifest.txt$) +PROJ.rdoc_main = 'README.txt' +PROJ.rdoc_dir = 'doc' +PROJ.rdoc_remote_dir = nil + +# Extensions +PROJ.extensions = FileList['ext/**/extconf.rb'] +PROJ.ruby_opts = %w(-w) +PROJ.libs = [] +%w(lib ext).each {|dir| PROJ.libs << dir if test ?d, dir} + +# Gem Packaging +PROJ.files = + if test ?f, 'Manifest.txt' + files = File.readlines('Manifest.txt').map {|fn| fn.chomp.strip} + files.delete '' + files + else [] end +PROJ.executables = PROJ.files.find_all {|fn| fn =~ %r/^bin/} +PROJ.dependencies = [] +PROJ.need_tar = true +PROJ.need_zip = false +PROJ.post_install_message = nil + +# File Annotations +PROJ.annotation_exclude = %w(^tasks/setup.rb$) +PROJ.annotation_extensions = %w(.txt .rb .erb) << '' +PROJ.annotation_tags = %w(FIXME OPTIMIZE TODO) + +# Subversion Repository +PROJ.svn = false +PROJ.svn_root = nil +PROJ.svn_trunk = 'trunk' +PROJ.svn_tags = 'tags' +PROJ.svn_branches = 'branches' + +# Announce +PROJ.ann_text = nil +PROJ.ann_paragraphs = [] +PROJ.ann_email = { + :from => nil, + :to => %w(ruby-talk@ruby-lang.org), + :server => 'localhost', + :port => 25, + :domain => ENV['HOSTNAME'], + :acct => nil, + :passwd => nil, + :authtype => :plain +} + +# Load the other rake files in the tasks folder +rakefiles = Dir.glob('tasks/*.rake').sort +rakefiles.unshift(rakefiles.delete('tasks/post_load.rake')).compact! +import(*rakefiles) + +# Setup some constants +WIN32 = %r/djgpp|(cyg|ms|bcc)win|mingw/ =~ RUBY_PLATFORM unless defined? WIN32 + +DEV_NULL = WIN32 ? 'NUL:' : '/dev/null' + +def quiet( &block ) + io = [STDOUT.dup, STDERR.dup] + STDOUT.reopen DEV_NULL + STDERR.reopen DEV_NULL + block.call +ensure + STDOUT.reopen io.first + STDERR.reopen io.last +end + +DIFF = if WIN32 then 'diff.exe' + else + if quiet {system "gdiff", __FILE__, __FILE__} then 'gdiff' + else 'diff' end + end unless defined? DIFF + +SUDO = if WIN32 then '' + else + if quiet {system 'which sudo'} then 'sudo' + else '' end + end + +RCOV = WIN32 ? 'rcov.bat' : 'rcov' +GEM = WIN32 ? 'gem.bat' : 'gem' + +%w(rcov spec/rake/spectask rubyforge bones facets/ansicode).each do |lib| + begin + require lib + Object.instance_eval {const_set "HAVE_#{lib.tr('/','_').upcase}", true} + rescue LoadError + Object.instance_eval {const_set "HAVE_#{lib.tr('/','_').upcase}", false} + end +end + +# Reads a file at +path+ and spits out an array of the +paragraphs+ +# specified. +# +# changes = paragraphs_of('History.txt', 0..1).join("\n\n") +# summary, *description = paragraphs_of('README.txt', 3, 3..8) +# +def paragraphs_of( path, *paragraphs ) + title = String === paragraphs.first ? paragraphs.shift : nil + ary = File.read(path).delete("\r").split(/\n\n+/) + + result = if title + tmp, matching = [], false + rgxp = %r/^=+\s*#{Regexp.escape(title)}/i + paragraphs << (0..-1) if paragraphs.empty? + + ary.each do |val| + if val =~ rgxp + break if matching + matching = true + rgxp = %r/^=+/i + elsif matching + tmp << val + end + end + tmp + else ary end + + result.values_at(*paragraphs) +end + +# Adds the given gem _name_ to the current project's dependency list. An +# optional gem _version_ can be given. If omitted, the newest gem version +# will be used. +# +def depend_on( name, version = nil ) + spec = Gem.source_index.find_name(name).last + version = spec.version.to_s if version.nil? and !spec.nil? + + PROJ.dependencies << (version.nil? ? [name] : [name, ">= #{version}"]) +end + +# Adds the given arguments to the include path if they are not already there +# +def ensure_in_path( *args ) + args.each do |path| + path = File.expand_path(path) + $:.unshift(path) if test(?d, path) and not $:.include?(path) + end +end + +# Find a rake task using the task name and remove any description text. This +# will prevent the task from being displayed in the list of available tasks. +# +def remove_desc_for_task( names ) + Array(names).each do |task_name| + task = Rake.application.tasks.find {|t| t.name == task_name} + next if task.nil? + task.instance_variable_set :@comment, nil + end +end + +# Change working directories to _dir_, call the _block_ of code, and then +# change back to the original working directory (the current directory when +# this method was called). +# +def in_directory( dir, &block ) + curdir = pwd + begin + cd dir + return block.call + ensure + cd curdir + end +end + +# Scans the current working directory and creates a list of files that are +# candidates to be in the manifest. +# +def manifest_files + files = [] + exclude = Regexp.new(PROJ.exclude.join('|')) + Find.find '.' do |path| + path.sub! %r/^(\.\/|\/)/o, '' + next unless test ?f, path + next if path =~ exclude + files << path + end + files.sort! +end + +# EOF diff --git a/tasks/spec.rake b/tasks/spec.rake new file mode 100644 index 0000000..f7efb42 --- /dev/null +++ b/tasks/spec.rake @@ -0,0 +1,43 @@ +# $Id$ + +if HAVE_SPEC_RAKE_SPECTASK + +namespace :spec do + + desc 'Run all specs with basic output' + Spec::Rake::SpecTask.new(:run) do |t| + t.spec_opts = PROJ.spec_opts + t.spec_files = PROJ.specs + t.libs += PROJ.libs + end + + desc 'Run all specs with text output' + Spec::Rake::SpecTask.new(:specdoc) do |t| + t.spec_opts = PROJ.spec_opts + ['--format', 'specdoc'] + t.spec_files = PROJ.specs + t.libs += PROJ.libs + end + + if HAVE_RCOV + desc 'Run all specs with RCov' + Spec::Rake::SpecTask.new(:rcov) do |t| + t.spec_opts = PROJ.spec_opts + t.spec_files = PROJ.specs + t.libs += PROJ.libs + t.rcov = true + t.rcov_opts = PROJ.rcov_opts + ['--exclude', 'spec'] + end + end + +end # namespace :spec + +desc 'Alias to spec:run' +task :spec => 'spec:run' + +task :clobber => 'spec:clobber_rcov' if HAVE_RCOV + +remove_desc_for_task %w(spec:clobber_rcov) + +end # if HAVE_SPEC_RAKE_SPECTASK + +# EOF diff --git a/tasks/svn.rake b/tasks/svn.rake new file mode 100644 index 0000000..64820fa --- /dev/null +++ b/tasks/svn.rake @@ -0,0 +1,44 @@ +# $Id$ + + +if PROJ.svn and system("svn --version 2>&1 > #{DEV_NULL}") + +unless PROJ.svn_root + info = %x/svn info ./ + m = %r/^Repository Root:\s+(.*)$/.match(info) + PROJ.svn_root = (m.nil? ? '' : m[1]) +end +PROJ.svn_root = File.join(PROJ.svn_root, PROJ.svn) if String === PROJ.svn + +namespace :svn do + + desc 'Show tags from the SVN repository' + task :show_tags do |t| + tags = %x/svn list #{File.join(PROJ.svn_root, PROJ.svn_tags)}/ + tags.gsub!(%r/\/$/, '') + puts tags + end + + desc 'Create a new tag in the SVN repository' + task :create_tag do |t| + v = ENV['VERSION'] or abort 'Must supply VERSION=x.y.z' + abort "Versions don't match #{v} vs #{PROJ.version}" if v != PROJ.version + + trunk = File.join(PROJ.svn_root, PROJ.svn_trunk) + tag = "%s-%s" % [PROJ.name, PROJ.version] + tag = File.join(PROJ.svn_root, PROJ.svn_tags, tag) + msg = "Creating tag for #{PROJ.name} version #{PROJ.version}" + + puts "Creating SVN tag '#{tag}'" + unless system "svn cp -m '#{msg}' #{trunk} #{tag}" + abort "Tag creation failed" + end + end + +end # namespace :svn + +task 'gem:release' => 'svn:create_tag' + +end # if PROJ.svn + +# EOF