├── 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 |
2 | <% @project.messages[0..4].each do |message| %>
3 | <%= render :partial => 'message/message', :locals => {:message => message} %>
4 | <% end %>
5 |
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 |
2 | <% @milestones.sort {|x, y| x.completed.to_s <=> y.completed.to_s }.each do |milestone| %>
3 | <%= render :partial => 'milestone/milestone', :locals => {:milestone => milestone} %>
4 | <% end %>
5 |
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 |
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 |
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), '', name, '>', 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 << '' << name << '>' << 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 << '' << name << '>' << 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 << '' << name << '>' << 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 "";
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 | });
--------------------------------------------------------------------------------