├── public ├── favicon.ico ├── images │ └── rails.png ├── robots.txt ├── javascripts │ ├── application.js │ ├── dragdrop.js │ └── controls.js ├── 404.html ├── 500.html ├── dispatch.rb ├── dispatch.cgi ├── stylesheets │ ├── handheld.css │ └── screen.css ├── dispatch.fcgi └── .htaccess ├── .gitignore ├── app ├── helpers │ ├── list_helper.rb │ ├── login_helper.rb │ ├── project_helper.rb │ ├── milestone_helper.rb │ ├── dashboard_helper.rb │ ├── message_helper.rb │ └── application_helper.rb ├── views │ ├── dashboard │ │ ├── index.rhtml │ │ └── .index.rhtml.swp │ ├── message │ │ ├── _messages.rhtml │ │ ├── _message.rhtml │ │ └── index.rhtml │ ├── milestone │ │ ├── _milestones.rhtml │ │ ├── _milestone.rhtml │ │ └── index.rhtml │ ├── project │ │ └── _navigation.rhtml │ ├── list │ │ └── index.rhtml │ ├── layouts │ │ └── standard.rhtml │ └── login │ │ └── login.rhtml ├── models │ ├── feed.rb │ ├── user.rb │ ├── message.rb │ ├── milestone.rb │ ├── todo.rb │ ├── todo_item.rb │ └── project.rb └── controllers │ ├── dashboard_controller.rb │ ├── project_controller.rb │ ├── message_controller.rb │ ├── application_controller.rb │ ├── milestone_controller.rb │ ├── list_controller.rb │ └── login_controller.rb ├── script ├── about ├── plugin ├── runner ├── server ├── console ├── destroy ├── generate ├── breakpointer ├── process │ ├── reaper │ ├── spawner │ └── inspector └── performance │ ├── profiler │ ├── request │ └── benchmarker ├── Rakefile ├── config ├── database.yml ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── routes.rb ├── environment.rb └── boot.rb ├── test ├── functional │ ├── list_controller_test.rb │ ├── login_controller_test.rb │ ├── message_controller_test.rb │ ├── project_controller_test.rb │ ├── dashboard_controller_test.rb │ └── milestone_controller_test.rb └── test_helper.rb ├── lib ├── basecamp_extensions.rb ├── tasks │ └── gems.rake ├── login_system.rb ├── basecamp_wrapper.rb ├── basecamp.rb └── xmlsimple.rb ├── doc └── README_FOR_APP └── README.textile /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .swp 2 | log/* 3 | -------------------------------------------------------------------------------- /app/helpers/list_helper.rb: -------------------------------------------------------------------------------- 1 | module ListHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/login_helper.rb: -------------------------------------------------------------------------------- 1 | module LoginHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/project_helper.rb: -------------------------------------------------------------------------------- 1 | module ProjectHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/milestone_helper.rb: -------------------------------------------------------------------------------- 1 | module MilestoneHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/dashboard/index.rhtml: -------------------------------------------------------------------------------- 1 |

Recent activity

2 | <%= basecamp_rss %> -------------------------------------------------------------------------------- /app/models/feed.rb: -------------------------------------------------------------------------------- 1 | class Feed < BasecampWrapper 2 | map_find_all_to :feed_items 3 | end 4 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < BasecampWrapper 2 | map_finders_to :people, :requires => :company 3 | end -------------------------------------------------------------------------------- /public/images/rails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/basecamp-mobile/master/public/images/rails.png -------------------------------------------------------------------------------- /app/models/message.rb: -------------------------------------------------------------------------------- 1 | class Message < BasecampWrapper 2 | map_finders_to :message_list, :requires => :project 3 | end -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file -------------------------------------------------------------------------------- /script/about: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/about' -------------------------------------------------------------------------------- /script/plugin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/plugin' -------------------------------------------------------------------------------- /script/runner: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/runner' -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/server' -------------------------------------------------------------------------------- /app/models/milestone.rb: -------------------------------------------------------------------------------- 1 | class Milestone < BasecampWrapper 2 | map_finders_to :milestones, :requires => :project 3 | end -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/console' -------------------------------------------------------------------------------- /script/destroy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/destroy' -------------------------------------------------------------------------------- /script/generate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/generate' -------------------------------------------------------------------------------- /app/models/todo.rb: -------------------------------------------------------------------------------- 1 | class Todo < BasecampWrapper 2 | map_finders_to :lists, :requires => :project 3 | has_many :todo_items 4 | end -------------------------------------------------------------------------------- /script/breakpointer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/breakpointer' -------------------------------------------------------------------------------- /app/views/dashboard/.index.rhtml.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/basecamp-mobile/master/app/views/dashboard/.index.rhtml.swp -------------------------------------------------------------------------------- /script/process/reaper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/process/reaper' 4 | -------------------------------------------------------------------------------- /script/process/spawner: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/process/spawner' 4 | -------------------------------------------------------------------------------- /script/process/inspector: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/process/inspector' 4 | -------------------------------------------------------------------------------- /script/performance/profiler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/profiler' 4 | -------------------------------------------------------------------------------- /script/performance/request: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/request' 4 | -------------------------------------------------------------------------------- /app/models/todo_item.rb: -------------------------------------------------------------------------------- 1 | class TodoItem < BasecampWrapper 2 | map_finders_to :get_list, :requires => :todo, :with_key => 'todo-items', :params => [false] 3 | end -------------------------------------------------------------------------------- /script/performance/benchmarker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/benchmarker' 4 | -------------------------------------------------------------------------------- /app/controllers/dashboard_controller.rb: -------------------------------------------------------------------------------- 1 | class DashboardController < ApplicationController 2 | layout 'standard' 3 | 4 | def index 5 | @page_title = 'Dashboard' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Place your application-specific JavaScript functions and classes here 2 | // This file is automatically included by javascript_include_tag :defaults 3 | -------------------------------------------------------------------------------- /app/views/message/_messages.rhtml: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/models/project.rb: -------------------------------------------------------------------------------- 1 | class Project < BasecampWrapper 2 | map_finders_to :projects 3 | has_many :todos, :milestones, :messages 4 | 5 | def self.find_active 6 | # This looks so odd 7 | find_all.find_all { |project| project.status != 'archived' } 8 | end 9 | end -------------------------------------------------------------------------------- /app/views/milestone/_milestones.rhtml: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 |

File not found

6 |

Change this error message for pages not found in public/404.html

7 | 8 | -------------------------------------------------------------------------------- /app/controllers/project_controller.rb: -------------------------------------------------------------------------------- 1 | class ProjectController < ApplicationController 2 | before_filter :load_project 3 | layout 'standard' 4 | 5 | def select_project 6 | redirect_to project_url(:id => params[:id]) 7 | end 8 | 9 | def project 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/message/_message.rhtml: -------------------------------------------------------------------------------- 1 | <% full_message = Message.message(message.id) %> 2 |
  • 3 | [<%= h categories(@project.id).find {|category| category.id == message.category.id}.name %>] <%= h message.title %>, by <%=h message_author(full_message.author_id) %>
    4 | <%= mobile_links(h(full_message.body)) %> 5 |
  • -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 |

    Application error

    6 |

    Change this error message for exceptions thrown outside of an action (like in Dispatcher setups or broken Ruby code) in public/500.html

    7 | 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require(File.join(File.dirname(__FILE__), 'config', 'boot')) 5 | 6 | require 'rake' 7 | require 'rake/testtask' 8 | require 'rake/rdoctask' 9 | 10 | require 'tasks/rails' 11 | -------------------------------------------------------------------------------- /app/views/project/_navigation.rhtml: -------------------------------------------------------------------------------- 1 | <%= link_to_if !['project', 'message'].include?(params[:controller]), 'Messages', project_url(:id => @project.id) %> 2 | | <%= link_to_if params[:controller] != 'list', 'To-Do', todos_url(:id => @project.id) %> 3 | | <%= link_to_if params[:controller] != 'milestone', 'Milestones', milestones_url(:id => @project.id) %> 4 |
    5 | -------------------------------------------------------------------------------- /app/controllers/message_controller.rb: -------------------------------------------------------------------------------- 1 | class MessageController < ApplicationController 2 | before_filter :load_project 3 | layout 'standard' 4 | 5 | def index 6 | end 7 | 8 | def create 9 | Message.post_message(@project.id, { :title => params[:title], :body => params[:body], :category_id => params[:category_id], :private => params[:private] || 0 }) 10 | flash[:notice] = 'Message posted' 11 | redirect_to project_url(:id => @project.id) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/milestone/_milestone.rhtml: -------------------------------------------------------------------------------- 1 |
  • 2 | <% if milestone.completed %> 3 | <%= h(milestone.title) + ' (completed)' %> 4 | <% else %> 5 | <% form_tag complete_milestone_url(:id => @project.id, :milestone_id => milestone.id) do %> 6 | <%=h milestone.title %> (deadline: <%=d milestone.deadline %>)
    7 | <%= submit_tag 'complete' %> 8 | <% end %> 9 | <% end %> 10 |
  • -------------------------------------------------------------------------------- /app/views/milestone/index.rhtml: -------------------------------------------------------------------------------- 1 | <%= render :partial => 'project/navigation' %> 2 | 3 |

    Milestones

    4 | 5 | <%= render :partial => 'milestones' %> 6 | 7 |

    Create a milestone

    8 | 9 |
    10 | Title
    11 | <%= text_field 'milestone', 'title' %>
    12 | Deadline
    13 | <%= date_select 'milestone', 'deadline' %>
    14 | <%= submit_tag 'create' %> 15 |
    16 | -------------------------------------------------------------------------------- /app/helpers/dashboard_helper.rb: -------------------------------------------------------------------------------- 1 | module DashboardHelper 2 | def basecamp_rss 3 | html = '' 4 | last_date = nil 5 | 6 | Feed.find_all.each do |item| 7 | item_date = d(item['pubDate'][0].to_date) 8 | html << content_tag('h3', item_date) if item_date != last_date 9 | html << item['title'][0] 10 | html << tag('br') 11 | last_date = item_date 12 | end 13 | 14 | return html 15 | rescue Exception 16 | content_tag 'p', 'No activity' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /public/dispatch.rb: -------------------------------------------------------------------------------- 1 | #!C:/InstantRails/ruby/bin/ruby 2 | 3 | require File.dirname(__FILE__) + "/../config/environment" unless defined?(RAILS_ROOT) 4 | 5 | # If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: 6 | # "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired 7 | require "dispatcher" 8 | 9 | ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) 10 | Dispatcher.dispatch -------------------------------------------------------------------------------- /public/dispatch.cgi: -------------------------------------------------------------------------------- 1 | #!C:/InstantRails/ruby/bin/ruby 2 | 3 | require File.dirname(__FILE__) + "/../config/environment" unless defined?(RAILS_ROOT) 4 | 5 | # If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: 6 | # "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired 7 | require "dispatcher" 8 | 9 | ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) 10 | Dispatcher.dispatch -------------------------------------------------------------------------------- /app/helpers/message_helper.rb: -------------------------------------------------------------------------------- 1 | module MessageHelper 2 | def options_for_categories(categories) 3 | categories.collect {|category| content_tag('option', category.name, :value => category.id) }.join("\n") 4 | end 5 | 6 | # Caches categories per-project 7 | # TODO: Make this use cache 8 | def categories(project_id) 9 | Message.message_categories(project_id) 10 | end 11 | 12 | # Caches people 13 | # TODO: Make this use cache 14 | def message_author(id) 15 | user = User.find(id) 16 | user.first_name 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: mysql 3 | database: 4 | host: localhost 5 | username: root 6 | password: 7 | 8 | # Warning: The database defined as 'test' will be erased and 9 | # re-generated from your development database when you run 'rake'. 10 | # Do not set this db to the same as development or production. 11 | test: 12 | adapter: mysql 13 | database: 14 | host: localhost 15 | username: root 16 | password: 17 | 18 | production: 19 | adapter: mysql 20 | database: 21 | host: 22 | username: 23 | password: 24 | -------------------------------------------------------------------------------- /test/functional/list_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'list_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class ListController; def rescue_action(e) raise e end; end 6 | 7 | class ListControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = ListController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/functional/login_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'login_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class LoginController; def rescue_action(e) raise e end; end 6 | 7 | class LoginControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = LoginController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/functional/message_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'message_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class MessageController; def rescue_action(e) raise e end; end 6 | 7 | class MessageControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = MessagesController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/functional/project_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'project_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class ProjectController; def rescue_action(e) raise e end; end 6 | 7 | class ProjectControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = ProjectController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/functional/dashboard_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'dashboard_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class DashboardController; def rescue_action(e) raise e end; end 6 | 7 | class DashboardControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = DashboardController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/functional/milestone_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | require 'milestone_controller' 3 | 4 | # Re-raise errors caught by the controller. 5 | class MilestoneController; def rescue_action(e) raise e end; end 6 | 7 | class MilestoneControllerTest < Test::Unit::TestCase 8 | def setup 9 | @controller = MilestoneController.new 10 | @request = ActionController::TestRequest.new 11 | @response = ActionController::TestResponse.new 12 | end 13 | 14 | # Replace this with your real tests. 15 | def test_truth 16 | assert true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /public/stylesheets/handheld.css: -------------------------------------------------------------------------------- 1 | body { font-size: 12px; background-color: white; font-family: "Helvetica Neue", Helvetica, sans-serif; } 2 | 3 | h1 { font-family: Helvetica, sans-serif; font-size: large; margin: 0; font-weight: bold; color: #411; } 4 | h2 { font-family: Helvetica, sans-serif; font-size: medium; margin: 0; padding-top: 15px; padding-bottom: 5px; color: #aaa; } 5 | h3, h4 { margin: 2px 0 4px 0; padding: 0; color: #411; font-size: normal} 6 | h4 { text-decoration: underline; margin: 0; } 7 | p { margin: 10px 0 0 0; padding: 0; } 8 | 9 | dt { float: left; width: 120px; font-size: 14px; line-height: 24px;} 10 | dd { margin: 0 0 5px 90px; font-size: 11px; line-height: 24px; color: #666; margin-left: 80px; } 11 | 12 | li { margin-bottom: 5px; } 13 | -------------------------------------------------------------------------------- /public/stylesheets/screen.css: -------------------------------------------------------------------------------- 1 | body { font-size: 12px; background-color: white; font-family: "Helvetica Neue", Helvetica, sans-serif; padding: 0.7em 4em 2em 4em; } 2 | 3 | h1 { font-family: Helvetica, sans-serif; font-size: large; margin: 0; font-weight: bold; color: #411; } 4 | h2 { font-family: Helvetica, sans-serif; font-size: medium; margin: 0; padding-top: 15px; padding-bottom: 5px; color: #aaa; } 5 | h3, h4 { margin: 2px 0 4px 0; padding: 0; color: #411; font-size: normal} 6 | h4 { text-decoration: underline; margin: 0; } 7 | p { margin: 10px 0 0 0; padding: 0; } 8 | 9 | dt { float: left; width: 120px; font-size: 14px; line-height: 24px;} 10 | dd { margin: 0 0 5px 90px; font-size: 11px; line-height: 24px; color: #666; margin-left: 80px; } 11 | 12 | li { margin-bottom: 5px; } 13 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # Filters added to this controller will be run for all controllers in the application. 2 | # Likewise, all the methods added will be available for all controllers. 3 | class ApplicationController < ActionController::Base 4 | include LoginSystem 5 | 6 | before_filter :authenticate, :except => [:login, :authenticate, :logout] 7 | 8 | private 9 | 10 | def authenticate 11 | # Fetch the basecamp connection out of the session 12 | BasecampWrapper.basecamp = session[:bc] 13 | 14 | # Authenticate 15 | login_required(BasecampWrapper.basecamp) 16 | end 17 | 18 | def load_project 19 | @project = Project.find(params[:id]) 20 | @page_title = "#{@project.company.name}: #{@project.name}" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/milestone_controller.rb: -------------------------------------------------------------------------------- 1 | class MilestoneController < ApplicationController 2 | before_filter :load_project 3 | layout 'standard' 4 | 5 | def index 6 | @milestones = @project.milestones 7 | end 8 | 9 | def complete 10 | Milestone.complete_milestone(@params[:milestone_id]) 11 | flash[:notice] = 'Milestone completed' 12 | redirect_to milestones_url(:id => @project.id) 13 | end 14 | 15 | def create 16 | Milestone.create_milestone(params[:id], {:title => params[:milestone][:title], :deadline => "#{params[:milestone]["deadline(1i)"]}/#{params[:milestone]["deadline(2i)"]}/#{params[:milestone]["deadline(3i)"]}".to_date}) 17 | flash[:notice] = 'Milestone created' 18 | redirect_to milestones_url(:id => @project.id) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/basecamp_extensions.rb: -------------------------------------------------------------------------------- 1 | class Basecamp 2 | attr_reader :url, :user_name, :password 3 | 4 | def test_connection 5 | projects 6 | end 7 | 8 | def feed_items 9 | response = post('/feed/recent_items_rss', {}, "Content-Type" => 'xml') 10 | 11 | if response.code.to_i / 100 == 2 12 | result = XmlSimple.xml_in(response.body) 13 | result['channel'][0]['item'] 14 | elsif response.code == "302" && !second_try 15 | connect!(@url, !@use_ssl) 16 | request(path, {}, true) 17 | else 18 | raise "#{response.message} (#{response.code})" 19 | end 20 | end 21 | 22 | def late_milestones(project_id) 23 | self.milestones(project_id).find_all {|milestone| milestone.deadline < Date.today and !milestone.completed } 24 | end 25 | end -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # In the development environment your application's code is reloaded on 4 | # every request. This slows down response time but is perfect for development 5 | # since you don't have to restart the webserver when you make code changes. 6 | config.cache_classes = false 7 | 8 | # Log error messages when you accidentally call methods on nil. 9 | config.whiny_nils = true 10 | 11 | # Show full error reports and disable caching 12 | config.action_controller.consider_all_requests_local = true 13 | config.action_controller.perform_caching = false 14 | config.action_view.debug_rjs = true 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false 18 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # The production environment is meant for finished, "live" apps. 4 | # Code is not reloaded between requests 5 | config.cache_classes = true 6 | 7 | # Use a different logger for distributed setups 8 | # config.logger = SyslogLogger.new 9 | 10 | # Full error reports are disabled and caching is turned on 11 | config.action_controller.consider_all_requests_local = false 12 | config.action_controller.perform_caching = true 13 | 14 | # Enable serving of images, stylesheets, and javascripts from an asset server 15 | # config.action_controller.asset_host = "http://assets.example.com" 16 | 17 | # Disable delivery errors if you bad email addresses should just be ignored 18 | # config.action_mailer.raise_delivery_errors = false 19 | -------------------------------------------------------------------------------- /app/views/message/index.rhtml: -------------------------------------------------------------------------------- 1 | <%= render :partial => 'project/navigation' %> 2 | 3 |

    Latest messages

    4 | <%= render :partial => 'messages' %> 5 | 6 |

    Post a new message

    7 |
    8 |
    9 | 10 | 11 |
    Category
    12 |
    13 |
    Title
    14 |
    15 |
    Body
    16 |
    17 |
    Private?
    18 |
    19 |
     
    20 |
    21 |
    22 |
    23 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | config.cache_classes = true 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.action_controller.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Tell ActionMailer not to deliver emails to the real world. 17 | # The :test delivery method accumulates sent emails in the 18 | # ActionMailer::Base.deliveries array. 19 | config.action_mailer.delivery_method = :test -------------------------------------------------------------------------------- /public/dispatch.fcgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # You may specify the path to the FastCGI crash log (a log of unhandled 4 | # exceptions which forced the FastCGI instance to exit, great for debugging) 5 | # and the number of requests to process before running garbage collection. 6 | # 7 | # By default, the FastCGI crash log is RAILS_ROOT/log/fastcgi.crash.log 8 | # and the GC period is nil (turned off). A reasonable number of requests 9 | # could range from 10-100 depending on the memory footprint of your app. 10 | # 11 | # Example: 12 | # # Default log path, normal GC behavior. 13 | # RailsFCGIHandler.process! 14 | # 15 | # # Default log path, 50 requests between GC. 16 | # RailsFCGIHandler.process! nil, 50 17 | # 18 | # # Custom log path, normal GC behavior. 19 | # RailsFCGIHandler.process! '/var/log/myapp_fcgi_crash.log' 20 | # 21 | require File.dirname(__FILE__) + "/../config/environment" 22 | require 'fcgi_handler' 23 | 24 | RailsFCGIHandler.process! 25 | -------------------------------------------------------------------------------- /app/views/list/index.rhtml: -------------------------------------------------------------------------------- 1 | <%= render :partial => 'project/navigation' %> 2 | 3 |

    Active to-do lists

    4 | 5 | <% @todos.each do |id, todo| %> 6 |

    <%= todo.name %>

    7 | 27 | <% end %> -------------------------------------------------------------------------------- /app/views/layouts/standard.rhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Basecamp Mobile<%= ": #{@page_title}" if @page_title %> 6 | 7 | 8 | 9 | 10 |

    Basecamp mobile

    11 | <%= content_tag('h2', @page_title) if @page_title %> 12 | <%= navigation if session[:bc] %> 13 | <%= standard_messages %> 14 | <%= yield %> 15 |
    16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/controllers/list_controller.rb: -------------------------------------------------------------------------------- 1 | class ListController < ApplicationController 2 | before_filter :load_project 3 | layout 'standard' 4 | 5 | def index 6 | @page_title = 'List' 7 | @todos = {} 8 | @list_items = {} 9 | 10 | @project.todos.each do |todo| 11 | show_list = false 12 | 13 | todo.todo_items.sort {|x, y| x.completed.to_s <=> y.completed.to_s }[0..5].each do |list_item| 14 | @list_items[todo.id] ||= [] 15 | @list_items[todo.id].push list_item 16 | show_list = true if not list_item.completed 17 | end 18 | 19 | @todos[todo.id] = todo if show_list 20 | end 21 | end 22 | 23 | def complete 24 | Todo.complete_item(params[:todo_id]) 25 | flash[:notice] = 'To do item completed' 26 | redirect_to todos_url(:id => @project.id) 27 | end 28 | 29 | def create 30 | Todo.create_item(params[:todo_id], params[:content]) 31 | flash[:notice] = 'Message posted' 32 | redirect_to todos_url(:id => @project.id) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tasks/gems.rake: -------------------------------------------------------------------------------- 1 | desc "Copy third-party gems into ./lib" 2 | task :freeze_other_gems do 3 | # TODO Get this list from parsing environment.rb 4 | libraries = %w(xml-simple) 5 | require 'rubygems' 6 | require 'find' 7 | 8 | libraries.each do |library| 9 | library_gem = Gem.cache.search(library).sort_by { |g| g.version }.last 10 | puts "Freezing #{library} for #{library_gem.version}..." 11 | 12 | # TODO Add dependencies to list of libraries to freeze 13 | #library_gem.dependencies.each { |g| libraries << g } 14 | 15 | folder_for_library = "#{library_gem.name}-#{library_gem.version}" 16 | system "cd vendor; gem unpack -v '#{library_gem.version}' #{library_gem.name};" 17 | 18 | # Copy files recursively to ./lib 19 | folder_for_library_with_lib = "vendor/#{folder_for_library}/lib/" 20 | Find.find(folder_for_library_with_lib) do |original_file| 21 | destination_file = "./lib/" + original_file.gsub(folder_for_library_with_lib, '') 22 | 23 | if File.directory?(original_file) 24 | if !File.exist?(destination_file) 25 | Dir.mkdir destination_file 26 | end 27 | else 28 | File.copy original_file, destination_file 29 | end 30 | end 31 | 32 | system "rm -r vendor/#{folder_for_library}" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | # General Apache options 2 | AddHandler fastcgi-script .fcgi 3 | AddHandler cgi-script .cgi 4 | Options +FollowSymLinks +ExecCGI 5 | 6 | # If you don't want Rails to look in certain directories, 7 | # use the following rewrite rules so that Apache won't rewrite certain requests 8 | # 9 | # Example: 10 | # RewriteCond %{REQUEST_URI} ^/notrails.* 11 | # RewriteRule .* - [L] 12 | 13 | # Redirect all requests not available on the filesystem to Rails 14 | # By default the cgi dispatcher is used which is very slow 15 | # 16 | # For better performance replace the dispatcher with the fastcgi one 17 | # 18 | # Example: 19 | # RewriteRule ^(.*)$ dispatch.fcgi [QSA,L] 20 | RewriteEngine On 21 | 22 | # If your Rails application is accessed via an Alias directive, 23 | # then you MUST also set the RewriteBase in this htaccess file. 24 | # 25 | # Example: 26 | # Alias /myrailsapp /path/to/myrailsapp/public 27 | # RewriteBase /myrailsapp 28 | 29 | RewriteRule ^$ index.html [QSA] 30 | RewriteRule ^([^.]+)$ $1.html [QSA] 31 | RewriteCond %{REQUEST_FILENAME} !-f 32 | RewriteRule ^(.*)$ dispatch.cgi [QSA,L] 33 | 34 | # In case Rails experiences terminal errors 35 | # Instead of displaying this message you can supply a file here which will be rendered instead 36 | # 37 | # Example: 38 | # ErrorDocument 500 /500.html 39 | 40 | ErrorDocument 500 "

    Application error

    Rails application failed to start properly" -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require File.expand_path(File.dirname(__FILE__) + "/../config/environment") 3 | require 'test_help' 4 | 5 | class Test::Unit::TestCase 6 | # Transactional fixtures accelerate your tests by wrapping each test method 7 | # in a transaction that's rolled back on completion. This ensures that the 8 | # test database remains unchanged so your fixtures don't have to be reloaded 9 | # between every test method. Fewer database queries means faster tests. 10 | # 11 | # Read Mike Clark's excellent walkthrough at 12 | # http://clarkware.com/cgi/blosxom/2005/10/24#Rails10FastTesting 13 | # 14 | # Every Active Record database supports transactions except MyISAM tables 15 | # in MySQL. Turn off transactional fixtures in this case; however, if you 16 | # don't care one way or the other, switching from MyISAM to InnoDB tables 17 | # is recommended. 18 | self.use_transactional_fixtures = true 19 | 20 | # Instantiated fixtures are slow, but give you @david where otherwise you 21 | # would need people(:david). If you don't want to migrate your existing 22 | # test cases which use the @david style and don't mind the speed hit (each 23 | # instantiated fixtures translates to a database query per test method), 24 | # then set this back to true. 25 | self.use_instantiated_fixtures = false 26 | 27 | # Add more helper methods to be used by all tests here... 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/login_controller.rb: -------------------------------------------------------------------------------- 1 | class LoginController < ApplicationController 2 | layout 'standard' 3 | 4 | def login 5 | @page_title = 'Login' 6 | end 7 | 8 | # Authenticate using the basecamp API, and create a session object containing 9 | # the authentication details. 10 | # 11 | # This method uses an exception handler to try and give good user feedback 12 | # on login issues. 13 | def authenticate 14 | session[:bc] = nil 15 | 16 | begin 17 | BasecampWrapper.basecamp = Basecamp.new(params[:url].gsub(/http:\/\//, ''), params[:username], params[:password]) 18 | 19 | # Test the login details are correct by accessing the API 20 | BasecampWrapper.basecamp.test_connection 21 | 22 | session[:bc] = BasecampWrapper.basecamp 23 | flash[:notice] = 'You are now logged in' 24 | rescue SocketError 25 | flash[:error] = 'Incorrect login details' 26 | rescue Errno::EADDRNOTAVAIL, Errno::EBADF 27 | flash[:error] = 'Incorrect login details' 28 | rescue RuntimeError => message 29 | case message 30 | when /401/ 31 | flash[:error] = 'Incorrect login details' 32 | when /403/ 33 | flash[:error] = 'Please check the API is enabled for your account' 34 | when /404/ 35 | flash[:error] = 'Incorrect login details' 36 | end 37 | rescue Errno::ECONNREFUSED 38 | flash[:error] = 'Connection refused.' 39 | end 40 | 41 | redirect_to '/' 42 | end 43 | 44 | def logout 45 | reset_session 46 | redirect_to '/' 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/views/login/login.rhtml: -------------------------------------------------------------------------------- 1 |

    Disclaimer

    2 |

    BasecampMobile accesses your Basecamp account AND CAN MODIFY YOUR DATA!

    3 |

    As such, we cannot be held resposible if you lose data through the use of this program.

    4 |

    By logging in, you agree to these terms.

    5 | 6 |

    This application currently does not support SSL (https).

    7 | 8 |
    9 |
    10 |
    Username
    11 |
    12 |
    Password
    13 |
    14 |
    Basecamp URL
    15 |
    16 |
     
    17 |
    18 | 19 | 20 |
    21 | 22 |

    About

    23 |

    This program has been developed by Helicoid and is designed to allow you 24 | to access your Basecamp account with your mobile phone. Basecamp is a web-based application developed 25 | by 37signals.

    26 |

    Download a copy of the code that runs this software from: http://code.helicoid.net

    27 | 28 |

    Logging in

    29 |

    To login, enter your Basecamp login details and your Basecamp account's URL.

    30 |

    Please make sure API access has been enabled in Basecamp! Login to your Basecamp 31 | account and look at the setting under the 'Account' tab entitled 'Basecamp API'.

    32 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ActionController::Routing::Routes.draw do |map| 2 | # The priority is based upon order of creation: first created -> highest priority. 3 | 4 | # Sample of regular route: 5 | # map.connect 'products/:id', :controller => 'catalog', :action => 'view' 6 | # Keep in mind you can assign values other than :controller and :action 7 | 8 | # Sample of named route: 9 | # map.purchase 'products/:id/purchase', :controller => 'catalog', :action => 'purchase' 10 | # This route can be invoked with purchase_url(:id => product.id) 11 | 12 | # You can have the root of your site routed by hooking up '' 13 | # -- just remember to delete public/index.html. 14 | 15 | map.dashboard '', :controller => 'dashboard', :action => 'index' 16 | map.connect 'login', :controller => 'login', :action => 'login' 17 | map.connect 'authenticate', :controller => 'login', :action => 'authenticate' 18 | map.logout 'logout', :controller => 'login', :action => 'logout' 19 | 20 | map.project 'projects/:id', :controller => 'message', :action => 'index' 21 | map.project_select 'project_select/', :controller => 'project', :action => 'select_project' 22 | 23 | map.todos 'projects/:id/todos', :controller => 'list', :action => 'index' 24 | map.complete_todo 'projects/:id/todos/:todo_id', :controller => 'list', :action => 'complete' 25 | map.create_todo 'projects/:id/todos/:todo_id/create', :controller => 'list', :action => 'create' 26 | 27 | map.create_milestone 'projects/:id/milestone/create', :controller => 'milestone', :action => 'create' 28 | map.milestones 'projects/:id/milestones', :controller => 'milestone', :action => 'index' 29 | map.complete_milestone 'projects/:id/milestones/:milestone_id/complete', :controller => 'milestone', :action => 'complete' 30 | 31 | map.post_message 'projects/:id/create_message', :controller => 'message', :action => 'create' 32 | 33 | # Allow downloading Web Service WSDL as a file with an extension 34 | # instead of a file named 'wsdl' 35 | map.connect ':controller/service.wsdl', :action => 'wsdl' 36 | 37 | # Install the default route as the lowest priority. 38 | map.connect ':controller/:action/:id' 39 | end 40 | -------------------------------------------------------------------------------- /lib/login_system.rb: -------------------------------------------------------------------------------- 1 | module LoginSystem 2 | 3 | protected 4 | 5 | # overwrite this if you want to restrict access to only a few actions 6 | # or if you want to check if the user has the correct rights 7 | # example: 8 | # 9 | # # only allow nonbobs 10 | # def authorize?(user) 11 | # user.login != "bob" 12 | # end 13 | def authorize?(user) 14 | true 15 | end 16 | 17 | # overwrite this method if you only want to protect certain actions of the controller 18 | # example: 19 | # 20 | # # don't protect the login and the about method 21 | # def protect?(action) 22 | # if ['action', 'about'].include?(action) 23 | # return false 24 | # else 25 | # return true 26 | # end 27 | # end 28 | def protect?(action) 29 | true 30 | end 31 | 32 | # login_required filter. add 33 | # 34 | # before_filter :login_required 35 | # 36 | # if the controller should be under any rights management. 37 | # for finer access control you can overwrite 38 | # 39 | # def authorize?(user) 40 | # 41 | def login_required(user) 42 | 43 | if not protect?(action_name) 44 | return true 45 | end 46 | 47 | if user and authorize?(user) 48 | return true 49 | end 50 | 51 | # store current location so that we can 52 | # come back after the user logged in 53 | store_location 54 | 55 | # call overwriteable reaction to unauthorized access 56 | access_denied 57 | return false 58 | end 59 | 60 | # overwrite if you want to have special behavior in case the user is not authorized 61 | # to access the current operation. 62 | # the default action is to redirect to the login screen 63 | # example use : 64 | # a popup window might just close itself for instance 65 | def access_denied 66 | redirect_to '/login' 67 | end 68 | 69 | # store current uri in the session. 70 | # we can return to this location by calling return_location 71 | def store_location 72 | session[:return_to] = request.request_uri 73 | end 74 | 75 | # move to the last store_location call or to the passed default one 76 | def redirect_back_or_default(default) 77 | if session[:return_to].nil? 78 | redirect_to default 79 | else 80 | redirect_to_url session[:return_to] 81 | session[:return_to] = nil 82 | end 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your web server when you modify this file. 2 | 3 | # Uncomment below to force Rails into production mode when 4 | # you don't control web/app server and can't set it the proper way 5 | # ENV['RAILS_ENV'] ||= 'production' 6 | 7 | # Specifies gem version of Rails to use when vendor/rails is not present 8 | 9 | # Bootstrap the Rails environment, frameworks, and default configuration 10 | require File.join(File.dirname(__FILE__), 'boot') 11 | 12 | Rails::Initializer.run do |config| 13 | # Settings in config/environments/* take precedence those specified here 14 | 15 | # Skip frameworks you're not going to use 16 | # config.frameworks -= [ :action_web_service, :action_mailer ] 17 | 18 | 19 | config.action_controller.session = { :session_key => '_basecamp_mobile_session', :secret => "This really isn't a secret seeing as it's in the public source code for this project" } 20 | config.frameworks -= [ :active_record ] 21 | 22 | # Add additional load paths for your own custom dirs 23 | # config.load_paths += %W( #{RAILS_ROOT}/extras ) 24 | 25 | # Force all environments to use the same logger level 26 | # (by default production uses :info, the others :debug) 27 | # config.log_level = :debug 28 | 29 | # Use the database for sessions instead of the file system 30 | # (create the session table with 'rake db:sessions:create') 31 | # config.action_controller.session_store = :memory_store 32 | 33 | # Use SQL instead of Active Record's schema dumper when creating the test database. 34 | # This is necessary if your schema can't be completely dumped by the schema dumper, 35 | # like if you have constraints or database-specific column types 36 | # config.active_record.schema_format = :sql 37 | 38 | # Activate observers that should always be running 39 | # config.active_record.observers = :cacher, :garbage_collector 40 | 41 | # Make Active Record use UTC-base instead of local time 42 | # config.active_record.default_timezone = :utc 43 | 44 | # See Rails::Configuration for more options 45 | end 46 | 47 | # Add new inflection rules using the following format 48 | # (all these examples are active by default): 49 | # Inflector.inflections do |inflect| 50 | # inflect.plural /^(ox)$/i, '\1en' 51 | # inflect.singular /^(ox)en/i, '\1' 52 | # inflect.irregular 'person', 'people' 53 | # inflect.uncountable %w( fish sheep ) 54 | # end 55 | 56 | # Include your application configuration below 57 | require 'basecamp' 58 | require 'basecamp_extensions' 59 | require 'basecamp_wrapper' 60 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # Don't change this file! 2 | # Configure your app in config/environment.rb and config/environments/*.rb 3 | 4 | RAILS_ROOT = "#{File.dirname(__FILE__)}/.." unless defined?(RAILS_ROOT) 5 | 6 | module Rails 7 | class << self 8 | def boot! 9 | unless booted? 10 | preinitialize 11 | pick_boot.run 12 | end 13 | end 14 | 15 | def booted? 16 | defined? Rails::Initializer 17 | end 18 | 19 | def pick_boot 20 | (vendor_rails? ? VendorBoot : GemBoot).new 21 | end 22 | 23 | def vendor_rails? 24 | File.exist?("#{RAILS_ROOT}/vendor/rails") 25 | end 26 | 27 | def preinitialize 28 | load(preinitializer_path) if File.exist?(preinitializer_path) 29 | end 30 | 31 | def preinitializer_path 32 | "#{RAILS_ROOT}/config/preinitializer.rb" 33 | end 34 | end 35 | 36 | class Boot 37 | def run 38 | load_initializer 39 | Rails::Initializer.run(:set_load_path) 40 | end 41 | end 42 | 43 | class VendorBoot < Boot 44 | def load_initializer 45 | require "#{RAILS_ROOT}/vendor/rails/railties/lib/initializer" 46 | Rails::Initializer.run(:install_gem_spec_stubs) 47 | Rails::GemDependency.add_frozen_gem_path 48 | end 49 | end 50 | 51 | class GemBoot < Boot 52 | def load_initializer 53 | self.class.load_rubygems 54 | load_rails_gem 55 | require 'initializer' 56 | end 57 | 58 | def load_rails_gem 59 | if version = self.class.gem_version 60 | gem 'rails', version 61 | else 62 | gem 'rails' 63 | end 64 | rescue Gem::LoadError => load_error 65 | $stderr.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.) 66 | exit 1 67 | end 68 | 69 | class << self 70 | def rubygems_version 71 | Gem::RubyGemsVersion rescue nil 72 | end 73 | 74 | def gem_version 75 | if defined? RAILS_GEM_VERSION 76 | RAILS_GEM_VERSION 77 | elsif ENV.include?('RAILS_GEM_VERSION') 78 | ENV['RAILS_GEM_VERSION'] 79 | else 80 | parse_gem_version(read_environment_rb) 81 | end 82 | end 83 | 84 | def load_rubygems 85 | require 'rubygems' 86 | min_version = '1.3.1' 87 | unless rubygems_version >= min_version 88 | $stderr.puts %Q(Rails requires RubyGems >= #{min_version} (you have #{rubygems_version}). Please `gem update --system` and try again.) 89 | exit 1 90 | end 91 | 92 | rescue LoadError 93 | $stderr.puts %Q(Rails requires RubyGems >= #{min_version}. Please install RubyGems and try again: http://rubygems.rubyforge.org) 94 | exit 1 95 | end 96 | 97 | def parse_gem_version(text) 98 | $1 if text =~ /^[^#]*RAILS_GEM_VERSION\s*=\s*["']([!~<>=]*\s*[\d.]+)["']/ 99 | end 100 | 101 | private 102 | def read_environment_rb 103 | File.read("#{RAILS_ROOT}/config/environment.rb") 104 | end 105 | end 106 | end 107 | end 108 | 109 | # All that for this: 110 | Rails.boot! 111 | -------------------------------------------------------------------------------- /doc/README_FOR_APP: -------------------------------------------------------------------------------- 1 | = Basecamp Mobile 2 | 3 | == Introduction 4 | 5 | This is a small rails application that provides access to Basecamp (http://www.basecamphq.com) in a format suitable for mobile phones. 6 | 7 | Basecamp Mobile was created by Alex Young (http://alexyoung.org) and is released under the MIT license by Helicoid (http://helicoid.net). 8 | 9 | Run "rake appdoc" to generate API documentation. 10 | 11 | 12 | == Installation and usage 13 | 14 | Installation depends on how your server is set up. However, you can start it up quickly by using WEBrick from the command line: 15 | 16 | ruby script/server 17 | 18 | 19 | == Hacking 20 | 21 | Since Basecamp Mobile has been packaged as a rails application, some effort has gone into making the "models" appear like ActiveRecord objects. This is largely to keep code in controllers and views simple and familiar. 22 | 23 | basecamp.rb provides a ruby abstraction of the Basecamp API. I have written basecamp_extensions.rb and basecamp_wrapper.rb to add a few extra things to basecamp.rb. By keeping my extensions separate and using some meta-programming it should provide some abstraction between Basecamp Mobile and the API, should the API change in the future. New features in rails or suitable libraries will hopefully make the wrapper obsolete. 24 | 25 | Models can be used in a familiar fashion: 26 | 27 | Project.find(project_id) 28 | Project.find_all 29 | Project.find(project_id).messages 30 | Project.find(project_id).milestones 31 | Project.find(project_id).todos 32 | 33 | == Help wanted 34 | 35 | I haven't had time to develop a caching strategy so far, and if you use Basecamp Mobile for any length of time you might notice it's slightly slow. To get started I used a few helpers that cache objects in the session, and I used the Basecamp Wrapper class to begin work on creating (what should be eventually) ActiveRecord-like objects. A good strategy will probably use this, and creating an observer-like caching system using what's already in rails. 36 | 37 | To get talking to me about this, or submit your own code, begin a conversation on our forum (http://forums.helicoid.net) or send a quick message to me (http://alexyoung.org/contact). 38 | 39 | == Libraries 40 | 41 | The following libraries have been used: 42 | 43 | * basecamp.rb, provided by 37signals 44 | * login_system.rb, from Typo 45 | 46 | 47 | == License 48 | 49 | This software is released under the MIT license. 50 | 51 | Copyright (c) 2006 Alex Young, Helicoid 52 | 53 | 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: 54 | 55 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 56 | 57 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # Methods added to this helper will be available to all templates in the application. 2 | module ApplicationHelper 3 | # Display the standard feedback messages 4 | def standard_messages 5 | html = '' 6 | [:notice, :error].each do |flash_type| 7 | if flash[flash_type] != nil 8 | html <<=<#{flash[flash_type]}
    10 | 11 | EOD 12 | flash[flash_type] = nil 13 | end 14 | end 15 | 16 | if html.size > 0 17 | return html + tag('hr') 18 | else 19 | return '' 20 | end 21 | end 22 | 23 | def navigation 24 | projects = Project.find_active 25 | 26 | return '' if projects.nil? 27 | 28 | if @project 29 | project_id = @project.id 30 | else 31 | project_id = nil 32 | end 33 | 34 | html = link_to_if !current_page?(:controller => 'dashboard', :action => 'index'), 'Dashboard', dashboard_url 35 | html << ' | ' + link_to('Logout', logout_url) 36 | html << tag('br') 37 | html << project_select_form(project_id) 38 | html << tag('hr') 39 | end 40 | 41 | def project_select_form(project_id) 42 | html = form_tag(project_select_url, :method => 'post') 43 | html << content_tag('select', project_select_optgroup(project_id), :name => 'id') + submit_tag('go') 44 | html << '' 45 | end 46 | 47 | def project_select_optgroup(project_id) 48 | projects = Project.find_active 49 | html = content_tag('option', 'Your projects') 50 | 51 | companies = projects.collect { |project| [project.company.id, project.company.name] }.uniq 52 | companies.each do |company| 53 | html << content_tag('optgroup', 54 | projects.collect do |project| 55 | if project.company.id == company[0] 56 | if project_id == project.id 57 | content_tag('option', project.name, :value => project.id, :selected => 'selected') 58 | else 59 | content_tag('option', project.name, :value => project.id) 60 | end 61 | end 62 | end.join("\n"), :label => company[1]) 63 | end 64 | html 65 | end 66 | 67 | def d(date) 68 | date.strftime('%A %B %d, %Y') 69 | end 70 | 71 | # Add http://www.google.com/gwt/n?u= to URLs, and make them into links 72 | def mobile_links(text, mobile_web_viewer = 'http://www.google.com/gwt/n?u=') 73 | text.gsub(MOBILE_LINK_REGEX) do 74 | all, a, b, c, d = $&, $1, $2, $3, $5 75 | if a =~ /#{text}#{d}) 81 | end 82 | end 83 | end 84 | 85 | private 86 | 87 | MOBILE_LINK_REGEX = / 88 | ( # leading text 89 | <\w+.*?>| # leading HTML tag, or 90 | [^=!:'"\/]| # leading punctuation, or 91 | ^ # beginning of line 92 | ) 93 | ( 94 | (?:http[s]?:\/\/)| # protocol spec, or 95 | (?:www\.) # www.* 96 | ) 97 | ( 98 | ([\w]+:?[=?&\/.-]?)* # url segment 99 | \w+[\/]? # url tail 100 | (?:\#\w*)? # trailing anchor 101 | ) 102 | ([[:punct:]]|\s|<|$) # trailing text 103 | /x unless const_defined?(:MOBILE_LINK_REGEX) 104 | end 105 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h2. Basecamp Mobile 2 | 3 | h3. Introduction 4 | 5 | This is a small rails application that provides access to "Basecamp":http://www.basecamphq.com in a format suitable for mobile phones. 6 | 7 | Basecamp Mobile was created by "Alex Young":http://alexyoung.org and is released under the MIT license by "Helicoid":http://helicoid.net. 8 | 9 | Run "rake appdoc" to generate API documentation. 10 | 11 | h3. Requirements 12 | 13 | Basecamp Mobile requires Rails 2.3.2. 14 | 15 | You can visit "basecamp.helicoid.net":basecamp.helicoid.net to try it out. 16 | 17 | h3. Installation and usage 18 | 19 | Installation depends on how your server is set up. However, you can start it up quickly by using WEBrick from the command line: 20 | 21 | ruby script/server 22 | 23 | 24 | h3. Hacking 25 | 26 | Since Basecamp Mobile has been packaged as a rails application, some effort has gone into making the "models" appear like ActiveRecord objects. This is largely to keep code in controllers and views simple and familiar. 27 | 28 | basecamp.rb provides a ruby abstraction of the Basecamp API. I have written basecamp_extensions.rb and basecamp_wrapper.rb to add a few extra things to basecamp.rb. By keeping my extensions separate and using some meta-programming it should provide some abstraction between Basecamp Mobile and the API, should the API change in the future. New features in rails or suitable libraries will hopefully make the wrapper obsolete. 29 | 30 | Models can be used in a familiar fashion: 31 | 32 |
    33 | Project.find(project_id)
    34 | Project.find_all
    35 | Project.find(project_id).messages
    36 | Project.find(project_id).milestones
    37 | Project.find(project_id).todos
    38 | 
    39 | 40 | h3. Help wanted 41 | 42 | I haven't had time to develop a caching strategy so far, and if you use Basecamp Mobile for any length of time you might notice it's slightly slow. To get started I used a few helpers that cache objects in the session, and I used the Basecamp Wrapper class to begin work on creating (what should be eventually) ActiveRecord-like objects. A good strategy will probably use this, and creating an observer-like caching system using what's already in rails. 43 | 44 | Caching will have to work using a different strategy in the future. 45 | 46 | To get talking to me about this, or submit your own code, begin a conversation on our forum (http://forums.helicoid.net) or send a quick message to me (http://alexyoung.org/contact). 47 | 48 | h3. Libraries 49 | 50 | The following libraries have been used: 51 | 52 | * basecamp.rb, provided by 37signals 53 | * login_system.rb, from Typo 54 | 55 | 56 | h3. License 57 | 58 | This software is released under the MIT license. 59 | 60 | Copyright (c) 2006-2009 Alex Young, Helicoid 61 | 62 | 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: 63 | 64 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 65 | 66 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 67 | -------------------------------------------------------------------------------- /lib/basecamp_wrapper.rb: -------------------------------------------------------------------------------- 1 | # A wrapper for the basecamp session object 2 | class BasecampWrapper 3 | F = {} 4 | @finder_mapping = nil 5 | @@basecamp = nil 6 | 7 | cattr_accessor :basecamp 8 | 9 | def initialize(record = nil) 10 | @record = record 11 | end 12 | 13 | def self.metaclass; class << self; self; end; end 14 | 15 | # Store the finder mappings in an array 16 | def self.add_finder_mapping(remote_method) ; F[self.to_s] = remote_method ; end 17 | def self.finder_mapping(klass) ; F[klass] ;end 18 | 19 | # Returns the ID of this 'model' 20 | def id ; @record.id if @record ; end 21 | 22 | # Lazy load related objects 23 | # class Project ; has_many :todos ; end 24 | # 25 | # project = Project.new(id) 26 | # project.todos => array of Todo objects 27 | # 28 | def self.has_many(*relationships) 29 | current_class = self.to_s.downcase 30 | 31 | relationships.each do |relationship| 32 | target_class = relationship.to_s.classify.constantize 33 | 34 | # Add a pluralized method name to this class to do the lazy loading (instance method) 35 | class_eval do 36 | define_method(relationship) do 37 | begin 38 | if @params 39 | target_class.send("find_all_by_#{target_class.instance_variable_get('@requires')}", send('id'), @params) 40 | else 41 | target_class.send("find_all_by_#{target_class.instance_variable_get('@requires')}", send('id')) 42 | end 43 | rescue 44 | [] 45 | end 46 | end 47 | end 48 | 49 | # Add a find_all_by_ method to the class related to this one (class method) 50 | target_class.metaclass.instance_eval do 51 | define_method("find_all_by_#{current_class}") do |val| 52 | begin 53 | if @with_key 54 | @@basecamp.send(finder_mapping(target_class.to_s), val)[@with_key].collect { |record| self.new(record) } 55 | else 56 | @@basecamp.send(finder_mapping(target_class.to_s), val).collect { |record| self.new(record) } 57 | end 58 | rescue NoMethodError => error 59 | [] 60 | end 61 | end 62 | end 63 | end 64 | end 65 | 66 | # This class requires a class to instantiate 67 | def self.requires(required_class) 68 | @requires = required_class 69 | end 70 | 71 | # Extract the results using a key on the returned hash 72 | def self.with_key(key) 73 | @with_key = key 74 | end 75 | 76 | # Send parameters to the method call 77 | def self.params(params) 78 | @params = params 79 | end 80 | 81 | # Map find and find_all to methods in another class. self.new is used to 82 | # attempt to create objects with the type of the parent. 83 | # 84 | # Add methods aliased by using: 85 | # find_all :method_that_returns_list_of_this_type 86 | # 87 | # Pass :requires => :class to denote that this class requires another (Project > To do). 88 | # Pass :with_key => 'key name' to automatically extract values from a hash. 89 | # 90 | def self.map_finders_to(sym, args = {}) 91 | add_finder_mapping(sym) 92 | requires(args[:requires]) if args.include? :requires 93 | with_key(args[:with_key]) if args.include? :with_key 94 | params(args[:params]) if args.include? :params 95 | 96 | class_eval do 97 | metaclass.instance_eval do 98 | define_method(:find_all) do 99 | @@basecamp.send(sym).collect { |record| self.new(record) } 100 | end 101 | 102 | define_method(:find) do |val| 103 | # Test it the baseclass has a method defined that looks like a finder 104 | sym_singular = sym.to_s.singularize 105 | if @@basecamp.respond_to? sym_singular 106 | self.new(@@basecamp.send(sym_singular, val)) 107 | else 108 | # Attempt to load a set of values and pick one out 109 | if (record_id = val.to_i) > 0 110 | self.new(@@basecamp.send(sym).find { |record| record.id == record_id }) 111 | else 112 | self.new(@@basecamp.send(sym).find(val)) 113 | end 114 | end 115 | end 116 | end 117 | end 118 | end 119 | 120 | # Use this to map find_all to a method that returns data blindly 121 | def self.map_find_all_to(sym) 122 | add_finder_mapping(sym) 123 | class_eval do 124 | metaclass.instance_eval do 125 | define_method(:find_all) { @@basecamp.send(sym) } 126 | end 127 | end 128 | end 129 | 130 | protected 131 | 132 | def method_missing(sym, *args, &block) 133 | if @record.respond_to? sym 134 | @record.send(sym) 135 | elsif @@basecamp.respond_to?(sym) 136 | @@basecamp.send(sym, *args, &block) 137 | else 138 | super 139 | end 140 | end 141 | 142 | def self.method_missing(sym, *args, &block) 143 | if @@basecamp.respond_to?(sym) 144 | @@basecamp.send(sym, *args, &block) 145 | else 146 | super 147 | end 148 | end 149 | end -------------------------------------------------------------------------------- /lib/basecamp.rb: -------------------------------------------------------------------------------- 1 | # the following are all standard ruby libraries 2 | require 'net/https' 3 | require 'yaml' 4 | require 'date' 5 | require 'time' 6 | 7 | begin 8 | require 'xmlsimple' 9 | rescue LoadError 10 | begin 11 | require 'rubygems' 12 | require 'xml-simple' 13 | rescue LoadError 14 | abort <<-ERROR 15 | The 'xml-simple' library could not be loaded. If you have RubyGems installed 16 | you can install xml-simple by doing "gem install xml-simple". 17 | ERROR 18 | end 19 | end 20 | 21 | # An interface to the Basecamp web-services API. Usage is straightforward: 22 | # 23 | # session = Basecamp.new('your.basecamp.com', 'username', 'password') 24 | # puts "projects: #{session.projects.length}" 25 | class Basecamp 26 | 27 | # A wrapper to encapsulate the data returned by Basecamp, for easier access. 28 | class Record #:nodoc: 29 | attr_reader :type 30 | 31 | def initialize(type, hash) 32 | @type = type 33 | @hash = hash 34 | end 35 | 36 | def [](name) 37 | name = dashify(name) 38 | case @hash[name] 39 | when Hash then 40 | @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ? 41 | @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } : 42 | Record.new(name, @hash[name]) 43 | else @hash[name] 44 | end 45 | end 46 | 47 | def id 48 | @hash["id"] 49 | end 50 | 51 | def attributes 52 | @hash.keys 53 | end 54 | 55 | def respond_to?(sym) 56 | super || @hash.has_key?(dashify(sym)) 57 | end 58 | 59 | def method_missing(sym, *args) 60 | if args.empty? && !block_given? && respond_to?(sym) 61 | self[sym] 62 | else 63 | super 64 | end 65 | end 66 | 67 | def to_s 68 | "\#" 69 | end 70 | 71 | def inspect 72 | to_s 73 | end 74 | 75 | private 76 | 77 | def dashify(name) 78 | name.to_s.tr("_", "-") 79 | end 80 | end 81 | 82 | # A wrapper to represent a file that should be uploaded. This is used so that 83 | # the form/multi-part encoder knows when to encode a field as a file, versus 84 | # when to encode it as a simple field. 85 | class FileUpload 86 | attr_reader :filename, :content 87 | 88 | def initialize(filename, content) 89 | @filename = filename 90 | @content = content 91 | end 92 | end 93 | 94 | attr_accessor :use_xml 95 | 96 | # Connects 97 | def initialize(url, user_name, password, use_ssl = false) 98 | @use_xml = false 99 | @user_name, @password = user_name, password 100 | connect!(url, use_ssl) 101 | end 102 | 103 | # Return the list of all accessible projects. 104 | def projects 105 | records "project", "/project/list" 106 | end 107 | 108 | # Returns the list of message categories for the given project 109 | def message_categories(project_id) 110 | records "post-category", "/projects/#{project_id}/post_categories" 111 | end 112 | 113 | # Returns the list of file categories for the given project 114 | def file_categories(project_id) 115 | records "attachment-category", "/projects/#{project_id}/attachment_categories" 116 | end 117 | 118 | # Return information for the company with the given id 119 | def company(id) 120 | record "/contacts/company/#{id}" 121 | end 122 | 123 | # Return an array of the people in the given company. If the project-id is 124 | # given, only people who have access to the given project will be returned. 125 | def people(company_id, project_id=nil) 126 | url = project_id ? "/projects/#{project_id}" : "" 127 | url << "/contacts/people/#{company_id}" 128 | records "person", url 129 | end 130 | 131 | # Return information about the person with the given id 132 | def person(id) 133 | record "/contacts/person/#{id}" 134 | end 135 | 136 | # Return information about the message(s) with the given id(s). The API 137 | # limits you to requesting 25 messages at a time, so if you need to get more 138 | # than that, you'll need to do it in multiple requests. 139 | def message(*ids) 140 | result = records("post", "/msg/get/#{ids.join(",")}") 141 | result.length == 1 ? result.first : result 142 | end 143 | 144 | # Returns a summary of all messages in the given project (and category, if 145 | # specified). The summary is simply the title and category of the message, 146 | # as well as the number of attachments (if any). 147 | def message_list(project_id, category_id=nil) 148 | url = "/projects/#{project_id}/msg" 149 | url << "/cat/#{category_id}" if category_id 150 | url << "/archive" 151 | 152 | records "post", url 153 | end 154 | 155 | # Create a new message in the given project. The +message+ parameter should 156 | # be a hash. The +email_to+ parameter must be an array of person-id's that 157 | # should be notified of the post. 158 | # 159 | # If you want to add attachments to the message, the +attachments+ parameter 160 | # should be an array of hashes, where each has has a :name key (optional), 161 | # and a :file key (required). The :file key must refer to a Basecamp::FileUpload 162 | # instance. 163 | # 164 | # msg = session.post_message(158141, 165 | # { :title => "Requirements", 166 | # :body => "Here are the requirements documents you asked for.", 167 | # :category_id => 2301121 }, 168 | # [john.id, martha.id], 169 | # [ { :name => "Primary Requirements", 170 | # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) }, 171 | # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ]) 172 | def post_message(project_id, message, notify=[], attachments=[]) 173 | prepare_attachments(attachments) 174 | record "/projects/#{project_id}/msg/create", 175 | :post => message, 176 | :notify => notify, 177 | :attachments => attachments 178 | end 179 | 180 | # Edit the message with the given id. The +message+ parameter should 181 | # be a hash. The +email_to+ parameter must be an array of person-id's that 182 | # should be notified of the post. 183 | # 184 | # The +attachments+ parameter, if used, should be the same as described for 185 | # #post_message. 186 | def update_message(id, message, notify=[], attachments=[]) 187 | prepare_attachments(attachments) 188 | record "/msg/update/#{id}", 189 | :post => message, 190 | :notify => notify, 191 | :attachments => attachments 192 | end 193 | 194 | # Deletes the message with the given id, and returns it. 195 | def delete_message(id) 196 | record "/msg/delete/#{id}" 197 | end 198 | 199 | # Return a list of the comments for the specified message. 200 | def comments(post_id) 201 | records "comment", "/msg/comments/#{post_id}" 202 | end 203 | 204 | # Retrieve a specific comment 205 | def comment(id) 206 | record "/msg/comment/#{id}" 207 | end 208 | 209 | # Add a new comment to a message. +comment+ must be a hash describing the 210 | # comment. You can add attachments to the comment, too, by giving them in 211 | # an array. See the #post_message method for a description of how to do that. 212 | def create_comment(post_id, comment, attachments=[]) 213 | prepare_attachments(attachments) 214 | record "/msg/create_comment", :comment => comment.merge(:post_id => post_id), 215 | :attachments => attachments 216 | end 217 | 218 | # Update the given comment. Attachments follow the same format as #post_message. 219 | def update_comment(id, comment, attachments=[]) 220 | prepare_attachments(attachments) 221 | record "/msg/update_comment", :comment_id => id, 222 | :comment => comment, :attachments => attachments 223 | end 224 | 225 | # Deletes (and returns) the given comment. 226 | def delete_comment(id) 227 | record "/msg/delete_comment/#{id}" 228 | end 229 | 230 | # ========================================================================= 231 | # TODO LISTS AND ITEMS 232 | # ========================================================================= 233 | 234 | # Marks the given item completed. 235 | def complete_item(id) 236 | record "/todos/complete_item/#{id}" 237 | end 238 | 239 | # Marks the given item uncompleted. 240 | def uncomplete_item(id) 241 | record "/todos/uncomplete_item/#{id}" 242 | end 243 | 244 | # Creates a new to-do item. 245 | def create_item(list_id, content, responsible_party=nil, notify=true) 246 | record "/todos/create_item/#{list_id}", 247 | :content => content, :responsible_party => responsible_party, 248 | :notify => notify 249 | end 250 | 251 | # Creates a new list using the given hash of list metadata. 252 | def create_list(project_id, list) 253 | record "/projects/#{project_id}/todos/create_list", list 254 | end 255 | 256 | # Deletes the given item from it's parent list. 257 | def delete_item(id) 258 | record "/todos/delete_item/#{id}" 259 | end 260 | 261 | # Deletes the given list and all of its items. 262 | def delete_list(id) 263 | record "/todos/delete_list/#{id}" 264 | end 265 | 266 | # Retrieves the specified list, and all of its items. 267 | def get_list(id) 268 | record "/todos/list/#{id}" 269 | end 270 | 271 | # Return all lists for a project. If complete is true, only completed lists 272 | # are returned. If complete is false, only uncompleted lists are returned. 273 | def lists(project_id, complete=nil) 274 | records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete 275 | end 276 | 277 | # Repositions an item to be at the given position in its list 278 | def move_item(id, to) 279 | record "/todos/move_item/#{id}", :to => to 280 | end 281 | 282 | # Repositions a list to be at the given position in its project 283 | def move_list(id, to) 284 | record "/todos/move_list/#{id}", :to => to 285 | end 286 | 287 | # Updates the given item 288 | def update_item(id, content, responsible_party=nil, notify=true) 289 | record "/todos/update_item/#{id}", 290 | :item => { :content => content }, :responsible_party => responsible_party, 291 | :notify => notify 292 | end 293 | 294 | # Updates the given list's metadata 295 | def update_list(id, list) 296 | record "/todos/update_list/#{id}", :list => list 297 | end 298 | 299 | # ========================================================================= 300 | # MILESTONES 301 | # ========================================================================= 302 | 303 | # Complete the milestone with the given id 304 | def complete_milestone(id) 305 | record "/milestones/complete/#{id}" 306 | end 307 | 308 | # Create a new milestone for the given project. +data+ must be hash of the 309 | # values to set, including +title+, +deadline+, +responsible_party+, and 310 | # +notify+. 311 | def create_milestone(project_id, data) 312 | create_milestones(project_id, [data]).first 313 | end 314 | 315 | # As #create_milestone, but can create multiple milestones in a single 316 | # request. The +milestones+ parameter must be an array of milestone values as 317 | # descrbed in #create_milestone. 318 | def create_milestones(project_id, milestones) 319 | records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones 320 | end 321 | 322 | # Destroys the milestone with the given id. 323 | def delete_milestone(id) 324 | record "/milestones/delete/#{id}" 325 | end 326 | 327 | # Returns a list of all milestones for the given project, optionally filtered 328 | # by whether they are completed, late, or upcoming. 329 | def milestones(project_id, find="all") 330 | records "milestone", "/projects/#{project_id}/milestones/list", :find => find 331 | end 332 | 333 | # Uncomplete the milestone with the given id 334 | def uncomplete_milestone(id) 335 | record "/milestones/uncomplete/#{id}" 336 | end 337 | 338 | # Updates an existing milestone. 339 | def update_milestone(id, data, move=false, move_off_weekends=false) 340 | record "/milestones/update/#{id}", :milestone => data, 341 | :move_upcoming_milestones => move, 342 | :move_upcoming_milestones_off_weekends => move_off_weekends 343 | end 344 | 345 | # Make a raw web-service request to Basecamp. This will return a Hash of 346 | # Arrays of the response, and may seem a little odd to the uninitiated. 347 | def request(path, parameters = {}, second_try = false) 348 | response = post(path, convert_body(parameters), "Content-Type" => content_type) 349 | 350 | if response.code.to_i / 100 == 2 351 | result = XmlSimple.xml_in(response.body, 'keeproot' => true, 352 | 'contentkey' => '__content__', 'forcecontent' => true) 353 | typecast_value(result) 354 | elsif response.code == "302" && !second_try 355 | connect!(@url, !@use_ssl) 356 | request(path, parameters, true) 357 | else 358 | raise "#{response.message} (#{response.code})" 359 | end 360 | end 361 | 362 | # A convenience method for wrapping the result of a query in a Record 363 | # object. This assumes that the result is a singleton, not a collection. 364 | def record(path, parameters={}) 365 | result = request(path, parameters) 366 | (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil 367 | end 368 | 369 | # A convenience method for wrapping the result of a query in Record 370 | # objects. This assumes that the result is a collection--any singleton 371 | # result will be wrapped in an array. 372 | def records(node, path, parameters={}) 373 | result = request(path, parameters).values.first or return [] 374 | result = result[node] or return [] 375 | result = [result] unless Array === result 376 | result.map { |row| Record.new(node, row) } 377 | end 378 | 379 | private 380 | 381 | def connect!(url, use_ssl) 382 | @use_ssl = use_ssl 383 | @url = url 384 | @connection = Net::HTTP.new(url, use_ssl ? 443 : 80) 385 | @connection.use_ssl = @use_ssl 386 | @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl 387 | end 388 | 389 | def convert_body(body) 390 | body = use_xml ? body.to_xml : body.to_yaml 391 | end 392 | 393 | def content_type 394 | use_xml ? "application/xml" : "application/x-yaml" 395 | end 396 | 397 | def post(path, body, header={}) 398 | request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml')) 399 | request.basic_auth(@user_name, @password) 400 | @connection.request(request, body) 401 | end 402 | 403 | def store_file(contents) 404 | response = post("/upload", contents, 'Content-Type' => 'application/octet-stream', 405 | 'Accept' => 'application/xml') 406 | 407 | if response.code == "200" 408 | result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false) 409 | return result["upload"]["id"] 410 | else 411 | raise "Could not store file: #{response.message} (#{response.code})" 412 | end 413 | end 414 | 415 | def typecast_value(value) 416 | case value 417 | when Hash 418 | if value.has_key?("__content__") 419 | content = translate_entities(value["__content__"]).strip 420 | case value["type"] 421 | when "integer" then content.to_i 422 | when "boolean" then content == "true" 423 | when "datetime" then Time.parse(content) 424 | when "date" then Date.parse(content) 425 | else content 426 | end 427 | # a special case to work-around a bug in XmlSimple. When you have an empty 428 | # tag that has an attribute, XmlSimple will not add the __content__ key 429 | # to the returned hash. Thus, we check for the presense of the 'type' 430 | # attribute to look for empty, typed tags, and simply return nil for 431 | # their value. 432 | elsif value.keys == %w(type) 433 | nil 434 | elsif value["nil"] == "true" 435 | nil 436 | # another special case, introduced by the latest rails, where an array 437 | # type now exists. This is parsed by XmlSimple as a two-key hash, where 438 | # one key is 'type' and the other is the actual array value. 439 | elsif value.keys.length == 2 && value["type"] == "array" 440 | value.delete("type") 441 | typecast_value(value) 442 | else 443 | value.empty? ? nil : value.inject({}) do |h,(k,v)| 444 | h[k] = typecast_value(v) 445 | h 446 | end 447 | end 448 | when Array 449 | value.map! { |i| typecast_value(i) } 450 | case value.length 451 | when 0 then nil 452 | when 1 then value.first 453 | else value 454 | end 455 | else 456 | raise "can't typecast #{value.inspect}" 457 | end 458 | end 459 | 460 | def translate_entities(value) 461 | value.gsub(/</, "<"). 462 | gsub(/>/, ">"). 463 | gsub(/"/, '"'). 464 | gsub(/'/, "'"). 465 | gsub(/&/, "&") 466 | end 467 | 468 | def prepare_attachments(list) 469 | (list || []).each do |data| 470 | upload = data[:file] 471 | id = store_file(upload.content) 472 | data[:file] = { :file => id, 473 | :content_type => "application/octet-stream", 474 | :original_filename => upload.filename } 475 | end 476 | end 477 | end 478 | 479 | # A minor hack to let Xml-Simple serialize symbolic keys in hashes 480 | class Symbol 481 | def [](*args) 482 | to_s[*args] 483 | end 484 | end 485 | 486 | class Hash 487 | def to_xml 488 | XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true) 489 | end 490 | end 491 | -------------------------------------------------------------------------------- /public/javascripts/dragdrop.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) 2 | // (c) 2005-2008 Sammi Williams (http://www.oriontransfer.co.nz, sammi@oriontransfer.co.nz) 3 | // 4 | // script.aculo.us is freely distributable under the terms of an MIT-style license. 5 | // For details, see the script.aculo.us web site: http://script.aculo.us/ 6 | 7 | if(Object.isUndefined(Effect)) 8 | throw("dragdrop.js requires including script.aculo.us' effects.js library"); 9 | 10 | var Droppables = { 11 | drops: [], 12 | 13 | remove: function(element) { 14 | this.drops = this.drops.reject(function(d) { return d.element==$(element) }); 15 | }, 16 | 17 | add: function(element) { 18 | element = $(element); 19 | var options = Object.extend({ 20 | greedy: true, 21 | hoverclass: null, 22 | tree: false 23 | }, arguments[1] || { }); 24 | 25 | // cache containers 26 | if(options.containment) { 27 | options._containers = []; 28 | var containment = options.containment; 29 | if(Object.isArray(containment)) { 30 | containment.each( function(c) { options._containers.push($(c)) }); 31 | } else { 32 | options._containers.push($(containment)); 33 | } 34 | } 35 | 36 | if(options.accept) options.accept = [options.accept].flatten(); 37 | 38 | Element.makePositioned(element); // fix IE 39 | options.element = element; 40 | 41 | this.drops.push(options); 42 | }, 43 | 44 | findDeepestChild: function(drops) { 45 | deepest = drops[0]; 46 | 47 | for (i = 1; i < drops.length; ++i) 48 | if (Element.isParent(drops[i].element, deepest.element)) 49 | deepest = drops[i]; 50 | 51 | return deepest; 52 | }, 53 | 54 | isContained: function(element, drop) { 55 | var containmentNode; 56 | if(drop.tree) { 57 | containmentNode = element.treeNode; 58 | } else { 59 | containmentNode = element.parentNode; 60 | } 61 | return drop._containers.detect(function(c) { return containmentNode == c }); 62 | }, 63 | 64 | isAffected: function(point, element, drop) { 65 | return ( 66 | (drop.element!=element) && 67 | ((!drop._containers) || 68 | this.isContained(element, drop)) && 69 | ((!drop.accept) || 70 | (Element.classNames(element).detect( 71 | function(v) { return drop.accept.include(v) } ) )) && 72 | Position.within(drop.element, point[0], point[1]) ); 73 | }, 74 | 75 | deactivate: function(drop) { 76 | if(drop.hoverclass) 77 | Element.removeClassName(drop.element, drop.hoverclass); 78 | this.last_active = null; 79 | }, 80 | 81 | activate: function(drop) { 82 | if(drop.hoverclass) 83 | Element.addClassName(drop.element, drop.hoverclass); 84 | this.last_active = drop; 85 | }, 86 | 87 | show: function(point, element) { 88 | if(!this.drops.length) return; 89 | var drop, affected = []; 90 | 91 | this.drops.each( function(drop) { 92 | if(Droppables.isAffected(point, element, drop)) 93 | affected.push(drop); 94 | }); 95 | 96 | if(affected.length>0) 97 | drop = Droppables.findDeepestChild(affected); 98 | 99 | if(this.last_active && this.last_active != drop) this.deactivate(this.last_active); 100 | if (drop) { 101 | Position.within(drop.element, point[0], point[1]); 102 | if(drop.onHover) 103 | drop.onHover(element, drop.element, Position.overlap(drop.overlap, drop.element)); 104 | 105 | if (drop != this.last_active) Droppables.activate(drop); 106 | } 107 | }, 108 | 109 | fire: function(event, element) { 110 | if(!this.last_active) return; 111 | Position.prepare(); 112 | 113 | if (this.isAffected([Event.pointerX(event), Event.pointerY(event)], element, this.last_active)) 114 | if (this.last_active.onDrop) { 115 | this.last_active.onDrop(element, this.last_active.element, event); 116 | return true; 117 | } 118 | }, 119 | 120 | reset: function() { 121 | if(this.last_active) 122 | this.deactivate(this.last_active); 123 | } 124 | }; 125 | 126 | var Draggables = { 127 | drags: [], 128 | observers: [], 129 | 130 | register: function(draggable) { 131 | if(this.drags.length == 0) { 132 | this.eventMouseUp = this.endDrag.bindAsEventListener(this); 133 | this.eventMouseMove = this.updateDrag.bindAsEventListener(this); 134 | this.eventKeypress = this.keyPress.bindAsEventListener(this); 135 | 136 | Event.observe(document, "mouseup", this.eventMouseUp); 137 | Event.observe(document, "mousemove", this.eventMouseMove); 138 | Event.observe(document, "keypress", this.eventKeypress); 139 | } 140 | this.drags.push(draggable); 141 | }, 142 | 143 | unregister: function(draggable) { 144 | this.drags = this.drags.reject(function(d) { return d==draggable }); 145 | if(this.drags.length == 0) { 146 | Event.stopObserving(document, "mouseup", this.eventMouseUp); 147 | Event.stopObserving(document, "mousemove", this.eventMouseMove); 148 | Event.stopObserving(document, "keypress", this.eventKeypress); 149 | } 150 | }, 151 | 152 | activate: function(draggable) { 153 | if(draggable.options.delay) { 154 | this._timeout = setTimeout(function() { 155 | Draggables._timeout = null; 156 | window.focus(); 157 | Draggables.activeDraggable = draggable; 158 | }.bind(this), draggable.options.delay); 159 | } else { 160 | window.focus(); // allows keypress events if window isn't currently focused, fails for Safari 161 | this.activeDraggable = draggable; 162 | } 163 | }, 164 | 165 | deactivate: function() { 166 | this.activeDraggable = null; 167 | }, 168 | 169 | updateDrag: function(event) { 170 | if(!this.activeDraggable) return; 171 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 172 | // Mozilla-based browsers fire successive mousemove events with 173 | // the same coordinates, prevent needless redrawing (moz bug?) 174 | if(this._lastPointer && (this._lastPointer.inspect() == pointer.inspect())) return; 175 | this._lastPointer = pointer; 176 | 177 | this.activeDraggable.updateDrag(event, pointer); 178 | }, 179 | 180 | endDrag: function(event) { 181 | if(this._timeout) { 182 | clearTimeout(this._timeout); 183 | this._timeout = null; 184 | } 185 | if(!this.activeDraggable) return; 186 | this._lastPointer = null; 187 | this.activeDraggable.endDrag(event); 188 | this.activeDraggable = null; 189 | }, 190 | 191 | keyPress: function(event) { 192 | if(this.activeDraggable) 193 | this.activeDraggable.keyPress(event); 194 | }, 195 | 196 | addObserver: function(observer) { 197 | this.observers.push(observer); 198 | this._cacheObserverCallbacks(); 199 | }, 200 | 201 | removeObserver: function(element) { // element instead of observer fixes mem leaks 202 | this.observers = this.observers.reject( function(o) { return o.element==element }); 203 | this._cacheObserverCallbacks(); 204 | }, 205 | 206 | notify: function(eventName, draggable, event) { // 'onStart', 'onEnd', 'onDrag' 207 | if(this[eventName+'Count'] > 0) 208 | this.observers.each( function(o) { 209 | if(o[eventName]) o[eventName](eventName, draggable, event); 210 | }); 211 | if(draggable.options[eventName]) draggable.options[eventName](draggable, event); 212 | }, 213 | 214 | _cacheObserverCallbacks: function() { 215 | ['onStart','onEnd','onDrag'].each( function(eventName) { 216 | Draggables[eventName+'Count'] = Draggables.observers.select( 217 | function(o) { return o[eventName]; } 218 | ).length; 219 | }); 220 | } 221 | }; 222 | 223 | /*--------------------------------------------------------------------------*/ 224 | 225 | var Draggable = Class.create({ 226 | initialize: function(element) { 227 | var defaults = { 228 | handle: false, 229 | reverteffect: function(element, top_offset, left_offset) { 230 | var dur = Math.sqrt(Math.abs(top_offset^2)+Math.abs(left_offset^2))*0.02; 231 | new Effect.Move(element, { x: -left_offset, y: -top_offset, duration: dur, 232 | queue: {scope:'_draggable', position:'end'} 233 | }); 234 | }, 235 | endeffect: function(element) { 236 | var toOpacity = Object.isNumber(element._opacity) ? element._opacity : 1.0; 237 | new Effect.Opacity(element, {duration:0.2, from:0.7, to:toOpacity, 238 | queue: {scope:'_draggable', position:'end'}, 239 | afterFinish: function(){ 240 | Draggable._dragging[element] = false 241 | } 242 | }); 243 | }, 244 | zindex: 1000, 245 | revert: false, 246 | quiet: false, 247 | scroll: false, 248 | scrollSensitivity: 20, 249 | scrollSpeed: 15, 250 | snap: false, // false, or xy or [x,y] or function(x,y){ return [x,y] } 251 | delay: 0 252 | }; 253 | 254 | if(!arguments[1] || Object.isUndefined(arguments[1].endeffect)) 255 | Object.extend(defaults, { 256 | starteffect: function(element) { 257 | element._opacity = Element.getOpacity(element); 258 | Draggable._dragging[element] = true; 259 | new Effect.Opacity(element, {duration:0.2, from:element._opacity, to:0.7}); 260 | } 261 | }); 262 | 263 | var options = Object.extend(defaults, arguments[1] || { }); 264 | 265 | this.element = $(element); 266 | 267 | if(options.handle && Object.isString(options.handle)) 268 | this.handle = this.element.down('.'+options.handle, 0); 269 | 270 | if(!this.handle) this.handle = $(options.handle); 271 | if(!this.handle) this.handle = this.element; 272 | 273 | if(options.scroll && !options.scroll.scrollTo && !options.scroll.outerHTML) { 274 | options.scroll = $(options.scroll); 275 | this._isScrollChild = Element.childOf(this.element, options.scroll); 276 | } 277 | 278 | Element.makePositioned(this.element); // fix IE 279 | 280 | this.options = options; 281 | this.dragging = false; 282 | 283 | this.eventMouseDown = this.initDrag.bindAsEventListener(this); 284 | Event.observe(this.handle, "mousedown", this.eventMouseDown); 285 | 286 | Draggables.register(this); 287 | }, 288 | 289 | destroy: function() { 290 | Event.stopObserving(this.handle, "mousedown", this.eventMouseDown); 291 | Draggables.unregister(this); 292 | }, 293 | 294 | currentDelta: function() { 295 | return([ 296 | parseInt(Element.getStyle(this.element,'left') || '0'), 297 | parseInt(Element.getStyle(this.element,'top') || '0')]); 298 | }, 299 | 300 | initDrag: function(event) { 301 | if(!Object.isUndefined(Draggable._dragging[this.element]) && 302 | Draggable._dragging[this.element]) return; 303 | if(Event.isLeftClick(event)) { 304 | // abort on form elements, fixes a Firefox issue 305 | var src = Event.element(event); 306 | if((tag_name = src.tagName.toUpperCase()) && ( 307 | tag_name=='INPUT' || 308 | tag_name=='SELECT' || 309 | tag_name=='OPTION' || 310 | tag_name=='BUTTON' || 311 | tag_name=='TEXTAREA')) return; 312 | 313 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 314 | var pos = Position.cumulativeOffset(this.element); 315 | this.offset = [0,1].map( function(i) { return (pointer[i] - pos[i]) }); 316 | 317 | Draggables.activate(this); 318 | Event.stop(event); 319 | } 320 | }, 321 | 322 | startDrag: function(event) { 323 | this.dragging = true; 324 | if(!this.delta) 325 | this.delta = this.currentDelta(); 326 | 327 | if(this.options.zindex) { 328 | this.originalZ = parseInt(Element.getStyle(this.element,'z-index') || 0); 329 | this.element.style.zIndex = this.options.zindex; 330 | } 331 | 332 | if(this.options.ghosting) { 333 | this._clone = this.element.cloneNode(true); 334 | this._originallyAbsolute = (this.element.getStyle('position') == 'absolute'); 335 | if (!this._originallyAbsolute) 336 | Position.absolutize(this.element); 337 | this.element.parentNode.insertBefore(this._clone, this.element); 338 | } 339 | 340 | if(this.options.scroll) { 341 | if (this.options.scroll == window) { 342 | var where = this._getWindowScroll(this.options.scroll); 343 | this.originalScrollLeft = where.left; 344 | this.originalScrollTop = where.top; 345 | } else { 346 | this.originalScrollLeft = this.options.scroll.scrollLeft; 347 | this.originalScrollTop = this.options.scroll.scrollTop; 348 | } 349 | } 350 | 351 | Draggables.notify('onStart', this, event); 352 | 353 | if(this.options.starteffect) this.options.starteffect(this.element); 354 | }, 355 | 356 | updateDrag: function(event, pointer) { 357 | if(!this.dragging) this.startDrag(event); 358 | 359 | if(!this.options.quiet){ 360 | Position.prepare(); 361 | Droppables.show(pointer, this.element); 362 | } 363 | 364 | Draggables.notify('onDrag', this, event); 365 | 366 | this.draw(pointer); 367 | if(this.options.change) this.options.change(this); 368 | 369 | if(this.options.scroll) { 370 | this.stopScrolling(); 371 | 372 | var p; 373 | if (this.options.scroll == window) { 374 | with(this._getWindowScroll(this.options.scroll)) { p = [ left, top, left+width, top+height ]; } 375 | } else { 376 | p = Position.page(this.options.scroll); 377 | p[0] += this.options.scroll.scrollLeft + Position.deltaX; 378 | p[1] += this.options.scroll.scrollTop + Position.deltaY; 379 | p.push(p[0]+this.options.scroll.offsetWidth); 380 | p.push(p[1]+this.options.scroll.offsetHeight); 381 | } 382 | var speed = [0,0]; 383 | if(pointer[0] < (p[0]+this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[0]+this.options.scrollSensitivity); 384 | if(pointer[1] < (p[1]+this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[1]+this.options.scrollSensitivity); 385 | if(pointer[0] > (p[2]-this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[2]-this.options.scrollSensitivity); 386 | if(pointer[1] > (p[3]-this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[3]-this.options.scrollSensitivity); 387 | this.startScrolling(speed); 388 | } 389 | 390 | // fix AppleWebKit rendering 391 | if(Prototype.Browser.WebKit) window.scrollBy(0,0); 392 | 393 | Event.stop(event); 394 | }, 395 | 396 | finishDrag: function(event, success) { 397 | this.dragging = false; 398 | 399 | if(this.options.quiet){ 400 | Position.prepare(); 401 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 402 | Droppables.show(pointer, this.element); 403 | } 404 | 405 | if(this.options.ghosting) { 406 | if (!this._originallyAbsolute) 407 | Position.relativize(this.element); 408 | delete this._originallyAbsolute; 409 | Element.remove(this._clone); 410 | this._clone = null; 411 | } 412 | 413 | var dropped = false; 414 | if(success) { 415 | dropped = Droppables.fire(event, this.element); 416 | if (!dropped) dropped = false; 417 | } 418 | if(dropped && this.options.onDropped) this.options.onDropped(this.element); 419 | Draggables.notify('onEnd', this, event); 420 | 421 | var revert = this.options.revert; 422 | if(revert && Object.isFunction(revert)) revert = revert(this.element); 423 | 424 | var d = this.currentDelta(); 425 | if(revert && this.options.reverteffect) { 426 | if (dropped == 0 || revert != 'failure') 427 | this.options.reverteffect(this.element, 428 | d[1]-this.delta[1], d[0]-this.delta[0]); 429 | } else { 430 | this.delta = d; 431 | } 432 | 433 | if(this.options.zindex) 434 | this.element.style.zIndex = this.originalZ; 435 | 436 | if(this.options.endeffect) 437 | this.options.endeffect(this.element); 438 | 439 | Draggables.deactivate(this); 440 | Droppables.reset(); 441 | }, 442 | 443 | keyPress: function(event) { 444 | if(event.keyCode!=Event.KEY_ESC) return; 445 | this.finishDrag(event, false); 446 | Event.stop(event); 447 | }, 448 | 449 | endDrag: function(event) { 450 | if(!this.dragging) return; 451 | this.stopScrolling(); 452 | this.finishDrag(event, true); 453 | Event.stop(event); 454 | }, 455 | 456 | draw: function(point) { 457 | var pos = Position.cumulativeOffset(this.element); 458 | if(this.options.ghosting) { 459 | var r = Position.realOffset(this.element); 460 | pos[0] += r[0] - Position.deltaX; pos[1] += r[1] - Position.deltaY; 461 | } 462 | 463 | var d = this.currentDelta(); 464 | pos[0] -= d[0]; pos[1] -= d[1]; 465 | 466 | if(this.options.scroll && (this.options.scroll != window && this._isScrollChild)) { 467 | pos[0] -= this.options.scroll.scrollLeft-this.originalScrollLeft; 468 | pos[1] -= this.options.scroll.scrollTop-this.originalScrollTop; 469 | } 470 | 471 | var p = [0,1].map(function(i){ 472 | return (point[i]-pos[i]-this.offset[i]) 473 | }.bind(this)); 474 | 475 | if(this.options.snap) { 476 | if(Object.isFunction(this.options.snap)) { 477 | p = this.options.snap(p[0],p[1],this); 478 | } else { 479 | if(Object.isArray(this.options.snap)) { 480 | p = p.map( function(v, i) { 481 | return (v/this.options.snap[i]).round()*this.options.snap[i] }.bind(this)); 482 | } else { 483 | p = p.map( function(v) { 484 | return (v/this.options.snap).round()*this.options.snap }.bind(this)); 485 | } 486 | }} 487 | 488 | var style = this.element.style; 489 | if((!this.options.constraint) || (this.options.constraint=='horizontal')) 490 | style.left = p[0] + "px"; 491 | if((!this.options.constraint) || (this.options.constraint=='vertical')) 492 | style.top = p[1] + "px"; 493 | 494 | if(style.visibility=="hidden") style.visibility = ""; // fix gecko rendering 495 | }, 496 | 497 | stopScrolling: function() { 498 | if(this.scrollInterval) { 499 | clearInterval(this.scrollInterval); 500 | this.scrollInterval = null; 501 | Draggables._lastScrollPointer = null; 502 | } 503 | }, 504 | 505 | startScrolling: function(speed) { 506 | if(!(speed[0] || speed[1])) return; 507 | this.scrollSpeed = [speed[0]*this.options.scrollSpeed,speed[1]*this.options.scrollSpeed]; 508 | this.lastScrolled = new Date(); 509 | this.scrollInterval = setInterval(this.scroll.bind(this), 10); 510 | }, 511 | 512 | scroll: function() { 513 | var current = new Date(); 514 | var delta = current - this.lastScrolled; 515 | this.lastScrolled = current; 516 | if(this.options.scroll == window) { 517 | with (this._getWindowScroll(this.options.scroll)) { 518 | if (this.scrollSpeed[0] || this.scrollSpeed[1]) { 519 | var d = delta / 1000; 520 | this.options.scroll.scrollTo( left + d*this.scrollSpeed[0], top + d*this.scrollSpeed[1] ); 521 | } 522 | } 523 | } else { 524 | this.options.scroll.scrollLeft += this.scrollSpeed[0] * delta / 1000; 525 | this.options.scroll.scrollTop += this.scrollSpeed[1] * delta / 1000; 526 | } 527 | 528 | Position.prepare(); 529 | Droppables.show(Draggables._lastPointer, this.element); 530 | Draggables.notify('onDrag', this); 531 | if (this._isScrollChild) { 532 | Draggables._lastScrollPointer = Draggables._lastScrollPointer || $A(Draggables._lastPointer); 533 | Draggables._lastScrollPointer[0] += this.scrollSpeed[0] * delta / 1000; 534 | Draggables._lastScrollPointer[1] += this.scrollSpeed[1] * delta / 1000; 535 | if (Draggables._lastScrollPointer[0] < 0) 536 | Draggables._lastScrollPointer[0] = 0; 537 | if (Draggables._lastScrollPointer[1] < 0) 538 | Draggables._lastScrollPointer[1] = 0; 539 | this.draw(Draggables._lastScrollPointer); 540 | } 541 | 542 | if(this.options.change) this.options.change(this); 543 | }, 544 | 545 | _getWindowScroll: function(w) { 546 | var T, L, W, H; 547 | with (w.document) { 548 | if (w.document.documentElement && documentElement.scrollTop) { 549 | T = documentElement.scrollTop; 550 | L = documentElement.scrollLeft; 551 | } else if (w.document.body) { 552 | T = body.scrollTop; 553 | L = body.scrollLeft; 554 | } 555 | if (w.innerWidth) { 556 | W = w.innerWidth; 557 | H = w.innerHeight; 558 | } else if (w.document.documentElement && documentElement.clientWidth) { 559 | W = documentElement.clientWidth; 560 | H = documentElement.clientHeight; 561 | } else { 562 | W = body.offsetWidth; 563 | H = body.offsetHeight; 564 | } 565 | } 566 | return { top: T, left: L, width: W, height: H }; 567 | } 568 | }); 569 | 570 | Draggable._dragging = { }; 571 | 572 | /*--------------------------------------------------------------------------*/ 573 | 574 | var SortableObserver = Class.create({ 575 | initialize: function(element, observer) { 576 | this.element = $(element); 577 | this.observer = observer; 578 | this.lastValue = Sortable.serialize(this.element); 579 | }, 580 | 581 | onStart: function() { 582 | this.lastValue = Sortable.serialize(this.element); 583 | }, 584 | 585 | onEnd: function() { 586 | Sortable.unmark(); 587 | if(this.lastValue != Sortable.serialize(this.element)) 588 | this.observer(this.element) 589 | } 590 | }); 591 | 592 | var Sortable = { 593 | SERIALIZE_RULE: /^[^_\-](?:[A-Za-z0-9\-\_]*)[_](.*)$/, 594 | 595 | sortables: { }, 596 | 597 | _findRootElement: function(element) { 598 | while (element.tagName.toUpperCase() != "BODY") { 599 | if(element.id && Sortable.sortables[element.id]) return element; 600 | element = element.parentNode; 601 | } 602 | }, 603 | 604 | options: function(element) { 605 | element = Sortable._findRootElement($(element)); 606 | if(!element) return; 607 | return Sortable.sortables[element.id]; 608 | }, 609 | 610 | destroy: function(element){ 611 | element = $(element); 612 | var s = Sortable.sortables[element.id]; 613 | 614 | if(s) { 615 | Draggables.removeObserver(s.element); 616 | s.droppables.each(function(d){ Droppables.remove(d) }); 617 | s.draggables.invoke('destroy'); 618 | 619 | delete Sortable.sortables[s.element.id]; 620 | } 621 | }, 622 | 623 | create: function(element) { 624 | element = $(element); 625 | var options = Object.extend({ 626 | element: element, 627 | tag: 'li', // assumes li children, override with tag: 'tagname' 628 | dropOnEmpty: false, 629 | tree: false, 630 | treeTag: 'ul', 631 | overlap: 'vertical', // one of 'vertical', 'horizontal' 632 | constraint: 'vertical', // one of 'vertical', 'horizontal', false 633 | containment: element, // also takes array of elements (or id's); or false 634 | handle: false, // or a CSS class 635 | only: false, 636 | delay: 0, 637 | hoverclass: null, 638 | ghosting: false, 639 | quiet: false, 640 | scroll: false, 641 | scrollSensitivity: 20, 642 | scrollSpeed: 15, 643 | format: this.SERIALIZE_RULE, 644 | 645 | // these take arrays of elements or ids and can be 646 | // used for better initialization performance 647 | elements: false, 648 | handles: false, 649 | 650 | onChange: Prototype.emptyFunction, 651 | onUpdate: Prototype.emptyFunction 652 | }, arguments[1] || { }); 653 | 654 | // clear any old sortable with same element 655 | this.destroy(element); 656 | 657 | // build options for the draggables 658 | var options_for_draggable = { 659 | revert: true, 660 | quiet: options.quiet, 661 | scroll: options.scroll, 662 | scrollSpeed: options.scrollSpeed, 663 | scrollSensitivity: options.scrollSensitivity, 664 | delay: options.delay, 665 | ghosting: options.ghosting, 666 | constraint: options.constraint, 667 | handle: options.handle }; 668 | 669 | if(options.starteffect) 670 | options_for_draggable.starteffect = options.starteffect; 671 | 672 | if(options.reverteffect) 673 | options_for_draggable.reverteffect = options.reverteffect; 674 | else 675 | if(options.ghosting) options_for_draggable.reverteffect = function(element) { 676 | element.style.top = 0; 677 | element.style.left = 0; 678 | }; 679 | 680 | if(options.endeffect) 681 | options_for_draggable.endeffect = options.endeffect; 682 | 683 | if(options.zindex) 684 | options_for_draggable.zindex = options.zindex; 685 | 686 | // build options for the droppables 687 | var options_for_droppable = { 688 | overlap: options.overlap, 689 | containment: options.containment, 690 | tree: options.tree, 691 | hoverclass: options.hoverclass, 692 | onHover: Sortable.onHover 693 | }; 694 | 695 | var options_for_tree = { 696 | onHover: Sortable.onEmptyHover, 697 | overlap: options.overlap, 698 | containment: options.containment, 699 | hoverclass: options.hoverclass 700 | }; 701 | 702 | // fix for gecko engine 703 | Element.cleanWhitespace(element); 704 | 705 | options.draggables = []; 706 | options.droppables = []; 707 | 708 | // drop on empty handling 709 | if(options.dropOnEmpty || options.tree) { 710 | Droppables.add(element, options_for_tree); 711 | options.droppables.push(element); 712 | } 713 | 714 | (options.elements || this.findElements(element, options) || []).each( function(e,i) { 715 | var handle = options.handles ? $(options.handles[i]) : 716 | (options.handle ? $(e).select('.' + options.handle)[0] : e); 717 | options.draggables.push( 718 | new Draggable(e, Object.extend(options_for_draggable, { handle: handle }))); 719 | Droppables.add(e, options_for_droppable); 720 | if(options.tree) e.treeNode = element; 721 | options.droppables.push(e); 722 | }); 723 | 724 | if(options.tree) { 725 | (Sortable.findTreeElements(element, options) || []).each( function(e) { 726 | Droppables.add(e, options_for_tree); 727 | e.treeNode = element; 728 | options.droppables.push(e); 729 | }); 730 | } 731 | 732 | // keep reference 733 | this.sortables[element.id] = options; 734 | 735 | // for onupdate 736 | Draggables.addObserver(new SortableObserver(element, options.onUpdate)); 737 | 738 | }, 739 | 740 | // return all suitable-for-sortable elements in a guaranteed order 741 | findElements: function(element, options) { 742 | return Element.findChildren( 743 | element, options.only, options.tree ? true : false, options.tag); 744 | }, 745 | 746 | findTreeElements: function(element, options) { 747 | return Element.findChildren( 748 | element, options.only, options.tree ? true : false, options.treeTag); 749 | }, 750 | 751 | onHover: function(element, dropon, overlap) { 752 | if(Element.isParent(dropon, element)) return; 753 | 754 | if(overlap > .33 && overlap < .66 && Sortable.options(dropon).tree) { 755 | return; 756 | } else if(overlap>0.5) { 757 | Sortable.mark(dropon, 'before'); 758 | if(dropon.previousSibling != element) { 759 | var oldParentNode = element.parentNode; 760 | element.style.visibility = "hidden"; // fix gecko rendering 761 | dropon.parentNode.insertBefore(element, dropon); 762 | if(dropon.parentNode!=oldParentNode) 763 | Sortable.options(oldParentNode).onChange(element); 764 | Sortable.options(dropon.parentNode).onChange(element); 765 | } 766 | } else { 767 | Sortable.mark(dropon, 'after'); 768 | var nextElement = dropon.nextSibling || null; 769 | if(nextElement != element) { 770 | var oldParentNode = element.parentNode; 771 | element.style.visibility = "hidden"; // fix gecko rendering 772 | dropon.parentNode.insertBefore(element, nextElement); 773 | if(dropon.parentNode!=oldParentNode) 774 | Sortable.options(oldParentNode).onChange(element); 775 | Sortable.options(dropon.parentNode).onChange(element); 776 | } 777 | } 778 | }, 779 | 780 | onEmptyHover: function(element, dropon, overlap) { 781 | var oldParentNode = element.parentNode; 782 | var droponOptions = Sortable.options(dropon); 783 | 784 | if(!Element.isParent(dropon, element)) { 785 | var index; 786 | 787 | var children = Sortable.findElements(dropon, {tag: droponOptions.tag, only: droponOptions.only}); 788 | var child = null; 789 | 790 | if(children) { 791 | var offset = Element.offsetSize(dropon, droponOptions.overlap) * (1.0 - overlap); 792 | 793 | for (index = 0; index < children.length; index += 1) { 794 | if (offset - Element.offsetSize (children[index], droponOptions.overlap) >= 0) { 795 | offset -= Element.offsetSize (children[index], droponOptions.overlap); 796 | } else if (offset - (Element.offsetSize (children[index], droponOptions.overlap) / 2) >= 0) { 797 | child = index + 1 < children.length ? children[index + 1] : null; 798 | break; 799 | } else { 800 | child = children[index]; 801 | break; 802 | } 803 | } 804 | } 805 | 806 | dropon.insertBefore(element, child); 807 | 808 | Sortable.options(oldParentNode).onChange(element); 809 | droponOptions.onChange(element); 810 | } 811 | }, 812 | 813 | unmark: function() { 814 | if(Sortable._marker) Sortable._marker.hide(); 815 | }, 816 | 817 | mark: function(dropon, position) { 818 | // mark on ghosting only 819 | var sortable = Sortable.options(dropon.parentNode); 820 | if(sortable && !sortable.ghosting) return; 821 | 822 | if(!Sortable._marker) { 823 | Sortable._marker = 824 | ($('dropmarker') || Element.extend(document.createElement('DIV'))). 825 | hide().addClassName('dropmarker').setStyle({position:'absolute'}); 826 | document.getElementsByTagName("body").item(0).appendChild(Sortable._marker); 827 | } 828 | var offsets = Position.cumulativeOffset(dropon); 829 | Sortable._marker.setStyle({left: offsets[0]+'px', top: offsets[1] + 'px'}); 830 | 831 | if(position=='after') 832 | if(sortable.overlap == 'horizontal') 833 | Sortable._marker.setStyle({left: (offsets[0]+dropon.clientWidth) + 'px'}); 834 | else 835 | Sortable._marker.setStyle({top: (offsets[1]+dropon.clientHeight) + 'px'}); 836 | 837 | Sortable._marker.show(); 838 | }, 839 | 840 | _tree: function(element, options, parent) { 841 | var children = Sortable.findElements(element, options) || []; 842 | 843 | for (var i = 0; i < children.length; ++i) { 844 | var match = children[i].id.match(options.format); 845 | 846 | if (!match) continue; 847 | 848 | var child = { 849 | id: encodeURIComponent(match ? match[1] : null), 850 | element: element, 851 | parent: parent, 852 | children: [], 853 | position: parent.children.length, 854 | container: $(children[i]).down(options.treeTag) 855 | }; 856 | 857 | /* Get the element containing the children and recurse over it */ 858 | if (child.container) 859 | this._tree(child.container, options, child); 860 | 861 | parent.children.push (child); 862 | } 863 | 864 | return parent; 865 | }, 866 | 867 | tree: function(element) { 868 | element = $(element); 869 | var sortableOptions = this.options(element); 870 | var options = Object.extend({ 871 | tag: sortableOptions.tag, 872 | treeTag: sortableOptions.treeTag, 873 | only: sortableOptions.only, 874 | name: element.id, 875 | format: sortableOptions.format 876 | }, arguments[1] || { }); 877 | 878 | var root = { 879 | id: null, 880 | parent: null, 881 | children: [], 882 | container: element, 883 | position: 0 884 | }; 885 | 886 | return Sortable._tree(element, options, root); 887 | }, 888 | 889 | /* Construct a [i] index for a particular node */ 890 | _constructIndex: function(node) { 891 | var index = ''; 892 | do { 893 | if (node.id) index = '[' + node.position + ']' + index; 894 | } while ((node = node.parent) != null); 895 | return index; 896 | }, 897 | 898 | sequence: function(element) { 899 | element = $(element); 900 | var options = Object.extend(this.options(element), arguments[1] || { }); 901 | 902 | return $(this.findElements(element, options) || []).map( function(item) { 903 | return item.id.match(options.format) ? item.id.match(options.format)[1] : ''; 904 | }); 905 | }, 906 | 907 | setSequence: function(element, new_sequence) { 908 | element = $(element); 909 | var options = Object.extend(this.options(element), arguments[2] || { }); 910 | 911 | var nodeMap = { }; 912 | this.findElements(element, options).each( function(n) { 913 | if (n.id.match(options.format)) 914 | nodeMap[n.id.match(options.format)[1]] = [n, n.parentNode]; 915 | n.parentNode.removeChild(n); 916 | }); 917 | 918 | new_sequence.each(function(ident) { 919 | var n = nodeMap[ident]; 920 | if (n) { 921 | n[1].appendChild(n[0]); 922 | delete nodeMap[ident]; 923 | } 924 | }); 925 | }, 926 | 927 | serialize: function(element) { 928 | element = $(element); 929 | var options = Object.extend(Sortable.options(element), arguments[1] || { }); 930 | var name = encodeURIComponent( 931 | (arguments[1] && arguments[1].name) ? arguments[1].name : element.id); 932 | 933 | if (options.tree) { 934 | return Sortable.tree(element, arguments[1]).children.map( function (item) { 935 | return [name + Sortable._constructIndex(item) + "[id]=" + 936 | encodeURIComponent(item.id)].concat(item.children.map(arguments.callee)); 937 | }).flatten().join('&'); 938 | } else { 939 | return Sortable.sequence(element, arguments[1]).map( function(item) { 940 | return name + "[]=" + encodeURIComponent(item); 941 | }).join('&'); 942 | } 943 | } 944 | }; 945 | 946 | // Returns true if child is contained within element 947 | Element.isParent = function(child, element) { 948 | if (!child.parentNode || child == element) return false; 949 | if (child.parentNode == element) return true; 950 | return Element.isParent(child.parentNode, element); 951 | }; 952 | 953 | Element.findChildren = function(element, only, recursive, tagName) { 954 | if(!element.hasChildNodes()) return null; 955 | tagName = tagName.toUpperCase(); 956 | if(only) only = [only].flatten(); 957 | var elements = []; 958 | $A(element.childNodes).each( function(e) { 959 | if(e.tagName && e.tagName.toUpperCase()==tagName && 960 | (!only || (Element.classNames(e).detect(function(v) { return only.include(v) })))) 961 | elements.push(e); 962 | if(recursive) { 963 | var grandchildren = Element.findChildren(e, only, recursive, tagName); 964 | if(grandchildren) elements.push(grandchildren); 965 | } 966 | }); 967 | 968 | return (elements.length>0 ? elements.flatten() : []); 969 | }; 970 | 971 | Element.offsetSize = function (element, type) { 972 | return element['offset' + ((type=='vertical' || type=='height') ? 'Height' : 'Width')]; 973 | }; -------------------------------------------------------------------------------- /lib/xmlsimple.rb: -------------------------------------------------------------------------------- 1 | # = XmlSimple 2 | # 3 | # Author:: Maik Schmidt 4 | # Copyright:: Copyright (c) 2003-2006 Maik Schmidt 5 | # License:: Distributes under the same terms as Ruby. 6 | # 7 | require 'rexml/document' 8 | require 'stringio' 9 | 10 | # Easy API to maintain XML (especially configuration files). 11 | class XmlSimple 12 | include REXML 13 | 14 | @@VERSION = '1.0.8' 15 | 16 | # A simple cache for XML documents that were already transformed 17 | # by xml_in. 18 | class Cache 19 | # Creates and initializes a new Cache object. 20 | def initialize 21 | @mem_share_cache = {} 22 | @mem_copy_cache = {} 23 | end 24 | 25 | # Saves a data structure into a file. 26 | # 27 | # data:: 28 | # Data structure to be saved. 29 | # filename:: 30 | # Name of the file belonging to the data structure. 31 | def save_storable(data, filename) 32 | cache_file = get_cache_filename(filename) 33 | File.open(cache_file, "w+") { |f| Marshal.dump(data, f) } 34 | end 35 | 36 | # Restores a data structure from a file. If restoring the data 37 | # structure failed for any reason, nil will be returned. 38 | # 39 | # filename:: 40 | # Name of the file belonging to the data structure. 41 | def restore_storable(filename) 42 | cache_file = get_cache_filename(filename) 43 | return nil unless File::exist?(cache_file) 44 | return nil unless File::mtime(cache_file).to_i > File::mtime(filename).to_i 45 | data = nil 46 | File.open(cache_file) { |f| data = Marshal.load(f) } 47 | data 48 | end 49 | 50 | # Saves a data structure in a shared memory cache. 51 | # 52 | # data:: 53 | # Data structure to be saved. 54 | # filename:: 55 | # Name of the file belonging to the data structure. 56 | def save_mem_share(data, filename) 57 | @mem_share_cache[filename] = [Time::now.to_i, data] 58 | end 59 | 60 | # Restores a data structure from a shared memory cache. You 61 | # should consider these elements as "read only". If restoring 62 | # the data structure failed for any reason, nil will be 63 | # returned. 64 | # 65 | # filename:: 66 | # Name of the file belonging to the data structure. 67 | def restore_mem_share(filename) 68 | get_from_memory_cache(filename, @mem_share_cache) 69 | end 70 | 71 | # Copies a data structure to a memory cache. 72 | # 73 | # data:: 74 | # Data structure to be copied. 75 | # filename:: 76 | # Name of the file belonging to the data structure. 77 | def save_mem_copy(data, filename) 78 | @mem_share_cache[filename] = [Time::now.to_i, Marshal.dump(data)] 79 | end 80 | 81 | # Restores a data structure from a memory cache. If restoring 82 | # the data structure failed for any reason, nil will be 83 | # returned. 84 | # 85 | # filename:: 86 | # Name of the file belonging to the data structure. 87 | def restore_mem_copy(filename) 88 | data = get_from_memory_cache(filename, @mem_share_cache) 89 | data = Marshal.load(data) unless data.nil? 90 | data 91 | end 92 | 93 | private 94 | 95 | # Returns the "cache filename" belonging to a filename, i.e. 96 | # the extension '.xml' in the original filename will be replaced 97 | # by '.stor'. If filename does not have this extension, '.stor' 98 | # will be appended. 99 | # 100 | # filename:: 101 | # Filename to get "cache filename" for. 102 | def get_cache_filename(filename) 103 | filename.sub(/(\.xml)?$/, '.stor') 104 | end 105 | 106 | # Returns a cache entry from a memory cache belonging to a 107 | # certain filename. If no entry could be found for any reason, 108 | # nil will be returned. 109 | # 110 | # filename:: 111 | # Name of the file the cache entry belongs to. 112 | # cache:: 113 | # Memory cache to get entry from. 114 | def get_from_memory_cache(filename, cache) 115 | return nil unless cache[filename] 116 | return nil unless cache[filename][0] > File::mtime(filename).to_i 117 | return cache[filename][1] 118 | end 119 | end 120 | 121 | # Create a "global" cache. 122 | @@cache = Cache.new 123 | 124 | # Creates and intializes a new XmlSimple object. 125 | # 126 | # defaults:: 127 | # Default values for options. 128 | def initialize(defaults = nil) 129 | unless defaults.nil? || defaults.instance_of?(Hash) 130 | raise ArgumentError, "Options have to be a Hash." 131 | end 132 | @default_options = normalize_option_names(defaults, KNOWN_OPTIONS['in'] & KNOWN_OPTIONS['out']) 133 | @options = Hash.new 134 | @_var_values = nil 135 | end 136 | 137 | # Converts an XML document in the same way as the Perl module XML::Simple. 138 | # 139 | # string:: 140 | # XML source. Could be one of the following: 141 | # 142 | # - nil: Tries to load and parse '.xml'. 143 | # - filename: Tries to load and parse filename. 144 | # - IO object: Reads from object until EOF is detected and parses result. 145 | # - XML string: Parses string. 146 | # 147 | # options:: 148 | # Options to be used. 149 | def xml_in(string = nil, options = nil) 150 | handle_options('in', options) 151 | 152 | # If no XML string or filename was supplied look for scriptname.xml. 153 | if string.nil? 154 | string = File::basename($0) 155 | string.sub!(/\.[^.]+$/, '') 156 | string += '.xml' 157 | 158 | directory = File::dirname($0) 159 | @options['searchpath'].unshift(directory) unless directory.nil? 160 | end 161 | 162 | if string.instance_of?(String) 163 | if string =~ /<.*?>/m 164 | @doc = parse(string) 165 | elsif string == '-' 166 | @doc = parse($stdin.readlines.to_s) 167 | else 168 | filename = find_xml_file(string, @options['searchpath']) 169 | 170 | if @options.has_key?('cache') 171 | @options['cache'].each { |scheme| 172 | case(scheme) 173 | when 'storable' 174 | content = @@cache.restore_storable(filename) 175 | when 'mem_share' 176 | content = @@cache.restore_mem_share(filename) 177 | when 'mem_copy' 178 | content = @@cache.restore_mem_copy(filename) 179 | else 180 | raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." 181 | end 182 | return content if content 183 | } 184 | end 185 | 186 | @doc = load_xml_file(filename) 187 | end 188 | elsif string.kind_of?(IO) || string.kind_of?(StringIO) 189 | @doc = parse(string.readlines.to_s) 190 | else 191 | raise ArgumentError, "Could not parse object of type: <#{string.type}>." 192 | end 193 | 194 | result = collapse(@doc.root) 195 | result = @options['keeproot'] ? merge({}, @doc.root.name, result) : result 196 | put_into_cache(result, filename) 197 | result 198 | end 199 | 200 | # This is the functional version of the instance method xml_in. 201 | def XmlSimple.xml_in(string = nil, options = nil) 202 | xml_simple = XmlSimple.new 203 | xml_simple.xml_in(string, options) 204 | end 205 | 206 | # Converts a data structure into an XML document. 207 | # 208 | # ref:: 209 | # Reference to data structure to be converted into XML. 210 | # options:: 211 | # Options to be used. 212 | def xml_out(ref, options = nil) 213 | handle_options('out', options) 214 | if ref.instance_of?(Array) 215 | ref = { @options['anonymoustag'] => ref } 216 | end 217 | 218 | if @options['keeproot'] 219 | keys = ref.keys 220 | if keys.size == 1 221 | ref = ref[keys[0]] 222 | @options['rootname'] = keys[0] 223 | end 224 | elsif @options['rootname'] == '' 225 | if ref.instance_of?(Hash) 226 | refsave = ref 227 | ref = {} 228 | refsave.each { |key, value| 229 | if !scalar(value) 230 | ref[key] = value 231 | else 232 | ref[key] = [ value.to_s ] 233 | end 234 | } 235 | end 236 | end 237 | 238 | @ancestors = [] 239 | xml = value_to_xml(ref, @options['rootname'], '') 240 | @ancestors = nil 241 | 242 | if @options['xmldeclaration'] 243 | xml = @options['xmldeclaration'] + "\n" + xml 244 | end 245 | 246 | if @options.has_key?('outputfile') 247 | if @options['outputfile'].kind_of?(IO) 248 | return @options['outputfile'].write(xml) 249 | else 250 | File.open(@options['outputfile'], "w") { |file| file.write(xml) } 251 | end 252 | end 253 | xml 254 | end 255 | 256 | # This is the functional version of the instance method xml_out. 257 | def XmlSimple.xml_out(hash, options = nil) 258 | xml_simple = XmlSimple.new 259 | xml_simple.xml_out(hash, options) 260 | end 261 | 262 | private 263 | 264 | # Declare options that are valid for xml_in and xml_out. 265 | KNOWN_OPTIONS = { 266 | 'in' => %w( 267 | keyattr keeproot forcecontent contentkey noattr 268 | searchpath forcearray suppressempty anonymoustag 269 | cache grouptags normalisespace normalizespace 270 | variables varattr 271 | ), 272 | 'out' => %w( 273 | keyattr keeproot contentkey noattr rootname 274 | xmldeclaration outputfile noescape suppressempty 275 | anonymoustag indent grouptags noindent 276 | ) 277 | } 278 | 279 | # Define some reasonable defaults. 280 | DEF_KEY_ATTRIBUTES = [] 281 | DEF_ROOT_NAME = 'opt' 282 | DEF_CONTENT_KEY = 'content' 283 | DEF_XML_DECLARATION = "" 284 | DEF_ANONYMOUS_TAG = 'anon' 285 | DEF_FORCE_ARRAY = true 286 | DEF_INDENTATION = ' ' 287 | 288 | # Normalizes option names in a hash, i.e., turns all 289 | # characters to lower case and removes all underscores. 290 | # Additionally, this method checks, if an unknown option 291 | # was used and raises an according exception. 292 | # 293 | # options:: 294 | # Hash to be normalized. 295 | # known_options:: 296 | # List of known options. 297 | def normalize_option_names(options, known_options) 298 | return nil if options.nil? 299 | result = Hash.new 300 | options.each { |key, value| 301 | lkey = key.downcase 302 | lkey.gsub!(/_/, '') 303 | if !known_options.member?(lkey) 304 | raise ArgumentError, "Unrecognised option: #{lkey}." 305 | end 306 | result[lkey] = value 307 | } 308 | result 309 | end 310 | 311 | # Merges a set of options with the default options. 312 | # 313 | # direction:: 314 | # 'in': If options should be handled for xml_in. 315 | # 'out': If options should be handled for xml_out. 316 | # options:: 317 | # Options to be merged with the default options. 318 | def handle_options(direction, options) 319 | @options = options || Hash.new 320 | 321 | raise ArgumentError, "Options must be a Hash!" unless @options.instance_of?(Hash) 322 | 323 | unless KNOWN_OPTIONS.has_key?(direction) 324 | raise ArgumentError, "Unknown direction: <#{direction}>." 325 | end 326 | 327 | known_options = KNOWN_OPTIONS[direction] 328 | @options = normalize_option_names(@options, known_options) 329 | 330 | unless @default_options.nil? 331 | known_options.each { |option| 332 | unless @options.has_key?(option) 333 | if @default_options.has_key?(option) 334 | @options[option] = @default_options[option] 335 | end 336 | end 337 | } 338 | end 339 | 340 | unless @options.has_key?('noattr') 341 | @options['noattr'] = false 342 | end 343 | 344 | if @options.has_key?('rootname') 345 | @options['rootname'] = '' if @options['rootname'].nil? 346 | else 347 | @options['rootname'] = DEF_ROOT_NAME 348 | end 349 | 350 | if @options.has_key?('xmldeclaration') && @options['xmldeclaration'] == true 351 | @options['xmldeclaration'] = DEF_XML_DECLARATION 352 | end 353 | 354 | if @options.has_key?('contentkey') 355 | if @options['contentkey'] =~ /^-(.*)$/ 356 | @options['contentkey'] = $1 357 | @options['collapseagain'] = true 358 | end 359 | else 360 | @options['contentkey'] = DEF_CONTENT_KEY 361 | end 362 | 363 | unless @options.has_key?('normalisespace') 364 | @options['normalisespace'] = @options['normalizespace'] 365 | end 366 | @options['normalisespace'] = 0 if @options['normalisespace'].nil? 367 | 368 | if @options.has_key?('searchpath') 369 | unless @options['searchpath'].instance_of?(Array) 370 | @options['searchpath'] = [ @options['searchpath'] ] 371 | end 372 | else 373 | @options['searchpath'] = [] 374 | end 375 | 376 | if @options.has_key?('cache') && scalar(@options['cache']) 377 | @options['cache'] = [ @options['cache'] ] 378 | end 379 | 380 | @options['anonymoustag'] = DEF_ANONYMOUS_TAG unless @options.has_key?('anonymoustag') 381 | 382 | if !@options.has_key?('indent') || @options['indent'].nil? 383 | @options['indent'] = DEF_INDENTATION 384 | end 385 | 386 | @options['indent'] = '' if @options.has_key?('noindent') 387 | 388 | # Special cleanup for 'keyattr' which could be an array or 389 | # a hash or left to default to array. 390 | if @options.has_key?('keyattr') 391 | if !scalar(@options['keyattr']) 392 | # Convert keyattr => { elem => '+attr' } 393 | # to keyattr => { elem => ['attr', '+'] } 394 | if @options['keyattr'].instance_of?(Hash) 395 | @options['keyattr'].each { |key, value| 396 | if value =~ /^([-+])?(.*)$/ 397 | @options['keyattr'][key] = [$2, $1 ? $1 : ''] 398 | end 399 | } 400 | elsif !@options['keyattr'].instance_of?(Array) 401 | raise ArgumentError, "'keyattr' must be String, Hash, or Array!" 402 | end 403 | else 404 | @options['keyattr'] = [ @options['keyattr'] ] 405 | end 406 | else 407 | @options['keyattr'] = DEF_KEY_ATTRIBUTES 408 | end 409 | 410 | if @options.has_key?('forcearray') 411 | if @options['forcearray'].instance_of?(Regexp) 412 | @options['forcearray'] = [ @options['forcearray'] ] 413 | end 414 | 415 | if @options['forcearray'].instance_of?(Array) 416 | force_list = @options['forcearray'] 417 | unless force_list.empty? 418 | @options['forcearray'] = {} 419 | force_list.each { |tag| 420 | if tag.instance_of?(Regexp) 421 | unless @options['forcearray']['_regex'].instance_of?(Array) 422 | @options['forcearray']['_regex'] = [] 423 | end 424 | @options['forcearray']['_regex'] << tag 425 | else 426 | @options['forcearray'][tag] = true 427 | end 428 | } 429 | else 430 | @options['forcearray'] = false 431 | end 432 | else 433 | @options['forcearray'] = @options['forcearray'] ? true : false 434 | end 435 | else 436 | @options['forcearray'] = DEF_FORCE_ARRAY 437 | end 438 | 439 | if @options.has_key?('grouptags') && !@options['grouptags'].instance_of?(Hash) 440 | raise ArgumentError, "Illegal value for 'GroupTags' option - expected a Hash." 441 | end 442 | 443 | if @options.has_key?('variables') && !@options['variables'].instance_of?(Hash) 444 | raise ArgumentError, "Illegal value for 'Variables' option - expected a Hash." 445 | end 446 | 447 | if @options.has_key?('variables') 448 | @_var_values = @options['variables'] 449 | elsif @options.has_key?('varattr') 450 | @_var_values = {} 451 | end 452 | end 453 | 454 | # Actually converts an XML document element into a data structure. 455 | # 456 | # element:: 457 | # The document element to be collapsed. 458 | def collapse(element) 459 | result = @options['noattr'] ? {} : get_attributes(element) 460 | 461 | if @options['normalisespace'] == 2 462 | result.each { |k, v| result[k] = normalise_space(v) } 463 | end 464 | 465 | if element.has_elements? 466 | element.each_element { |child| 467 | value = collapse(child) 468 | if empty(value) && (element.attributes.empty? || @options['noattr']) 469 | next if @options.has_key?('suppressempty') && @options['suppressempty'] == true 470 | end 471 | result = merge(result, child.name, value) 472 | } 473 | if has_mixed_content?(element) 474 | # normalisespace? 475 | content = element.texts.map { |x| x.to_s } 476 | content = content[0] if content.size == 1 477 | result[@options['contentkey']] = content 478 | end 479 | elsif element.has_text? # i.e. it has only text. 480 | return collapse_text_node(result, element) 481 | end 482 | 483 | # Turn Arrays into Hashes if key fields present. 484 | count = fold_arrays(result) 485 | 486 | # Disintermediate grouped tags. 487 | if @options.has_key?('grouptags') 488 | result.each { |key, value| 489 | next unless (value.instance_of?(Hash) && (value.size == 1)) 490 | child_key, child_value = value.to_a[0] 491 | if @options['grouptags'][key] == child_key 492 | result[key] = child_value 493 | end 494 | } 495 | end 496 | 497 | # Fold Hases containing a single anonymous Array up into just the Array. 498 | if count == 1 499 | anonymoustag = @options['anonymoustag'] 500 | if result.has_key?(anonymoustag) && result[anonymoustag].instance_of?(Array) 501 | return result[anonymoustag] 502 | end 503 | end 504 | 505 | if result.empty? && @options.has_key?('suppressempty') 506 | return @options['suppressempty'] == '' ? '' : nil 507 | end 508 | 509 | result 510 | end 511 | 512 | # Collapses a text node and merges it with an existing Hash, if 513 | # possible. 514 | # Thanks to Curtis Schofield for reporting a subtle bug. 515 | # 516 | # hash:: 517 | # Hash to merge text node value with, if possible. 518 | # element:: 519 | # Text node to be collapsed. 520 | def collapse_text_node(hash, element) 521 | value = node_to_text(element) 522 | if empty(value) && !element.has_attributes? 523 | return {} 524 | end 525 | 526 | if element.has_attributes? && !@options['noattr'] 527 | return merge(hash, @options['contentkey'], value) 528 | else 529 | if @options['forcecontent'] 530 | return merge(hash, @options['contentkey'], value) 531 | else 532 | return value 533 | end 534 | end 535 | end 536 | 537 | # Folds all arrays in a Hash. 538 | # 539 | # hash:: 540 | # Hash to be folded. 541 | def fold_arrays(hash) 542 | fold_amount = 0 543 | keyattr = @options['keyattr'] 544 | if (keyattr.instance_of?(Array) || keyattr.instance_of?(Hash)) 545 | hash.each { |key, value| 546 | if value.instance_of?(Array) 547 | if keyattr.instance_of?(Array) 548 | hash[key] = fold_array(value) 549 | else 550 | hash[key] = fold_array_by_name(key, value) 551 | end 552 | fold_amount += 1 553 | end 554 | } 555 | end 556 | fold_amount 557 | end 558 | 559 | # Folds an Array to a Hash, if possible. Folding happens 560 | # according to the content of keyattr, which has to be 561 | # an array. 562 | # 563 | # array:: 564 | # Array to be folded. 565 | def fold_array(array) 566 | hash = Hash.new 567 | array.each { |x| 568 | return array unless x.instance_of?(Hash) 569 | key_matched = false 570 | @options['keyattr'].each { |key| 571 | if x.has_key?(key) 572 | key_matched = true 573 | value = x[key] 574 | return array if value.instance_of?(Hash) || value.instance_of?(Array) 575 | value = normalise_space(value) if @options['normalisespace'] == 1 576 | x.delete(key) 577 | hash[value] = x 578 | break 579 | end 580 | } 581 | return array unless key_matched 582 | } 583 | hash = collapse_content(hash) if @options['collapseagain'] 584 | hash 585 | end 586 | 587 | # Folds an Array to a Hash, if possible. Folding happens 588 | # according to the content of keyattr, which has to be 589 | # a Hash. 590 | # 591 | # name:: 592 | # Name of the attribute to be folded upon. 593 | # array:: 594 | # Array to be folded. 595 | def fold_array_by_name(name, array) 596 | return array unless @options['keyattr'].has_key?(name) 597 | key, flag = @options['keyattr'][name] 598 | 599 | hash = Hash.new 600 | array.each { |x| 601 | if x.instance_of?(Hash) && x.has_key?(key) 602 | value = x[key] 603 | return array if value.instance_of?(Hash) || value.instance_of?(Array) 604 | value = normalise_space(value) if @options['normalisespace'] == 1 605 | hash[value] = x 606 | hash[value]["-#{key}"] = hash[value][key] if flag == '-' 607 | hash[value].delete(key) unless flag == '+' 608 | else 609 | $stderr.puts("Warning: <#{name}> element has no '#{key}' attribute.") 610 | return array 611 | end 612 | } 613 | hash = collapse_content(hash) if @options['collapseagain'] 614 | hash 615 | end 616 | 617 | # Tries to collapse a Hash even more ;-) 618 | # 619 | # hash:: 620 | # Hash to be collapsed again. 621 | def collapse_content(hash) 622 | content_key = @options['contentkey'] 623 | hash.each_value { |value| 624 | return hash unless value.instance_of?(Hash) && value.size == 1 && value.has_key?(content_key) 625 | hash.each_key { |key| hash[key] = hash[key][content_key] } 626 | } 627 | hash 628 | end 629 | 630 | # Adds a new key/value pair to an existing Hash. If the key to be added 631 | # does already exist and the existing value associated with key is not 632 | # an Array, it will be converted into an Array. Then the new value is 633 | # appended to that Array. 634 | # 635 | # hash:: 636 | # Hash to add key/value pair to. 637 | # key:: 638 | # Key to be added. 639 | # value:: 640 | # Value to be associated with key. 641 | def merge(hash, key, value) 642 | if value.instance_of?(String) 643 | value = normalise_space(value) if @options['normalisespace'] == 2 644 | 645 | # do variable substitutions 646 | unless @_var_values.nil? || @_var_values.empty? 647 | value.gsub!(/\$\{(\w+)\}/) { |x| get_var($1) } 648 | end 649 | 650 | # look for variable definitions 651 | if @options.has_key?('varattr') 652 | varattr = @options['varattr'] 653 | if hash.has_key?(varattr) 654 | set_var(hash[varattr], value) 655 | end 656 | end 657 | end 658 | if hash.has_key?(key) 659 | if hash[key].instance_of?(Array) 660 | hash[key] << value 661 | else 662 | hash[key] = [ hash[key], value ] 663 | end 664 | elsif value.instance_of?(Array) # Handle anonymous arrays. 665 | hash[key] = [ value ] 666 | else 667 | if force_array?(key) 668 | hash[key] = [ value ] 669 | else 670 | hash[key] = value 671 | end 672 | end 673 | hash 674 | end 675 | 676 | # Checks, if the 'forcearray' option has to be used for 677 | # a certain key. 678 | def force_array?(key) 679 | return false if key == @options['contentkey'] 680 | return true if @options['forcearray'] == true 681 | forcearray = @options['forcearray'] 682 | if forcearray.instance_of?(Hash) 683 | return true if forcearray.has_key?(key) 684 | return false unless forcearray.has_key?('_regex') 685 | forcearray['_regex'].each { |x| return true if key =~ x } 686 | end 687 | return false 688 | end 689 | 690 | # Converts the attributes array of a document node into a Hash. 691 | # Returns an empty Hash, if node has no attributes. 692 | # 693 | # node:: 694 | # Document node to extract attributes from. 695 | def get_attributes(node) 696 | attributes = {} 697 | node.attributes.each { |n,v| attributes[n] = v } 698 | attributes 699 | end 700 | 701 | # Determines, if a document element has mixed content. 702 | # 703 | # element:: 704 | # Document element to be checked. 705 | def has_mixed_content?(element) 706 | if element.has_text? && element.has_elements? 707 | return true if element.texts.join('') !~ /^\s*$/s 708 | end 709 | false 710 | end 711 | 712 | # Called when a variable definition is encountered in the XML. 713 | # A variable definition looks like 714 | # value 715 | # where attrname matches the varattr setting. 716 | def set_var(name, value) 717 | @_var_values[name] = value 718 | end 719 | 720 | # Called during variable substitution to get the value for the 721 | # named variable. 722 | def get_var(name) 723 | if @_var_values.has_key?(name) 724 | return @_var_values[name] 725 | else 726 | return "${#{name}}" 727 | end 728 | end 729 | 730 | # Recurses through a data structure building up and returning an 731 | # XML representation of that structure as a string. 732 | # 733 | # ref:: 734 | # Reference to the data structure to be encoded. 735 | # name:: 736 | # The XML tag name to be used for this item. 737 | # indent:: 738 | # A string of spaces for use as the current indent level. 739 | def value_to_xml(ref, name, indent) 740 | named = !name.nil? && name != '' 741 | nl = @options.has_key?('noindent') ? '' : "\n" 742 | 743 | if !scalar(ref) 744 | if @ancestors.member?(ref) 745 | raise ArgumentError, "Circular data structures not supported!" 746 | end 747 | @ancestors << ref 748 | else 749 | if named 750 | return [indent, '<', name, '>', @options['noescape'] ? ref.to_s : escape_value(ref.to_s), '', nl].join('') 751 | else 752 | return ref.to_s + nl 753 | end 754 | end 755 | 756 | # Unfold hash to array if possible. 757 | if ref.instance_of?(Hash) && !ref.empty? && !@options['keyattr'].empty? && indent != '' 758 | ref = hash_to_array(name, ref) 759 | end 760 | 761 | result = [] 762 | if ref.instance_of?(Hash) 763 | # Reintermediate grouped values if applicable. 764 | if @options.has_key?('grouptags') 765 | ref.each { |key, value| 766 | if @options['grouptags'].has_key?(key) 767 | ref[key] = { @options['grouptags'][key] => value } 768 | end 769 | } 770 | end 771 | 772 | nested = [] 773 | text_content = nil 774 | if named 775 | result << indent << '<' << name 776 | end 777 | 778 | if !ref.empty? 779 | ref.each { |key, value| 780 | next if !key.nil? && key[0, 1] == '-' 781 | if value.nil? 782 | unless @options.has_key?('suppressempty') && @options['suppressempty'].nil? 783 | raise ArgumentError, "Use of uninitialized value!" 784 | end 785 | value = {} 786 | end 787 | 788 | if !scalar(value) || @options['noattr'] 789 | nested << value_to_xml(value, key, indent + @options['indent']) 790 | else 791 | value = value.to_s 792 | value = escape_value(value) unless @options['noescape'] 793 | if key == @options['contentkey'] 794 | text_content = value 795 | else 796 | result << ' ' << key << '="' << value << '"' 797 | end 798 | end 799 | } 800 | else 801 | text_content = '' 802 | end 803 | 804 | if !nested.empty? || !text_content.nil? 805 | if named 806 | result << '>' 807 | if !text_content.nil? 808 | result << text_content 809 | nested[0].sub!(/^\s+/, '') if !nested.empty? 810 | else 811 | result << nl 812 | end 813 | if !nested.empty? 814 | result << nested << indent 815 | end 816 | result << '' << nl 817 | else 818 | result << nested 819 | end 820 | else 821 | result << ' />' << nl 822 | end 823 | elsif ref.instance_of?(Array) 824 | ref.each { |value| 825 | if scalar(value) 826 | result << indent << '<' << name << '>' 827 | result << (@options['noescape'] ? value.to_s : escape_value(value.to_s)) 828 | result << '' << nl 829 | elsif value.instance_of?(Hash) 830 | result << value_to_xml(value, name, indent) 831 | else 832 | result << indent << '<' << name << '>' << nl 833 | result << value_to_xml(value, @options['anonymoustag'], indent + @options['indent']) 834 | result << indent << '' << nl 835 | end 836 | } 837 | else 838 | # Probably, this is obsolete. 839 | raise ArgumentError, "Can't encode a value of type: #{ref.type}." 840 | end 841 | @ancestors.pop if !scalar(ref) 842 | result.join('') 843 | end 844 | 845 | # Checks, if a certain value is a "scalar" value. Whatever 846 | # that will be in Ruby ... ;-) 847 | # 848 | # value:: 849 | # Value to be checked. 850 | def scalar(value) 851 | return false if value.instance_of?(Hash) || value.instance_of?(Array) 852 | return true 853 | end 854 | 855 | # Attempts to unfold a hash of hashes into an array of hashes. Returns 856 | # a reference to th array on success or the original hash, if unfolding 857 | # is not possible. 858 | # 859 | # parent:: 860 | # 861 | # hashref:: 862 | # Reference to the hash to be unfolded. 863 | def hash_to_array(parent, hashref) 864 | arrayref = [] 865 | hashref.each { |key, value| 866 | return hashref unless value.instance_of?(Hash) 867 | 868 | if @options['keyattr'].instance_of?(Hash) 869 | return hashref unless @options['keyattr'].has_key?(parent) 870 | arrayref << { @options['keyattr'][parent][0] => key }.update(value) 871 | else 872 | arrayref << { @options['keyattr'][0] => key }.update(value) 873 | end 874 | } 875 | arrayref 876 | end 877 | 878 | # Replaces XML markup characters by their external entities. 879 | # 880 | # data:: 881 | # The string to be escaped. 882 | def escape_value(data) 883 | return data if data.nil? || data == '' 884 | result = data.dup 885 | result.gsub!('&', '&') 886 | result.gsub!('<', '<') 887 | result.gsub!('>', '>') 888 | result.gsub!('"', '"') 889 | result.gsub!("'", ''') 890 | result 891 | end 892 | 893 | # Removes leading and trailing whitespace and sequences of 894 | # whitespaces from a string. 895 | # 896 | # text:: 897 | # String to be normalised. 898 | def normalise_space(text) 899 | text.sub!(/^\s+/, '') 900 | text.sub!(/\s+$/, '') 901 | text.gsub!(/\s\s+/, ' ') 902 | text 903 | end 904 | 905 | # Checks, if an object is nil, an empty String or an empty Hash. 906 | # Thanks to Norbert Gawor for a bugfix. 907 | # 908 | # value:: 909 | # Value to be checked for emptyness. 910 | def empty(value) 911 | case value 912 | when Hash 913 | return value.empty? 914 | when String 915 | return value !~ /\S/m 916 | else 917 | return value.nil? 918 | end 919 | end 920 | 921 | # Converts a document node into a String. 922 | # If the node could not be converted into a String 923 | # for any reason, default will be returned. 924 | # 925 | # node:: 926 | # Document node to be converted. 927 | # default:: 928 | # Value to be returned, if node could not be converted. 929 | def node_to_text(node, default = nil) 930 | if node.instance_of?(Element) 931 | return node.texts.join('') 932 | elsif node.instance_of?(Attribute) 933 | return node.value.nil? ? default : node.value.strip 934 | elsif node.instance_of?(Text) 935 | return node.to_s.strip 936 | else 937 | return default 938 | end 939 | end 940 | 941 | # Parses an XML string and returns the according document. 942 | # 943 | # xml_string:: 944 | # XML string to be parsed. 945 | # 946 | # The following exception may be raised: 947 | # 948 | # REXML::ParseException:: 949 | # If the specified file is not wellformed. 950 | def parse(xml_string) 951 | Document.new(xml_string) 952 | end 953 | 954 | # Searches in a list of paths for a certain file. Returns 955 | # the full path to the file, if it could be found. Otherwise, 956 | # an exception will be raised. 957 | # 958 | # filename:: 959 | # Name of the file to search for. 960 | # searchpath:: 961 | # List of paths to search in. 962 | def find_xml_file(file, searchpath) 963 | filename = File::basename(file) 964 | 965 | if filename != file 966 | return file if File::file?(file) 967 | else 968 | searchpath.each { |path| 969 | full_path = File::join(path, filename) 970 | return full_path if File::file?(full_path) 971 | } 972 | end 973 | 974 | if searchpath.empty? 975 | return file if File::file?(file) 976 | raise ArgumentError, "File does not exist: #{file}." 977 | end 978 | raise ArgumentError, "Could not find <#{filename}> in <#{searchpath.join(':')}>" 979 | end 980 | 981 | # Loads and parses an XML configuration file. 982 | # 983 | # filename:: 984 | # Name of the configuration file to be loaded. 985 | # 986 | # The following exceptions may be raised: 987 | # 988 | # Errno::ENOENT:: 989 | # If the specified file does not exist. 990 | # REXML::ParseException:: 991 | # If the specified file is not wellformed. 992 | def load_xml_file(filename) 993 | parse(File.readlines(filename).to_s) 994 | end 995 | 996 | # Caches the data belonging to a certain file. 997 | # 998 | # data:: 999 | # Data to be cached. 1000 | # filename:: 1001 | # Name of file the data was read from. 1002 | def put_into_cache(data, filename) 1003 | if @options.has_key?('cache') 1004 | @options['cache'].each { |scheme| 1005 | case(scheme) 1006 | when 'storable' 1007 | @@cache.save_storable(data, filename) 1008 | when 'mem_share' 1009 | @@cache.save_mem_share(data, filename) 1010 | when 'mem_copy' 1011 | @@cache.save_mem_copy(data, filename) 1012 | else 1013 | raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." 1014 | end 1015 | } 1016 | end 1017 | end 1018 | end 1019 | 1020 | # vim:sw=2 1021 | -------------------------------------------------------------------------------- /public/javascripts/controls.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) 2 | // (c) 2005-2008 Ivan Krstic (http://blogs.law.harvard.edu/ivan) 3 | // (c) 2005-2008 Jon Tirsen (http://www.tirsen.com) 4 | // Contributors: 5 | // Richard Livsey 6 | // Rahul Bhargava 7 | // Rob Wills 8 | // 9 | // script.aculo.us is freely distributable under the terms of an MIT-style license. 10 | // For details, see the script.aculo.us web site: http://script.aculo.us/ 11 | 12 | // Autocompleter.Base handles all the autocompletion functionality 13 | // that's independent of the data source for autocompletion. This 14 | // includes drawing the autocompletion menu, observing keyboard 15 | // and mouse events, and similar. 16 | // 17 | // Specific autocompleters need to provide, at the very least, 18 | // a getUpdatedChoices function that will be invoked every time 19 | // the text inside the monitored textbox changes. This method 20 | // should get the text for which to provide autocompletion by 21 | // invoking this.getToken(), NOT by directly accessing 22 | // this.element.value. This is to allow incremental tokenized 23 | // autocompletion. Specific auto-completion logic (AJAX, etc) 24 | // belongs in getUpdatedChoices. 25 | // 26 | // Tokenized incremental autocompletion is enabled automatically 27 | // when an autocompleter is instantiated with the 'tokens' option 28 | // in the options parameter, e.g.: 29 | // new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' }); 30 | // will incrementally autocomplete with a comma as the token. 31 | // Additionally, ',' in the above example can be replaced with 32 | // a token array, e.g. { tokens: [',', '\n'] } which 33 | // enables autocompletion on multiple tokens. This is most 34 | // useful when one of the tokens is \n (a newline), as it 35 | // allows smart autocompletion after linebreaks. 36 | 37 | if(typeof Effect == 'undefined') 38 | throw("controls.js requires including script.aculo.us' effects.js library"); 39 | 40 | var Autocompleter = { }; 41 | Autocompleter.Base = Class.create({ 42 | baseInitialize: function(element, update, options) { 43 | element = $(element); 44 | this.element = element; 45 | this.update = $(update); 46 | this.hasFocus = false; 47 | this.changed = false; 48 | this.active = false; 49 | this.index = 0; 50 | this.entryCount = 0; 51 | this.oldElementValue = this.element.value; 52 | 53 | if(this.setOptions) 54 | this.setOptions(options); 55 | else 56 | this.options = options || { }; 57 | 58 | this.options.paramName = this.options.paramName || this.element.name; 59 | this.options.tokens = this.options.tokens || []; 60 | this.options.frequency = this.options.frequency || 0.4; 61 | this.options.minChars = this.options.minChars || 1; 62 | this.options.onShow = this.options.onShow || 63 | function(element, update){ 64 | if(!update.style.position || update.style.position=='absolute') { 65 | update.style.position = 'absolute'; 66 | Position.clone(element, update, { 67 | setHeight: false, 68 | offsetTop: element.offsetHeight 69 | }); 70 | } 71 | Effect.Appear(update,{duration:0.15}); 72 | }; 73 | this.options.onHide = this.options.onHide || 74 | function(element, update){ new Effect.Fade(update,{duration:0.15}) }; 75 | 76 | if(typeof(this.options.tokens) == 'string') 77 | this.options.tokens = new Array(this.options.tokens); 78 | // Force carriage returns as token delimiters anyway 79 | if (!this.options.tokens.include('\n')) 80 | this.options.tokens.push('\n'); 81 | 82 | this.observer = null; 83 | 84 | this.element.setAttribute('autocomplete','off'); 85 | 86 | Element.hide(this.update); 87 | 88 | Event.observe(this.element, 'blur', this.onBlur.bindAsEventListener(this)); 89 | Event.observe(this.element, 'keydown', this.onKeyPress.bindAsEventListener(this)); 90 | }, 91 | 92 | show: function() { 93 | if(Element.getStyle(this.update, 'display')=='none') this.options.onShow(this.element, this.update); 94 | if(!this.iefix && 95 | (Prototype.Browser.IE) && 96 | (Element.getStyle(this.update, 'position')=='absolute')) { 97 | new Insertion.After(this.update, 98 | ''); 101 | this.iefix = $(this.update.id+'_iefix'); 102 | } 103 | if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50); 104 | }, 105 | 106 | fixIEOverlapping: function() { 107 | Position.clone(this.update, this.iefix, {setTop:(!this.update.style.height)}); 108 | this.iefix.style.zIndex = 1; 109 | this.update.style.zIndex = 2; 110 | Element.show(this.iefix); 111 | }, 112 | 113 | hide: function() { 114 | this.stopIndicator(); 115 | if(Element.getStyle(this.update, 'display')!='none') this.options.onHide(this.element, this.update); 116 | if(this.iefix) Element.hide(this.iefix); 117 | }, 118 | 119 | startIndicator: function() { 120 | if(this.options.indicator) Element.show(this.options.indicator); 121 | }, 122 | 123 | stopIndicator: function() { 124 | if(this.options.indicator) Element.hide(this.options.indicator); 125 | }, 126 | 127 | onKeyPress: function(event) { 128 | if(this.active) 129 | switch(event.keyCode) { 130 | case Event.KEY_TAB: 131 | case Event.KEY_RETURN: 132 | this.selectEntry(); 133 | Event.stop(event); 134 | case Event.KEY_ESC: 135 | this.hide(); 136 | this.active = false; 137 | Event.stop(event); 138 | return; 139 | case Event.KEY_LEFT: 140 | case Event.KEY_RIGHT: 141 | return; 142 | case Event.KEY_UP: 143 | this.markPrevious(); 144 | this.render(); 145 | Event.stop(event); 146 | return; 147 | case Event.KEY_DOWN: 148 | this.markNext(); 149 | this.render(); 150 | Event.stop(event); 151 | return; 152 | } 153 | else 154 | if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN || 155 | (Prototype.Browser.WebKit > 0 && event.keyCode == 0)) return; 156 | 157 | this.changed = true; 158 | this.hasFocus = true; 159 | 160 | if(this.observer) clearTimeout(this.observer); 161 | this.observer = 162 | setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000); 163 | }, 164 | 165 | activate: function() { 166 | this.changed = false; 167 | this.hasFocus = true; 168 | this.getUpdatedChoices(); 169 | }, 170 | 171 | onHover: function(event) { 172 | var element = Event.findElement(event, 'LI'); 173 | if(this.index != element.autocompleteIndex) 174 | { 175 | this.index = element.autocompleteIndex; 176 | this.render(); 177 | } 178 | Event.stop(event); 179 | }, 180 | 181 | onClick: function(event) { 182 | var element = Event.findElement(event, 'LI'); 183 | this.index = element.autocompleteIndex; 184 | this.selectEntry(); 185 | this.hide(); 186 | }, 187 | 188 | onBlur: function(event) { 189 | // needed to make click events working 190 | setTimeout(this.hide.bind(this), 250); 191 | this.hasFocus = false; 192 | this.active = false; 193 | }, 194 | 195 | render: function() { 196 | if(this.entryCount > 0) { 197 | for (var i = 0; i < this.entryCount; i++) 198 | this.index==i ? 199 | Element.addClassName(this.getEntry(i),"selected") : 200 | Element.removeClassName(this.getEntry(i),"selected"); 201 | if(this.hasFocus) { 202 | this.show(); 203 | this.active = true; 204 | } 205 | } else { 206 | this.active = false; 207 | this.hide(); 208 | } 209 | }, 210 | 211 | markPrevious: function() { 212 | if(this.index > 0) this.index--; 213 | else this.index = this.entryCount-1; 214 | this.getEntry(this.index).scrollIntoView(true); 215 | }, 216 | 217 | markNext: function() { 218 | if(this.index < this.entryCount-1) this.index++; 219 | else this.index = 0; 220 | this.getEntry(this.index).scrollIntoView(false); 221 | }, 222 | 223 | getEntry: function(index) { 224 | return this.update.firstChild.childNodes[index]; 225 | }, 226 | 227 | getCurrentEntry: function() { 228 | return this.getEntry(this.index); 229 | }, 230 | 231 | selectEntry: function() { 232 | this.active = false; 233 | this.updateElement(this.getCurrentEntry()); 234 | }, 235 | 236 | updateElement: function(selectedElement) { 237 | if (this.options.updateElement) { 238 | this.options.updateElement(selectedElement); 239 | return; 240 | } 241 | var value = ''; 242 | if (this.options.select) { 243 | var nodes = $(selectedElement).select('.' + this.options.select) || []; 244 | if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select); 245 | } else 246 | value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal'); 247 | 248 | var bounds = this.getTokenBounds(); 249 | if (bounds[0] != -1) { 250 | var newValue = this.element.value.substr(0, bounds[0]); 251 | var whitespace = this.element.value.substr(bounds[0]).match(/^\s+/); 252 | if (whitespace) 253 | newValue += whitespace[0]; 254 | this.element.value = newValue + value + this.element.value.substr(bounds[1]); 255 | } else { 256 | this.element.value = value; 257 | } 258 | this.oldElementValue = this.element.value; 259 | this.element.focus(); 260 | 261 | if (this.options.afterUpdateElement) 262 | this.options.afterUpdateElement(this.element, selectedElement); 263 | }, 264 | 265 | updateChoices: function(choices) { 266 | if(!this.changed && this.hasFocus) { 267 | this.update.innerHTML = choices; 268 | Element.cleanWhitespace(this.update); 269 | Element.cleanWhitespace(this.update.down()); 270 | 271 | if(this.update.firstChild && this.update.down().childNodes) { 272 | this.entryCount = 273 | this.update.down().childNodes.length; 274 | for (var i = 0; i < this.entryCount; i++) { 275 | var entry = this.getEntry(i); 276 | entry.autocompleteIndex = i; 277 | this.addObservers(entry); 278 | } 279 | } else { 280 | this.entryCount = 0; 281 | } 282 | 283 | this.stopIndicator(); 284 | this.index = 0; 285 | 286 | if(this.entryCount==1 && this.options.autoSelect) { 287 | this.selectEntry(); 288 | this.hide(); 289 | } else { 290 | this.render(); 291 | } 292 | } 293 | }, 294 | 295 | addObservers: function(element) { 296 | Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this)); 297 | Event.observe(element, "click", this.onClick.bindAsEventListener(this)); 298 | }, 299 | 300 | onObserverEvent: function() { 301 | this.changed = false; 302 | this.tokenBounds = null; 303 | if(this.getToken().length>=this.options.minChars) { 304 | this.getUpdatedChoices(); 305 | } else { 306 | this.active = false; 307 | this.hide(); 308 | } 309 | this.oldElementValue = this.element.value; 310 | }, 311 | 312 | getToken: function() { 313 | var bounds = this.getTokenBounds(); 314 | return this.element.value.substring(bounds[0], bounds[1]).strip(); 315 | }, 316 | 317 | getTokenBounds: function() { 318 | if (null != this.tokenBounds) return this.tokenBounds; 319 | var value = this.element.value; 320 | if (value.strip().empty()) return [-1, 0]; 321 | var diff = arguments.callee.getFirstDifferencePos(value, this.oldElementValue); 322 | var offset = (diff == this.oldElementValue.length ? 1 : 0); 323 | var prevTokenPos = -1, nextTokenPos = value.length; 324 | var tp; 325 | for (var index = 0, l = this.options.tokens.length; index < l; ++index) { 326 | tp = value.lastIndexOf(this.options.tokens[index], diff + offset - 1); 327 | if (tp > prevTokenPos) prevTokenPos = tp; 328 | tp = value.indexOf(this.options.tokens[index], diff + offset); 329 | if (-1 != tp && tp < nextTokenPos) nextTokenPos = tp; 330 | } 331 | return (this.tokenBounds = [prevTokenPos + 1, nextTokenPos]); 332 | } 333 | }); 334 | 335 | Autocompleter.Base.prototype.getTokenBounds.getFirstDifferencePos = function(newS, oldS) { 336 | var boundary = Math.min(newS.length, oldS.length); 337 | for (var index = 0; index < boundary; ++index) 338 | if (newS[index] != oldS[index]) 339 | return index; 340 | return boundary; 341 | }; 342 | 343 | Ajax.Autocompleter = Class.create(Autocompleter.Base, { 344 | initialize: function(element, update, url, options) { 345 | this.baseInitialize(element, update, options); 346 | this.options.asynchronous = true; 347 | this.options.onComplete = this.onComplete.bind(this); 348 | this.options.defaultParams = this.options.parameters || null; 349 | this.url = url; 350 | }, 351 | 352 | getUpdatedChoices: function() { 353 | this.startIndicator(); 354 | 355 | var entry = encodeURIComponent(this.options.paramName) + '=' + 356 | encodeURIComponent(this.getToken()); 357 | 358 | this.options.parameters = this.options.callback ? 359 | this.options.callback(this.element, entry) : entry; 360 | 361 | if(this.options.defaultParams) 362 | this.options.parameters += '&' + this.options.defaultParams; 363 | 364 | new Ajax.Request(this.url, this.options); 365 | }, 366 | 367 | onComplete: function(request) { 368 | this.updateChoices(request.responseText); 369 | } 370 | }); 371 | 372 | // The local array autocompleter. Used when you'd prefer to 373 | // inject an array of autocompletion options into the page, rather 374 | // than sending out Ajax queries, which can be quite slow sometimes. 375 | // 376 | // The constructor takes four parameters. The first two are, as usual, 377 | // the id of the monitored textbox, and id of the autocompletion menu. 378 | // The third is the array you want to autocomplete from, and the fourth 379 | // is the options block. 380 | // 381 | // Extra local autocompletion options: 382 | // - choices - How many autocompletion choices to offer 383 | // 384 | // - partialSearch - If false, the autocompleter will match entered 385 | // text only at the beginning of strings in the 386 | // autocomplete array. Defaults to true, which will 387 | // match text at the beginning of any *word* in the 388 | // strings in the autocomplete array. If you want to 389 | // search anywhere in the string, additionally set 390 | // the option fullSearch to true (default: off). 391 | // 392 | // - fullSsearch - Search anywhere in autocomplete array strings. 393 | // 394 | // - partialChars - How many characters to enter before triggering 395 | // a partial match (unlike minChars, which defines 396 | // how many characters are required to do any match 397 | // at all). Defaults to 2. 398 | // 399 | // - ignoreCase - Whether to ignore case when autocompleting. 400 | // Defaults to true. 401 | // 402 | // It's possible to pass in a custom function as the 'selector' 403 | // option, if you prefer to write your own autocompletion logic. 404 | // In that case, the other options above will not apply unless 405 | // you support them. 406 | 407 | Autocompleter.Local = Class.create(Autocompleter.Base, { 408 | initialize: function(element, update, array, options) { 409 | this.baseInitialize(element, update, options); 410 | this.options.array = array; 411 | }, 412 | 413 | getUpdatedChoices: function() { 414 | this.updateChoices(this.options.selector(this)); 415 | }, 416 | 417 | setOptions: function(options) { 418 | this.options = Object.extend({ 419 | choices: 10, 420 | partialSearch: true, 421 | partialChars: 2, 422 | ignoreCase: true, 423 | fullSearch: false, 424 | selector: function(instance) { 425 | var ret = []; // Beginning matches 426 | var partial = []; // Inside matches 427 | var entry = instance.getToken(); 428 | var count = 0; 429 | 430 | for (var i = 0; i < instance.options.array.length && 431 | ret.length < instance.options.choices ; i++) { 432 | 433 | var elem = instance.options.array[i]; 434 | var foundPos = instance.options.ignoreCase ? 435 | elem.toLowerCase().indexOf(entry.toLowerCase()) : 436 | elem.indexOf(entry); 437 | 438 | while (foundPos != -1) { 439 | if (foundPos == 0 && elem.length != entry.length) { 440 | ret.push("
  • " + elem.substr(0, entry.length) + "" + 441 | elem.substr(entry.length) + "
  • "); 442 | break; 443 | } else if (entry.length >= instance.options.partialChars && 444 | instance.options.partialSearch && foundPos != -1) { 445 | if (instance.options.fullSearch || /\s/.test(elem.substr(foundPos-1,1))) { 446 | partial.push("
  • " + elem.substr(0, foundPos) + "" + 447 | elem.substr(foundPos, entry.length) + "" + elem.substr( 448 | foundPos + entry.length) + "
  • "); 449 | break; 450 | } 451 | } 452 | 453 | foundPos = instance.options.ignoreCase ? 454 | elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) : 455 | elem.indexOf(entry, foundPos + 1); 456 | 457 | } 458 | } 459 | if (partial.length) 460 | ret = ret.concat(partial.slice(0, instance.options.choices - ret.length)); 461 | return "
      " + ret.join('') + "
    "; 462 | } 463 | }, options || { }); 464 | } 465 | }); 466 | 467 | // AJAX in-place editor and collection editor 468 | // Full rewrite by Christophe Porteneuve (April 2007). 469 | 470 | // Use this if you notice weird scrolling problems on some browsers, 471 | // the DOM might be a bit confused when this gets called so do this 472 | // waits 1 ms (with setTimeout) until it does the activation 473 | Field.scrollFreeActivate = function(field) { 474 | setTimeout(function() { 475 | Field.activate(field); 476 | }, 1); 477 | }; 478 | 479 | Ajax.InPlaceEditor = Class.create({ 480 | initialize: function(element, url, options) { 481 | this.url = url; 482 | this.element = element = $(element); 483 | this.prepareOptions(); 484 | this._controls = { }; 485 | arguments.callee.dealWithDeprecatedOptions(options); // DEPRECATION LAYER!!! 486 | Object.extend(this.options, options || { }); 487 | if (!this.options.formId && this.element.id) { 488 | this.options.formId = this.element.id + '-inplaceeditor'; 489 | if ($(this.options.formId)) 490 | this.options.formId = ''; 491 | } 492 | if (this.options.externalControl) 493 | this.options.externalControl = $(this.options.externalControl); 494 | if (!this.options.externalControl) 495 | this.options.externalControlOnly = false; 496 | this._originalBackground = this.element.getStyle('background-color') || 'transparent'; 497 | this.element.title = this.options.clickToEditText; 498 | this._boundCancelHandler = this.handleFormCancellation.bind(this); 499 | this._boundComplete = (this.options.onComplete || Prototype.emptyFunction).bind(this); 500 | this._boundFailureHandler = this.handleAJAXFailure.bind(this); 501 | this._boundSubmitHandler = this.handleFormSubmission.bind(this); 502 | this._boundWrapperHandler = this.wrapUp.bind(this); 503 | this.registerListeners(); 504 | }, 505 | checkForEscapeOrReturn: function(e) { 506 | if (!this._editing || e.ctrlKey || e.altKey || e.shiftKey) return; 507 | if (Event.KEY_ESC == e.keyCode) 508 | this.handleFormCancellation(e); 509 | else if (Event.KEY_RETURN == e.keyCode) 510 | this.handleFormSubmission(e); 511 | }, 512 | createControl: function(mode, handler, extraClasses) { 513 | var control = this.options[mode + 'Control']; 514 | var text = this.options[mode + 'Text']; 515 | if ('button' == control) { 516 | var btn = document.createElement('input'); 517 | btn.type = 'submit'; 518 | btn.value = text; 519 | btn.className = 'editor_' + mode + '_button'; 520 | if ('cancel' == mode) 521 | btn.onclick = this._boundCancelHandler; 522 | this._form.appendChild(btn); 523 | this._controls[mode] = btn; 524 | } else if ('link' == control) { 525 | var link = document.createElement('a'); 526 | link.href = '#'; 527 | link.appendChild(document.createTextNode(text)); 528 | link.onclick = 'cancel' == mode ? this._boundCancelHandler : this._boundSubmitHandler; 529 | link.className = 'editor_' + mode + '_link'; 530 | if (extraClasses) 531 | link.className += ' ' + extraClasses; 532 | this._form.appendChild(link); 533 | this._controls[mode] = link; 534 | } 535 | }, 536 | createEditField: function() { 537 | var text = (this.options.loadTextURL ? this.options.loadingText : this.getText()); 538 | var fld; 539 | if (1 >= this.options.rows && !/\r|\n/.test(this.getText())) { 540 | fld = document.createElement('input'); 541 | fld.type = 'text'; 542 | var size = this.options.size || this.options.cols || 0; 543 | if (0 < size) fld.size = size; 544 | } else { 545 | fld = document.createElement('textarea'); 546 | fld.rows = (1 >= this.options.rows ? this.options.autoRows : this.options.rows); 547 | fld.cols = this.options.cols || 40; 548 | } 549 | fld.name = this.options.paramName; 550 | fld.value = text; // No HTML breaks conversion anymore 551 | fld.className = 'editor_field'; 552 | if (this.options.submitOnBlur) 553 | fld.onblur = this._boundSubmitHandler; 554 | this._controls.editor = fld; 555 | if (this.options.loadTextURL) 556 | this.loadExternalText(); 557 | this._form.appendChild(this._controls.editor); 558 | }, 559 | createForm: function() { 560 | var ipe = this; 561 | function addText(mode, condition) { 562 | var text = ipe.options['text' + mode + 'Controls']; 563 | if (!text || condition === false) return; 564 | ipe._form.appendChild(document.createTextNode(text)); 565 | }; 566 | this._form = $(document.createElement('form')); 567 | this._form.id = this.options.formId; 568 | this._form.addClassName(this.options.formClassName); 569 | this._form.onsubmit = this._boundSubmitHandler; 570 | this.createEditField(); 571 | if ('textarea' == this._controls.editor.tagName.toLowerCase()) 572 | this._form.appendChild(document.createElement('br')); 573 | if (this.options.onFormCustomization) 574 | this.options.onFormCustomization(this, this._form); 575 | addText('Before', this.options.okControl || this.options.cancelControl); 576 | this.createControl('ok', this._boundSubmitHandler); 577 | addText('Between', this.options.okControl && this.options.cancelControl); 578 | this.createControl('cancel', this._boundCancelHandler, 'editor_cancel'); 579 | addText('After', this.options.okControl || this.options.cancelControl); 580 | }, 581 | destroy: function() { 582 | if (this._oldInnerHTML) 583 | this.element.innerHTML = this._oldInnerHTML; 584 | this.leaveEditMode(); 585 | this.unregisterListeners(); 586 | }, 587 | enterEditMode: function(e) { 588 | if (this._saving || this._editing) return; 589 | this._editing = true; 590 | this.triggerCallback('onEnterEditMode'); 591 | if (this.options.externalControl) 592 | this.options.externalControl.hide(); 593 | this.element.hide(); 594 | this.createForm(); 595 | this.element.parentNode.insertBefore(this._form, this.element); 596 | if (!this.options.loadTextURL) 597 | this.postProcessEditField(); 598 | if (e) Event.stop(e); 599 | }, 600 | enterHover: function(e) { 601 | if (this.options.hoverClassName) 602 | this.element.addClassName(this.options.hoverClassName); 603 | if (this._saving) return; 604 | this.triggerCallback('onEnterHover'); 605 | }, 606 | getText: function() { 607 | return this.element.innerHTML.unescapeHTML(); 608 | }, 609 | handleAJAXFailure: function(transport) { 610 | this.triggerCallback('onFailure', transport); 611 | if (this._oldInnerHTML) { 612 | this.element.innerHTML = this._oldInnerHTML; 613 | this._oldInnerHTML = null; 614 | } 615 | }, 616 | handleFormCancellation: function(e) { 617 | this.wrapUp(); 618 | if (e) Event.stop(e); 619 | }, 620 | handleFormSubmission: function(e) { 621 | var form = this._form; 622 | var value = $F(this._controls.editor); 623 | this.prepareSubmission(); 624 | var params = this.options.callback(form, value) || ''; 625 | if (Object.isString(params)) 626 | params = params.toQueryParams(); 627 | params.editorId = this.element.id; 628 | if (this.options.htmlResponse) { 629 | var options = Object.extend({ evalScripts: true }, this.options.ajaxOptions); 630 | Object.extend(options, { 631 | parameters: params, 632 | onComplete: this._boundWrapperHandler, 633 | onFailure: this._boundFailureHandler 634 | }); 635 | new Ajax.Updater({ success: this.element }, this.url, options); 636 | } else { 637 | var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); 638 | Object.extend(options, { 639 | parameters: params, 640 | onComplete: this._boundWrapperHandler, 641 | onFailure: this._boundFailureHandler 642 | }); 643 | new Ajax.Request(this.url, options); 644 | } 645 | if (e) Event.stop(e); 646 | }, 647 | leaveEditMode: function() { 648 | this.element.removeClassName(this.options.savingClassName); 649 | this.removeForm(); 650 | this.leaveHover(); 651 | this.element.style.backgroundColor = this._originalBackground; 652 | this.element.show(); 653 | if (this.options.externalControl) 654 | this.options.externalControl.show(); 655 | this._saving = false; 656 | this._editing = false; 657 | this._oldInnerHTML = null; 658 | this.triggerCallback('onLeaveEditMode'); 659 | }, 660 | leaveHover: function(e) { 661 | if (this.options.hoverClassName) 662 | this.element.removeClassName(this.options.hoverClassName); 663 | if (this._saving) return; 664 | this.triggerCallback('onLeaveHover'); 665 | }, 666 | loadExternalText: function() { 667 | this._form.addClassName(this.options.loadingClassName); 668 | this._controls.editor.disabled = true; 669 | var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); 670 | Object.extend(options, { 671 | parameters: 'editorId=' + encodeURIComponent(this.element.id), 672 | onComplete: Prototype.emptyFunction, 673 | onSuccess: function(transport) { 674 | this._form.removeClassName(this.options.loadingClassName); 675 | var text = transport.responseText; 676 | if (this.options.stripLoadedTextTags) 677 | text = text.stripTags(); 678 | this._controls.editor.value = text; 679 | this._controls.editor.disabled = false; 680 | this.postProcessEditField(); 681 | }.bind(this), 682 | onFailure: this._boundFailureHandler 683 | }); 684 | new Ajax.Request(this.options.loadTextURL, options); 685 | }, 686 | postProcessEditField: function() { 687 | var fpc = this.options.fieldPostCreation; 688 | if (fpc) 689 | $(this._controls.editor)['focus' == fpc ? 'focus' : 'activate'](); 690 | }, 691 | prepareOptions: function() { 692 | this.options = Object.clone(Ajax.InPlaceEditor.DefaultOptions); 693 | Object.extend(this.options, Ajax.InPlaceEditor.DefaultCallbacks); 694 | [this._extraDefaultOptions].flatten().compact().each(function(defs) { 695 | Object.extend(this.options, defs); 696 | }.bind(this)); 697 | }, 698 | prepareSubmission: function() { 699 | this._saving = true; 700 | this.removeForm(); 701 | this.leaveHover(); 702 | this.showSaving(); 703 | }, 704 | registerListeners: function() { 705 | this._listeners = { }; 706 | var listener; 707 | $H(Ajax.InPlaceEditor.Listeners).each(function(pair) { 708 | listener = this[pair.value].bind(this); 709 | this._listeners[pair.key] = listener; 710 | if (!this.options.externalControlOnly) 711 | this.element.observe(pair.key, listener); 712 | if (this.options.externalControl) 713 | this.options.externalControl.observe(pair.key, listener); 714 | }.bind(this)); 715 | }, 716 | removeForm: function() { 717 | if (!this._form) return; 718 | this._form.remove(); 719 | this._form = null; 720 | this._controls = { }; 721 | }, 722 | showSaving: function() { 723 | this._oldInnerHTML = this.element.innerHTML; 724 | this.element.innerHTML = this.options.savingText; 725 | this.element.addClassName(this.options.savingClassName); 726 | this.element.style.backgroundColor = this._originalBackground; 727 | this.element.show(); 728 | }, 729 | triggerCallback: function(cbName, arg) { 730 | if ('function' == typeof this.options[cbName]) { 731 | this.options[cbName](this, arg); 732 | } 733 | }, 734 | unregisterListeners: function() { 735 | $H(this._listeners).each(function(pair) { 736 | if (!this.options.externalControlOnly) 737 | this.element.stopObserving(pair.key, pair.value); 738 | if (this.options.externalControl) 739 | this.options.externalControl.stopObserving(pair.key, pair.value); 740 | }.bind(this)); 741 | }, 742 | wrapUp: function(transport) { 743 | this.leaveEditMode(); 744 | // Can't use triggerCallback due to backward compatibility: requires 745 | // binding + direct element 746 | this._boundComplete(transport, this.element); 747 | } 748 | }); 749 | 750 | Object.extend(Ajax.InPlaceEditor.prototype, { 751 | dispose: Ajax.InPlaceEditor.prototype.destroy 752 | }); 753 | 754 | Ajax.InPlaceCollectionEditor = Class.create(Ajax.InPlaceEditor, { 755 | initialize: function($super, element, url, options) { 756 | this._extraDefaultOptions = Ajax.InPlaceCollectionEditor.DefaultOptions; 757 | $super(element, url, options); 758 | }, 759 | 760 | createEditField: function() { 761 | var list = document.createElement('select'); 762 | list.name = this.options.paramName; 763 | list.size = 1; 764 | this._controls.editor = list; 765 | this._collection = this.options.collection || []; 766 | if (this.options.loadCollectionURL) 767 | this.loadCollection(); 768 | else 769 | this.checkForExternalText(); 770 | this._form.appendChild(this._controls.editor); 771 | }, 772 | 773 | loadCollection: function() { 774 | this._form.addClassName(this.options.loadingClassName); 775 | this.showLoadingText(this.options.loadingCollectionText); 776 | var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); 777 | Object.extend(options, { 778 | parameters: 'editorId=' + encodeURIComponent(this.element.id), 779 | onComplete: Prototype.emptyFunction, 780 | onSuccess: function(transport) { 781 | var js = transport.responseText.strip(); 782 | if (!/^\[.*\]$/.test(js)) // TODO: improve sanity check 783 | throw('Server returned an invalid collection representation.'); 784 | this._collection = eval(js); 785 | this.checkForExternalText(); 786 | }.bind(this), 787 | onFailure: this.onFailure 788 | }); 789 | new Ajax.Request(this.options.loadCollectionURL, options); 790 | }, 791 | 792 | showLoadingText: function(text) { 793 | this._controls.editor.disabled = true; 794 | var tempOption = this._controls.editor.firstChild; 795 | if (!tempOption) { 796 | tempOption = document.createElement('option'); 797 | tempOption.value = ''; 798 | this._controls.editor.appendChild(tempOption); 799 | tempOption.selected = true; 800 | } 801 | tempOption.update((text || '').stripScripts().stripTags()); 802 | }, 803 | 804 | checkForExternalText: function() { 805 | this._text = this.getText(); 806 | if (this.options.loadTextURL) 807 | this.loadExternalText(); 808 | else 809 | this.buildOptionList(); 810 | }, 811 | 812 | loadExternalText: function() { 813 | this.showLoadingText(this.options.loadingText); 814 | var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); 815 | Object.extend(options, { 816 | parameters: 'editorId=' + encodeURIComponent(this.element.id), 817 | onComplete: Prototype.emptyFunction, 818 | onSuccess: function(transport) { 819 | this._text = transport.responseText.strip(); 820 | this.buildOptionList(); 821 | }.bind(this), 822 | onFailure: this.onFailure 823 | }); 824 | new Ajax.Request(this.options.loadTextURL, options); 825 | }, 826 | 827 | buildOptionList: function() { 828 | this._form.removeClassName(this.options.loadingClassName); 829 | this._collection = this._collection.map(function(entry) { 830 | return 2 === entry.length ? entry : [entry, entry].flatten(); 831 | }); 832 | var marker = ('value' in this.options) ? this.options.value : this._text; 833 | var textFound = this._collection.any(function(entry) { 834 | return entry[0] == marker; 835 | }.bind(this)); 836 | this._controls.editor.update(''); 837 | var option; 838 | this._collection.each(function(entry, index) { 839 | option = document.createElement('option'); 840 | option.value = entry[0]; 841 | option.selected = textFound ? entry[0] == marker : 0 == index; 842 | option.appendChild(document.createTextNode(entry[1])); 843 | this._controls.editor.appendChild(option); 844 | }.bind(this)); 845 | this._controls.editor.disabled = false; 846 | Field.scrollFreeActivate(this._controls.editor); 847 | } 848 | }); 849 | 850 | //**** DEPRECATION LAYER FOR InPlace[Collection]Editor! **** 851 | //**** This only exists for a while, in order to let **** 852 | //**** users adapt to the new API. Read up on the new **** 853 | //**** API and convert your code to it ASAP! **** 854 | 855 | Ajax.InPlaceEditor.prototype.initialize.dealWithDeprecatedOptions = function(options) { 856 | if (!options) return; 857 | function fallback(name, expr) { 858 | if (name in options || expr === undefined) return; 859 | options[name] = expr; 860 | }; 861 | fallback('cancelControl', (options.cancelLink ? 'link' : (options.cancelButton ? 'button' : 862 | options.cancelLink == options.cancelButton == false ? false : undefined))); 863 | fallback('okControl', (options.okLink ? 'link' : (options.okButton ? 'button' : 864 | options.okLink == options.okButton == false ? false : undefined))); 865 | fallback('highlightColor', options.highlightcolor); 866 | fallback('highlightEndColor', options.highlightendcolor); 867 | }; 868 | 869 | Object.extend(Ajax.InPlaceEditor, { 870 | DefaultOptions: { 871 | ajaxOptions: { }, 872 | autoRows: 3, // Use when multi-line w/ rows == 1 873 | cancelControl: 'link', // 'link'|'button'|false 874 | cancelText: 'cancel', 875 | clickToEditText: 'Click to edit', 876 | externalControl: null, // id|elt 877 | externalControlOnly: false, 878 | fieldPostCreation: 'activate', // 'activate'|'focus'|false 879 | formClassName: 'inplaceeditor-form', 880 | formId: null, // id|elt 881 | highlightColor: '#ffff99', 882 | highlightEndColor: '#ffffff', 883 | hoverClassName: '', 884 | htmlResponse: true, 885 | loadingClassName: 'inplaceeditor-loading', 886 | loadingText: 'Loading...', 887 | okControl: 'button', // 'link'|'button'|false 888 | okText: 'ok', 889 | paramName: 'value', 890 | rows: 1, // If 1 and multi-line, uses autoRows 891 | savingClassName: 'inplaceeditor-saving', 892 | savingText: 'Saving...', 893 | size: 0, 894 | stripLoadedTextTags: false, 895 | submitOnBlur: false, 896 | textAfterControls: '', 897 | textBeforeControls: '', 898 | textBetweenControls: '' 899 | }, 900 | DefaultCallbacks: { 901 | callback: function(form) { 902 | return Form.serialize(form); 903 | }, 904 | onComplete: function(transport, element) { 905 | // For backward compatibility, this one is bound to the IPE, and passes 906 | // the element directly. It was too often customized, so we don't break it. 907 | new Effect.Highlight(element, { 908 | startcolor: this.options.highlightColor, keepBackgroundImage: true }); 909 | }, 910 | onEnterEditMode: null, 911 | onEnterHover: function(ipe) { 912 | ipe.element.style.backgroundColor = ipe.options.highlightColor; 913 | if (ipe._effect) 914 | ipe._effect.cancel(); 915 | }, 916 | onFailure: function(transport, ipe) { 917 | alert('Error communication with the server: ' + transport.responseText.stripTags()); 918 | }, 919 | onFormCustomization: null, // Takes the IPE and its generated form, after editor, before controls. 920 | onLeaveEditMode: null, 921 | onLeaveHover: function(ipe) { 922 | ipe._effect = new Effect.Highlight(ipe.element, { 923 | startcolor: ipe.options.highlightColor, endcolor: ipe.options.highlightEndColor, 924 | restorecolor: ipe._originalBackground, keepBackgroundImage: true 925 | }); 926 | } 927 | }, 928 | Listeners: { 929 | click: 'enterEditMode', 930 | keydown: 'checkForEscapeOrReturn', 931 | mouseover: 'enterHover', 932 | mouseout: 'leaveHover' 933 | } 934 | }); 935 | 936 | Ajax.InPlaceCollectionEditor.DefaultOptions = { 937 | loadingCollectionText: 'Loading options...' 938 | }; 939 | 940 | // Delayed observer, like Form.Element.Observer, 941 | // but waits for delay after last key input 942 | // Ideal for live-search fields 943 | 944 | Form.Element.DelayedObserver = Class.create({ 945 | initialize: function(element, delay, callback) { 946 | this.delay = delay || 0.5; 947 | this.element = $(element); 948 | this.callback = callback; 949 | this.timer = null; 950 | this.lastValue = $F(this.element); 951 | Event.observe(this.element,'keyup',this.delayedListener.bindAsEventListener(this)); 952 | }, 953 | delayedListener: function(event) { 954 | if(this.lastValue == $F(this.element)) return; 955 | if(this.timer) clearTimeout(this.timer); 956 | this.timer = setTimeout(this.onTimerEvent.bind(this), this.delay * 1000); 957 | this.lastValue = $F(this.element); 958 | }, 959 | onTimerEvent: function() { 960 | this.timer = null; 961 | this.callback(this.element, $F(this.element)); 962 | } 963 | }); --------------------------------------------------------------------------------