summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--app/controllers/admin_controller.rb8
-rw-r--r--app/controllers/content_controller.rb2
-rw-r--r--app/controllers/csp_reports_controller.rb22
-rw-r--r--app/controllers/nodes_controller.rb46
-rw-r--r--app/helpers/node_actions_helper.rb66
-rw-r--r--app/models/concerns/file_attachment.rb3
-rw-r--r--app/models/concerns/nested_tree.rb8
-rw-r--r--app/models/node.rb218
-rw-r--r--app/models/node_action.rb119
-rw-r--r--app/models/page.rb17
-rw-r--r--app/models/user.rb31
-rw-r--r--app/views/admin/index.html.erb28
-rw-r--r--app/views/assets/show.html.erb10
-rw-r--r--app/views/layouts/admin.html.erb5
-rw-r--r--app/views/layouts/application.html.erb12
-rw-r--r--app/views/layouts/events.html.erb17
-rw-r--r--app/views/node_actions/_action_row.html.erb18
-rw-r--r--app/views/node_actions/_change_details.html.erb16
-rw-r--r--app/views/node_actions/index.html.erb16
-rw-r--r--app/views/nodes/destroy.html.erb2
-rw-r--r--app/views/nodes/new.html.erb7
-rw-r--r--app/views/nodes/show.html.erb58
-rw-r--r--app/views/nodes/trashed.html.erb42
-rw-r--r--config/initializers/content_security_policy.rb49
-rw-r--r--config/initializers/csp_log.rb4
-rw-r--r--config/initializers/error_log.rb20
-rw-r--r--config/locales/de.yml10
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/routes.rb14
-rw-r--r--db/migrate/20260716165542_add_password_digest_to_users.rb5
-rw-r--r--db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb6
-rw-r--r--doc/DESIGN_HISTORY.md200
-rw-r--r--lib/authentication.rb16
-rw-r--r--lib/ccc_conventions.rb5
-rw-r--r--public/javascripts/admin_interface.js31
-rw-r--r--public/javascripts/admin_search.js19
-rw-r--r--public/javascripts/controls.js963
-rw-r--r--public/javascripts/dragdrop.js973
-rw-r--r--public/javascripts/effects.js1128
-rw-r--r--public/javascripts/prototype.js4320
-rw-r--r--public/stylesheets/admin.css66
-rw-r--r--public/stylesheets/ccc.css749
-rw-r--r--public/stylesheets/ccc.css_579
-rw-r--r--public/stylesheets/ccc.css_old257
-rw-r--r--test/controllers/content_controller_test.rb13
-rw-r--r--test/controllers/csp_reports_controller_test.rb8
-rw-r--r--test/controllers/nodes_controller_test.rb105
-rw-r--r--test/fixtures/nodes.yml10
-rw-r--r--test/integration/csp_header_test.rb12
-rw-r--r--test/models/concerns/rrule_humanizer_test.rb7
-rw-r--r--test/models/helpers/node_actions_helper_test.rb39
-rw-r--r--test/models/node_test.rb101
-rw-r--r--test/models/node_trash_test.rb168
-rw-r--r--test/models/page_test.rb35
-rw-r--r--test/models/user_test.rb72
57 files changed, 1992 insertions, 8773 deletions
diff --git a/Gemfile b/Gemfile
index 97257a9..cad34df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -38,6 +38,7 @@ gem 'rails_icons'
38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) 38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body)
39gem 'acts_as_list' # page revision ordering 39gem 'acts_as_list' # page revision ordering
40gem 'will_paginate', '~> 3.0' 40gem 'will_paginate', '~> 3.0'
41gem 'bcrypt', '~> 3.1'
41 42
42# Pinned to git until a release widens the activerecord < 8.1 ceiling. 43# Pinned to git until a release widens the activerecord < 8.1 ceiling.
43# Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative. 44# Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative.
diff --git a/Gemfile.lock b/Gemfile.lock
index e113726..8e4d5af 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -95,6 +95,7 @@ GEM
95 activerecord (>= 6.1) 95 activerecord (>= 6.1)
96 activesupport (>= 6.1) 96 activesupport (>= 6.1)
97 base64 (0.3.0) 97 base64 (0.3.0)
98 bcrypt (3.1.22)
98 bigdecimal (4.1.2) 99 bigdecimal (4.1.2)
99 builder (3.3.0) 100 builder (3.3.0)
100 coffee-rails (4.2.2) 101 coffee-rails (4.2.2)
@@ -334,6 +335,7 @@ PLATFORMS
334DEPENDENCIES 335DEPENDENCIES
335 acts-as-taggable-on! 336 acts-as-taggable-on!
336 acts_as_list 337 acts_as_list
338 bcrypt (~> 3.1)
337 chaos_calendar! 339 chaos_calendar!
338 coffee-rails (~> 4.0) 340 coffee-rails (~> 4.0)
339 concurrent-ruby (~> 1.3) 341 concurrent-ruby (~> 1.3)
@@ -375,6 +377,7 @@ CHECKSUMS
375 acts-as-taggable-on (13.0.0) 377 acts-as-taggable-on (13.0.0)
376 acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 378 acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
377 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b 379 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
380 bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032
378 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd 381 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
379 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f 382 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
380 bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 383 bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 40cfbdc..d9cf1be 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -6,7 +6,6 @@ class AdminController < ApplicationController
6 6
7 def index 7 def index
8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) 8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5)
9 @recent_changes = Node.recently_changed.limit(5)
10 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc).limit(5) 9 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc).limit(5)
11 end 10 end
12 11
@@ -72,4 +71,11 @@ class AdminController < ApplicationController
72 end 71 end
73 end 72 end
74 end 73 end
74
75 # Deliberately raises, to verify the error-log tripwire end to end.
76 # Behind login_required like the rest of the controller; harmless --
77 # the visitor gets the ordinary 500 page.
78 def boom
79 raise "Deliberate test exception via admin/boom"
80 end
75end 81end
diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb
index 8d33105..8be4bfb 100644
--- a/app/controllers/content_controller.rb
+++ b/app/controllers/content_controller.rb
@@ -31,7 +31,7 @@ class ContentController < ApplicationController
31 def render_gallery 31 def render_gallery
32 unless @page.nil? 32 unless @page.nil?
33 @images = @page.assets.images 33 @images = @page.assets.images
34 render :file => "content/gallery" 34 render :template => 'content/gallery'
35 else 35 else
36 head :not_found 36 head :not_found
37 end 37 end
diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb
new file mode 100644
index 0000000..5a3b55e
--- /dev/null
+++ b/app/controllers/csp_reports_controller.rb
@@ -0,0 +1,22 @@
1class CspReportsController < ApplicationController
2 # Browsers POST application/csp-report with no CSRF token, no session.
3 skip_before_action :verify_authenticity_token
4
5 def create
6 request.body.rewind if request.body.respond_to?(:rewind)
7 raw = request.body.read(8192)
8 raw = request.raw_post if raw.blank?
9
10 report = (JSON.parse(raw)["csp-report"] rescue nil)
11
12 if report
13 directive = report["effective-directive"] || report["violated-directive"]
14 at = (URI.parse(report["document-uri"]).path rescue "unparsed")
15 CSP_LOGGER.warn("CSP violation: #{directive} blocked=#{report['blocked-uri']} at=#{at}")
16 else
17 CSP_LOGGER.warn("CSP violation: unparseable report (#{raw.to_s.bytesize} bytes)")
18 end
19
20 head :no_content
21 end
22end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 9834a17..6caa827 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -13,7 +13,9 @@ class NodesController < ApplicationController
13 :publish, 13 :publish,
14 :unlock, 14 :unlock,
15 :autosave, 15 :autosave,
16 :revert 16 :revert,
17 :trash,
18 :restore_from_trash
17 ] 19 ]
18 20
19 around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave] 21 around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave]
@@ -60,6 +62,9 @@ class NodesController < ApplicationController
60 def show 62 def show
61 @page = @node.draft || @node.head 63 @page = @node.draft || @node.head
62 @translations = @page.translation_summary 64 @translations = @page.translation_summary
65 @page_actions = NodeAction.where(:page_id => @node.pages.select(:id))
66 .order(:occurred_at, :id)
67 .group_by(&:page_id)
63 end 68 end
64 69
65 def edit 70 def edit
@@ -139,8 +144,40 @@ class NodesController < ApplicationController
139 redirect_to node_path(@node) 144 redirect_to node_path(@node)
140 end 145 end
141 146
147 def trash
148 if @node.trash!(current_user)
149 flash[:notice] = "Page has been moved to the Trash"
150 redirect_to trashed_nodes_path
151 else
152 flash[:notice] = "Page is already in the Trash"
153 redirect_to node_path(@node)
154 end
155 rescue ActiveRecord::RecordInvalid, LockedByAnotherUser => e
156 flash[:error] = e.message
157 redirect_to node_path(@node)
158 end
159
160 def restore_from_trash
161 parent = Node.find(params[:parent_id])
162 @node.restore_from_trash!(parent, current_user)
163 flash[:notice] = "Page has been restored from the Trash"
164 redirect_to node_path(@node)
165 rescue ActiveRecord::RecordNotFound
166 flash[:error] = "Restore target not found"
167 redirect_to node_path(@node)
168 rescue ActiveRecord::RecordInvalid => e
169 flash[:error] = e.message
170 redirect_to node_path(@node)
171 end
172
142 def destroy 173 def destroy
143 @node.destroy 174 @node.destroy_from_trash!(current_user)
175 flash[:notice] = "Page has been permanently deleted"
176 redirect_to trashed_nodes_path
177
178 rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
179 flash[:error] = e.message
180 redirect_to node_path(@node)
144 end 181 end
145 182
146 def publish 183 def publish
@@ -214,6 +251,11 @@ class NodesController < ApplicationController
214 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 251 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
215 end 252 end
216 253
254 def trashed
255 @nodes = Node.trash.children.order(:slug)
256 .paginate(:page => params[:page], :per_page => 50)
257 end
258
217 private 259 private
218 260
219 def slug_for(title) 261 def slug_for(title)
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 996f98d..fd8cc36 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -1,6 +1,31 @@
1module NodeActionsHelper 1module NodeActionsHelper
2 include ERB::Util 2 include ERB::Util
3 3
4 # One glyph per verb, rendered before the sentence in _action_row.
5 # Unknown verbs get the dashed circle -- the unknown-verb principle
6 # extended to iconography: the log outlives its vocabulary.
7 VERB_ICONS = {
8 "create" => "file-plus",
9 "publish" => "send",
10 "move" => "arrows-move",
11 "trash" => "trash",
12 "restore_from_trash" => "arrow-back-up",
13 "destroy" => "trash-x",
14 "discard_autosave" => "eraser",
15 "destroy_draft" => "eraser",
16 }.freeze
17
18 def verb_icon action
19 name = if action.action == "publish" && action.metadata["via"] == "revision"
20 "history"
21 else
22 VERB_ICONS.fetch(action.action, "circle-dashed")
23 end
24 content_tag(:span, icon(name, library: "tabler", "aria-hidden": true),
25 :class => "node_action_icon node_action_icon--#{name}",
26 :title => action.action)
27 end
28
4 # One sentence per entry, rendered from metadata alone, so entries 29 # One sentence per entry, rendered from metadata alone, so entries
5 # stay renderable after the rows they reference are gone. Live 30 # stay renderable after the rows they reference are gone. Live
6 # associations only upgrade plain names to links. Every metadata 31 # associations only upgrade plain names to links. Every metadata
@@ -59,6 +84,32 @@ module NodeActionsHelper
59 end 84 end
60 end 85 end
61 86
87 def revision_lifecycle_badges actions
88 return "" if actions.blank?
89
90 badges = actions.map do |action|
91 key = case action.action
92 when "create" then "node_actions.revision_created"
93 when "publish" then action.metadata["via"] == "revision" ?
94 "node_actions.revision_restored" :
95 "node_actions.revision_published"
96 end
97 next unless key
98
99 badge = h(t(key, :date => action.occurred_at.strftime("%Y-%m-%d"),
100 :actor => action.actor_name))
101 if action.inferred_from
102 badge = safe_join([badge, content_tag(:span, t("node_actions.backfilled"),
103 :class => "node_action_inferred",
104 :title => action.inferred_from)], " ")
105 end
106 badge
107 end.compact
108
109 return "" if badges.empty?
110 safe_join(["— ", safe_join(badges, " · ")])
111 end
112
62 private 113 private
63 114
64 def revision_ref action, key 115 def revision_ref action, key
@@ -114,4 +165,19 @@ module NodeActionsHelper
114 t("node_actions.destroy_draft", 165 t("node_actions.destroy_draft",
115 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe 166 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
116 end 167 end
168
169 def summarize_trash action
170 t("node_actions.trash", :actor => actor_ref(action), :subject => subject_ref(action),
171 :from => h(action.metadata.dig("path", "from"))).html_safe
172 end
173
174 def summarize_restore_from_trash action
175 t("node_actions.restore_from_trash", :actor => actor_ref(action), :subject => subject_ref(action),
176 :to => h(action.metadata.dig("path", "to"))).html_safe
177 end
178
179 def summarize_destroy action
180 t("node_actions.destroy", :actor => actor_ref(action), :subject => subject_ref(action),
181 :path => h(action.metadata["path"])).html_safe
182 end
117end 183end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 613a6f0..3c09456 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -106,7 +106,8 @@ module FileAttachment
106 end 106 end
107 107
108 def sanitize_filename(filename) 108 def sanitize_filename(filename)
109 File.basename(filename).gsub(/[^\w\.\-]/, '_') 109 name = File.basename(filename).unicode_normalize(:nfc)
110 name.gsub(/(?u)[^\w\.\-]/, '_')
110 end 111 end
111 112
112 # Proxy object returned by asset.upload, providing the Paperclip-compatible 113 # Proxy object returned by asset.upload, providing the Paperclip-compatible
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
index 9a2eb0e..befcdb3 100644
--- a/app/models/concerns/nested_tree.rb
+++ b/app/models/concerns/nested_tree.rb
@@ -9,8 +9,6 @@ module NestedTree
9 included do 9 included do
10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children 10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent 11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
12
13 before_destroy :delete_descendants
14 end 12 end
15 13
16 class_methods do 14 class_methods do
@@ -77,10 +75,4 @@ module NestedTree
77 def move_to_child_of(new_parent) 75 def move_to_child_of(new_parent)
78 update!(parent_id: new_parent.id) 76 update!(parent_id: new_parent.id)
79 end 77 end
80
81 private
82
83 def delete_descendants
84 self.class.where(id: descendants.pluck(:id)).delete_all
85 end
86end 78end
diff --git a/app/models/node.rb b/app/models/node.rb
index 717f5b4..a440c2f 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,17 +4,23 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true 7 has_many :node_actions, :dependent => :nullify
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true 8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover
11 # them -- this dependent: :destroy is their only cleanup on node destroy.
9 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true 12 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
13
10 has_many :permissions, :dependent => :destroy 14 has_many :permissions, :dependent => :destroy
11 has_many :events, :dependent => :destroy 15 has_many :events, :dependent => :destroy
12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 16 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
13 17
14 # Callbacks 18 # Callbacks
15 after_create :initialize_empty_page 19 after_create :initialize_empty_page
16 before_save :check_for_changed_slug 20 before_save :check_for_changed_slug
17 after_save :update_unique_names_of_children 21 after_save :update_unique_names_of_children
22 before_destroy :refuse_destroy_with_children
23 before_destroy :refuse_destroying_trash_node
18 24
19 # Validations 25 # Validations
20 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } 26 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
@@ -22,6 +28,21 @@ class Node < ApplicationRecord
22 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 28 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
23 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 29 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
24 30
31 validate :reserved_slug_stays_reserved
32 validate :no_head_inside_trash
33 validates :default_template_name,
34 :inclusion => { :in => ->(_) { Page.custom_templates } },
35 :allow_blank => true,
36 :if => :default_template_name_changed?
37
38 # Everything outside the Trash subtree, the Trash node included.
39 # Relies on unique_name being authoritative for tree position --
40 # the same trust public routing places in it.
41 scope :not_in_trash, -> {
42 where.not(:unique_name => CccConventions::TRASH_SLUG)
43 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
44 }
45
25 # Class methods 46 # Class methods
26 47
27 # Returns a page for a given node. If no revision is supplied, it returns 48 # Returns a page for a given node. If no revision is supplied, it returns
@@ -48,6 +69,19 @@ class Node < ApplicationRecord
48 nil 69 nil
49 end 70 end
50 71
72 # The Trash container node. Lazily self-creating and idempotent, so
73 # every environment acquires it on first touch.
74 # Never call from validations. the positional predicates below
75 # exist for that.
76 def self.trash
77 root.children.find_by(:slug => CccConventions::TRASH_SLUG) ||
78 root.children.create!(:slug => CccConventions::TRASH_SLUG).tap do |node|
79 Globalize.with_locale(I18n.default_locale) do
80 node.draft.update!(:title => "Trash")
81 end
82 end
83 end
84
51 # Instance Methods 85 # Instance Methods
52 86
53 # Acquires (or reaffirms) the editing lock without creating a draft or 87 # Acquires (or reaffirms) the editing lock without creating a draft or
@@ -194,6 +228,10 @@ class Node < ApplicationRecord
194 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
195 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
196 230
231 if in_trash? || trash_node?
232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
233 end
234
197 path_before = self.unique_name 235 path_before = self.unique_name
198 236
199 ActiveRecord::Base.transaction do 237 ActiveRecord::Base.transaction do
@@ -258,6 +296,117 @@ class Node < ApplicationRecord
258 end 296 end
259 end 297 end
260 298
299
300 # Moves this node and its subtree into the Trash. Demotes every head
301 # in the subtree first (aggregators and search operate on heads
302 # regardless of tree position); where a node has no draft, the former
303 # head becomes its draft so content stays editable and restorable --
304 # otherwise the former head remains a plain revision. One log entry,
305 # at the root, carrying the leaving-public-view snapshot.
306 def trash! current_user = nil
307 return nil if in_trash?
308 raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node?
309
310 ActiveRecord::Base.transaction do
311 path_before = unique_name
312 was_published = head_id.present?
313 final_published_at = head&.published_at
314
315 demoted = 0
316 ([self] + descendants.to_a).each do |node|
317 next unless node.head_id
318 former = node.head
319 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil?
321 node.save!
322 demoted += 1
323 end
324
325 self.reload
326 move_to_child_of(Node.trash)
327 self.reload
328 update_unique_name
329 send(:update_unique_names_of_children)
330 unlock!
331
332 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0
336
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata)
338 self
339 end
340 end
341
342 # Returns the node to the living tree under a chosen parent. The
343 # subtree comes back exactly as it sits in the Trash: all drafts,
344 # nothing published. Republication is a separate, witnessed act
345 # per node.
346 def restore_from_trash! new_parent, current_user = nil
347 return nil unless in_trash?
348
349 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
350 new_parent.trash_node? || new_parent.in_trash?
351 raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node"
352 end
353
354 ActiveRecord::Base.transaction do
355 path_before = unique_name
356 move_to_child_of(new_parent)
357 self.reload
358 update_unique_name
359 send(:update_unique_names_of_children)
360
361 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
362 :path => { "from" => path_before, "to" => unique_name })
363 self
364 end
365 end
366
367 # Final deletion -- only from inside the Trash. Removes the whole
368 # subtree, deepest first, each node through a real destroy! so every
369 # per-node cascade runs (the categorical difference from the old
370 # delete_all nuke). refuse_destroy_with_children on bare destroy is
371 # untouched
372 # One log entry at the root, per the subtree rule, written before the
373 # rows die.
374 def destroy_from_trash! current_user = nil
375 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
376
377 ActiveRecord::Base.transaction do
378 doomed = self_and_descendants_ordered_with_level
379 .sort_by { |_, level| -level }
380 .map(&:first)
381
382 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata)
386 doomed.each(&:destroy!)
387 end
388 end
389
390 # The most recent trash entry. Its path.from is the restore hint.
391 def last_trash_entry
392 node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first
393 end
394
395 # The node's pre-trash parent, if a living node still answers to
396 # that path. Nil when the parent was itself trashed (its unique_name
397 # changed) or deleted; a different node that has since taken over
398 # the path is a legitimate suggestion.
399 def suggested_restore_parent
400 from = last_trash_entry&.metadata&.dig("path", "from")
401 return nil unless from
402
403 parent_path = from.rpartition("/").first
404 return Node.root if parent_path.empty?
405
406 candidate = Node.find_by(:unique_name => parent_path)
407 candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node?
408 end
409
261 # returns an array with all parts of a unique_name rather than a string 410 # returns an array with all parts of a unique_name rather than a string
262 def unique_path 411 def unique_path
263 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -269,7 +418,7 @@ class Node < ApplicationRecord
269 end 418 end
270 419
271 def computed_unique_name 420 def computed_unique_name
272 path = path_to_root[1..-1].join("/") # excluding root 421 path_to_root[1..-1].join("/") # excluding root
273 end 422 end
274 423
275 def current_unique_name 424 def current_unique_name
@@ -313,6 +462,20 @@ class Node < ApplicationRecord
313 autosave || draft || head 462 autosave || draft || head
314 end 463 end
315 464
465 def trash_node?
466 parent&.root? && slug == CccConventions::TRASH_SLUG
467 end
468
469 # Inside the Trash subtree. False for the Trash node itself.
470 def in_trash?
471 current = parent
472 while current
473 return true if current.trash_node?
474 current = current.parent
475 end
476 false
477 end
478
316 # Returns immutable node id for all new nodes so that the atom feed entry ids 479 # Returns immutable node id for all new nodes so that the atom feed entry ids
317 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
318 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -353,7 +516,7 @@ class Node < ApplicationRecord
353 end 516 end
354 517
355 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
356 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") 519 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash
357 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
358 521
359 scope.order( 522 scope.order(
@@ -361,6 +524,15 @@ class Node < ApplicationRecord
361 ) 524 )
362 end 525 end
363 526
527 # Nodes are never destroyed recursively
528 # Descendants must be removed or reparented individually first.
529 # The Trash feature will be the ordinary path to deletion.
530 def refuse_destroy_with_children
531 return unless children.exists?
532 errors.add(:base, "Cannot destroy a node that still has children")
533 throw :abort
534 end
535
364 def self.recently_changed 536 def self.recently_changed
365 includes(:head).where( 537 includes(:head).where(
366 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", 538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
@@ -417,4 +589,36 @@ class Node < ApplicationRecord
417 end 589 end
418 end 590 end
419 end 591 end
592
593 private
594
595 def reserved_slug_stays_reserved
596 if parent&.root? && !trash_node_already_me?
597 errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG
598 errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG
599 end
600
601 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
602 errors.add(:slug, "of the Trash node cannot change") if slug_changed?
603 errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed?
604 errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present?
605 errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present?
606 end
607 end
608
609 def trash_node_already_me?
610 existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG)
611 existing.nil? || existing.id == id
612 end
613
614 def no_head_inside_trash
615 return unless head_id.present?
616 errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node?
617 end
618
619 def refuse_destroying_trash_node
620 return unless trash_node?
621 errors.add(:base, "The Trash node cannot be destroyed")
622 throw :abort
623 end
420end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 792ae1e..63a99ae 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -8,79 +8,78 @@ class NodeAction < ApplicationRecord
8 8
9 # == Metadata contract == 9 # == Metadata contract ==
10 # 10 #
11 # metadata is written once at creation and never updated. It is the 11 # metadata is written once at creation, never updated. It is the
12 # single place anything that must survive deletion of the referenced 12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only. 13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y} -- 14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # never _old/_new suffixes. Optional pairs are written only when the 15 # Optional pairs only when the sides differ; booleans only when
16 # two sides differ; required keys are always present. All titles and 16 # true; required keys always present. Titles and names are read
17 # names are read pinned to I18n.default_locale unless inside a 17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 # locale-keyed block.
19 # 18 #
20 # Baseline, every entry (written here, not by call sites): 19 # Baseline, every entry (written here, not by call sites):
21 # "username" -- actor's login at write time 20 # "username" -- actor's login at write time
22 # "human_readable_node_name" -- node title, default locale 21 # "human_readable_node_name" -- node title, default locale
23 # 22 #
24 # "create": 23 # "create":
25 # "title" -- initial title, default locale 24 # "title" -- initial title, flat string (NOT a pair)
26 # "path" -- unique path at creation time. Historical value only: 25 # "path" -- unique path at creation, flat string. Historical
27 # later renames/moves do NOT update old entries. Never 26 # value only; never a join key.
28 # use as a join key; node_id is the join key while the
29 # node lives.
30 # 27 #
31 # "publish" (any promotion of a page to head; the diff against the 28 # "publish" (any promotion to head; diff computed BEFORE head is
32 # outgoing head is computed BEFORE head is re-pointed, over the 29 # re-pointed, over the union of both pages' locales, by head_diff --
33 # union of both pages' locales, by the shared diff function also 30 # shared with the backfill):
34 # used by the backfill): 31 # "via" -- "draft" | "revision" (rollback). Always written;
35 # "via" -- "draft" (ordinary publish) | "revision" (rollback 32 # absent means a pre-contract entry.
36 # via restore_revision!). Always written; absent 33 # "title" -- pair, always; "from" null on first publish
37 # means a pre-contract entry. 34 # "author" -- pair, when the byline changed (incl. first publish)
38 # "title" -- pair, always present. "from" is null on a first 35 # "tags" -- pair of arrays, when changed
39 # publish (no outgoing head). 36 # "assets_changed", "template_changed",
40 # "author" -- pair, only when the byline (page.user) changed
41 # "tags" -- pair of arrays, only when changed
42 # "assets_changed", "template_changed"
43 # -- booleans, only when true; page_id links to the
44 # revision for the real diff (never stored here)
45 # "abstract_changed", "body_changed" 37 # "abstract_changed", "body_changed"
46 # -- booleans for the default locale, only when true 38 # -- the last two for the default locale; page_id links
47 # "translation_diff" -- only when any non-default locale differs: 39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
48 # { "<locale>" => { 41 # { "<locale>" => {
49 # "status" -- "added" | "removed" | "changed" 42 # "status" -- "added" | "removed" | "changed"
50 # "title" -- pair; "from" null when added, 43 # "title" -- pair, only when it differs; "from" null when
51 # "to" null when removed 44 # added, "to" null when removed
52 # "abstract_changed", "body_changed" -- booleans, only when 45 # "abstract_changed", "body_changed" -- status "changed" only
53 # true, only for status "changed"
54 # } } 46 # } }
55 # 47 #
56 # "move" (reparenting and/or unique-path change; one entry at the 48 # "move" (reparent and/or path change; one entry at the subtree
57 # subtree root, descendants get none -- a descendant's own zoomed 49 # root, descendants get none):
58 # view will not show path history): 50 # "path" -- pair
59 # "path" -- pair
60 # 51 #
61 # Reserved for the Trash feature, final shape decided there: 52 # "trash" (subtree into the Trash; every head in the subtree is
62 # "demote" (via "trash" | "depublish"; carries the leaving-public- 53 # demoted first; one entry at the root; snapshots the
63 # view snapshot: head presence, final published_at), "trash", 54 # leaving-public-view state, since Trash holds no heads and destroy
64 # "restore_from_trash", "destroy" (final path only; publish-state 55 # can no longer know):
65 # snapshot lives on the entry that removed it from public view). 56 # "path" -- pair; "from" doubles as the restore hint
66 # Whether trash logs one entry or a trash+demote pair is decided 57 # "was_published" -- boolean
67 # with that feature. 58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
68 # 60 #
69 # Backfilled entries mirror this vocabulary exactly. Their diff 61 # "restore_from_trash" (reparent back to a living node; returns as
70 # content is computed, not guessed (consecutive revisions still 62 # drafts, republication is a separate witnessed act):
71 # exist); only actor (from page.editor) and occurred_at are 63 # "path" -- pair
72 # inferred, and inferred_from names the heuristic per entry
73 # ("from_page_revision", "from_published_at_heuristic").
74 # inferred_from null = witnessed live.
75 # 64 #
76 # The "locale" column is currently written by no verb (it belonged 65 # "destroy" (only from inside the Trash, never with children; the
77 # to the retired translation_destroy) and is retained for backfill 66 # entry is written in the same transaction before the row dies):
78 # or future draft-scoped verbs. 67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
79 # 70 #
80 # This log records; it does not undo. Reversibility stays in 71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
81 # restore_revision! and the revisions system. No IP, session, or 72 # depublish workflow, if ever built.
82 # user agent, ever. Success only -- rejected attempts are not 73 #
83 # logged. 74 # Backfilled entries mirror this vocabulary; diff content is
75 # computed, only actor (page.editor) and occurred_at are inferred,
76 # inferred_from names the heuristic ("from_node_created_at",
77 # "from_page_revision"). Null = witnessed live.
78 #
79 # The "locale" column is written by no verb; retained.
80 #
81 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only.
84 83
85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, 84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
86 occurred_at: nil, inferred_from: nil, **extra) 85 occurred_at: nil, inferred_from: nil, **extra)
@@ -167,4 +166,12 @@ class NodeAction < ApplicationRecord
167 def subject_name 166 def subject_name
168 metadata["human_readable_node_name"] || node&.unique_name || "deleted node" 167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
169 end 168 end
169
170 def diff_link_params
171 prev = NodeAction.where(node_id: node_id, action: "publish")
172 .where("id < ?", id)
173 .order(id: :desc).first
174 return nil unless prev&.page && page
175 { start_revision: prev.page.revision, end_revision: page.revision }
176 end
170end 177end
diff --git a/app/models/page.rb b/app/models/page.rb
index e5a8d9d..f33d88d 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -11,11 +11,21 @@ class Page < ApplicationRecord
11 11
12 translates :title, :abstract, :body # Globalize2 12 translates :title, :abstract, :body # Globalize2
13 13
14 # Template names render as filesystem paths; only names actually
15 # present in the public template directory are acceptable. Validated
16 # only on change so legacy rows whose template file has since
17 # vanished stay saveable -- valid_template already falls back to
18 # standard_template for those at render time.
19 validates :template_name,
20 :inclusion => { :in => ->(_) { Page.custom_templates } },
21 :allow_blank => true,
22 :if => :template_name_changed?
23
14 # Associations 24 # Associations
15 belongs_to :node, optional: true 25 belongs_to :node, optional: true
16 belongs_to :user, optional: true 26 belongs_to :user, optional: true
17 belongs_to :editor, :class_name => "User", optional: true 27 belongs_to :editor, :class_name => "User", optional: true
18 has_many :related_assets 28 has_many :related_assets, :dependent => :destroy
19 has_many :assets, -> { order("position ASC") }, :through => :related_assets 29 has_many :assets, -> { order("position ASC") }, :through => :related_assets
20 30
21 # Named scopes 31 # Named scopes
@@ -77,7 +87,10 @@ class Page < ApplicationRecord
77 .paginate(:page => page, :per_page => options[:limit]) 87 .paginate(:page => page, :per_page => options[:limit])
78 end 88 end
79 89
80 scope.order("#{options[:order_by]} #{direction}") 90 column = options[:order_by].to_s.sub(/\Apages\./, "")
91 column = "id" unless %w[id published_at created_at updated_at].include?(column)
92
93 scope.order("pages.#{column} #{direction}")
81 .paginate(:page => page, :per_page => options[:limit]) 94 .paginate(:page => page, :per_page => options[:limit])
82 end 95 end
83 96
diff --git a/app/models/user.rb b/app/models/user.rb
index 92ac33a..5e47ae7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,10 @@
1require 'digest/sha1' 1require 'digest/sha1'
2 2
3class User < ApplicationRecord 3class User < ApplicationRecord
4 has_secure_password(validations: false)
5
6 alias_method :authenticate_bcrypt, :authenticate
7
4 # Mixins and Plugins 8 # Mixins and Plugins
5 include Authentication 9 include Authentication
6 include Authentication::ByPassword 10 include Authentication::ByPassword
@@ -23,9 +27,30 @@ class User < ApplicationRecord
23 27
24 # Authenticates a user by their login name and unencrypted password. Returns the user or nil. 28 # Authenticates a user by their login name and unencrypted password. Returns the user or nil.
25 def self.authenticate(login, password) 29 def self.authenticate(login, password)
26 return nil if login.blank? || password.blank? 30 return if login.blank? || password.blank?
27 u = find_by_login(login) # need to get the salt 31
28 u && u.authenticated?(password) ? u : nil 32 user = find_by(login: login)
33 return unless user
34
35 user&.authenticate(password)
36 end
37
38 def authenticate(password)
39 if password_digest.present?
40 return self if authenticate_bcrypt(password)
41 return nil
42 end
43
44 return nil unless authenticate_legacy(password)
45
46 transaction do
47 self.password = password
48 self.crypted_password = nil
49 self.salt = nil
50 save!(validate: false)
51 end
52
53 self
29 end 54 end
30 55
31 # TODO: Do we really want to have downcase logins only? 56 # TODO: Do we really want to have downcase logins only?
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index 1b4a0a0..319530d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -40,32 +40,10 @@
40 <%= link_to "See all drafts →", drafts_nodes_path %> 40 <%= link_to "See all drafts →", drafts_nodes_path %>
41 </div> 41 </div>
42 42
43 <!--div class="dashboard_widget">
44 <h3>Recent changes</h3>
45 <ul>
46 <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %>
47 </ul>
48 <%= link_to "See all recent changes →", recent_nodes_path %>
49 </div-->
50
51 <div class="dashboard_widget"> 43 <div class="dashboard_widget">
52 <h3>Recent changes</h3> 44 <h3>Recent changes</h3>
53 <table id="node_action_list"> 45 <table id="node_action_list">
54 <% @actions.each do |action| %> 46 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
55 <tr class="node_action node_action--<%= action.action %>">
56 <td class="node_action_time"><%= raw(action.occurred_at.strftime("%Y-%m-%d %H:%M").gsub(" ", "<br/>")) %></td>
57 <td class="node_action_body">
58 <%= action_summary(action) %>
59 <% if action.node_id && params[:node_id].blank? %>
60 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
61 <% end %>
62 <% if action.inferred_from %>
63 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
64 <% end %>
65 <%= render "node_actions/change_details", :action_entry => action if action_details?(action) %>
66 </td>
67 </tr>
68 <% end %>
69 </table> 47 </table>
70 <%= link_to "See all recent changes →", admin_log_path %> 48 <%= link_to "See all recent changes →", admin_log_path %>
71 </div> 49 </div>
@@ -90,5 +68,9 @@
90 <%= link_to menu_items_path, class: "action_button" do %> 68 <%= link_to menu_items_path, class: "action_button" do %>
91 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation 69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
92 <% end %> 70 <% end %>
71 <% trash_count = Node.trash.children.count %>
72 <%= link_to trashed_nodes_path, class: "action_button" do %>
73 <%= icon("trash", library: "tabler", "aria-hidden": true) %> Trash<%= " (#{trash_count})" if trash_count > 0 %>
74 <% end %>
93 </div> 75 </div>
94</div> 76</div>
diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb
index 5717dd9..ff00883 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -20,7 +20,15 @@
20 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div> 20 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
21 21
22 <div class="node_description">Public Path</div> 22 <div class="node_description">Public Path</div>
23 <div class="node_content"><%= @asset.upload.url.sub(/\?\d+$/, "") %></div> 23 <div class="node_content">
24 <% public_path = @asset.upload.url.sub(/\?\d+$/, "") %>
25 <%= link_to public_path, public_path, target: "_blank", rel: "noopener" %>
26 <button type="button" class="action_button copy_button"
27 data-copy-url="<%= request.base_url + public_path %>">
28 <%= icon("copy", library: "tabler", "aria-hidden": true) %>
29 <span class="copy_button_label">Copy URL</span>
30 </button>
31 </div>
24 32
25 <div class="node_description">Content Type</div> 33 <div class="node_description">Content Type</div>
26 <div class="node_content"><%= @asset.upload.content_type %></div> 34 <div class="node_content"><%= @asset.upload.content_type %></div>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 0856a0f..e220beb 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -7,19 +7,18 @@
7 <%= csrf_meta_tags %> 7 <%= csrf_meta_tags %>
8 8
9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title> 9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title>
10 <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %>
11 <%= javascript_include_tag 'admin_bundle' %> 10 <%= javascript_include_tag 'admin_bundle' %>
12 <%= tinymce_assets %> 11 <%= tinymce_assets %>
13 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>"> 12 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>">
14 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script> 13 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script>
15 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script> 14 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script>
16 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script> 15 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script>
17 <script> 16 <%= javascript_tag nonce: true do %>
18 var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; 17 var ADMIN_SEARCH_URL = "<%= admin_search_path %>";
19 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; 18 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>";
20 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; 19 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>";
21 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; 20 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>";
22 </script> 21 <% end %>
23 </head> 22 </head>
24 23
25 <body> 24 <body>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index d7681af..f981fc1 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -18,12 +18,12 @@
18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> 18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %>
19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> 19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %>
20 20
21 <script> 21 <%= javascript_tag nonce: true do %>
22 (function() { document.addEventListener("DOMContentLoaded", function() { 22 document.addEventListener("DOMContentLoaded", function() {
23 if (localStorage.getItem('override-prefers-color-scheme', false)) 23 if (localStorage.getItem('override-prefers-color-scheme'))
24 document.getElementById("light-mode").checked = true; 24 document.getElementById("light-mode").checked = true;
25 }); })(); 25 });
26 </script> 26 <% end %>
27 27
28 </head> 28 </head>
29 29
@@ -62,12 +62,10 @@
62 <div id="center_column"> 62 <div id="center_column">
63 <%= yield :layout %> 63 <%= yield :layout %>
64 <div id="footer"> 64 <div id="footer">
65 <br />
66 <p style="text-align: center"> 65 <p style="text-align: center">
67 <%= link_to "Impressum", content_path("imprint") %> 66 <%= link_to "Impressum", content_path("imprint") %>
68 <%= link_to "Datenschutz", content_path("datenschutz") %> 67 <%= link_to "Datenschutz", content_path("datenschutz") %>
69 <%= language_selector %> 68 <%= language_selector %>
70 <!-- %= link_to t(:sponsors), content_path("sponsors") % -->
71 </p> 69 </p>
72 </div> 70 </div>
73 </div> 71 </div>
diff --git a/app/views/layouts/events.html.erb b/app/views/layouts/events.html.erb
deleted file mode 100644
index 93a6c0c..0000000
--- a/app/views/layouts/events.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
1<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
2 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
3
4<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
5<head>
6 <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
7 <title>Events: <%= controller.action_name %></title>
8 <%= stylesheet_link_tag 'scaffold' %>
9</head>
10<body>
11
12<p style="color: green"><%= flash[:notice] %></p>
13
14<%= yield %>
15
16</body>
17</html>
diff --git a/app/views/node_actions/_action_row.html.erb b/app/views/node_actions/_action_row.html.erb
new file mode 100644
index 0000000..a9a3b8d
--- /dev/null
+++ b/app/views/node_actions/_action_row.html.erb
@@ -0,0 +1,18 @@
1<tr class="node_action node_action--<%= action.action %>">
2 <td class="node_action_time">
3 <span class="node_action_date"><%= action.occurred_at.strftime("%Y-%m-%d") %></span>
4 <%= verb_icon(action) %>
5 <span class="node_action_clock"><%= action.occurred_at.strftime("%H:%M") %></span>
6 </td>
7 <td class="node_action_body">
8 <%= action_summary(action) %>
9 <% if action.node_id && params[:node_id].blank? %>
10 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
11 <% end %>
12 <% if action.inferred_from %>
13 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
14 <% end %>
15 <%= render "node_actions/change_details", :action_entry => action if action_details?(action) %>
16 </td>
17</tr>
18
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
index 2583e8b..066d0f3 100644
--- a/app/views/node_actions/_change_details.html.erb
+++ b/app/views/node_actions/_change_details.html.erb
@@ -5,9 +5,13 @@
5 <tr> 5 <tr>
6 <th><%= I18n.default_locale.to_s.upcase %></th> 6 <th><%= I18n.default_locale.to_s.upcase %></th>
7 <td> 7 <td>
8 <%= safe_join(default_items, ", ") %> 8 <%= safe_join(default_items, tag.br) %>
9 <% if action_entry.page && action_entry.node %> 9 <% if action_entry.page && action_entry.node %>
10 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %> 10 <% if (diff_params = action_entry.diff_link_params) %>
11 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params) %>
12 <% else %>
13 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
14 <% end %>
11 <% end %> 15 <% end %>
12 </td> 16 </td>
13 </tr> 17 </tr>
@@ -16,9 +20,13 @@
16 <tr> 20 <tr>
17 <th><%= locale.upcase %></th> 21 <th><%= locale.upcase %></th>
18 <td> 22 <td>
19 <%= safe_join(translation_changes(diff), ", ") %> 23 <%= safe_join(translation_changes(diff), tag.br) %>
20 <% if action_entry.page && action_entry.node %> 24 <% if action_entry.page && action_entry.node %>
21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %> 25 <% if (diff_params = action_entry.diff_link_params) %>
26 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params.merge(:locale => locale)) %>
27 <% else %>
28 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
29 <% end %>
22 <% end %> 30 <% end %>
23 </td> 31 </td>
24 </tr> 32 </tr>
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
index ee06213..20ef5b3 100644
--- a/app/views/node_actions/index.html.erb
+++ b/app/views/node_actions/index.html.erb
@@ -7,21 +7,7 @@
7<%= will_paginate @actions %> 7<%= will_paginate @actions %>
8 8
9<table id="node_action_list"> 9<table id="node_action_list">
10 <% @actions.each do |action| %> 10 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
11 <tr class="node_action node_action--<%= action.action %>">
12 <td class="node_action_time"><%= action.occurred_at.strftime("%Y-%m-%d %H:%M") %></td>
13 <td class="node_action_body">
14 <%= action_summary(action) %>
15 <% if action.node_id && params[:node_id].blank? %>
16 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
17 <% end %>
18 <% if action.inferred_from %>
19 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
20 <% end %>
21 <%= render "change_details", :action_entry => action if action_details?(action) %>
22 </td>
23 </tr>
24 <% end %>
25</table> 11</table>
26 12
27<%= will_paginate @actions %> 13<%= will_paginate @actions %>
diff --git a/app/views/nodes/destroy.html.erb b/app/views/nodes/destroy.html.erb
deleted file mode 100644
index 065cf1d..0000000
--- a/app/views/nodes/destroy.html.erb
+++ /dev/null
@@ -1,2 +0,0 @@
1<h1>Nodes#destroy</h1>
2<p>Find me in app/views/nodes/destroy.html.erb</p>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 38eac84..805fbc9 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -33,7 +33,7 @@
33 <div class="node_description">Parent</div> 33 <div class="node_description">Parent</div>
34 <div class="node_content"> 34 <div class="node_content">
35 <%= text_field_tag :parent_search_term, @parent&.title %> 35 <%= text_field_tag :parent_search_term, @parent&.title %>
36 <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.current_unique_name } %> 36 <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.computed_unique_name } %>
37 <div id="parent_search_results" class="search_results"></div> 37 <div id="parent_search_results" class="search_results"></div>
38 </div> 38 </div>
39 </div> 39 </div>
@@ -41,7 +41,10 @@
41 <div class="node_description">Resulting path</div> 41 <div class="node_description">Resulting path</div>
42 <div class="node_content"> 42 <div class="node_content">
43 <span id="resulting_path">—</span> 43 <span id="resulting_path">—</span>
44 <button type="button" id="copy_resulting_path" class="unselected">copy</button> 44 <button type="button" id="copy_resulting_path" class="unselected copy_button" data-copy-target="#resulting_path">
45 <span class="copy_button_label">copy</span>
46 </button>
47
45 <span class="field_hint">This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it).</span> 48 <span class="field_hint">This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it).</span>
46 </div> 49 </div>
47 50
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index ae25571..af05778 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -46,7 +46,7 @@
46 <% end %> 46 <% end %>
47 47
48 <% unless locked_by_other %> 48 <% unless locked_by_other %>
49 <% if @node.draft && !@node.autosave %> 49 <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %>
50 <div class="node_info_item"> 50 <div class="node_info_item">
51 <%= button_to 'Publish', publish_node_path(@node), method: :put, 51 <%= button_to 'Publish', publish_node_path(@node), method: :put,
52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> 52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %>
@@ -62,6 +62,15 @@
62 <% end %> 62 <% end %>
63 </div> 63 </div>
64 <% end %> 64 <% end %>
65 <% unless @node.trash_node? || @node.in_trash? || @node.root? %>
66 <div class="node_info_item">
67 <%= button_to trash_node_path(@node), method: :put,
68 form: { data: { confirm: "Move this page and everything beneath it to the Trash? All published content in it will go offline." }, class: 'button_to destructive' } do %>
69 <%= icon("trash", library: "tabler", "aria-hidden": true) %>
70 Move to Trash
71 <% end %>
72 </div>
73 <% end %>
65 <% end %> 74 <% end %>
66 </div> 75 </div>
67 76
@@ -70,6 +79,48 @@
70 <% end %> 79 <% end %>
71 </div> 80 </div>
72 81
82 <% if @node.in_trash? %>
83 <div class="node_description">Trash</div>
84 <div class="node_content node_info_group">
85 <div class="node_info_group_items">
86 <% if (entry = @node.last_trash_entry) && entry.metadata.dig("path", "from") %>
87 <div class="node_info_item">
88 <span class="node_info_label">Was at</span>
89 <%= entry.metadata.dig("path", "from") %>
90 </div>
91 <% end %>
92 <div class="node_info_item">
93 <span class="node_info_label">Restore to</span>
94 <% suggestion = @node.suggested_restore_parent %>
95 <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %>
96 <div class="restore_picker">
97 <%= text_field_tag :restore_search_term,
98 suggestion && (suggestion.head&.title || suggestion.draft&.title || suggestion.slug),
99 :placeholder => "Search for a new parent…" %>
100 <div id="restore_search_results" class="search_results"></div>
101 </div>
102 <%= hidden_field_tag :parent_id, suggestion&.id %>
103 <%= submit_tag "Restore here", :class => "action_button action_button_no_margin_top" %>
104 <% end %>
105 <span class="field_hint">Restored pages come back unpublished. Republish each page deliberately.</span>
106 </div>
107 </div>
108 <div class="node_info_group_items">
109 <div class="node_info_item">
110 <% doomed_below = @node.descendants.count %>
111 <%= button_to node_path(@node), method: :delete,
112 form: { data: { confirm: doomed_below > 0 ?
113 "Delete this page and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
114 "Delete this page permanently? This cannot be undone." },
115 class: 'button_to destructive' } do %>
116 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
117 Delete permanently<%= " (including #{doomed_below} beneath)" if doomed_below > 0 %>
118 <% end %>
119 </div>
120 </div>
121 </div>
122 <% end %>
123
73 <div class="node_description">Translations</div> 124 <div class="node_description">Translations</div>
74 <div class="node_content node_info_group"> 125 <div class="node_content node_info_group">
75 <div class="node_info_group_items"> 126 <div class="node_info_group_items">
@@ -178,7 +229,10 @@
178 </summary> 229 </summary>
179 <ul> 230 <ul>
180 <% @node.pages.order(:revision).each do |page| %> 231 <% @node.pages.order(:revision).each do |page| %>
181 <li><%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %></li> 232 <li>
233 <%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %>
234 <span class="revision_lifecycle"><%= revision_lifecycle_badges(@page_actions[page.id]) %></span>
235 </li>
182 <% end %> 236 <% end %>
183 </ul> 237 </ul>
184 </details> 238 </details>
diff --git a/app/views/nodes/trashed.html.erb b/app/views/nodes/trashed.html.erb
new file mode 100644
index 0000000..9a4808e
--- /dev/null
+++ b/app/views/nodes/trashed.html.erb
@@ -0,0 +1,42 @@
1<h1>Trash</h1>
2
3<% if @nodes.empty? %>
4 <p class="field_hint">The Trash is empty.</p>
5<% else %>
6 <%= will_paginate @nodes %>
7 <table id="trashed_node_list">
8 <tr>
9 <th>Title</th>
10 <th>Was at</th>
11 <th>Trashed</th>
12 <th>Actions</th>
13 </tr>
14 <% @nodes.each do |node| %>
15 <% entry = node.last_trash_entry %>
16 <tr>
17 <% doomed_below = node.descendants.count %>
18 <td>
19 <%= link_to (node.draft&.title || node.slug), node_path(node) %><%= " (+ #{doomed_below} beneath)" if doomed_below > 0 %>
20 </td>
21 <td><%= entry&.metadata&.dig("path", "from") %></td>
22 <td>
23 <% if entry %>
24 <%= entry.occurred_at.strftime("%Y-%m-%d %H:%M") %> by <%= entry.actor_name %>
25 <% end %>
26 </td>
27 <td>
28 <%= button_to node_path(node), method: :delete,
29 form: { data: { confirm: doomed_below > 0 ?
30 "Delete \"#{node.draft&.title || node.slug}\" and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
31 "Delete \"#{node.draft&.title || node.slug}\" permanently? This cannot be undone." },
32 class: 'button_to destructive' } do %>
33 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
34 Delete permanently
35 <% end %>
36 </td>
37 </tr>
38 <% end %>
39 </table>
40 <%= will_paginate @nodes %>
41 <p class="field_hint">To restore a page, open it and pick a new parent in its Trash section.</p>
42<% end %>
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index d51d713..1569942 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -4,26 +4,29 @@
4# See the Securing Rails Applications Guide for more information: 4# See the Securing Rails Applications Guide for more information:
5# https://guides.rubyonrails.org/security.html#content-security-policy-header 5# https://guides.rubyonrails.org/security.html#content-security-policy-header
6 6
7# Rails.application.configure do 7Rails.application.configure do
8# config.content_security_policy do |policy| 8 config.content_security_policy do |policy|
9# policy.default_src :self, :https 9 policy.default_src :self
10# policy.font_src :self, :https, :data 10 policy.script_src :self
11# policy.img_src :self, :https, :data 11 policy.style_src :self, :unsafe_inline
12# policy.object_src :none 12 policy.img_src :self, :data
13# policy.script_src :self, :https 13 policy.font_src :self
14# policy.style_src :self, :https 14 policy.object_src :none
15# # Specify URI for violation reports 15 policy.frame_ancestors :none
16# # policy.report_uri "/csp-violation-report-endpoint" 16 policy.base_uri :self
17# end 17 policy.form_action :self
18# 18 policy.report_uri "/csp_reports"
19# # Generate session nonces for permitted importmap, inline scripts, and inline styles. 19 end
20# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 20
21# config.content_security_policy_nonce_directives = %w(script-src style-src) 21 # Per-request nonce; script-src only. style-src keeps unsafe_inline
22# 22 # deliberately: TinyMCE emits img[style] in body content and the
23# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` 23 # public layout carries style attributes -- CSS injection is a
24# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. 24 # low-yield channel, script-src is where the protection lives.
25# # config.content_security_policy_nonce_auto = true 25 config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
26# 26 config.content_security_policy_nonce_directives = %w[script-src]
27# # Report violations without enforcing the policy. 27
28# # config.content_security_policy_report_only = true 28 # Report-only: nothing blocks. Enforcement is a later, deliberate
29# end 29 # flip once the reports have mapped reality (admin inline scripts,
30 # legacy hotlinked images in old bodies, embeds).
31 config.content_security_policy_report_only = true
32end
diff --git a/config/initializers/csp_log.rb b/config/initializers/csp_log.rb
new file mode 100644
index 0000000..1dde9c4
--- /dev/null
+++ b/config/initializers/csp_log.rb
@@ -0,0 +1,4 @@
1# CSP violation reports get their own file, independent of config.logger.
2CSP_LOGGER = Rails.env.production? ?
3 ActiveSupport::Logger.new(Rails.root.join("log", "csp_violations.log")) :
4 Rails.logger
diff --git a/config/initializers/error_log.rb b/config/initializers/error_log.rb
new file mode 100644
index 0000000..74117cb
--- /dev/null
+++ b/config/initializers/error_log.rb
@@ -0,0 +1,20 @@
1# Every controller-level exception (the 500s) in one lean file,
2# independent of the base log level -- log/production.log can stay
3# quiet without losing error visibility.
4if Rails.env.production?
5 error_logger = ActiveSupport::Logger.new(Rails.root.join("log", "errors.log"))
6
7 ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
8 if (exception = payload[:exception_object])
9 status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception.class.name)
10 next if status < 500
11
12 error_logger.error(
13 "#{Time.now.iso8601} [#{status}] #{payload[:controller]}##{payload[:action]} #{payload[:path]} " \
14 "-- #{exception.class}: #{exception.message}\n " +
15 Array(exception.backtrace).first(5).join("\n ")
16 )
17 end
18 end
19end
20
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 9de6aac..f64f6cd 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -89,14 +89,14 @@ de:
89 backfilled: "rekonstruiert" 89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen" 90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen" 91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} aus" 92 unknown: "%{actor} hat %{action} auf %{subject} ausgeführt"
93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht" 93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt" 94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht" 95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})" 96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt" 97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben" 98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben" 99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen" 100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\"" 101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\"" 102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
@@ -110,3 +110,9 @@ de:
110 detail_tags: "Tags %{from} → %{to}" 110 detail_tags: "Tags %{from} → %{to}"
111 template_changed: "Template geändert" 111 template_changed: "Template geändert"
112 assets_changed: "Bildliste geändert" 112 assets_changed: "Bildliste geändert"
113 trash: "%{actor} hat %{subject} in den Papierkorb verschoben (vorher unter %{from})"
114 restore_from_trash: "%{actor} hat %{subject} aus dem Papierkorb nach %{to} wiederhergestellt"
115 destroy: "%{actor} hat %{subject} endgültig gelöscht (zuletzt unter %{path})"
116 revision_created: "angelegt am %{date} von %{actor}"
117 revision_published: "veröffentlicht am %{date} von %{actor}"
118 revision_restored: "wiederhergestellt am %{date} von %{actor}"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f1856c1..8a8acc1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -62,3 +62,9 @@ en:
62 detail_tags: "tags %{from} → %{to}" 62 detail_tags: "tags %{from} → %{to}"
63 template_changed: "template changed" 63 template_changed: "template changed"
64 assets_changed: "attached images changed" 64 assets_changed: "attached images changed"
65 trash: "%{actor} moved %{subject} to the Trash (was at %{from})"
66 restore_from_trash: "%{actor} restored %{subject} from the Trash to %{to}"
67 destroy: "%{actor} permanently deleted %{subject} (last at %{path})"
68 revision_created: "created %{date} by %{actor}"
69 revision_published: "published %{date} by %{actor}"
70 revision_restored: "restored %{date} by %{actor}"
diff --git a/config/routes.rb b/config/routes.rb
index c2b9590..6ddf48c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,8 @@ Cccms::Application.routes.draw do
9 defaults: { page_path: ['home'] }, 9 defaults: { page_path: ['home'] },
10 constraints: { locale: /de|en/ } 10 constraints: { locale: /de|en/ }
11 11
12 post 'csp_reports' => 'csp_reports#create'
13
12 # All application routes are scoped under an optional two-letter locale 14 # All application routes are scoped under an optional two-letter locale
13 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted 15 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted
14 # for the default locale (:de) in generated URLs via default_url_options 16 # for the default locale (:de) in generated URLs via default_url_options
@@ -44,6 +46,7 @@ Cccms::Application.routes.draw do
44 get :mine 46 get :mine
45 get :chapters 47 get :chapters
46 get :sitemap 48 get :sitemap
49 get :trashed
47 end 50 end
48 51
49 member do 52 member do
@@ -53,6 +56,8 @@ Cccms::Application.routes.draw do
53 put :revoke_shared_preview 56 put :revoke_shared_preview
54 put :autosave 57 put :autosave
55 put :revert 58 put :revert
59 put :trash
60 put :restore_from_trash
56 end 61 end
57 62
58 resources :translations, controller: 'page_translations', 63 resources :translations, controller: 'page_translations',
@@ -87,6 +92,7 @@ Cccms::Application.routes.draw do
87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get 92 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get
88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get 93 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get
89 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get 94 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
95 match 'boom' => 'admin#boom', :as => :admin_boom, :via => :get
90 end 96 end
91 97
92 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 98 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
@@ -103,11 +109,11 @@ Cccms::Application.routes.draw do
103 109
104 resource :session 110 resource :session
105 111
106 get 'rss/updates', :to => 'rss#updates', :as => :rss 112 get 'rss/updates', :to => 'rss#updates', :as => :rss, :defaults => { :format => :xml }
107 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, 113 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, :defaults => { :format => :xml },
108 :constraints => { :format => /xml|rdf/ } 114 :constraints => { :format => /xml|rdf/ }
109 get 'rss/tags/:tag/updates', :to => 'rss#tag_updates', :as => :rss_tag 115 get 'rss/tags/:tag/updates', :to => 'rss#tag_updates', :as => :rss_tag, :defaults => { :format => :xml }
110 get 'rss/tags/:tag/updates.:format', :to => 'rss#tag_updates', :as => :rss_tag_feed, 116 get 'rss/tags/:tag/updates.:format', :to => 'rss#tag_updates', :as => :rss_tag_feed, :defaults => { :format => :xml },
111 :constraints => { :format => /xml/ } 117 :constraints => { :format => /xml/ }
112 118
113 match 'galleries/*page_path' => 'content#render_gallery', :via => :get 119 match 'galleries/*page_path' => 'content#render_gallery', :via => :get
diff --git a/db/migrate/20260716165542_add_password_digest_to_users.rb b/db/migrate/20260716165542_add_password_digest_to_users.rb
new file mode 100644
index 0000000..df711d7
--- /dev/null
+++ b/db/migrate/20260716165542_add_password_digest_to_users.rb
@@ -0,0 +1,5 @@
1class AddPasswordDigestToUsers < ActiveRecord::Migration[8.1]
2 def change
3 add_column :users, :password_digest, :string
4 end
5end
diff --git a/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb b/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb
new file mode 100644
index 0000000..697ab77
--- /dev/null
+++ b/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb
@@ -0,0 +1,6 @@
1class DropNestedSetColumnsFromNodes < ActiveRecord::Migration[8.1]
2 def change
3 remove_column :nodes, :lft, :integer
4 remove_column :nodes, :rgt, :integer
5 end
6end
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md
new file mode 100644
index 0000000..5eb664b
--- /dev/null
+++ b/doc/DESIGN_HISTORY.md
@@ -0,0 +1,200 @@
1## The head / draft / autosave / translation lifecycle
2
3### Era 1 — the original design (2009, `doc/README_FOR_APP`).
4
5Two layers only: `head` (the published, public revision) and `draft`
6(a full copy of head's content, created the moment someone starts
7editing).
8
9No separate lock concept. The draft's *author* was the lock.
10Pessimistic, one editor at a time, enforced by ownership rather than a
11distinct column. An author could withdraw authorship (the draft
12becomes author-less, open for someone else to pick up) or discard the
13draft entirely, reverting to head.
14
15Admins could override any lock or remove any stuck draft on another
16editor's behalf. No autosave.
17
18Globalize wasn't part of the permission model (`Permission` grants
19admin rights to steal a lock a node.
20
21### Why a third layer got added.
22
23Under the original model, the moment someone started typing, a real,
24numbered revision already existed: `acts_as_list` assigns revision
25numbers at creation, scoped to `node_id`.
26
27A speculative edit (open the editor, type a few words, close the tab)
28still counted as a real Draft, and a real future revision if it were
29ever published.
30
31The rebuild introduced `autosave` as a genuinely separate layer: the
32same `Page` model, but created with `node_id: nil` specifically so
33it's excluded from `Node#pages`, the actual revision list.
34
35Typing progress survives a crash or a closed tab, but nothing becomes
36a real revision until the editor deliberately saves, which promotes
37the autosave's full content, wholesale, into the draft layer via
38`clone_attributes_from`.
39
40A canonical six-state reference table (head / draft / autosave present
41or absent, and what each combination permits) lives alongside the
42model code as the authority for any future work on this lifecycle.
43
44### Translations (Globalize), layered on top
45
46The governing principle for admin views from that rebuild:
47
48*presentation locale* (what language the admin chrome renders in) and
49*content-target locale* (which translation a given screen reads or
50writes) are separate concerns that must never share a mechanism.
51
52The route's `:locale` segment governs only the former. A locale is
53either the default one — edited exclusively through the primary node
54editor, pinned via `Globalize.with_locale` regardless of what the
55route happens to say, so a stray `/en/` URL can't silently edit the
56German content (as has been the case before the rewrite in 2026), or
57one of the non-default locales, edited exclusively through a
58Translations sub-resource under a deliberately distinct route
59parameter (`translation_locale`), chosen specifically because it must
60never leak into `default_url_options` the way the chrome locale does.
61Never both paths for the same locale.
62
63For now, admin chrome language stays tied to the route locale for now.
64A per-editor preference column was considered and set aside as
65revisit-later, not built.
66
67Translations carry no independent publish state: a translation goes
68live exactly when the draft containing it is promoted to head, same as
69everything else in that `Page` row. Both a per-translation
70`published_at` and full per-locale draft/head/autosave parity with
71`Node` were considered and rejected as unneeded machinery absent a
72real editorial need for staggered release timing.
73
74## Events and the calendar
75
76### Problem
77
78Calendar entries required an internal Node — every event needed a page
79somewhere in the tree. Editors avoided this: either the updates stream
80absorbed non-news content, or a page got created with no natural place
81in the navigation. The calendar widget went dormant rather than get
82used under those terms.
83
84### Rebuild.
85
86The Event model was rewritten around the RRULE humanizer
87(`app/models/concerns/rrule_humanizer.rb`) and a picker UI bound to it
88by a deliberate scope rule: the picker only generates or reads back
89RRULE shapes the humanizer can also render as prose. A picker that
90could express more than the humanizer can describe would let an editor
91create a schedule that renders as blank text on the public page —
92worse than a raw string the editor had to type by hand.
93`Event#node_id` became optional, so an event can point outward (an
94external URL) instead of requiring a page. This is what made reviving
95the calendar possible at all.
96
97### The "where to put an event" problem
98
99Once events no longer needed a node, turning the calendar on directly
100would have meant every chapter's regular open evening becoming an
101entry — a handful of one-off items buried under dozens of recurring,
102low-signal rows.
103
104### Resolution:
105
106Two widgets, not one:
107
108- `open_erfas_today` took the recurring, date-filtered case — which
109 chapters are open today. Its label went through rejected drafts
110 before landing on phrasing that admits it's a curated sample, not a
111 complete listing.
112- `div#frontpage_calendar` was reserved for the one-off case —
113 conferences, memorial gatherings. Its CSS carries an unconditional
114 `display: none` with no override anywhere in the stylesheet — not a
115 bug, a widget staged for a UI that hasn't been built yet.
116
117## The action log (`NodeAction`)
118
119### Origin
120
121The action log grew directly out of an audit of what timestamps
122actually mean: Two things were missing in the old model: no record of
123who actually clicked Publish (`page.editor` reflects the last content
124save, not necessarily that specific act), and the dashboard's
125recent-changes widget was actively wrong, showing an unrelated
126in-progress draft's byline next to a timestamp that didn't correspond
127to why the node appeared on the list at all.
128
129The log was designed to fill the gap and to answer "what did people
130do," a question the existing timestamp fields were never designed to
131answer.
132
133### Governing principle
134
135A derived log is only as reliable as the discipline that maintains it.
136
137Named explicitly before any code was written, not discovered afterward.
138Three ways it can drift:
139
1401. A future code path can simply forget to write to it — this project
141 had already found that exact shape twice (`wipe_draft!`'s ungated
142 branch; `Page.aggregate` silently ignoring a `conditions=` argument).
1432. If the log write and the actual mutation aren't in the same
144 transaction, they can diverge on a crash. Resolved by writing from
145 *inside* the model methods themselves (`autosave!`, `save_draft!`,
146 `publish_draft!`, `trash!`, `destroy!`) rather than from the
147 controllers that call them — every future caller is covered for
148 free, the same reasoning already proven by the JS-autosave work.
1493. It can never retroactively reconstruct anything from before it
150 existed — addressed deliberately by the backfill (below), not
151 ignored.
152
153### Avoiding noise
154
155A draft save or publish that produces no visible difference shouldn't
156create a visible log entry. Solved by reusing
157`Page#diff_against` / `has_changes_to?` — comparisons that already
158existed and were already tested — rather than inventing new diffing
159logic for the log to own.
160
161### The verb vocabulary
162
163Firstly, the log deliberately excludes locks (transient, no history
164value), autosave and draft-saving itself (exactly the noise the log
165exists to filter out), and tag/asset/event edits made *on a draft*
166(those will later surface as changed-flags at publish time, which
167is when they become real).
168
169Deliberately included, on reflection: reverts and discards (arguably
170more worth tracking than promotions: "who discarded this work, and
171when" is the fact someone would actually go looking for), shared
172preview link generation and revocation (security-relevant, handing
173unpublished content to an outsider), and slug/path changes
174(link-breaking, earns its own record separate from ordinary edits).
175
176### Rollback isn't a separate verb.
177
178`restore_revision!` writes a `publish` entry with a discriminator
179(`via: "revision"` instead of `via: "draft"`) rather than its own
180vocabulary. One verb, one meaning ("a page got promoted to head"),
181distinguished by how it got there.
182
183### Backfill
184
185Historical entries mirror the live vocabulary exactly. Diff content
186(what actually changed) is computed from the real historical revision
187data, since consecutive `Page` rows already existed to compare — only
188the actor and the timestamp are ever inferred, each backfilled entry
189carrying an `inferred_from` field naming the specific heuristic used
190(e.g. "from a page revision," "from `published_at`"). A `null`
191`inferred_from` means the entry was witnessed live, not reconstructed —
192so the log can always answer, for any entry, how much to trust it.
193
194### The full metadata contract per verb
195
196`create`, `publish`, `move`, `trash`, `restore_from_trash`, `destroy`
197— lives as a code comment directly above `record!` in
198`app/models/node.rb`. That's the authoritative, current version; this
199entry explains why it's shaped the way it is, not what it says field
200by field.
diff --git a/lib/authentication.rb b/lib/authentication.rb
index a936589..4d8a5ae 100644
--- a/lib/authentication.rb
+++ b/lib/authentication.rb
@@ -31,7 +31,7 @@ module Authentication
31 end 31 end
32 32
33 def make_token 33 def make_token
34 secure_digest(Time.now, (1..10).map{ rand.to_s }) 34 SecureRandom.hex(20)
35 end 35 end
36 end # class methods 36 end # class methods
37 37
@@ -46,7 +46,6 @@ module Authentication
46 include ModelInstanceMethods 46 include ModelInstanceMethods
47 47
48 # Virtual attribute for the unencrypted password 48 # Virtual attribute for the unencrypted password
49 attr_accessor :password
50 validates_presence_of :password, :if => :password_required? 49 validates_presence_of :password, :if => :password_required?
51 validates_presence_of :password_confirmation, :if => :password_required? 50 validates_presence_of :password_confirmation, :if => :password_required?
52 validates_confirmation_of :password, :if => :password_required? 51 validates_confirmation_of :password, :if => :password_required?
@@ -85,19 +84,22 @@ module Authentication
85 self.class.password_digest(password, salt) 84 self.class.password_digest(password, salt)
86 end 85 end
87 86
88 def authenticated?(password) 87 def authenticate_legacy(password)
89 crypted_password == encrypt(password) 88 crypted_password == encrypt(password)
90 end 89 end
91 90
92 # before filter 91 # before filter
93 def encrypt_password 92 def encrypt_password
94 return if password.blank? 93 return if password.blank?
95 self.salt = self.class.make_token if new_record? 94 return if password_digest.present?
95
96 self.salt ||= self.class.make_token
96 self.crypted_password = encrypt(password) 97 self.crypted_password = encrypt(password)
97 end 98 end
99
98 def password_required? 100 def password_required?
99 crypted_password.blank? || !password.blank? 101 (crypted_password.blank? && password_digest.blank?) || !password.blank?
100 end 102 end
101 end # instance methods 103 end # instance methods
102 end 104 end
103end \ No newline at end of file 105end
diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb
index 352dd3c..7ff5a3a 100644
--- a/lib/ccc_conventions.rb
+++ b/lib/ccc_conventions.rb
@@ -1,6 +1,7 @@
1module CccConventions 1module CccConventions
2 ERFA_PARENT_NAME = "club/erfas" 2 TRASH_SLUG = "trash"
3 CHAOSTREFF_PARENT_NAME = "club/chaostreffs" 3 ERFA_PARENT_NAME = "club/erfas"
4 CHAOSTREFF_PARENT_NAME = "club/chaostreffs"
4 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze 5 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze
5 6
6 NODE_KINDS = { 7 NODE_KINDS = {
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 3988016..e9f96c2 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -57,6 +57,10 @@ $(document).ready(function () {
57 move_to_search.initialize_search(); 57 move_to_search.initialize_search();
58 } 58 }
59 59
60 if ($("#restore_search_term").length != 0) {
61 restore_search.initialize_search();
62 }
63
60 if ($("#event_node_search_term").length != 0) { 64 if ($("#event_node_search_term").length != 0) {
61 event_search.initialize_search(); 65 event_search.initialize_search();
62 } 66 }
@@ -78,10 +82,29 @@ $(document).ready(function () {
78 }); 82 });
79 83
80 $(document).ajaxSend(function(event, request, settings) { 84 $(document).ajaxSend(function(event, request, settings) {
81 if (typeof(AUTH_TOKEN) == "undefined") return; 85 var meta = document.querySelector("meta[name='csrf-token']");
82 // settings.data is a serialized string like "foo=bar&baz=boink" (or null) 86 if (meta) request.setRequestHeader("X-CSRF-Token", meta.content);
83 settings.data = settings.data || ""; 87 });
84 settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN); 88
89 document.addEventListener('click', function (event) {
90 var button = event.target.closest('.copy_button');
91 if (!button) return;
92
93 var text = button.dataset.copyUrl;
94 if (text === undefined && button.dataset.copyTarget) {
95 var target = document.querySelector(button.dataset.copyTarget);
96 text = target ? target.textContent : undefined;
97 }
98
99 if (!text || text === '—' || !navigator.clipboard) return;
100
101 navigator.clipboard.writeText(text).then(function () {
102 var label = button.querySelector('.copy_button_label');
103 if (!label) return;
104 var original = label.textContent;
105 label.textContent = 'Copied!';
106 setTimeout(function () { label.textContent = original; }, 1500);
107 });
85 }); 108 });
86 109
87}); 110});
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index d135503..ad368cb 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -202,12 +202,6 @@ parent_search = {
202 $("#title").bind("input", function() { 202 $("#title").bind("input", function() {
203 parent_search.update_resulting_path(); 203 parent_search.update_resulting_path();
204 }); 204 });
205
206 $("#copy_resulting_path").bind("click", function() {
207 var path = $("#resulting_path").text();
208 if (path === "—" || !navigator.clipboard) return;
209 navigator.clipboard.writeText(path);
210 });
211 }, 205 },
212 206
213 update_resulting_path : function() { 207 update_resulting_path : function() {
@@ -262,6 +256,19 @@ move_to_search = {
262 } 256 }
263}; 257};
264 258
259restore_search = {
260 initialize_search : function() {
261 initSearchPicker({
262 inputSelector: "#restore_search_term",
263 resultsSelector: "#restore_search_results",
264 onSelect: function(node) {
265 $("#restore_search_term").val(node.title);
266 $("#parent_id").val(node.node_id);
267 }
268 });
269 }
270};
271
265event_search = { 272event_search = {
266 initialize_search : function() { 273 initialize_search : function() {
267 initSearchPicker({ 274 initSearchPicker({
diff --git a/public/javascripts/controls.js b/public/javascripts/controls.js
deleted file mode 100644
index ca29aef..0000000
--- a/public/javascripts/controls.js
+++ /dev/null
@@ -1,963 +0,0 @@
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
37if(typeof Effect == 'undefined')
38 throw("controls.js requires including script.aculo.us' effects.js library");
39
40var Autocompleter = { };
41Autocompleter.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 '<iframe id="' + this.update.id + '_iefix" '+
99 'style="display:none;position:absolute;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' +
100 'src="javascript:false;" frameborder="0" scrolling="no"></iframe>');
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
335Autocompleter.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
343Ajax.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
407Autocompleter.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("<li><strong>" + elem.substr(0, entry.length) + "</strong>" +
441 elem.substr(entry.length) + "</li>");
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("<li>" + elem.substr(0, foundPos) + "<strong>" +
447 elem.substr(foundPos, entry.length) + "</strong>" + elem.substr(
448 foundPos + entry.length) + "</li>");
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 "<ul>" + ret.join('') + "</ul>";
462 }
463 }, options || { });
464 }
465});
466
467// AJAX in-place editor and collection editor
468// Full rewrite by Christophe Porteneuve <tdd@tddsworld.com> (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
473Field.scrollFreeActivate = function(field) {
474 setTimeout(function() {
475 Field.activate(field);
476 }, 1);
477};
478
479Ajax.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
750Object.extend(Ajax.InPlaceEditor.prototype, {
751 dispose: Ajax.InPlaceEditor.prototype.destroy
752});
753
754Ajax.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
855Ajax.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
869Object.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
936Ajax.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
944Form.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}); \ No newline at end of file
diff --git a/public/javascripts/dragdrop.js b/public/javascripts/dragdrop.js
deleted file mode 100644
index 07229f9..0000000
--- a/public/javascripts/dragdrop.js
+++ /dev/null
@@ -1,973 +0,0 @@
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
7if(Object.isUndefined(Effect))
8 throw("dragdrop.js requires including script.aculo.us' effects.js library");
9
10var 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
126var 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
225var 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
570Draggable._dragging = { };
571
572/*--------------------------------------------------------------------------*/
573
574var 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
592var 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
947Element.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
953Element.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
971Element.offsetSize = function (element, type) {
972 return element['offset' + ((type=='vertical' || type=='height') ? 'Height' : 'Width')];
973}; \ No newline at end of file
diff --git a/public/javascripts/effects.js b/public/javascripts/effects.js
deleted file mode 100644
index 5a639d2..0000000
--- a/public/javascripts/effects.js
+++ /dev/null
@@ -1,1128 +0,0 @@
1// Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
2// Contributors:
3// Justin Palmer (http://encytemedia.com/)
4// Mark Pilgrim (http://diveintomark.org/)
5// Martin Bialasinki
6//
7// script.aculo.us is freely distributable under the terms of an MIT-style license.
8// For details, see the script.aculo.us web site: http://script.aculo.us/
9
10// converts rgb() and #xxx to #xxxxxx format,
11// returns self (or first argument) if not convertable
12String.prototype.parseColor = function() {
13 var color = '#';
14 if (this.slice(0,4) == 'rgb(') {
15 var cols = this.slice(4,this.length-1).split(',');
16 var i=0; do { color += parseInt(cols[i]).toColorPart() } while (++i<3);
17 } else {
18 if (this.slice(0,1) == '#') {
19 if (this.length==4) for(var i=1;i<4;i++) color += (this.charAt(i) + this.charAt(i)).toLowerCase();
20 if (this.length==7) color = this.toLowerCase();
21 }
22 }
23 return (color.length==7 ? color : (arguments[0] || this));
24};
25
26/*--------------------------------------------------------------------------*/
27
28Element.collectTextNodes = function(element) {
29 return $A($(element).childNodes).collect( function(node) {
30 return (node.nodeType==3 ? node.nodeValue :
31 (node.hasChildNodes() ? Element.collectTextNodes(node) : ''));
32 }).flatten().join('');
33};
34
35Element.collectTextNodesIgnoreClass = function(element, className) {
36 return $A($(element).childNodes).collect( function(node) {
37 return (node.nodeType==3 ? node.nodeValue :
38 ((node.hasChildNodes() && !Element.hasClassName(node,className)) ?
39 Element.collectTextNodesIgnoreClass(node, className) : ''));
40 }).flatten().join('');
41};
42
43Element.setContentZoom = function(element, percent) {
44 element = $(element);
45 element.setStyle({fontSize: (percent/100) + 'em'});
46 if (Prototype.Browser.WebKit) window.scrollBy(0,0);
47 return element;
48};
49
50Element.getInlineOpacity = function(element){
51 return $(element).style.opacity || '';
52};
53
54Element.forceRerendering = function(element) {
55 try {
56 element = $(element);
57 var n = document.createTextNode(' ');
58 element.appendChild(n);
59 element.removeChild(n);
60 } catch(e) { }
61};
62
63/*--------------------------------------------------------------------------*/
64
65var Effect = {
66 _elementDoesNotExistError: {
67 name: 'ElementDoesNotExistError',
68 message: 'The specified DOM element does not exist, but is required for this effect to operate'
69 },
70 Transitions: {
71 linear: Prototype.K,
72 sinoidal: function(pos) {
73 return (-Math.cos(pos*Math.PI)/2) + .5;
74 },
75 reverse: function(pos) {
76 return 1-pos;
77 },
78 flicker: function(pos) {
79 var pos = ((-Math.cos(pos*Math.PI)/4) + .75) + Math.random()/4;
80 return pos > 1 ? 1 : pos;
81 },
82 wobble: function(pos) {
83 return (-Math.cos(pos*Math.PI*(9*pos))/2) + .5;
84 },
85 pulse: function(pos, pulses) {
86 return (-Math.cos((pos*((pulses||5)-.5)*2)*Math.PI)/2) + .5;
87 },
88 spring: function(pos) {
89 return 1 - (Math.cos(pos * 4.5 * Math.PI) * Math.exp(-pos * 6));
90 },
91 none: function(pos) {
92 return 0;
93 },
94 full: function(pos) {
95 return 1;
96 }
97 },
98 DefaultOptions: {
99 duration: 1.0, // seconds
100 fps: 100, // 100= assume 66fps max.
101 sync: false, // true for combining
102 from: 0.0,
103 to: 1.0,
104 delay: 0.0,
105 queue: 'parallel'
106 },
107 tagifyText: function(element) {
108 var tagifyStyle = 'position:relative';
109 if (Prototype.Browser.IE) tagifyStyle += ';zoom:1';
110
111 element = $(element);
112 $A(element.childNodes).each( function(child) {
113 if (child.nodeType==3) {
114 child.nodeValue.toArray().each( function(character) {
115 element.insertBefore(
116 new Element('span', {style: tagifyStyle}).update(
117 character == ' ' ? String.fromCharCode(160) : character),
118 child);
119 });
120 Element.remove(child);
121 }
122 });
123 },
124 multiple: function(element, effect) {
125 var elements;
126 if (((typeof element == 'object') ||
127 Object.isFunction(element)) &&
128 (element.length))
129 elements = element;
130 else
131 elements = $(element).childNodes;
132
133 var options = Object.extend({
134 speed: 0.1,
135 delay: 0.0
136 }, arguments[2] || { });
137 var masterDelay = options.delay;
138
139 $A(elements).each( function(element, index) {
140 new effect(element, Object.extend(options, { delay: index * options.speed + masterDelay }));
141 });
142 },
143 PAIRS: {
144 'slide': ['SlideDown','SlideUp'],
145 'blind': ['BlindDown','BlindUp'],
146 'appear': ['Appear','Fade']
147 },
148 toggle: function(element, effect) {
149 element = $(element);
150 effect = (effect || 'appear').toLowerCase();
151 var options = Object.extend({
152 queue: { position:'end', scope:(element.id || 'global'), limit: 1 }
153 }, arguments[2] || { });
154 Effect[element.visible() ?
155 Effect.PAIRS[effect][1] : Effect.PAIRS[effect][0]](element, options);
156 }
157};
158
159Effect.DefaultOptions.transition = Effect.Transitions.sinoidal;
160
161/* ------------- core effects ------------- */
162
163Effect.ScopedQueue = Class.create(Enumerable, {
164 initialize: function() {
165 this.effects = [];
166 this.interval = null;
167 },
168 _each: function(iterator) {
169 this.effects._each(iterator);
170 },
171 add: function(effect) {
172 var timestamp = new Date().getTime();
173
174 var position = Object.isString(effect.options.queue) ?
175 effect.options.queue : effect.options.queue.position;
176
177 switch(position) {
178 case 'front':
179 // move unstarted effects after this effect
180 this.effects.findAll(function(e){ return e.state=='idle' }).each( function(e) {
181 e.startOn += effect.finishOn;
182 e.finishOn += effect.finishOn;
183 });
184 break;
185 case 'with-last':
186 timestamp = this.effects.pluck('startOn').max() || timestamp;
187 break;
188 case 'end':
189 // start effect after last queued effect has finished
190 timestamp = this.effects.pluck('finishOn').max() || timestamp;
191 break;
192 }
193
194 effect.startOn += timestamp;
195 effect.finishOn += timestamp;
196
197 if (!effect.options.queue.limit || (this.effects.length < effect.options.queue.limit))
198 this.effects.push(effect);
199
200 if (!this.interval)
201 this.interval = setInterval(this.loop.bind(this), 15);
202 },
203 remove: function(effect) {
204 this.effects = this.effects.reject(function(e) { return e==effect });
205 if (this.effects.length == 0) {
206 clearInterval(this.interval);
207 this.interval = null;
208 }
209 },
210 loop: function() {
211 var timePos = new Date().getTime();
212 for(var i=0, len=this.effects.length;i<len;i++)
213 this.effects[i] && this.effects[i].loop(timePos);
214 }
215});
216
217Effect.Queues = {
218 instances: $H(),
219 get: function(queueName) {
220 if (!Object.isString(queueName)) return queueName;
221
222 return this.instances.get(queueName) ||
223 this.instances.set(queueName, new Effect.ScopedQueue());
224 }
225};
226Effect.Queue = Effect.Queues.get('global');
227
228Effect.Base = Class.create({
229 position: null,
230 start: function(options) {
231 function codeForEvent(options,eventName){
232 return (
233 (options[eventName+'Internal'] ? 'this.options.'+eventName+'Internal(this);' : '') +
234 (options[eventName] ? 'this.options.'+eventName+'(this);' : '')
235 );
236 }
237 if (options && options.transition === false) options.transition = Effect.Transitions.linear;
238 this.options = Object.extend(Object.extend({ },Effect.DefaultOptions), options || { });
239 this.currentFrame = 0;
240 this.state = 'idle';
241 this.startOn = this.options.delay*1000;
242 this.finishOn = this.startOn+(this.options.duration*1000);
243 this.fromToDelta = this.options.to-this.options.from;
244 this.totalTime = this.finishOn-this.startOn;
245 this.totalFrames = this.options.fps*this.options.duration;
246
247 this.render = (function() {
248 function dispatch(effect, eventName) {
249 if (effect.options[eventName + 'Internal'])
250 effect.options[eventName + 'Internal'](effect);
251 if (effect.options[eventName])
252 effect.options[eventName](effect);
253 }
254
255 return function(pos) {
256 if (this.state === "idle") {
257 this.state = "running";
258 dispatch(this, 'beforeSetup');
259 if (this.setup) this.setup();
260 dispatch(this, 'afterSetup');
261 }
262 if (this.state === "running") {
263 pos = (this.options.transition(pos) * this.fromToDelta) + this.options.from;
264 this.position = pos;
265 dispatch(this, 'beforeUpdate');
266 if (this.update) this.update(pos);
267 dispatch(this, 'afterUpdate');
268 }
269 };
270 })();
271
272 this.event('beforeStart');
273 if (!this.options.sync)
274 Effect.Queues.get(Object.isString(this.options.queue) ?
275 'global' : this.options.queue.scope).add(this);
276 },
277 loop: function(timePos) {
278 if (timePos >= this.startOn) {
279 if (timePos >= this.finishOn) {
280 this.render(1.0);
281 this.cancel();
282 this.event('beforeFinish');
283 if (this.finish) this.finish();
284 this.event('afterFinish');
285 return;
286 }
287 var pos = (timePos - this.startOn) / this.totalTime,
288 frame = (pos * this.totalFrames).round();
289 if (frame > this.currentFrame) {
290 this.render(pos);
291 this.currentFrame = frame;
292 }
293 }
294 },
295 cancel: function() {
296 if (!this.options.sync)
297 Effect.Queues.get(Object.isString(this.options.queue) ?
298 'global' : this.options.queue.scope).remove(this);
299 this.state = 'finished';
300 },
301 event: function(eventName) {
302 if (this.options[eventName + 'Internal']) this.options[eventName + 'Internal'](this);
303 if (this.options[eventName]) this.options[eventName](this);
304 },
305 inspect: function() {
306 var data = $H();
307 for(property in this)
308 if (!Object.isFunction(this[property])) data.set(property, this[property]);
309 return '#<Effect:' + data.inspect() + ',options:' + $H(this.options).inspect() + '>';
310 }
311});
312
313Effect.Parallel = Class.create(Effect.Base, {
314 initialize: function(effects) {
315 this.effects = effects || [];
316 this.start(arguments[1]);
317 },
318 update: function(position) {
319 this.effects.invoke('render', position);
320 },
321 finish: function(position) {
322 this.effects.each( function(effect) {
323 effect.render(1.0);
324 effect.cancel();
325 effect.event('beforeFinish');
326 if (effect.finish) effect.finish(position);
327 effect.event('afterFinish');
328 });
329 }
330});
331
332Effect.Tween = Class.create(Effect.Base, {
333 initialize: function(object, from, to) {
334 object = Object.isString(object) ? $(object) : object;
335 var args = $A(arguments), method = args.last(),
336 options = args.length == 5 ? args[3] : null;
337 this.method = Object.isFunction(method) ? method.bind(object) :
338 Object.isFunction(object[method]) ? object[method].bind(object) :
339 function(value) { object[method] = value };
340 this.start(Object.extend({ from: from, to: to }, options || { }));
341 },
342 update: function(position) {
343 this.method(position);
344 }
345});
346
347Effect.Event = Class.create(Effect.Base, {
348 initialize: function() {
349 this.start(Object.extend({ duration: 0 }, arguments[0] || { }));
350 },
351 update: Prototype.emptyFunction
352});
353
354Effect.Opacity = Class.create(Effect.Base, {
355 initialize: function(element) {
356 this.element = $(element);
357 if (!this.element) throw(Effect._elementDoesNotExistError);
358 // make this work on IE on elements without 'layout'
359 if (Prototype.Browser.IE && (!this.element.currentStyle.hasLayout))
360 this.element.setStyle({zoom: 1});
361 var options = Object.extend({
362 from: this.element.getOpacity() || 0.0,
363 to: 1.0
364 }, arguments[1] || { });
365 this.start(options);
366 },
367 update: function(position) {
368 this.element.setOpacity(position);
369 }
370});
371
372Effect.Move = Class.create(Effect.Base, {
373 initialize: function(element) {
374 this.element = $(element);
375 if (!this.element) throw(Effect._elementDoesNotExistError);
376 var options = Object.extend({
377 x: 0,
378 y: 0,
379 mode: 'relative'
380 }, arguments[1] || { });
381 this.start(options);
382 },
383 setup: function() {
384 this.element.makePositioned();
385 this.originalLeft = parseFloat(this.element.getStyle('left') || '0');
386 this.originalTop = parseFloat(this.element.getStyle('top') || '0');
387 if (this.options.mode == 'absolute') {
388 this.options.x = this.options.x - this.originalLeft;
389 this.options.y = this.options.y - this.originalTop;
390 }
391 },
392 update: function(position) {
393 this.element.setStyle({
394 left: (this.options.x * position + this.originalLeft).round() + 'px',
395 top: (this.options.y * position + this.originalTop).round() + 'px'
396 });
397 }
398});
399
400// for backwards compatibility
401Effect.MoveBy = function(element, toTop, toLeft) {
402 return new Effect.Move(element,
403 Object.extend({ x: toLeft, y: toTop }, arguments[3] || { }));
404};
405
406Effect.Scale = Class.create(Effect.Base, {
407 initialize: function(element, percent) {
408 this.element = $(element);
409 if (!this.element) throw(Effect._elementDoesNotExistError);
410 var options = Object.extend({
411 scaleX: true,
412 scaleY: true,
413 scaleContent: true,
414 scaleFromCenter: false,
415 scaleMode: 'box', // 'box' or 'contents' or { } with provided values
416 scaleFrom: 100.0,
417 scaleTo: percent
418 }, arguments[2] || { });
419 this.start(options);
420 },
421 setup: function() {
422 this.restoreAfterFinish = this.options.restoreAfterFinish || false;
423 this.elementPositioning = this.element.getStyle('position');
424
425 this.originalStyle = { };
426 ['top','left','width','height','fontSize'].each( function(k) {
427 this.originalStyle[k] = this.element.style[k];
428 }.bind(this));
429
430 this.originalTop = this.element.offsetTop;
431 this.originalLeft = this.element.offsetLeft;
432
433 var fontSize = this.element.getStyle('font-size') || '100%';
434 ['em','px','%','pt'].each( function(fontSizeType) {
435 if (fontSize.indexOf(fontSizeType)>0) {
436 this.fontSize = parseFloat(fontSize);
437 this.fontSizeType = fontSizeType;
438 }
439 }.bind(this));
440
441 this.factor = (this.options.scaleTo - this.options.scaleFrom)/100;
442
443 this.dims = null;
444 if (this.options.scaleMode=='box')
445 this.dims = [this.element.offsetHeight, this.element.offsetWidth];
446 if (/^content/.test(this.options.scaleMode))
447 this.dims = [this.element.scrollHeight, this.element.scrollWidth];
448 if (!this.dims)
449 this.dims = [this.options.scaleMode.originalHeight,
450 this.options.scaleMode.originalWidth];
451 },
452 update: function(position) {
453 var currentScale = (this.options.scaleFrom/100.0) + (this.factor * position);
454 if (this.options.scaleContent && this.fontSize)
455 this.element.setStyle({fontSize: this.fontSize * currentScale + this.fontSizeType });
456 this.setDimensions(this.dims[0] * currentScale, this.dims[1] * currentScale);
457 },
458 finish: function(position) {
459 if (this.restoreAfterFinish) this.element.setStyle(this.originalStyle);
460 },
461 setDimensions: function(height, width) {
462 var d = { };
463 if (this.options.scaleX) d.width = width.round() + 'px';
464 if (this.options.scaleY) d.height = height.round() + 'px';
465 if (this.options.scaleFromCenter) {
466 var topd = (height - this.dims[0])/2;
467 var leftd = (width - this.dims[1])/2;
468 if (this.elementPositioning == 'absolute') {
469 if (this.options.scaleY) d.top = this.originalTop-topd + 'px';
470 if (this.options.scaleX) d.left = this.originalLeft-leftd + 'px';
471 } else {
472 if (this.options.scaleY) d.top = -topd + 'px';
473 if (this.options.scaleX) d.left = -leftd + 'px';
474 }
475 }
476 this.element.setStyle(d);
477 }
478});
479
480Effect.Highlight = Class.create(Effect.Base, {
481 initialize: function(element) {
482 this.element = $(element);
483 if (!this.element) throw(Effect._elementDoesNotExistError);
484 var options = Object.extend({ startcolor: '#ffff99' }, arguments[1] || { });
485 this.start(options);
486 },
487 setup: function() {
488 // Prevent executing on elements not in the layout flow
489 if (this.element.getStyle('display')=='none') { this.cancel(); return; }
490 // Disable background image during the effect
491 this.oldStyle = { };
492 if (!this.options.keepBackgroundImage) {
493 this.oldStyle.backgroundImage = this.element.getStyle('background-image');
494 this.element.setStyle({backgroundImage: 'none'});
495 }
496 if (!this.options.endcolor)
497 this.options.endcolor = this.element.getStyle('background-color').parseColor('#ffffff');
498 if (!this.options.restorecolor)
499 this.options.restorecolor = this.element.getStyle('background-color');
500 // init color calculations
501 this._base = $R(0,2).map(function(i){ return parseInt(this.options.startcolor.slice(i*2+1,i*2+3),16) }.bind(this));
502 this._delta = $R(0,2).map(function(i){ return parseInt(this.options.endcolor.slice(i*2+1,i*2+3),16)-this._base[i] }.bind(this));
503 },
504 update: function(position) {
505 this.element.setStyle({backgroundColor: $R(0,2).inject('#',function(m,v,i){
506 return m+((this._base[i]+(this._delta[i]*position)).round().toColorPart()); }.bind(this)) });
507 },
508 finish: function() {
509 this.element.setStyle(Object.extend(this.oldStyle, {
510 backgroundColor: this.options.restorecolor
511 }));
512 }
513});
514
515Effect.ScrollTo = function(element) {
516 var options = arguments[1] || { },
517 scrollOffsets = document.viewport.getScrollOffsets(),
518 elementOffsets = $(element).cumulativeOffset();
519
520 if (options.offset) elementOffsets[1] += options.offset;
521
522 return new Effect.Tween(null,
523 scrollOffsets.top,
524 elementOffsets[1],
525 options,
526 function(p){ scrollTo(scrollOffsets.left, p.round()); }
527 );
528};
529
530/* ------------- combination effects ------------- */
531
532Effect.Fade = function(element) {
533 element = $(element);
534 var oldOpacity = element.getInlineOpacity();
535 var options = Object.extend({
536 from: element.getOpacity() || 1.0,
537 to: 0.0,
538 afterFinishInternal: function(effect) {
539 if (effect.options.to!=0) return;
540 effect.element.hide().setStyle({opacity: oldOpacity});
541 }
542 }, arguments[1] || { });
543 return new Effect.Opacity(element,options);
544};
545
546Effect.Appear = function(element) {
547 element = $(element);
548 var options = Object.extend({
549 from: (element.getStyle('display') == 'none' ? 0.0 : element.getOpacity() || 0.0),
550 to: 1.0,
551 // force Safari to render floated elements properly
552 afterFinishInternal: function(effect) {
553 effect.element.forceRerendering();
554 },
555 beforeSetup: function(effect) {
556 effect.element.setOpacity(effect.options.from).show();
557 }}, arguments[1] || { });
558 return new Effect.Opacity(element,options);
559};
560
561Effect.Puff = function(element) {
562 element = $(element);
563 var oldStyle = {
564 opacity: element.getInlineOpacity(),
565 position: element.getStyle('position'),
566 top: element.style.top,
567 left: element.style.left,
568 width: element.style.width,
569 height: element.style.height
570 };
571 return new Effect.Parallel(
572 [ new Effect.Scale(element, 200,
573 { sync: true, scaleFromCenter: true, scaleContent: true, restoreAfterFinish: true }),
574 new Effect.Opacity(element, { sync: true, to: 0.0 } ) ],
575 Object.extend({ duration: 1.0,
576 beforeSetupInternal: function(effect) {
577 Position.absolutize(effect.effects[0].element);
578 },
579 afterFinishInternal: function(effect) {
580 effect.effects[0].element.hide().setStyle(oldStyle); }
581 }, arguments[1] || { })
582 );
583};
584
585Effect.BlindUp = function(element) {
586 element = $(element);
587 element.makeClipping();
588 return new Effect.Scale(element, 0,
589 Object.extend({ scaleContent: false,
590 scaleX: false,
591 restoreAfterFinish: true,
592 afterFinishInternal: function(effect) {
593 effect.element.hide().undoClipping();
594 }
595 }, arguments[1] || { })
596 );
597};
598
599Effect.BlindDown = function(element) {
600 element = $(element);
601 var elementDimensions = element.getDimensions();
602 return new Effect.Scale(element, 100, Object.extend({
603 scaleContent: false,
604 scaleX: false,
605 scaleFrom: 0,
606 scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width},
607 restoreAfterFinish: true,
608 afterSetup: function(effect) {
609 effect.element.makeClipping().setStyle({height: '0px'}).show();
610 },
611 afterFinishInternal: function(effect) {
612 effect.element.undoClipping();
613 }
614 }, arguments[1] || { }));
615};
616
617Effect.SwitchOff = function(element) {
618 element = $(element);
619 var oldOpacity = element.getInlineOpacity();
620 return new Effect.Appear(element, Object.extend({
621 duration: 0.4,
622 from: 0,
623 transition: Effect.Transitions.flicker,
624 afterFinishInternal: function(effect) {
625 new Effect.Scale(effect.element, 1, {
626 duration: 0.3, scaleFromCenter: true,
627 scaleX: false, scaleContent: false, restoreAfterFinish: true,
628 beforeSetup: function(effect) {
629 effect.element.makePositioned().makeClipping();
630 },
631 afterFinishInternal: function(effect) {
632 effect.element.hide().undoClipping().undoPositioned().setStyle({opacity: oldOpacity});
633 }
634 });
635 }
636 }, arguments[1] || { }));
637};
638
639Effect.DropOut = function(element) {
640 element = $(element);
641 var oldStyle = {
642 top: element.getStyle('top'),
643 left: element.getStyle('left'),
644 opacity: element.getInlineOpacity() };
645 return new Effect.Parallel(
646 [ new Effect.Move(element, {x: 0, y: 100, sync: true }),
647 new Effect.Opacity(element, { sync: true, to: 0.0 }) ],
648 Object.extend(
649 { duration: 0.5,
650 beforeSetup: function(effect) {
651 effect.effects[0].element.makePositioned();
652 },
653 afterFinishInternal: function(effect) {
654 effect.effects[0].element.hide().undoPositioned().setStyle(oldStyle);
655 }
656 }, arguments[1] || { }));
657};
658
659Effect.Shake = function(element) {
660 element = $(element);
661 var options = Object.extend({
662 distance: 20,
663 duration: 0.5
664 }, arguments[1] || {});
665 var distance = parseFloat(options.distance);
666 var split = parseFloat(options.duration) / 10.0;
667 var oldStyle = {
668 top: element.getStyle('top'),
669 left: element.getStyle('left') };
670 return new Effect.Move(element,
671 { x: distance, y: 0, duration: split, afterFinishInternal: function(effect) {
672 new Effect.Move(effect.element,
673 { x: -distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) {
674 new Effect.Move(effect.element,
675 { x: distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) {
676 new Effect.Move(effect.element,
677 { x: -distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) {
678 new Effect.Move(effect.element,
679 { x: distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) {
680 new Effect.Move(effect.element,
681 { x: -distance, y: 0, duration: split, afterFinishInternal: function(effect) {
682 effect.element.undoPositioned().setStyle(oldStyle);
683 }}); }}); }}); }}); }}); }});
684};
685
686Effect.SlideDown = function(element) {
687 element = $(element).cleanWhitespace();
688 // SlideDown need to have the content of the element wrapped in a container element with fixed height!
689 var oldInnerBottom = element.down().getStyle('bottom');
690 var elementDimensions = element.getDimensions();
691 return new Effect.Scale(element, 100, Object.extend({
692 scaleContent: false,
693 scaleX: false,
694 scaleFrom: window.opera ? 0 : 1,
695 scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width},
696 restoreAfterFinish: true,
697 afterSetup: function(effect) {
698 effect.element.makePositioned();
699 effect.element.down().makePositioned();
700 if (window.opera) effect.element.setStyle({top: ''});
701 effect.element.makeClipping().setStyle({height: '0px'}).show();
702 },
703 afterUpdateInternal: function(effect) {
704 effect.element.down().setStyle({bottom:
705 (effect.dims[0] - effect.element.clientHeight) + 'px' });
706 },
707 afterFinishInternal: function(effect) {
708 effect.element.undoClipping().undoPositioned();
709 effect.element.down().undoPositioned().setStyle({bottom: oldInnerBottom}); }
710 }, arguments[1] || { })
711 );
712};
713
714Effect.SlideUp = function(element) {
715 element = $(element).cleanWhitespace();
716 var oldInnerBottom = element.down().getStyle('bottom');
717 var elementDimensions = element.getDimensions();
718 return new Effect.Scale(element, window.opera ? 0 : 1,
719 Object.extend({ scaleContent: false,
720 scaleX: false,
721 scaleMode: 'box',
722 scaleFrom: 100,
723 scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width},
724 restoreAfterFinish: true,
725 afterSetup: function(effect) {
726 effect.element.makePositioned();
727 effect.element.down().makePositioned();
728 if (window.opera) effect.element.setStyle({top: ''});
729 effect.element.makeClipping().show();
730 },
731 afterUpdateInternal: function(effect) {
732 effect.element.down().setStyle({bottom:
733 (effect.dims[0] - effect.element.clientHeight) + 'px' });
734 },
735 afterFinishInternal: function(effect) {
736 effect.element.hide().undoClipping().undoPositioned();
737 effect.element.down().undoPositioned().setStyle({bottom: oldInnerBottom});
738 }
739 }, arguments[1] || { })
740 );
741};
742
743// Bug in opera makes the TD containing this element expand for a instance after finish
744Effect.Squish = function(element) {
745 return new Effect.Scale(element, window.opera ? 1 : 0, {
746 restoreAfterFinish: true,
747 beforeSetup: function(effect) {
748 effect.element.makeClipping();
749 },
750 afterFinishInternal: function(effect) {
751 effect.element.hide().undoClipping();
752 }
753 });
754};
755
756Effect.Grow = function(element) {
757 element = $(element);
758 var options = Object.extend({
759 direction: 'center',
760 moveTransition: Effect.Transitions.sinoidal,
761 scaleTransition: Effect.Transitions.sinoidal,
762 opacityTransition: Effect.Transitions.full
763 }, arguments[1] || { });
764 var oldStyle = {
765 top: element.style.top,
766 left: element.style.left,
767 height: element.style.height,
768 width: element.style.width,
769 opacity: element.getInlineOpacity() };
770
771 var dims = element.getDimensions();
772 var initialMoveX, initialMoveY;
773 var moveX, moveY;
774
775 switch (options.direction) {
776 case 'top-left':
777 initialMoveX = initialMoveY = moveX = moveY = 0;
778 break;
779 case 'top-right':
780 initialMoveX = dims.width;
781 initialMoveY = moveY = 0;
782 moveX = -dims.width;
783 break;
784 case 'bottom-left':
785 initialMoveX = moveX = 0;
786 initialMoveY = dims.height;
787 moveY = -dims.height;
788 break;
789 case 'bottom-right':
790 initialMoveX = dims.width;
791 initialMoveY = dims.height;
792 moveX = -dims.width;
793 moveY = -dims.height;
794 break;
795 case 'center':
796 initialMoveX = dims.width / 2;
797 initialMoveY = dims.height / 2;
798 moveX = -dims.width / 2;
799 moveY = -dims.height / 2;
800 break;
801 }
802
803 return new Effect.Move(element, {
804 x: initialMoveX,
805 y: initialMoveY,
806 duration: 0.01,
807 beforeSetup: function(effect) {
808 effect.element.hide().makeClipping().makePositioned();
809 },
810 afterFinishInternal: function(effect) {
811 new Effect.Parallel(
812 [ new Effect.Opacity(effect.element, { sync: true, to: 1.0, from: 0.0, transition: options.opacityTransition }),
813 new Effect.Move(effect.element, { x: moveX, y: moveY, sync: true, transition: options.moveTransition }),
814 new Effect.Scale(effect.element, 100, {
815 scaleMode: { originalHeight: dims.height, originalWidth: dims.width },
816 sync: true, scaleFrom: window.opera ? 1 : 0, transition: options.scaleTransition, restoreAfterFinish: true})
817 ], Object.extend({
818 beforeSetup: function(effect) {
819 effect.effects[0].element.setStyle({height: '0px'}).show();
820 },
821 afterFinishInternal: function(effect) {
822 effect.effects[0].element.undoClipping().undoPositioned().setStyle(oldStyle);
823 }
824 }, options)
825 );
826 }
827 });
828};
829
830Effect.Shrink = function(element) {
831 element = $(element);
832 var options = Object.extend({
833 direction: 'center',
834 moveTransition: Effect.Transitions.sinoidal,
835 scaleTransition: Effect.Transitions.sinoidal,
836 opacityTransition: Effect.Transitions.none
837 }, arguments[1] || { });
838 var oldStyle = {
839 top: element.style.top,
840 left: element.style.left,
841 height: element.style.height,
842 width: element.style.width,
843 opacity: element.getInlineOpacity() };
844
845 var dims = element.getDimensions();
846 var moveX, moveY;
847
848 switch (options.direction) {
849 case 'top-left':
850 moveX = moveY = 0;
851 break;
852 case 'top-right':
853 moveX = dims.width;
854 moveY = 0;
855 break;
856 case 'bottom-left':
857 moveX = 0;
858 moveY = dims.height;
859 break;
860 case 'bottom-right':
861 moveX = dims.width;
862 moveY = dims.height;
863 break;
864 case 'center':
865 moveX = dims.width / 2;
866 moveY = dims.height / 2;
867 break;
868 }
869
870 return new Effect.Parallel(
871 [ new Effect.Opacity(element, { sync: true, to: 0.0, from: 1.0, transition: options.opacityTransition }),
872 new Effect.Scale(element, window.opera ? 1 : 0, { sync: true, transition: options.scaleTransition, restoreAfterFinish: true}),
873 new Effect.Move(element, { x: moveX, y: moveY, sync: true, transition: options.moveTransition })
874 ], Object.extend({
875 beforeStartInternal: function(effect) {
876 effect.effects[0].element.makePositioned().makeClipping();
877 },
878 afterFinishInternal: function(effect) {
879 effect.effects[0].element.hide().undoClipping().undoPositioned().setStyle(oldStyle); }
880 }, options)
881 );
882};
883
884Effect.Pulsate = function(element) {
885 element = $(element);
886 var options = arguments[1] || { },
887 oldOpacity = element.getInlineOpacity(),
888 transition = options.transition || Effect.Transitions.linear,
889 reverser = function(pos){
890 return 1 - transition((-Math.cos((pos*(options.pulses||5)*2)*Math.PI)/2) + .5);
891 };
892
893 return new Effect.Opacity(element,
894 Object.extend(Object.extend({ duration: 2.0, from: 0,
895 afterFinishInternal: function(effect) { effect.element.setStyle({opacity: oldOpacity}); }
896 }, options), {transition: reverser}));
897};
898
899Effect.Fold = function(element) {
900 element = $(element);
901 var oldStyle = {
902 top: element.style.top,
903 left: element.style.left,
904 width: element.style.width,
905 height: element.style.height };
906 element.makeClipping();
907 return new Effect.Scale(element, 5, Object.extend({
908 scaleContent: false,
909 scaleX: false,
910 afterFinishInternal: function(effect) {
911 new Effect.Scale(element, 1, {
912 scaleContent: false,
913 scaleY: false,
914 afterFinishInternal: function(effect) {
915 effect.element.hide().undoClipping().setStyle(oldStyle);
916 } });
917 }}, arguments[1] || { }));
918};
919
920Effect.Morph = Class.create(Effect.Base, {
921 initialize: function(element) {
922 this.element = $(element);
923 if (!this.element) throw(Effect._elementDoesNotExistError);
924 var options = Object.extend({
925 style: { }
926 }, arguments[1] || { });
927
928 if (!Object.isString(options.style)) this.style = $H(options.style);
929 else {
930 if (options.style.include(':'))
931 this.style = options.style.parseStyle();
932 else {
933 this.element.addClassName(options.style);
934 this.style = $H(this.element.getStyles());
935 this.element.removeClassName(options.style);
936 var css = this.element.getStyles();
937 this.style = this.style.reject(function(style) {
938 return style.value == css[style.key];
939 });
940 options.afterFinishInternal = function(effect) {
941 effect.element.addClassName(effect.options.style);
942 effect.transforms.each(function(transform) {
943 effect.element.style[transform.style] = '';
944 });
945 };
946 }
947 }
948 this.start(options);
949 },
950
951 setup: function(){
952 function parseColor(color){
953 if (!color || ['rgba(0, 0, 0, 0)','transparent'].include(color)) color = '#ffffff';
954 color = color.parseColor();
955 return $R(0,2).map(function(i){
956 return parseInt( color.slice(i*2+1,i*2+3), 16 );
957 });
958 }
959 this.transforms = this.style.map(function(pair){
960 var property = pair[0], value = pair[1], unit = null;
961
962 if (value.parseColor('#zzzzzz') != '#zzzzzz') {
963 value = value.parseColor();
964 unit = 'color';
965 } else if (property == 'opacity') {
966 value = parseFloat(value);
967 if (Prototype.Browser.IE && (!this.element.currentStyle.hasLayout))
968 this.element.setStyle({zoom: 1});
969 } else if (Element.CSS_LENGTH.test(value)) {
970 var components = value.match(/^([\+\-]?[0-9\.]+)(.*)$/);
971 value = parseFloat(components[1]);
972 unit = (components.length == 3) ? components[2] : null;
973 }
974
975 var originalValue = this.element.getStyle(property);
976 return {
977 style: property.camelize(),
978 originalValue: unit=='color' ? parseColor(originalValue) : parseFloat(originalValue || 0),
979 targetValue: unit=='color' ? parseColor(value) : value,
980 unit: unit
981 };
982 }.bind(this)).reject(function(transform){
983 return (
984 (transform.originalValue == transform.targetValue) ||
985 (
986 transform.unit != 'color' &&
987 (isNaN(transform.originalValue) || isNaN(transform.targetValue))
988 )
989 );
990 });
991 },
992 update: function(position) {
993 var style = { }, transform, i = this.transforms.length;
994 while(i--)
995 style[(transform = this.transforms[i]).style] =
996 transform.unit=='color' ? '#'+
997 (Math.round(transform.originalValue[0]+
998 (transform.targetValue[0]-transform.originalValue[0])*position)).toColorPart() +
999 (Math.round(transform.originalValue[1]+
1000 (transform.targetValue[1]-transform.originalValue[1])*position)).toColorPart() +
1001 (Math.round(transform.originalValue[2]+
1002 (transform.targetValue[2]-transform.originalValue[2])*position)).toColorPart() :
1003 (transform.originalValue +
1004 (transform.targetValue - transform.originalValue) * position).toFixed(3) +
1005 (transform.unit === null ? '' : transform.unit);
1006 this.element.setStyle(style, true);
1007 }
1008});
1009
1010Effect.Transform = Class.create({
1011 initialize: function(tracks){
1012 this.tracks = [];
1013 this.options = arguments[1] || { };
1014 this.addTracks(tracks);
1015 },
1016 addTracks: function(tracks){
1017 tracks.each(function(track){
1018 track = $H(track);
1019 var data = track.values().first();
1020 this.tracks.push($H({
1021 ids: track.keys().first(),
1022 effect: Effect.Morph,
1023 options: { style: data }
1024 }));
1025 }.bind(this));
1026 return this;
1027 },
1028 play: function(){
1029 return new Effect.Parallel(
1030 this.tracks.map(function(track){
1031 var ids = track.get('ids'), effect = track.get('effect'), options = track.get('options');
1032 var elements = [$(ids) || $$(ids)].flatten();
1033 return elements.map(function(e){ return new effect(e, Object.extend({ sync:true }, options)) });
1034 }).flatten(),
1035 this.options
1036 );
1037 }
1038});
1039
1040Element.CSS_PROPERTIES = $w(
1041 'backgroundColor backgroundPosition borderBottomColor borderBottomStyle ' +
1042 'borderBottomWidth borderLeftColor borderLeftStyle borderLeftWidth ' +
1043 'borderRightColor borderRightStyle borderRightWidth borderSpacing ' +
1044 'borderTopColor borderTopStyle borderTopWidth bottom clip color ' +
1045 'fontSize fontWeight height left letterSpacing lineHeight ' +
1046 'marginBottom marginLeft marginRight marginTop markerOffset maxHeight '+
1047 'maxWidth minHeight minWidth opacity outlineColor outlineOffset ' +
1048 'outlineWidth paddingBottom paddingLeft paddingRight paddingTop ' +
1049 'right textIndent top width wordSpacing zIndex');
1050
1051Element.CSS_LENGTH = /^(([\+\-]?[0-9\.]+)(em|ex|px|in|cm|mm|pt|pc|\%))|0$/;
1052
1053String.__parseStyleElement = document.createElement('div');
1054String.prototype.parseStyle = function(){
1055 var style, styleRules = $H();
1056 if (Prototype.Browser.WebKit)
1057 style = new Element('div',{style:this}).style;
1058 else {
1059 String.__parseStyleElement.innerHTML = '<div style="' + this + '"></div>';
1060 style = String.__parseStyleElement.childNodes[0].style;
1061 }
1062
1063 Element.CSS_PROPERTIES.each(function(property){
1064 if (style[property]) styleRules.set(property, style[property]);
1065 });
1066
1067 if (Prototype.Browser.IE && this.include('opacity'))
1068 styleRules.set('opacity', this.match(/opacity:\s*((?:0|1)?(?:\.\d*)?)/)[1]);
1069
1070 return styleRules;
1071};
1072
1073if (document.defaultView && document.defaultView.getComputedStyle) {
1074 Element.getStyles = function(element) {
1075 var css = document.defaultView.getComputedStyle($(element), null);
1076 return Element.CSS_PROPERTIES.inject({ }, function(styles, property) {
1077 styles[property] = css[property];
1078 return styles;
1079 });
1080 };
1081} else {
1082 Element.getStyles = function(element) {
1083 element = $(element);
1084 var css = element.currentStyle, styles;
1085 styles = Element.CSS_PROPERTIES.inject({ }, function(results, property) {
1086 results[property] = css[property];
1087 return results;
1088 });
1089 if (!styles.opacity) styles.opacity = element.getOpacity();
1090 return styles;
1091 };
1092}
1093
1094Effect.Methods = {
1095 morph: function(element, style) {
1096 element = $(element);
1097 new Effect.Morph(element, Object.extend({ style: style }, arguments[2] || { }));
1098 return element;
1099 },
1100 visualEffect: function(element, effect, options) {
1101 element = $(element);
1102 var s = effect.dasherize().camelize(), klass = s.charAt(0).toUpperCase() + s.substring(1);
1103 new Effect[klass](element, options);
1104 return element;
1105 },
1106 highlight: function(element, options) {
1107 element = $(element);
1108 new Effect.Highlight(element, options);
1109 return element;
1110 }
1111};
1112
1113$w('fade appear grow shrink fold blindUp blindDown slideUp slideDown '+
1114 'pulsate shake puff squish switchOff dropOut').each(
1115 function(effect) {
1116 Effect.Methods[effect] = function(element, options){
1117 element = $(element);
1118 Effect[effect.charAt(0).toUpperCase() + effect.substring(1)](element, options);
1119 return element;
1120 };
1121 }
1122);
1123
1124$w('getInlineOpacity forceRerendering setContentZoom collectTextNodes collectTextNodesIgnoreClass getStyles').each(
1125 function(f) { Effect.Methods[f] = Element[f]; }
1126);
1127
1128Element.addMethods(Effect.Methods); \ No newline at end of file
diff --git a/public/javascripts/prototype.js b/public/javascripts/prototype.js
deleted file mode 100644
index dfe8ab4..0000000
--- a/public/javascripts/prototype.js
+++ /dev/null
@@ -1,4320 +0,0 @@
1/* Prototype JavaScript framework, version 1.6.0.3
2 * (c) 2005-2008 Sam Stephenson
3 *
4 * Prototype is freely distributable under the terms of an MIT-style license.
5 * For details, see the Prototype web site: http://www.prototypejs.org/
6 *
7 *--------------------------------------------------------------------------*/
8
9var Prototype = {
10 Version: '1.6.0.3',
11
12 Browser: {
13 IE: !!(window.attachEvent &&
14 navigator.userAgent.indexOf('Opera') === -1),
15 Opera: navigator.userAgent.indexOf('Opera') > -1,
16 WebKit: navigator.userAgent.indexOf('AppleWebKit/') > -1,
17 Gecko: navigator.userAgent.indexOf('Gecko') > -1 &&
18 navigator.userAgent.indexOf('KHTML') === -1,
19 MobileSafari: !!navigator.userAgent.match(/Apple.*Mobile.*Safari/)
20 },
21
22 BrowserFeatures: {
23 XPath: !!document.evaluate,
24 SelectorsAPI: !!document.querySelector,
25 ElementExtensions: !!window.HTMLElement,
26 SpecificElementExtensions:
27 document.createElement('div')['__proto__'] &&
28 document.createElement('div')['__proto__'] !==
29 document.createElement('form')['__proto__']
30 },
31
32 ScriptFragment: '<script[^>]*>([\\S\\s]*?)<\/script>',
33 JSONFilter: /^\/\*-secure-([\s\S]*)\*\/\s*$/,
34
35 emptyFunction: function() { },
36 K: function(x) { return x }
37};
38
39if (Prototype.Browser.MobileSafari)
40 Prototype.BrowserFeatures.SpecificElementExtensions = false;
41
42
43/* Based on Alex Arnell's inheritance implementation. */
44var Class = {
45 create: function() {
46 var parent = null, properties = $A(arguments);
47 if (Object.isFunction(properties[0]))
48 parent = properties.shift();
49
50 function klass() {
51 this.initialize.apply(this, arguments);
52 }
53
54 Object.extend(klass, Class.Methods);
55 klass.superclass = parent;
56 klass.subclasses = [];
57
58 if (parent) {
59 var subclass = function() { };
60 subclass.prototype = parent.prototype;
61 klass.prototype = new subclass;
62 parent.subclasses.push(klass);
63 }
64
65 for (var i = 0; i < properties.length; i++)
66 klass.addMethods(properties[i]);
67
68 if (!klass.prototype.initialize)
69 klass.prototype.initialize = Prototype.emptyFunction;
70
71 klass.prototype.constructor = klass;
72
73 return klass;
74 }
75};
76
77Class.Methods = {
78 addMethods: function(source) {
79 var ancestor = this.superclass && this.superclass.prototype;
80 var properties = Object.keys(source);
81
82 if (!Object.keys({ toString: true }).length)
83 properties.push("toString", "valueOf");
84
85 for (var i = 0, length = properties.length; i < length; i++) {
86 var property = properties[i], value = source[property];
87 if (ancestor && Object.isFunction(value) &&
88 value.argumentNames().first() == "$super") {
89 var method = value;
90 value = (function(m) {
91 return function() { return ancestor[m].apply(this, arguments) };
92 })(property).wrap(method);
93
94 value.valueOf = method.valueOf.bind(method);
95 value.toString = method.toString.bind(method);
96 }
97 this.prototype[property] = value;
98 }
99
100 return this;
101 }
102};
103
104var Abstract = { };
105
106Object.extend = function(destination, source) {
107 for (var property in source)
108 destination[property] = source[property];
109 return destination;
110};
111
112Object.extend(Object, {
113 inspect: function(object) {
114 try {
115 if (Object.isUndefined(object)) return 'undefined';
116 if (object === null) return 'null';
117 return object.inspect ? object.inspect() : String(object);
118 } catch (e) {
119 if (e instanceof RangeError) return '...';
120 throw e;
121 }
122 },
123
124 toJSON: function(object) {
125 var type = typeof object;
126 switch (type) {
127 case 'undefined':
128 case 'function':
129 case 'unknown': return;
130 case 'boolean': return object.toString();
131 }
132
133 if (object === null) return 'null';
134 if (object.toJSON) return object.toJSON();
135 if (Object.isElement(object)) return;
136
137 var results = [];
138 for (var property in object) {
139 var value = Object.toJSON(object[property]);
140 if (!Object.isUndefined(value))
141 results.push(property.toJSON() + ': ' + value);
142 }
143
144 return '{' + results.join(', ') + '}';
145 },
146
147 toQueryString: function(object) {
148 return $H(object).toQueryString();
149 },
150
151 toHTML: function(object) {
152 return object && object.toHTML ? object.toHTML() : String.interpret(object);
153 },
154
155 keys: function(object) {
156 var keys = [];
157 for (var property in object)
158 keys.push(property);
159 return keys;
160 },
161
162 values: function(object) {
163 var values = [];
164 for (var property in object)
165 values.push(object[property]);
166 return values;
167 },
168
169 clone: function(object) {
170 return Object.extend({ }, object);
171 },
172
173 isElement: function(object) {
174 return !!(object && object.nodeType == 1);
175 },
176
177 isArray: function(object) {
178 return object != null && typeof object == "object" &&
179 'splice' in object && 'join' in object;
180 },
181
182 isHash: function(object) {
183 return object instanceof Hash;
184 },
185
186 isFunction: function(object) {
187 return typeof object == "function";
188 },
189
190 isString: function(object) {
191 return typeof object == "string";
192 },
193
194 isNumber: function(object) {
195 return typeof object == "number";
196 },
197
198 isUndefined: function(object) {
199 return typeof object == "undefined";
200 }
201});
202
203Object.extend(Function.prototype, {
204 argumentNames: function() {
205 var names = this.toString().match(/^[\s\(]*function[^(]*\(([^\)]*)\)/)[1]
206 .replace(/\s+/g, '').split(',');
207 return names.length == 1 && !names[0] ? [] : names;
208 },
209
210 bind: function() {
211 if (arguments.length < 2 && Object.isUndefined(arguments[0])) return this;
212 var __method = this, args = $A(arguments), object = args.shift();
213 return function() {
214 return __method.apply(object, args.concat($A(arguments)));
215 }
216 },
217
218 bindAsEventListener: function() {
219 var __method = this, args = $A(arguments), object = args.shift();
220 return function(event) {
221 return __method.apply(object, [event || window.event].concat(args));
222 }
223 },
224
225 curry: function() {
226 if (!arguments.length) return this;
227 var __method = this, args = $A(arguments);
228 return function() {
229 return __method.apply(this, args.concat($A(arguments)));
230 }
231 },
232
233 delay: function() {
234 var __method = this, args = $A(arguments), timeout = args.shift() * 1000;
235 return window.setTimeout(function() {
236 return __method.apply(__method, args);
237 }, timeout);
238 },
239
240 defer: function() {
241 var args = [0.01].concat($A(arguments));
242 return this.delay.apply(this, args);
243 },
244
245 wrap: function(wrapper) {
246 var __method = this;
247 return function() {
248 return wrapper.apply(this, [__method.bind(this)].concat($A(arguments)));
249 }
250 },
251
252 methodize: function() {
253 if (this._methodized) return this._methodized;
254 var __method = this;
255 return this._methodized = function() {
256 return __method.apply(null, [this].concat($A(arguments)));
257 };
258 }
259});
260
261Date.prototype.toJSON = function() {
262 return '"' + this.getUTCFullYear() + '-' +
263 (this.getUTCMonth() + 1).toPaddedString(2) + '-' +
264 this.getUTCDate().toPaddedString(2) + 'T' +
265 this.getUTCHours().toPaddedString(2) + ':' +
266 this.getUTCMinutes().toPaddedString(2) + ':' +
267 this.getUTCSeconds().toPaddedString(2) + 'Z"';
268};
269
270var Try = {
271 these: function() {
272 var returnValue;
273
274 for (var i = 0, length = arguments.length; i < length; i++) {
275 var lambda = arguments[i];
276 try {
277 returnValue = lambda();
278 break;
279 } catch (e) { }
280 }
281
282 return returnValue;
283 }
284};
285
286RegExp.prototype.match = RegExp.prototype.test;
287
288RegExp.escape = function(str) {
289 return String(str).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
290};
291
292/*--------------------------------------------------------------------------*/
293
294var PeriodicalExecuter = Class.create({
295 initialize: function(callback, frequency) {
296 this.callback = callback;
297 this.frequency = frequency;
298 this.currentlyExecuting = false;
299
300 this.registerCallback();
301 },
302
303 registerCallback: function() {
304 this.timer = setInterval(this.onTimerEvent.bind(this), this.frequency * 1000);
305 },
306
307 execute: function() {
308 this.callback(this);
309 },
310
311 stop: function() {
312 if (!this.timer) return;
313 clearInterval(this.timer);
314 this.timer = null;
315 },
316
317 onTimerEvent: function() {
318 if (!this.currentlyExecuting) {
319 try {
320 this.currentlyExecuting = true;
321 this.execute();
322 } finally {
323 this.currentlyExecuting = false;
324 }
325 }
326 }
327});
328Object.extend(String, {
329 interpret: function(value) {
330 return value == null ? '' : String(value);
331 },
332 specialChar: {
333 '\b': '\\b',
334 '\t': '\\t',
335 '\n': '\\n',
336 '\f': '\\f',
337 '\r': '\\r',
338 '\\': '\\\\'
339 }
340});
341
342Object.extend(String.prototype, {
343 gsub: function(pattern, replacement) {
344 var result = '', source = this, match;
345 replacement = arguments.callee.prepareReplacement(replacement);
346
347 while (source.length > 0) {
348 if (match = source.match(pattern)) {
349 result += source.slice(0, match.index);
350 result += String.interpret(replacement(match));
351 source = source.slice(match.index + match[0].length);
352 } else {
353 result += source, source = '';
354 }
355 }
356 return result;
357 },
358
359 sub: function(pattern, replacement, count) {
360 replacement = this.gsub.prepareReplacement(replacement);
361 count = Object.isUndefined(count) ? 1 : count;
362
363 return this.gsub(pattern, function(match) {
364 if (--count < 0) return match[0];
365 return replacement(match);
366 });
367 },
368
369 scan: function(pattern, iterator) {
370 this.gsub(pattern, iterator);
371 return String(this);
372 },
373
374 truncate: function(length, truncation) {
375 length = length || 30;
376 truncation = Object.isUndefined(truncation) ? '...' : truncation;
377 return this.length > length ?
378 this.slice(0, length - truncation.length) + truncation : String(this);
379 },
380
381 strip: function() {
382 return this.replace(/^\s+/, '').replace(/\s+$/, '');
383 },
384
385 stripTags: function() {
386 return this.replace(/<\/?[^>]+>/gi, '');
387 },
388
389 stripScripts: function() {
390 return this.replace(new RegExp(Prototype.ScriptFragment, 'img'), '');
391 },
392
393 extractScripts: function() {
394 var matchAll = new RegExp(Prototype.ScriptFragment, 'img');
395 var matchOne = new RegExp(Prototype.ScriptFragment, 'im');
396 return (this.match(matchAll) || []).map(function(scriptTag) {
397 return (scriptTag.match(matchOne) || ['', ''])[1];
398 });
399 },
400
401 evalScripts: function() {
402 return this.extractScripts().map(function(script) { return eval(script) });
403 },
404
405 escapeHTML: function() {
406 var self = arguments.callee;
407 self.text.data = this;
408 return self.div.innerHTML;
409 },
410
411 unescapeHTML: function() {
412 var div = new Element('div');
413 div.innerHTML = this.stripTags();
414 return div.childNodes[0] ? (div.childNodes.length > 1 ?
415 $A(div.childNodes).inject('', function(memo, node) { return memo+node.nodeValue }) :
416 div.childNodes[0].nodeValue) : '';
417 },
418
419 toQueryParams: function(separator) {
420 var match = this.strip().match(/([^?#]*)(#.*)?$/);
421 if (!match) return { };
422
423 return match[1].split(separator || '&').inject({ }, function(hash, pair) {
424 if ((pair = pair.split('='))[0]) {
425 var key = decodeURIComponent(pair.shift());
426 var value = pair.length > 1 ? pair.join('=') : pair[0];
427 if (value != undefined) value = decodeURIComponent(value);
428
429 if (key in hash) {
430 if (!Object.isArray(hash[key])) hash[key] = [hash[key]];
431 hash[key].push(value);
432 }
433 else hash[key] = value;
434 }
435 return hash;
436 });
437 },
438
439 toArray: function() {
440 return this.split('');
441 },
442
443 succ: function() {
444 return this.slice(0, this.length - 1) +
445 String.fromCharCode(this.charCodeAt(this.length - 1) + 1);
446 },
447
448 times: function(count) {
449 return count < 1 ? '' : new Array(count + 1).join(this);
450 },
451
452 camelize: function() {
453 var parts = this.split('-'), len = parts.length;
454 if (len == 1) return parts[0];
455
456 var camelized = this.charAt(0) == '-'
457 ? parts[0].charAt(0).toUpperCase() + parts[0].substring(1)
458 : parts[0];
459
460 for (var i = 1; i < len; i++)
461 camelized += parts[i].charAt(0).toUpperCase() + parts[i].substring(1);
462
463 return camelized;
464 },
465
466 capitalize: function() {
467 return this.charAt(0).toUpperCase() + this.substring(1).toLowerCase();
468 },
469
470 underscore: function() {
471 return this.gsub(/::/, '/').gsub(/([A-Z]+)([A-Z][a-z])/,'#{1}_#{2}').gsub(/([a-z\d])([A-Z])/,'#{1}_#{2}').gsub(/-/,'_').toLowerCase();
472 },
473
474 dasherize: function() {
475 return this.gsub(/_/,'-');
476 },
477
478 inspect: function(useDoubleQuotes) {
479 var escapedString = this.gsub(/[\x00-\x1f\\]/, function(match) {
480 var character = String.specialChar[match[0]];
481 return character ? character : '\\u00' + match[0].charCodeAt().toPaddedString(2, 16);
482 });
483 if (useDoubleQuotes) return '"' + escapedString.replace(/"/g, '\\"') + '"';
484 return "'" + escapedString.replace(/'/g, '\\\'') + "'";
485 },
486
487 toJSON: function() {
488 return this.inspect(true);
489 },
490
491 unfilterJSON: function(filter) {
492 return this.sub(filter || Prototype.JSONFilter, '#{1}');
493 },
494
495 isJSON: function() {
496 var str = this;
497 if (str.blank()) return false;
498 str = this.replace(/\\./g, '@').replace(/"[^"\\\n\r]*"/g, '');
499 return (/^[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]*$/).test(str);
500 },
501
502 evalJSON: function(sanitize) {
503 var json = this.unfilterJSON();
504 try {
505 if (!sanitize || json.isJSON()) return eval('(' + json + ')');
506 } catch (e) { }
507 throw new SyntaxError('Badly formed JSON string: ' + this.inspect());
508 },
509
510 include: function(pattern) {
511 return this.indexOf(pattern) > -1;
512 },
513
514 startsWith: function(pattern) {
515 return this.indexOf(pattern) === 0;
516 },
517
518 endsWith: function(pattern) {
519 var d = this.length - pattern.length;
520 return d >= 0 && this.lastIndexOf(pattern) === d;
521 },
522
523 empty: function() {
524 return this == '';
525 },
526
527 blank: function() {
528 return /^\s*$/.test(this);
529 },
530
531 interpolate: function(object, pattern) {
532 return new Template(this, pattern).evaluate(object);
533 }
534});
535
536if (Prototype.Browser.WebKit || Prototype.Browser.IE) Object.extend(String.prototype, {
537 escapeHTML: function() {
538 return this.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
539 },
540 unescapeHTML: function() {
541 return this.stripTags().replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');
542 }
543});
544
545String.prototype.gsub.prepareReplacement = function(replacement) {
546 if (Object.isFunction(replacement)) return replacement;
547 var template = new Template(replacement);
548 return function(match) { return template.evaluate(match) };
549};
550
551String.prototype.parseQuery = String.prototype.toQueryParams;
552
553Object.extend(String.prototype.escapeHTML, {
554 div: document.createElement('div'),
555 text: document.createTextNode('')
556});
557
558String.prototype.escapeHTML.div.appendChild(String.prototype.escapeHTML.text);
559
560var Template = Class.create({
561 initialize: function(template, pattern) {
562 this.template = template.toString();
563 this.pattern = pattern || Template.Pattern;
564 },
565
566 evaluate: function(object) {
567 if (Object.isFunction(object.toTemplateReplacements))
568 object = object.toTemplateReplacements();
569
570 return this.template.gsub(this.pattern, function(match) {
571 if (object == null) return '';
572
573 var before = match[1] || '';
574 if (before == '\\') return match[2];
575
576 var ctx = object, expr = match[3];
577 var pattern = /^([^.[]+|\[((?:.*?[^\\])?)\])(\.|\[|$)/;
578 match = pattern.exec(expr);
579 if (match == null) return before;
580
581 while (match != null) {
582 var comp = match[1].startsWith('[') ? match[2].gsub('\\\\]', ']') : match[1];
583 ctx = ctx[comp];
584 if (null == ctx || '' == match[3]) break;
585 expr = expr.substring('[' == match[3] ? match[1].length : match[0].length);
586 match = pattern.exec(expr);
587 }
588
589 return before + String.interpret(ctx);
590 });
591 }
592});
593Template.Pattern = /(^|.|\r|\n)(#\{(.*?)\})/;
594
595var $break = { };
596
597var Enumerable = {
598 each: function(iterator, context) {
599 var index = 0;
600 try {
601 this._each(function(value) {
602 iterator.call(context, value, index++);
603 });
604 } catch (e) {
605 if (e != $break) throw e;
606 }
607 return this;
608 },
609
610 eachSlice: function(number, iterator, context) {
611 var index = -number, slices = [], array = this.toArray();
612 if (number < 1) return array;
613 while ((index += number) < array.length)
614 slices.push(array.slice(index, index+number));
615 return slices.collect(iterator, context);
616 },
617
618 all: function(iterator, context) {
619 iterator = iterator || Prototype.K;
620 var result = true;
621 this.each(function(value, index) {
622 result = result && !!iterator.call(context, value, index);
623 if (!result) throw $break;
624 });
625 return result;
626 },
627
628 any: function(iterator, context) {
629 iterator = iterator || Prototype.K;
630 var result = false;
631 this.each(function(value, index) {
632 if (result = !!iterator.call(context, value, index))
633 throw $break;
634 });
635 return result;
636 },
637
638 collect: function(iterator, context) {
639 iterator = iterator || Prototype.K;
640 var results = [];
641 this.each(function(value, index) {
642 results.push(iterator.call(context, value, index));
643 });
644 return results;
645 },
646
647 detect: function(iterator, context) {
648 var result;
649 this.each(function(value, index) {
650 if (iterator.call(context, value, index)) {
651 result = value;
652 throw $break;
653 }
654 });
655 return result;
656 },
657
658 findAll: function(iterator, context) {
659 var results = [];
660 this.each(function(value, index) {
661 if (iterator.call(context, value, index))
662 results.push(value);
663 });
664 return results;
665 },
666
667 grep: function(filter, iterator, context) {
668 iterator = iterator || Prototype.K;
669 var results = [];
670
671 if (Object.isString(filter))
672 filter = new RegExp(filter);
673
674 this.each(function(value, index) {
675 if (filter.match(value))
676 results.push(iterator.call(context, value, index));
677 });
678 return results;
679 },
680
681 include: function(object) {
682 if (Object.isFunction(this.indexOf))
683 if (this.indexOf(object) != -1) return true;
684
685 var found = false;
686 this.each(function(value) {
687 if (value == object) {
688 found = true;
689 throw $break;
690 }
691 });
692 return found;
693 },
694
695 inGroupsOf: function(number, fillWith) {
696 fillWith = Object.isUndefined(fillWith) ? null : fillWith;
697 return this.eachSlice(number, function(slice) {
698 while(slice.length < number) slice.push(fillWith);
699 return slice;
700 });
701 },
702
703 inject: function(memo, iterator, context) {
704 this.each(function(value, index) {
705 memo = iterator.call(context, memo, value, index);
706 });
707 return memo;
708 },
709
710 invoke: function(method) {
711 var args = $A(arguments).slice(1);
712 return this.map(function(value) {
713 return value[method].apply(value, args);
714 });
715 },
716
717 max: function(iterator, context) {
718 iterator = iterator || Prototype.K;
719 var result;
720 this.each(function(value, index) {
721 value = iterator.call(context, value, index);
722 if (result == null || value >= result)
723 result = value;
724 });
725 return result;
726 },
727
728 min: function(iterator, context) {
729 iterator = iterator || Prototype.K;
730 var result;
731 this.each(function(value, index) {
732 value = iterator.call(context, value, index);
733 if (result == null || value < result)
734 result = value;
735 });
736 return result;
737 },
738
739 partition: function(iterator, context) {
740 iterator = iterator || Prototype.K;
741 var trues = [], falses = [];
742 this.each(function(value, index) {
743 (iterator.call(context, value, index) ?
744 trues : falses).push(value);
745 });
746 return [trues, falses];
747 },
748
749 pluck: function(property) {
750 var results = [];
751 this.each(function(value) {
752 results.push(value[property]);
753 });
754 return results;
755 },
756
757 reject: function(iterator, context) {
758 var results = [];
759 this.each(function(value, index) {
760 if (!iterator.call(context, value, index))
761 results.push(value);
762 });
763 return results;
764 },
765
766 sortBy: function(iterator, context) {
767 return this.map(function(value, index) {
768 return {
769 value: value,
770 criteria: iterator.call(context, value, index)
771 };
772 }).sort(function(left, right) {
773 var a = left.criteria, b = right.criteria;
774 return a < b ? -1 : a > b ? 1 : 0;
775 }).pluck('value');
776 },
777
778 toArray: function() {
779 return this.map();
780 },
781
782 zip: function() {
783 var iterator = Prototype.K, args = $A(arguments);
784 if (Object.isFunction(args.last()))
785 iterator = args.pop();
786
787 var collections = [this].concat(args).map($A);
788 return this.map(function(value, index) {
789 return iterator(collections.pluck(index));
790 });
791 },
792
793 size: function() {
794 return this.toArray().length;
795 },
796
797 inspect: function() {
798 return '#<Enumerable:' + this.toArray().inspect() + '>';
799 }
800};
801
802Object.extend(Enumerable, {
803 map: Enumerable.collect,
804 find: Enumerable.detect,
805 select: Enumerable.findAll,
806 filter: Enumerable.findAll,
807 member: Enumerable.include,
808 entries: Enumerable.toArray,
809 every: Enumerable.all,
810 some: Enumerable.any
811});
812function $A(iterable) {
813 if (!iterable) return [];
814 if (iterable.toArray) return iterable.toArray();
815 var length = iterable.length || 0, results = new Array(length);
816 while (length--) results[length] = iterable[length];
817 return results;
818}
819
820if (Prototype.Browser.WebKit) {
821 $A = function(iterable) {
822 if (!iterable) return [];
823 // In Safari, only use the `toArray` method if it's not a NodeList.
824 // A NodeList is a function, has an function `item` property, and a numeric
825 // `length` property. Adapted from Google Doctype.
826 if (!(typeof iterable === 'function' && typeof iterable.length ===
827 'number' && typeof iterable.item === 'function') && iterable.toArray)
828 return iterable.toArray();
829 var length = iterable.length || 0, results = new Array(length);
830 while (length--) results[length] = iterable[length];
831 return results;
832 };
833}
834
835Array.from = $A;
836
837Object.extend(Array.prototype, Enumerable);
838
839if (!Array.prototype._reverse) Array.prototype._reverse = Array.prototype.reverse;
840
841Object.extend(Array.prototype, {
842 _each: function(iterator) {
843 for (var i = 0, length = this.length; i < length; i++)
844 iterator(this[i]);
845 },
846
847 clear: function() {
848 this.length = 0;
849 return this;
850 },
851
852 first: function() {
853 return this[0];
854 },
855
856 last: function() {
857 return this[this.length - 1];
858 },
859
860 compact: function() {
861 return this.select(function(value) {
862 return value != null;
863 });
864 },
865
866 flatten: function() {
867 return this.inject([], function(array, value) {
868 return array.concat(Object.isArray(value) ?
869 value.flatten() : [value]);
870 });
871 },
872
873 without: function() {
874 var values = $A(arguments);
875 return this.select(function(value) {
876 return !values.include(value);
877 });
878 },
879
880 reverse: function(inline) {
881 return (inline !== false ? this : this.toArray())._reverse();
882 },
883
884 reduce: function() {
885 return this.length > 1 ? this : this[0];
886 },
887
888 uniq: function(sorted) {
889 return this.inject([], function(array, value, index) {
890 if (0 == index || (sorted ? array.last() != value : !array.include(value)))
891 array.push(value);
892 return array;
893 });
894 },
895
896 intersect: function(array) {
897 return this.uniq().findAll(function(item) {
898 return array.detect(function(value) { return item === value });
899 });
900 },
901
902 clone: function() {
903 return [].concat(this);
904 },
905
906 size: function() {
907 return this.length;
908 },
909
910 inspect: function() {
911 return '[' + this.map(Object.inspect).join(', ') + ']';
912 },
913
914 toJSON: function() {
915 var results = [];
916 this.each(function(object) {
917 var value = Object.toJSON(object);
918 if (!Object.isUndefined(value)) results.push(value);
919 });
920 return '[' + results.join(', ') + ']';
921 }
922});
923
924// use native browser JS 1.6 implementation if available
925if (Object.isFunction(Array.prototype.forEach))
926 Array.prototype._each = Array.prototype.forEach;
927
928if (!Array.prototype.indexOf) Array.prototype.indexOf = function(item, i) {
929 i || (i = 0);
930 var length = this.length;
931 if (i < 0) i = length + i;
932 for (; i < length; i++)
933 if (this[i] === item) return i;
934 return -1;
935};
936
937if (!Array.prototype.lastIndexOf) Array.prototype.lastIndexOf = function(item, i) {
938 i = isNaN(i) ? this.length : (i < 0 ? this.length + i : i) + 1;
939 var n = this.slice(0, i).reverse().indexOf(item);
940 return (n < 0) ? n : i - n - 1;
941};
942
943Array.prototype.toArray = Array.prototype.clone;
944
945function $w(string) {
946 if (!Object.isString(string)) return [];
947 string = string.strip();
948 return string ? string.split(/\s+/) : [];
949}
950
951if (Prototype.Browser.Opera){
952 Array.prototype.concat = function() {
953 var array = [];
954 for (var i = 0, length = this.length; i < length; i++) array.push(this[i]);
955 for (var i = 0, length = arguments.length; i < length; i++) {
956 if (Object.isArray(arguments[i])) {
957 for (var j = 0, arrayLength = arguments[i].length; j < arrayLength; j++)
958 array.push(arguments[i][j]);
959 } else {
960 array.push(arguments[i]);
961 }
962 }
963 return array;
964 };
965}
966Object.extend(Number.prototype, {
967 toColorPart: function() {
968 return this.toPaddedString(2, 16);
969 },
970
971 succ: function() {
972 return this + 1;
973 },
974
975 times: function(iterator, context) {
976 $R(0, this, true).each(iterator, context);
977 return this;
978 },
979
980 toPaddedString: function(length, radix) {
981 var string = this.toString(radix || 10);
982 return '0'.times(length - string.length) + string;
983 },
984
985 toJSON: function() {
986 return isFinite(this) ? this.toString() : 'null';
987 }
988});
989
990$w('abs round ceil floor').each(function(method){
991 Number.prototype[method] = Math[method].methodize();
992});
993function $H(object) {
994 return new Hash(object);
995};
996
997var Hash = Class.create(Enumerable, (function() {
998
999 function toQueryPair(key, value) {
1000 if (Object.isUndefined(value)) return key;
1001 return key + '=' + encodeURIComponent(String.interpret(value));
1002 }
1003
1004 return {
1005 initialize: function(object) {
1006 this._object = Object.isHash(object) ? object.toObject() : Object.clone(object);
1007 },
1008
1009 _each: function(iterator) {
1010 for (var key in this._object) {
1011 var value = this._object[key], pair = [key, value];
1012 pair.key = key;
1013 pair.value = value;
1014 iterator(pair);
1015 }
1016 },
1017
1018 set: function(key, value) {
1019 return this._object[key] = value;
1020 },
1021
1022 get: function(key) {
1023 // simulating poorly supported hasOwnProperty
1024 if (this._object[key] !== Object.prototype[key])
1025 return this._object[key];
1026 },
1027
1028 unset: function(key) {
1029 var value = this._object[key];
1030 delete this._object[key];
1031 return value;
1032 },
1033
1034 toObject: function() {
1035 return Object.clone(this._object);
1036 },
1037
1038 keys: function() {
1039 return this.pluck('key');
1040 },
1041
1042 values: function() {
1043 return this.pluck('value');
1044 },
1045
1046 index: function(value) {
1047 var match = this.detect(function(pair) {
1048 return pair.value === value;
1049 });
1050 return match && match.key;
1051 },
1052
1053 merge: function(object) {
1054 return this.clone().update(object);
1055 },
1056
1057 update: function(object) {
1058 return new Hash(object).inject(this, function(result, pair) {
1059 result.set(pair.key, pair.value);
1060 return result;
1061 });
1062 },
1063
1064 toQueryString: function() {
1065 return this.inject([], function(results, pair) {
1066 var key = encodeURIComponent(pair.key), values = pair.value;
1067
1068 if (values && typeof values == 'object') {
1069 if (Object.isArray(values))
1070 return results.concat(values.map(toQueryPair.curry(key)));
1071 } else results.push(toQueryPair(key, values));
1072 return results;
1073 }).join('&');
1074 },
1075
1076 inspect: function() {
1077 return '#<Hash:{' + this.map(function(pair) {
1078 return pair.map(Object.inspect).join(': ');
1079 }).join(', ') + '}>';
1080 },
1081
1082 toJSON: function() {
1083 return Object.toJSON(this.toObject());
1084 },
1085
1086 clone: function() {
1087 return new Hash(this);
1088 }
1089 }
1090})());
1091
1092Hash.prototype.toTemplateReplacements = Hash.prototype.toObject;
1093Hash.from = $H;
1094var ObjectRange = Class.create(Enumerable, {
1095 initialize: function(start, end, exclusive) {
1096 this.start = start;
1097 this.end = end;
1098 this.exclusive = exclusive;
1099 },
1100
1101 _each: function(iterator) {
1102 var value = this.start;
1103 while (this.include(value)) {
1104 iterator(value);
1105 value = value.succ();
1106 }
1107 },
1108
1109 include: function(value) {
1110 if (value < this.start)
1111 return false;
1112 if (this.exclusive)
1113 return value < this.end;
1114 return value <= this.end;
1115 }
1116});
1117
1118var $R = function(start, end, exclusive) {
1119 return new ObjectRange(start, end, exclusive);
1120};
1121
1122var Ajax = {
1123 getTransport: function() {
1124 return Try.these(
1125 function() {return new XMLHttpRequest()},
1126 function() {return new ActiveXObject('Msxml2.XMLHTTP')},
1127 function() {return new ActiveXObject('Microsoft.XMLHTTP')}
1128 ) || false;
1129 },
1130
1131 activeRequestCount: 0
1132};
1133
1134Ajax.Responders = {
1135 responders: [],
1136
1137 _each: function(iterator) {
1138 this.responders._each(iterator);
1139 },
1140
1141 register: function(responder) {
1142 if (!this.include(responder))
1143 this.responders.push(responder);
1144 },
1145
1146 unregister: function(responder) {
1147 this.responders = this.responders.without(responder);
1148 },
1149
1150 dispatch: function(callback, request, transport, json) {
1151 this.each(function(responder) {
1152 if (Object.isFunction(responder[callback])) {
1153 try {
1154 responder[callback].apply(responder, [request, transport, json]);
1155 } catch (e) { }
1156 }
1157 });
1158 }
1159};
1160
1161Object.extend(Ajax.Responders, Enumerable);
1162
1163Ajax.Responders.register({
1164 onCreate: function() { Ajax.activeRequestCount++ },
1165 onComplete: function() { Ajax.activeRequestCount-- }
1166});
1167
1168Ajax.Base = Class.create({
1169 initialize: function(options) {
1170 this.options = {
1171 method: 'post',
1172 asynchronous: true,
1173 contentType: 'application/x-www-form-urlencoded',
1174 encoding: 'UTF-8',
1175 parameters: '',
1176 evalJSON: true,
1177 evalJS: true
1178 };
1179 Object.extend(this.options, options || { });
1180
1181 this.options.method = this.options.method.toLowerCase();
1182
1183 if (Object.isString(this.options.parameters))
1184 this.options.parameters = this.options.parameters.toQueryParams();
1185 else if (Object.isHash(this.options.parameters))
1186 this.options.parameters = this.options.parameters.toObject();
1187 }
1188});
1189
1190Ajax.Request = Class.create(Ajax.Base, {
1191 _complete: false,
1192
1193 initialize: function($super, url, options) {
1194 $super(options);
1195 this.transport = Ajax.getTransport();
1196 this.request(url);
1197 },
1198
1199 request: function(url) {
1200 this.url = url;
1201 this.method = this.options.method;
1202 var params = Object.clone(this.options.parameters);
1203
1204 if (!['get', 'post'].include(this.method)) {
1205 // simulate other verbs over post
1206 params['_method'] = this.method;
1207 this.method = 'post';
1208 }
1209
1210 this.parameters = params;
1211
1212 if (params = Object.toQueryString(params)) {
1213 // when GET, append parameters to URL
1214 if (this.method == 'get')
1215 this.url += (this.url.include('?') ? '&' : '?') + params;
1216 else if (/Konqueror|Safari|KHTML/.test(navigator.userAgent))
1217 params += '&_=';
1218 }
1219
1220 try {
1221 var response = new Ajax.Response(this);
1222 if (this.options.onCreate) this.options.onCreate(response);
1223 Ajax.Responders.dispatch('onCreate', this, response);
1224
1225 this.transport.open(this.method.toUpperCase(), this.url,
1226 this.options.asynchronous);
1227
1228 if (this.options.asynchronous) this.respondToReadyState.bind(this).defer(1);
1229
1230 this.transport.onreadystatechange = this.onStateChange.bind(this);
1231 this.setRequestHeaders();
1232
1233 this.body = this.method == 'post' ? (this.options.postBody || params) : null;
1234 this.transport.send(this.body);
1235
1236 /* Force Firefox to handle ready state 4 for synchronous requests */
1237 if (!this.options.asynchronous && this.transport.overrideMimeType)
1238 this.onStateChange();
1239
1240 }
1241 catch (e) {
1242 this.dispatchException(e);
1243 }
1244 },
1245
1246 onStateChange: function() {
1247 var readyState = this.transport.readyState;
1248 if (readyState > 1 && !((readyState == 4) && this._complete))
1249 this.respondToReadyState(this.transport.readyState);
1250 },
1251
1252 setRequestHeaders: function() {
1253 var headers = {
1254 'X-Requested-With': 'XMLHttpRequest',
1255 'X-Prototype-Version': Prototype.Version,
1256 'Accept': 'text/javascript, text/html, application/xml, text/xml, */*'
1257 };
1258
1259 if (this.method == 'post') {
1260 headers['Content-type'] = this.options.contentType +
1261 (this.options.encoding ? '; charset=' + this.options.encoding : '');
1262
1263 /* Force "Connection: close" for older Mozilla browsers to work
1264 * around a bug where XMLHttpRequest sends an incorrect
1265 * Content-length header. See Mozilla Bugzilla #246651.
1266 */
1267 if (this.transport.overrideMimeType &&
1268 (navigator.userAgent.match(/Gecko\/(\d{4})/) || [0,2005])[1] < 2005)
1269 headers['Connection'] = 'close';
1270 }
1271
1272 // user-defined headers
1273 if (typeof this.options.requestHeaders == 'object') {
1274 var extras = this.options.requestHeaders;
1275
1276 if (Object.isFunction(extras.push))
1277 for (var i = 0, length = extras.length; i < length; i += 2)
1278 headers[extras[i]] = extras[i+1];
1279 else
1280 $H(extras).each(function(pair) { headers[pair.key] = pair.value });
1281 }
1282
1283 for (var name in headers)
1284 this.transport.setRequestHeader(name, headers[name]);
1285 },
1286
1287 success: function() {
1288 var status = this.getStatus();
1289 return !status || (status >= 200 && status < 300);
1290 },
1291
1292 getStatus: function() {
1293 try {
1294 return this.transport.status || 0;
1295 } catch (e) { return 0 }
1296 },
1297
1298 respondToReadyState: function(readyState) {
1299 var state = Ajax.Request.Events[readyState], response = new Ajax.Response(this);
1300
1301 if (state == 'Complete') {
1302 try {
1303 this._complete = true;
1304 (this.options['on' + response.status]
1305 || this.options['on' + (this.success() ? 'Success' : 'Failure')]
1306 || Prototype.emptyFunction)(response, response.headerJSON);
1307 } catch (e) {
1308 this.dispatchException(e);
1309 }
1310
1311 var contentType = response.getHeader('Content-type');
1312 if (this.options.evalJS == 'force'
1313 || (this.options.evalJS && this.isSameOrigin() && contentType
1314 && contentType.match(/^\s*(text|application)\/(x-)?(java|ecma)script(;.*)?\s*$/i)))
1315 this.evalResponse();
1316 }
1317
1318 try {
1319 (this.options['on' + state] || Prototype.emptyFunction)(response, response.headerJSON);
1320 Ajax.Responders.dispatch('on' + state, this, response, response.headerJSON);
1321 } catch (e) {
1322 this.dispatchException(e);
1323 }
1324
1325 if (state == 'Complete') {
1326 // avoid memory leak in MSIE: clean up
1327 this.transport.onreadystatechange = Prototype.emptyFunction;
1328 }
1329 },
1330
1331 isSameOrigin: function() {
1332 var m = this.url.match(/^\s*https?:\/\/[^\/]*/);
1333 return !m || (m[0] == '#{protocol}//#{domain}#{port}'.interpolate({
1334 protocol: location.protocol,
1335 domain: document.domain,
1336 port: location.port ? ':' + location.port : ''
1337 }));
1338 },
1339
1340 getHeader: function(name) {
1341 try {
1342 return this.transport.getResponseHeader(name) || null;
1343 } catch (e) { return null }
1344 },
1345
1346 evalResponse: function() {
1347 try {
1348 return eval((this.transport.responseText || '').unfilterJSON());
1349 } catch (e) {
1350 this.dispatchException(e);
1351 }
1352 },
1353
1354 dispatchException: function(exception) {
1355 (this.options.onException || Prototype.emptyFunction)(this, exception);
1356 Ajax.Responders.dispatch('onException', this, exception);
1357 }
1358});
1359
1360Ajax.Request.Events =
1361 ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete'];
1362
1363Ajax.Response = Class.create({
1364 initialize: function(request){
1365 this.request = request;
1366 var transport = this.transport = request.transport,
1367 readyState = this.readyState = transport.readyState;
1368
1369 if((readyState > 2 && !Prototype.Browser.IE) || readyState == 4) {
1370 this.status = this.getStatus();
1371 this.statusText = this.getStatusText();
1372 this.responseText = String.interpret(transport.responseText);
1373 this.headerJSON = this._getHeaderJSON();
1374 }
1375
1376 if(readyState == 4) {
1377 var xml = transport.responseXML;
1378 this.responseXML = Object.isUndefined(xml) ? null : xml;
1379 this.responseJSON = this._getResponseJSON();
1380 }
1381 },
1382
1383 status: 0,
1384 statusText: '',
1385
1386 getStatus: Ajax.Request.prototype.getStatus,
1387
1388 getStatusText: function() {
1389 try {
1390 return this.transport.statusText || '';
1391 } catch (e) { return '' }
1392 },
1393
1394 getHeader: Ajax.Request.prototype.getHeader,
1395
1396 getAllHeaders: function() {
1397 try {
1398 return this.getAllResponseHeaders();
1399 } catch (e) { return null }
1400 },
1401
1402 getResponseHeader: function(name) {
1403 return this.transport.getResponseHeader(name);
1404 },
1405
1406 getAllResponseHeaders: function() {
1407 return this.transport.getAllResponseHeaders();
1408 },
1409
1410 _getHeaderJSON: function() {
1411 var json = this.getHeader('X-JSON');
1412 if (!json) return null;
1413 json = decodeURIComponent(escape(json));
1414 try {
1415 return json.evalJSON(this.request.options.sanitizeJSON ||
1416 !this.request.isSameOrigin());
1417 } catch (e) {
1418 this.request.dispatchException(e);
1419 }
1420 },
1421
1422 _getResponseJSON: function() {
1423 var options = this.request.options;
1424 if (!options.evalJSON || (options.evalJSON != 'force' &&
1425 !(this.getHeader('Content-type') || '').include('application/json')) ||
1426 this.responseText.blank())
1427 return null;
1428 try {
1429 return this.responseText.evalJSON(options.sanitizeJSON ||
1430 !this.request.isSameOrigin());
1431 } catch (e) {
1432 this.request.dispatchException(e);
1433 }
1434 }
1435});
1436
1437Ajax.Updater = Class.create(Ajax.Request, {
1438 initialize: function($super, container, url, options) {
1439 this.container = {
1440 success: (container.success || container),
1441 failure: (container.failure || (container.success ? null : container))
1442 };
1443
1444 options = Object.clone(options);
1445 var onComplete = options.onComplete;
1446 options.onComplete = (function(response, json) {
1447 this.updateContent(response.responseText);
1448 if (Object.isFunction(onComplete)) onComplete(response, json);
1449 }).bind(this);
1450
1451 $super(url, options);
1452 },
1453
1454 updateContent: function(responseText) {
1455 var receiver = this.container[this.success() ? 'success' : 'failure'],
1456 options = this.options;
1457
1458 if (!options.evalScripts) responseText = responseText.stripScripts();
1459
1460 if (receiver = $(receiver)) {
1461 if (options.insertion) {
1462 if (Object.isString(options.insertion)) {
1463 var insertion = { }; insertion[options.insertion] = responseText;
1464 receiver.insert(insertion);
1465 }
1466 else options.insertion(receiver, responseText);
1467 }
1468 else receiver.update(responseText);
1469 }
1470 }
1471});
1472
1473Ajax.PeriodicalUpdater = Class.create(Ajax.Base, {
1474 initialize: function($super, container, url, options) {
1475 $super(options);
1476 this.onComplete = this.options.onComplete;
1477
1478 this.frequency = (this.options.frequency || 2);
1479 this.decay = (this.options.decay || 1);
1480
1481 this.updater = { };
1482 this.container = container;
1483 this.url = url;
1484
1485 this.start();
1486 },
1487
1488 start: function() {
1489 this.options.onComplete = this.updateComplete.bind(this);
1490 this.onTimerEvent();
1491 },
1492
1493 stop: function() {
1494 this.updater.options.onComplete = undefined;
1495 clearTimeout(this.timer);
1496 (this.onComplete || Prototype.emptyFunction).apply(this, arguments);
1497 },
1498
1499 updateComplete: function(response) {
1500 if (this.options.decay) {
1501 this.decay = (response.responseText == this.lastText ?
1502 this.decay * this.options.decay : 1);
1503
1504 this.lastText = response.responseText;
1505 }
1506 this.timer = this.onTimerEvent.bind(this).delay(this.decay * this.frequency);
1507 },
1508
1509 onTimerEvent: function() {
1510 this.updater = new Ajax.Updater(this.container, this.url, this.options);
1511 }
1512});
1513function $(element) {
1514 if (arguments.length > 1) {
1515 for (var i = 0, elements = [], length = arguments.length; i < length; i++)
1516 elements.push($(arguments[i]));
1517 return elements;
1518 }
1519 if (Object.isString(element))
1520 element = document.getElementById(element);
1521 return Element.extend(element);
1522}
1523
1524if (Prototype.BrowserFeatures.XPath) {
1525 document._getElementsByXPath = function(expression, parentElement) {
1526 var results = [];
1527 var query = document.evaluate(expression, $(parentElement) || document,
1528 null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
1529 for (var i = 0, length = query.snapshotLength; i < length; i++)
1530 results.push(Element.extend(query.snapshotItem(i)));
1531 return results;
1532 };
1533}
1534
1535/*--------------------------------------------------------------------------*/
1536
1537if (!window.Node) var Node = { };
1538
1539if (!Node.ELEMENT_NODE) {
1540 // DOM level 2 ECMAScript Language Binding
1541 Object.extend(Node, {
1542 ELEMENT_NODE: 1,
1543 ATTRIBUTE_NODE: 2,
1544 TEXT_NODE: 3,
1545 CDATA_SECTION_NODE: 4,
1546 ENTITY_REFERENCE_NODE: 5,
1547 ENTITY_NODE: 6,
1548 PROCESSING_INSTRUCTION_NODE: 7,
1549 COMMENT_NODE: 8,
1550 DOCUMENT_NODE: 9,
1551 DOCUMENT_TYPE_NODE: 10,
1552 DOCUMENT_FRAGMENT_NODE: 11,
1553 NOTATION_NODE: 12
1554 });
1555}
1556
1557(function() {
1558 var element = this.Element;
1559 this.Element = function(tagName, attributes) {
1560 attributes = attributes || { };
1561 tagName = tagName.toLowerCase();
1562 var cache = Element.cache;
1563 if (Prototype.Browser.IE && attributes.name) {
1564 tagName = '<' + tagName + ' name="' + attributes.name + '">';
1565 delete attributes.name;
1566 return Element.writeAttribute(document.createElement(tagName), attributes);
1567 }
1568 if (!cache[tagName]) cache[tagName] = Element.extend(document.createElement(tagName));
1569 return Element.writeAttribute(cache[tagName].cloneNode(false), attributes);
1570 };
1571 Object.extend(this.Element, element || { });
1572 if (element) this.Element.prototype = element.prototype;
1573}).call(window);
1574
1575Element.cache = { };
1576
1577Element.Methods = {
1578 visible: function(element) {
1579 return $(element).style.display != 'none';
1580 },
1581
1582 toggle: function(element) {
1583 element = $(element);
1584 Element[Element.visible(element) ? 'hide' : 'show'](element);
1585 return element;
1586 },
1587
1588 hide: function(element) {
1589 element = $(element);
1590 element.style.display = 'none';
1591 return element;
1592 },
1593
1594 show: function(element) {
1595 element = $(element);
1596 element.style.display = '';
1597 return element;
1598 },
1599
1600 remove: function(element) {
1601 element = $(element);
1602 element.parentNode.removeChild(element);
1603 return element;
1604 },
1605
1606 update: function(element, content) {
1607 element = $(element);
1608 if (content && content.toElement) content = content.toElement();
1609 if (Object.isElement(content)) return element.update().insert(content);
1610 content = Object.toHTML(content);
1611 element.innerHTML = content.stripScripts();
1612 content.evalScripts.bind(content).defer();
1613 return element;
1614 },
1615
1616 replace: function(element, content) {
1617 element = $(element);
1618 if (content && content.toElement) content = content.toElement();
1619 else if (!Object.isElement(content)) {
1620 content = Object.toHTML(content);
1621 var range = element.ownerDocument.createRange();
1622 range.selectNode(element);
1623 content.evalScripts.bind(content).defer();
1624 content = range.createContextualFragment(content.stripScripts());
1625 }
1626 element.parentNode.replaceChild(content, element);
1627 return element;
1628 },
1629
1630 insert: function(element, insertions) {
1631 element = $(element);
1632
1633 if (Object.isString(insertions) || Object.isNumber(insertions) ||
1634 Object.isElement(insertions) || (insertions && (insertions.toElement || insertions.toHTML)))
1635 insertions = {bottom:insertions};
1636
1637 var content, insert, tagName, childNodes;
1638
1639 for (var position in insertions) {
1640 content = insertions[position];
1641 position = position.toLowerCase();
1642 insert = Element._insertionTranslations[position];
1643
1644 if (content && content.toElement) content = content.toElement();
1645 if (Object.isElement(content)) {
1646 insert(element, content);
1647 continue;
1648 }
1649
1650 content = Object.toHTML(content);
1651
1652 tagName = ((position == 'before' || position == 'after')
1653 ? element.parentNode : element).tagName.toUpperCase();
1654
1655 childNodes = Element._getContentFromAnonymousElement(tagName, content.stripScripts());
1656
1657 if (position == 'top' || position == 'after') childNodes.reverse();
1658 childNodes.each(insert.curry(element));
1659
1660 content.evalScripts.bind(content).defer();
1661 }
1662
1663 return element;
1664 },
1665
1666 wrap: function(element, wrapper, attributes) {
1667 element = $(element);
1668 if (Object.isElement(wrapper))
1669 $(wrapper).writeAttribute(attributes || { });
1670 else if (Object.isString(wrapper)) wrapper = new Element(wrapper, attributes);
1671 else wrapper = new Element('div', wrapper);
1672 if (element.parentNode)
1673 element.parentNode.replaceChild(wrapper, element);
1674 wrapper.appendChild(element);
1675 return wrapper;
1676 },
1677
1678 inspect: function(element) {
1679 element = $(element);
1680 var result = '<' + element.tagName.toLowerCase();
1681 $H({'id': 'id', 'className': 'class'}).each(function(pair) {
1682 var property = pair.first(), attribute = pair.last();
1683 var value = (element[property] || '').toString();
1684 if (value) result += ' ' + attribute + '=' + value.inspect(true);
1685 });
1686 return result + '>';
1687 },
1688
1689 recursivelyCollect: function(element, property) {
1690 element = $(element);
1691 var elements = [];
1692 while (element = element[property])
1693 if (element.nodeType == 1)
1694 elements.push(Element.extend(element));
1695 return elements;
1696 },
1697
1698 ancestors: function(element) {
1699 return $(element).recursivelyCollect('parentNode');
1700 },
1701
1702 descendants: function(element) {
1703 return $(element).select("*");
1704 },
1705
1706 firstDescendant: function(element) {
1707 element = $(element).firstChild;
1708 while (element && element.nodeType != 1) element = element.nextSibling;
1709 return $(element);
1710 },
1711
1712 immediateDescendants: function(element) {
1713 if (!(element = $(element).firstChild)) return [];
1714 while (element && element.nodeType != 1) element = element.nextSibling;
1715 if (element) return [element].concat($(element).nextSiblings());
1716 return [];
1717 },
1718
1719 previousSiblings: function(element) {
1720 return $(element).recursivelyCollect('previousSibling');
1721 },
1722
1723 nextSiblings: function(element) {
1724 return $(element).recursivelyCollect('nextSibling');
1725 },
1726
1727 siblings: function(element) {
1728 element = $(element);
1729 return element.previousSiblings().reverse().concat(element.nextSiblings());
1730 },
1731
1732 match: function(element, selector) {
1733 if (Object.isString(selector))
1734 selector = new Selector(selector);
1735 return selector.match($(element));
1736 },
1737
1738 up: function(element, expression, index) {
1739 element = $(element);
1740 if (arguments.length == 1) return $(element.parentNode);
1741 var ancestors = element.ancestors();
1742 return Object.isNumber(expression) ? ancestors[expression] :
1743 Selector.findElement(ancestors, expression, index);
1744 },
1745
1746 down: function(element, expression, index) {
1747 element = $(element);
1748 if (arguments.length == 1) return element.firstDescendant();
1749 return Object.isNumber(expression) ? element.descendants()[expression] :
1750 Element.select(element, expression)[index || 0];
1751 },
1752
1753 previous: function(element, expression, index) {
1754 element = $(element);
1755 if (arguments.length == 1) return $(Selector.handlers.previousElementSibling(element));
1756 var previousSiblings = element.previousSiblings();
1757 return Object.isNumber(expression) ? previousSiblings[expression] :
1758 Selector.findElement(previousSiblings, expression, index);
1759 },
1760
1761 next: function(element, expression, index) {
1762 element = $(element);
1763 if (arguments.length == 1) return $(Selector.handlers.nextElementSibling(element));
1764 var nextSiblings = element.nextSiblings();
1765 return Object.isNumber(expression) ? nextSiblings[expression] :
1766 Selector.findElement(nextSiblings, expression, index);
1767 },
1768
1769 select: function() {
1770 var args = $A(arguments), element = $(args.shift());
1771 return Selector.findChildElements(element, args);
1772 },
1773
1774 adjacent: function() {
1775 var args = $A(arguments), element = $(args.shift());
1776 return Selector.findChildElements(element.parentNode, args).without(element);
1777 },
1778
1779 identify: function(element) {
1780 element = $(element);
1781 var id = element.readAttribute('id'), self = arguments.callee;
1782 if (id) return id;
1783 do { id = 'anonymous_element_' + self.counter++ } while ($(id));
1784 element.writeAttribute('id', id);
1785 return id;
1786 },
1787
1788 readAttribute: function(element, name) {
1789 element = $(element);
1790 if (Prototype.Browser.IE) {
1791 var t = Element._attributeTranslations.read;
1792 if (t.values[name]) return t.values[name](element, name);
1793 if (t.names[name]) name = t.names[name];
1794 if (name.include(':')) {
1795 return (!element.attributes || !element.attributes[name]) ? null :
1796 element.attributes[name].value;
1797 }
1798 }
1799 return element.getAttribute(name);
1800 },
1801
1802 writeAttribute: function(element, name, value) {
1803 element = $(element);
1804 var attributes = { }, t = Element._attributeTranslations.write;
1805
1806 if (typeof name == 'object') attributes = name;
1807 else attributes[name] = Object.isUndefined(value) ? true : value;
1808
1809 for (var attr in attributes) {
1810 name = t.names[attr] || attr;
1811 value = attributes[attr];
1812 if (t.values[attr]) name = t.values[attr](element, value);
1813 if (value === false || value === null)
1814 element.removeAttribute(name);
1815 else if (value === true)
1816 element.setAttribute(name, name);
1817 else element.setAttribute(name, value);
1818 }
1819 return element;
1820 },
1821
1822 getHeight: function(element) {
1823 return $(element).getDimensions().height;
1824 },
1825
1826 getWidth: function(element) {
1827 return $(element).getDimensions().width;
1828 },
1829
1830 classNames: function(element) {
1831 return new Element.ClassNames(element);
1832 },
1833
1834 hasClassName: function(element, className) {
1835 if (!(element = $(element))) return;
1836 var elementClassName = element.className;
1837 return (elementClassName.length > 0 && (elementClassName == className ||
1838 new RegExp("(^|\\s)" + className + "(\\s|$)").test(elementClassName)));
1839 },
1840
1841 addClassName: function(element, className) {
1842 if (!(element = $(element))) return;
1843 if (!element.hasClassName(className))
1844 element.className += (element.className ? ' ' : '') + className;
1845 return element;
1846 },
1847
1848 removeClassName: function(element, className) {
1849 if (!(element = $(element))) return;
1850 element.className = element.className.replace(
1851 new RegExp("(^|\\s+)" + className + "(\\s+|$)"), ' ').strip();
1852 return element;
1853 },
1854
1855 toggleClassName: function(element, className) {
1856 if (!(element = $(element))) return;
1857 return element[element.hasClassName(className) ?
1858 'removeClassName' : 'addClassName'](className);
1859 },
1860
1861 // removes whitespace-only text node children
1862 cleanWhitespace: function(element) {
1863 element = $(element);
1864 var node = element.firstChild;
1865 while (node) {
1866 var nextNode = node.nextSibling;
1867 if (node.nodeType == 3 && !/\S/.test(node.nodeValue))
1868 element.removeChild(node);
1869 node = nextNode;
1870 }
1871 return element;
1872 },
1873
1874 empty: function(element) {
1875 return $(element).innerHTML.blank();
1876 },
1877
1878 descendantOf: function(element, ancestor) {
1879 element = $(element), ancestor = $(ancestor);
1880
1881 if (element.compareDocumentPosition)
1882 return (element.compareDocumentPosition(ancestor) & 8) === 8;
1883
1884 if (ancestor.contains)
1885 return ancestor.contains(element) && ancestor !== element;
1886
1887 while (element = element.parentNode)
1888 if (element == ancestor) return true;
1889
1890 return false;
1891 },
1892
1893 scrollTo: function(element) {
1894 element = $(element);
1895 var pos = element.cumulativeOffset();
1896 window.scrollTo(pos[0], pos[1]);
1897 return element;
1898 },
1899
1900 getStyle: function(element, style) {
1901 element = $(element);
1902 style = style == 'float' ? 'cssFloat' : style.camelize();
1903 var value = element.style[style];
1904 if (!value || value == 'auto') {
1905 var css = document.defaultView.getComputedStyle(element, null);
1906 value = css ? css[style] : null;
1907 }
1908 if (style == 'opacity') return value ? parseFloat(value) : 1.0;
1909 return value == 'auto' ? null : value;
1910 },
1911
1912 getOpacity: function(element) {
1913 return $(element).getStyle('opacity');
1914 },
1915
1916 setStyle: function(element, styles) {
1917 element = $(element);
1918 var elementStyle = element.style, match;
1919 if (Object.isString(styles)) {
1920 element.style.cssText += ';' + styles;
1921 return styles.include('opacity') ?
1922 element.setOpacity(styles.match(/opacity:\s*(\d?\.?\d*)/)[1]) : element;
1923 }
1924 for (var property in styles)
1925 if (property == 'opacity') element.setOpacity(styles[property]);
1926 else
1927 elementStyle[(property == 'float' || property == 'cssFloat') ?
1928 (Object.isUndefined(elementStyle.styleFloat) ? 'cssFloat' : 'styleFloat') :
1929 property] = styles[property];
1930
1931 return element;
1932 },
1933
1934 setOpacity: function(element, value) {
1935 element = $(element);
1936 element.style.opacity = (value == 1 || value === '') ? '' :
1937 (value < 0.00001) ? 0 : value;
1938 return element;
1939 },
1940
1941 getDimensions: function(element) {
1942 element = $(element);
1943 var display = element.getStyle('display');
1944 if (display != 'none' && display != null) // Safari bug
1945 return {width: element.offsetWidth, height: element.offsetHeight};
1946
1947 // All *Width and *Height properties give 0 on elements with display none,
1948 // so enable the element temporarily
1949 var els = element.style;
1950 var originalVisibility = els.visibility;
1951 var originalPosition = els.position;
1952 var originalDisplay = els.display;
1953 els.visibility = 'hidden';
1954 els.position = 'absolute';
1955 els.display = 'block';
1956 var originalWidth = element.clientWidth;
1957 var originalHeight = element.clientHeight;
1958 els.display = originalDisplay;
1959 els.position = originalPosition;
1960 els.visibility = originalVisibility;
1961 return {width: originalWidth, height: originalHeight};
1962 },
1963
1964 makePositioned: function(element) {
1965 element = $(element);
1966 var pos = Element.getStyle(element, 'position');
1967 if (pos == 'static' || !pos) {
1968 element._madePositioned = true;
1969 element.style.position = 'relative';
1970 // Opera returns the offset relative to the positioning context, when an
1971 // element is position relative but top and left have not been defined
1972 if (Prototype.Browser.Opera) {
1973 element.style.top = 0;
1974 element.style.left = 0;
1975 }
1976 }
1977 return element;
1978 },
1979
1980 undoPositioned: function(element) {
1981 element = $(element);
1982 if (element._madePositioned) {
1983 element._madePositioned = undefined;
1984 element.style.position =
1985 element.style.top =
1986 element.style.left =
1987 element.style.bottom =
1988 element.style.right = '';
1989 }
1990 return element;
1991 },
1992
1993 makeClipping: function(element) {
1994 element = $(element);
1995 if (element._overflow) return element;
1996 element._overflow = Element.getStyle(element, 'overflow') || 'auto';
1997 if (element._overflow !== 'hidden')
1998 element.style.overflow = 'hidden';
1999 return element;
2000 },
2001
2002 undoClipping: function(element) {
2003 element = $(element);
2004 if (!element._overflow) return element;
2005 element.style.overflow = element._overflow == 'auto' ? '' : element._overflow;
2006 element._overflow = null;
2007 return element;
2008 },
2009
2010 cumulativeOffset: function(element) {
2011 var valueT = 0, valueL = 0;
2012 do {
2013 valueT += element.offsetTop || 0;
2014 valueL += element.offsetLeft || 0;
2015 element = element.offsetParent;
2016 } while (element);
2017 return Element._returnOffset(valueL, valueT);
2018 },
2019
2020 positionedOffset: function(element) {
2021 var valueT = 0, valueL = 0;
2022 do {
2023 valueT += element.offsetTop || 0;
2024 valueL += element.offsetLeft || 0;
2025 element = element.offsetParent;
2026 if (element) {
2027 if (element.tagName.toUpperCase() == 'BODY') break;
2028 var p = Element.getStyle(element, 'position');
2029 if (p !== 'static') break;
2030 }
2031 } while (element);
2032 return Element._returnOffset(valueL, valueT);
2033 },
2034
2035 absolutize: function(element) {
2036 element = $(element);
2037 if (element.getStyle('position') == 'absolute') return element;
2038 // Position.prepare(); // To be done manually by Scripty when it needs it.
2039
2040 var offsets = element.positionedOffset();
2041 var top = offsets[1];
2042 var left = offsets[0];
2043 var width = element.clientWidth;
2044 var height = element.clientHeight;
2045
2046 element._originalLeft = left - parseFloat(element.style.left || 0);
2047 element._originalTop = top - parseFloat(element.style.top || 0);
2048 element._originalWidth = element.style.width;
2049 element._originalHeight = element.style.height;
2050
2051 element.style.position = 'absolute';
2052 element.style.top = top + 'px';
2053 element.style.left = left + 'px';
2054 element.style.width = width + 'px';
2055 element.style.height = height + 'px';
2056 return element;
2057 },
2058
2059 relativize: function(element) {
2060 element = $(element);
2061 if (element.getStyle('position') == 'relative') return element;
2062 // Position.prepare(); // To be done manually by Scripty when it needs it.
2063
2064 element.style.position = 'relative';
2065 var top = parseFloat(element.style.top || 0) - (element._originalTop || 0);
2066 var left = parseFloat(element.style.left || 0) - (element._originalLeft || 0);
2067
2068 element.style.top = top + 'px';
2069 element.style.left = left + 'px';
2070 element.style.height = element._originalHeight;
2071 element.style.width = element._originalWidth;
2072 return element;
2073 },
2074
2075 cumulativeScrollOffset: function(element) {
2076 var valueT = 0, valueL = 0;
2077 do {
2078 valueT += element.scrollTop || 0;
2079 valueL += element.scrollLeft || 0;
2080 element = element.parentNode;
2081 } while (element);
2082 return Element._returnOffset(valueL, valueT);
2083 },
2084
2085 getOffsetParent: function(element) {
2086 if (element.offsetParent) return $(element.offsetParent);
2087 if (element == document.body) return $(element);
2088
2089 while ((element = element.parentNode) && element != document.body)
2090 if (Element.getStyle(element, 'position') != 'static')
2091 return $(element);
2092
2093 return $(document.body);
2094 },
2095
2096 viewportOffset: function(forElement) {
2097 var valueT = 0, valueL = 0;
2098
2099 var element = forElement;
2100 do {
2101 valueT += element.offsetTop || 0;
2102 valueL += element.offsetLeft || 0;
2103
2104 // Safari fix
2105 if (element.offsetParent == document.body &&
2106 Element.getStyle(element, 'position') == 'absolute') break;
2107
2108 } while (element = element.offsetParent);
2109
2110 element = forElement;
2111 do {
2112 if (!Prototype.Browser.Opera || (element.tagName && (element.tagName.toUpperCase() == 'BODY'))) {
2113 valueT -= element.scrollTop || 0;
2114 valueL -= element.scrollLeft || 0;
2115 }
2116 } while (element = element.parentNode);
2117
2118 return Element._returnOffset(valueL, valueT);
2119 },
2120
2121 clonePosition: function(element, source) {
2122 var options = Object.extend({
2123 setLeft: true,
2124 setTop: true,
2125 setWidth: true,
2126 setHeight: true,
2127 offsetTop: 0,
2128 offsetLeft: 0
2129 }, arguments[2] || { });
2130
2131 // find page position of source
2132 source = $(source);
2133 var p = source.viewportOffset();
2134
2135 // find coordinate system to use
2136 element = $(element);
2137 var delta = [0, 0];
2138 var parent = null;
2139 // delta [0,0] will do fine with position: fixed elements,
2140 // position:absolute needs offsetParent deltas
2141 if (Element.getStyle(element, 'position') == 'absolute') {
2142 parent = element.getOffsetParent();
2143 delta = parent.viewportOffset();
2144 }
2145
2146 // correct by body offsets (fixes Safari)
2147 if (parent == document.body) {
2148 delta[0] -= document.body.offsetLeft;
2149 delta[1] -= document.body.offsetTop;
2150 }
2151
2152 // set position
2153 if (options.setLeft) element.style.left = (p[0] - delta[0] + options.offsetLeft) + 'px';
2154 if (options.setTop) element.style.top = (p[1] - delta[1] + options.offsetTop) + 'px';
2155 if (options.setWidth) element.style.width = source.offsetWidth + 'px';
2156 if (options.setHeight) element.style.height = source.offsetHeight + 'px';
2157 return element;
2158 }
2159};
2160
2161Element.Methods.identify.counter = 1;
2162
2163Object.extend(Element.Methods, {
2164 getElementsBySelector: Element.Methods.select,
2165 childElements: Element.Methods.immediateDescendants
2166});
2167
2168Element._attributeTranslations = {
2169 write: {
2170 names: {
2171 className: 'class',
2172 htmlFor: 'for'
2173 },
2174 values: { }
2175 }
2176};
2177
2178if (Prototype.Browser.Opera) {
2179 Element.Methods.getStyle = Element.Methods.getStyle.wrap(
2180 function(proceed, element, style) {
2181 switch (style) {
2182 case 'left': case 'top': case 'right': case 'bottom':
2183 if (proceed(element, 'position') === 'static') return null;
2184 case 'height': case 'width':
2185 // returns '0px' for hidden elements; we want it to return null
2186 if (!Element.visible(element)) return null;
2187
2188 // returns the border-box dimensions rather than the content-box
2189 // dimensions, so we subtract padding and borders from the value
2190 var dim = parseInt(proceed(element, style), 10);
2191
2192 if (dim !== element['offset' + style.capitalize()])
2193 return dim + 'px';
2194
2195 var properties;
2196 if (style === 'height') {
2197 properties = ['border-top-width', 'padding-top',
2198 'padding-bottom', 'border-bottom-width'];
2199 }
2200 else {
2201 properties = ['border-left-width', 'padding-left',
2202 'padding-right', 'border-right-width'];
2203 }
2204 return properties.inject(dim, function(memo, property) {
2205 var val = proceed(element, property);
2206 return val === null ? memo : memo - parseInt(val, 10);
2207 }) + 'px';
2208 default: return proceed(element, style);
2209 }
2210 }
2211 );
2212
2213 Element.Methods.readAttribute = Element.Methods.readAttribute.wrap(
2214 function(proceed, element, attribute) {
2215 if (attribute === 'title') return element.title;
2216 return proceed(element, attribute);
2217 }
2218 );
2219}
2220
2221else if (Prototype.Browser.IE) {
2222 // IE doesn't report offsets correctly for static elements, so we change them
2223 // to "relative" to get the values, then change them back.
2224 Element.Methods.getOffsetParent = Element.Methods.getOffsetParent.wrap(
2225 function(proceed, element) {
2226 element = $(element);
2227 // IE throws an error if element is not in document
2228 try { element.offsetParent }
2229 catch(e) { return $(document.body) }
2230 var position = element.getStyle('position');
2231 if (position !== 'static') return proceed(element);
2232 element.setStyle({ position: 'relative' });
2233 var value = proceed(element);
2234 element.setStyle({ position: position });
2235 return value;
2236 }
2237 );
2238
2239 $w('positionedOffset viewportOffset').each(function(method) {
2240 Element.Methods[method] = Element.Methods[method].wrap(
2241 function(proceed, element) {
2242 element = $(element);
2243 try { element.offsetParent }
2244 catch(e) { return Element._returnOffset(0,0) }
2245 var position = element.getStyle('position');
2246 if (position !== 'static') return proceed(element);
2247 // Trigger hasLayout on the offset parent so that IE6 reports
2248 // accurate offsetTop and offsetLeft values for position: fixed.
2249 var offsetParent = element.getOffsetParent();
2250 if (offsetParent && offsetParent.getStyle('position') === 'fixed')
2251 offsetParent.setStyle({ zoom: 1 });
2252 element.setStyle({ position: 'relative' });
2253 var value = proceed(element);
2254 element.setStyle({ position: position });
2255 return value;
2256 }
2257 );
2258 });
2259
2260 Element.Methods.cumulativeOffset = Element.Methods.cumulativeOffset.wrap(
2261 function(proceed, element) {
2262 try { element.offsetParent }
2263 catch(e) { return Element._returnOffset(0,0) }
2264 return proceed(element);
2265 }
2266 );
2267
2268 Element.Methods.getStyle = function(element, style) {
2269 element = $(element);
2270 style = (style == 'float' || style == 'cssFloat') ? 'styleFloat' : style.camelize();
2271 var value = element.style[style];
2272 if (!value && element.currentStyle) value = element.currentStyle[style];
2273
2274 if (style == 'opacity') {
2275 if (value = (element.getStyle('filter') || '').match(/alpha\(opacity=(.*)\)/))
2276 if (value[1]) return parseFloat(value[1]) / 100;
2277 return 1.0;
2278 }
2279
2280 if (value == 'auto') {
2281 if ((style == 'width' || style == 'height') && (element.getStyle('display') != 'none'))
2282 return element['offset' + style.capitalize()] + 'px';
2283 return null;
2284 }
2285 return value;
2286 };
2287
2288 Element.Methods.setOpacity = function(element, value) {
2289 function stripAlpha(filter){
2290 return filter.replace(/alpha\([^\)]*\)/gi,'');
2291 }
2292 element = $(element);
2293 var currentStyle = element.currentStyle;
2294 if ((currentStyle && !currentStyle.hasLayout) ||
2295 (!currentStyle && element.style.zoom == 'normal'))
2296 element.style.zoom = 1;
2297
2298 var filter = element.getStyle('filter'), style = element.style;
2299 if (value == 1 || value === '') {
2300 (filter = stripAlpha(filter)) ?
2301 style.filter = filter : style.removeAttribute('filter');
2302 return element;
2303 } else if (value < 0.00001) value = 0;
2304 style.filter = stripAlpha(filter) +
2305 'alpha(opacity=' + (value * 100) + ')';
2306 return element;
2307 };
2308
2309 Element._attributeTranslations = {
2310 read: {
2311 names: {
2312 'class': 'className',
2313 'for': 'htmlFor'
2314 },
2315 values: {
2316 _getAttr: function(element, attribute) {
2317 return element.getAttribute(attribute, 2);
2318 },
2319 _getAttrNode: function(element, attribute) {
2320 var node = element.getAttributeNode(attribute);
2321 return node ? node.value : "";
2322 },
2323 _getEv: function(element, attribute) {
2324 attribute = element.getAttribute(attribute);
2325 return attribute ? attribute.toString().slice(23, -2) : null;
2326 },
2327 _flag: function(element, attribute) {
2328 return $(element).hasAttribute(attribute) ? attribute : null;
2329 },
2330 style: function(element) {
2331 return element.style.cssText.toLowerCase();
2332 },
2333 title: function(element) {
2334 return element.title;
2335 }
2336 }
2337 }
2338 };
2339
2340 Element._attributeTranslations.write = {
2341 names: Object.extend({
2342 cellpadding: 'cellPadding',
2343 cellspacing: 'cellSpacing'
2344 }, Element._attributeTranslations.read.names),
2345 values: {
2346 checked: function(element, value) {
2347 element.checked = !!value;
2348 },
2349
2350 style: function(element, value) {
2351 element.style.cssText = value ? value : '';
2352 }
2353 }
2354 };
2355
2356 Element._attributeTranslations.has = {};
2357
2358 $w('colSpan rowSpan vAlign dateTime accessKey tabIndex ' +
2359 'encType maxLength readOnly longDesc frameBorder').each(function(attr) {
2360 Element._attributeTranslations.write.names[attr.toLowerCase()] = attr;
2361 Element._attributeTranslations.has[attr.toLowerCase()] = attr;
2362 });
2363
2364 (function(v) {
2365 Object.extend(v, {
2366 href: v._getAttr,
2367 src: v._getAttr,
2368 type: v._getAttr,
2369 action: v._getAttrNode,
2370 disabled: v._flag,
2371 checked: v._flag,
2372 readonly: v._flag,
2373 multiple: v._flag,
2374 onload: v._getEv,
2375 onunload: v._getEv,
2376 onclick: v._getEv,
2377 ondblclick: v._getEv,
2378 onmousedown: v._getEv,
2379 onmouseup: v._getEv,
2380 onmouseover: v._getEv,
2381 onmousemove: v._getEv,
2382 onmouseout: v._getEv,
2383 onfocus: v._getEv,
2384 onblur: v._getEv,
2385 onkeypress: v._getEv,
2386 onkeydown: v._getEv,
2387 onkeyup: v._getEv,
2388 onsubmit: v._getEv,
2389 onreset: v._getEv,
2390 onselect: v._getEv,
2391 onchange: v._getEv
2392 });
2393 })(Element._attributeTranslations.read.values);
2394}
2395
2396else if (Prototype.Browser.Gecko && /rv:1\.8\.0/.test(navigator.userAgent)) {
2397 Element.Methods.setOpacity = function(element, value) {
2398 element = $(element);
2399 element.style.opacity = (value == 1) ? 0.999999 :
2400 (value === '') ? '' : (value < 0.00001) ? 0 : value;
2401 return element;
2402 };
2403}
2404
2405else if (Prototype.Browser.WebKit) {
2406 Element.Methods.setOpacity = function(element, value) {
2407 element = $(element);
2408 element.style.opacity = (value == 1 || value === '') ? '' :
2409 (value < 0.00001) ? 0 : value;
2410
2411 if (value == 1)
2412 if(element.tagName.toUpperCase() == 'IMG' && element.width) {
2413 element.width++; element.width--;
2414 } else try {
2415 var n = document.createTextNode(' ');
2416 element.appendChild(n);
2417 element.removeChild(n);
2418 } catch (e) { }
2419
2420 return element;
2421 };
2422
2423 // Safari returns margins on body which is incorrect if the child is absolutely
2424 // positioned. For performance reasons, redefine Element#cumulativeOffset for
2425 // KHTML/WebKit only.
2426 Element.Methods.cumulativeOffset = function(element) {
2427 var valueT = 0, valueL = 0;
2428 do {
2429 valueT += element.offsetTop || 0;
2430 valueL += element.offsetLeft || 0;
2431 if (element.offsetParent == document.body)
2432 if (Element.getStyle(element, 'position') == 'absolute') break;
2433
2434 element = element.offsetParent;
2435 } while (element);
2436
2437 return Element._returnOffset(valueL, valueT);
2438 };
2439}
2440
2441if (Prototype.Browser.IE || Prototype.Browser.Opera) {
2442 // IE and Opera are missing .innerHTML support for TABLE-related and SELECT elements
2443 Element.Methods.update = function(element, content) {
2444 element = $(element);
2445
2446 if (content && content.toElement) content = content.toElement();
2447 if (Object.isElement(content)) return element.update().insert(content);
2448
2449 content = Object.toHTML(content);
2450 var tagName = element.tagName.toUpperCase();
2451
2452 if (tagName in Element._insertionTranslations.tags) {
2453 $A(element.childNodes).each(function(node) { element.removeChild(node) });
2454 Element._getContentFromAnonymousElement(tagName, content.stripScripts())
2455 .each(function(node) { element.appendChild(node) });
2456 }
2457 else element.innerHTML = content.stripScripts();
2458
2459 content.evalScripts.bind(content).defer();
2460 return element;
2461 };
2462}
2463
2464if ('outerHTML' in document.createElement('div')) {
2465 Element.Methods.replace = function(element, content) {
2466 element = $(element);
2467
2468 if (content && content.toElement) content = content.toElement();
2469 if (Object.isElement(content)) {
2470 element.parentNode.replaceChild(content, element);
2471 return element;
2472 }
2473
2474 content = Object.toHTML(content);
2475 var parent = element.parentNode, tagName = parent.tagName.toUpperCase();
2476
2477 if (Element._insertionTranslations.tags[tagName]) {
2478 var nextSibling = element.next();
2479 var fragments = Element._getContentFromAnonymousElement(tagName, content.stripScripts());
2480 parent.removeChild(element);
2481 if (nextSibling)
2482 fragments.each(function(node) { parent.insertBefore(node, nextSibling) });
2483 else
2484 fragments.each(function(node) { parent.appendChild(node) });
2485 }
2486 else element.outerHTML = content.stripScripts();
2487
2488 content.evalScripts.bind(content).defer();
2489 return element;
2490 };
2491}
2492
2493Element._returnOffset = function(l, t) {
2494 var result = [l, t];
2495 result.left = l;
2496 result.top = t;
2497 return result;
2498};
2499
2500Element._getContentFromAnonymousElement = function(tagName, html) {
2501 var div = new Element('div'), t = Element._insertionTranslations.tags[tagName];
2502 if (t) {
2503 div.innerHTML = t[0] + html + t[1];
2504 t[2].times(function() { div = div.firstChild });
2505 } else div.innerHTML = html;
2506 return $A(div.childNodes);
2507};
2508
2509Element._insertionTranslations = {
2510 before: function(element, node) {
2511 element.parentNode.insertBefore(node, element);
2512 },
2513 top: function(element, node) {
2514 element.insertBefore(node, element.firstChild);
2515 },
2516 bottom: function(element, node) {
2517 element.appendChild(node);
2518 },
2519 after: function(element, node) {
2520 element.parentNode.insertBefore(node, element.nextSibling);
2521 },
2522 tags: {
2523 TABLE: ['<table>', '</table>', 1],
2524 TBODY: ['<table><tbody>', '</tbody></table>', 2],
2525 TR: ['<table><tbody><tr>', '</tr></tbody></table>', 3],
2526 TD: ['<table><tbody><tr><td>', '</td></tr></tbody></table>', 4],
2527 SELECT: ['<select>', '</select>', 1]
2528 }
2529};
2530
2531(function() {
2532 Object.extend(this.tags, {
2533 THEAD: this.tags.TBODY,
2534 TFOOT: this.tags.TBODY,
2535 TH: this.tags.TD
2536 });
2537}).call(Element._insertionTranslations);
2538
2539Element.Methods.Simulated = {
2540 hasAttribute: function(element, attribute) {
2541 attribute = Element._attributeTranslations.has[attribute] || attribute;
2542 var node = $(element).getAttributeNode(attribute);
2543 return !!(node && node.specified);
2544 }
2545};
2546
2547Element.Methods.ByTag = { };
2548
2549Object.extend(Element, Element.Methods);
2550
2551if (!Prototype.BrowserFeatures.ElementExtensions &&
2552 document.createElement('div')['__proto__']) {
2553 window.HTMLElement = { };
2554 window.HTMLElement.prototype = document.createElement('div')['__proto__'];
2555 Prototype.BrowserFeatures.ElementExtensions = true;
2556}
2557
2558Element.extend = (function() {
2559 if (Prototype.BrowserFeatures.SpecificElementExtensions)
2560 return Prototype.K;
2561
2562 var Methods = { }, ByTag = Element.Methods.ByTag;
2563
2564 var extend = Object.extend(function(element) {
2565 if (!element || element._extendedByPrototype ||
2566 element.nodeType != 1 || element == window) return element;
2567
2568 var methods = Object.clone(Methods),
2569 tagName = element.tagName.toUpperCase(), property, value;
2570
2571 // extend methods for specific tags
2572 if (ByTag[tagName]) Object.extend(methods, ByTag[tagName]);
2573
2574 for (property in methods) {
2575 value = methods[property];
2576 if (Object.isFunction(value) && !(property in element))
2577 element[property] = value.methodize();
2578 }
2579
2580 element._extendedByPrototype = Prototype.emptyFunction;
2581 return element;
2582
2583 }, {
2584 refresh: function() {
2585 // extend methods for all tags (Safari doesn't need this)
2586 if (!Prototype.BrowserFeatures.ElementExtensions) {
2587 Object.extend(Methods, Element.Methods);
2588 Object.extend(Methods, Element.Methods.Simulated);
2589 }
2590 }
2591 });
2592
2593 extend.refresh();
2594 return extend;
2595})();
2596
2597Element.hasAttribute = function(element, attribute) {
2598 if (element.hasAttribute) return element.hasAttribute(attribute);
2599 return Element.Methods.Simulated.hasAttribute(element, attribute);
2600};
2601
2602Element.addMethods = function(methods) {
2603 var F = Prototype.BrowserFeatures, T = Element.Methods.ByTag;
2604
2605 if (!methods) {
2606 Object.extend(Form, Form.Methods);
2607 Object.extend(Form.Element, Form.Element.Methods);
2608 Object.extend(Element.Methods.ByTag, {
2609 "FORM": Object.clone(Form.Methods),
2610 "INPUT": Object.clone(Form.Element.Methods),
2611 "SELECT": Object.clone(Form.Element.Methods),
2612 "TEXTAREA": Object.clone(Form.Element.Methods)
2613 });
2614 }
2615
2616 if (arguments.length == 2) {
2617 var tagName = methods;
2618 methods = arguments[1];
2619 }
2620
2621 if (!tagName) Object.extend(Element.Methods, methods || { });
2622 else {
2623 if (Object.isArray(tagName)) tagName.each(extend);
2624 else extend(tagName);
2625 }
2626
2627 function extend(tagName) {
2628 tagName = tagName.toUpperCase();
2629 if (!Element.Methods.ByTag[tagName])
2630 Element.Methods.ByTag[tagName] = { };
2631 Object.extend(Element.Methods.ByTag[tagName], methods);
2632 }
2633
2634 function copy(methods, destination, onlyIfAbsent) {
2635 onlyIfAbsent = onlyIfAbsent || false;
2636 for (var property in methods) {
2637 var value = methods[property];
2638 if (!Object.isFunction(value)) continue;
2639 if (!onlyIfAbsent || !(property in destination))
2640 destination[property] = value.methodize();
2641 }
2642 }
2643
2644 function findDOMClass(tagName) {
2645 var klass;
2646 var trans = {
2647 "OPTGROUP": "OptGroup", "TEXTAREA": "TextArea", "P": "Paragraph",
2648 "FIELDSET": "FieldSet", "UL": "UList", "OL": "OList", "DL": "DList",
2649 "DIR": "Directory", "H1": "Heading", "H2": "Heading", "H3": "Heading",
2650 "H4": "Heading", "H5": "Heading", "H6": "Heading", "Q": "Quote",
2651 "INS": "Mod", "DEL": "Mod", "A": "Anchor", "IMG": "Image", "CAPTION":
2652 "TableCaption", "COL": "TableCol", "COLGROUP": "TableCol", "THEAD":
2653 "TableSection", "TFOOT": "TableSection", "TBODY": "TableSection", "TR":
2654 "TableRow", "TH": "TableCell", "TD": "TableCell", "FRAMESET":
2655 "FrameSet", "IFRAME": "IFrame"
2656 };
2657 if (trans[tagName]) klass = 'HTML' + trans[tagName] + 'Element';
2658 if (window[klass]) return window[klass];
2659 klass = 'HTML' + tagName + 'Element';
2660 if (window[klass]) return window[klass];
2661 klass = 'HTML' + tagName.capitalize() + 'Element';
2662 if (window[klass]) return window[klass];
2663
2664 window[klass] = { };
2665 window[klass].prototype = document.createElement(tagName)['__proto__'];
2666 return window[klass];
2667 }
2668
2669 if (F.ElementExtensions) {
2670 copy(Element.Methods, HTMLElement.prototype);
2671 copy(Element.Methods.Simulated, HTMLElement.prototype, true);
2672 }
2673
2674 if (F.SpecificElementExtensions) {
2675 for (var tag in Element.Methods.ByTag) {
2676 var klass = findDOMClass(tag);
2677 if (Object.isUndefined(klass)) continue;
2678 copy(T[tag], klass.prototype);
2679 }
2680 }
2681
2682 Object.extend(Element, Element.Methods);
2683 delete Element.ByTag;
2684
2685 if (Element.extend.refresh) Element.extend.refresh();
2686 Element.cache = { };
2687};
2688
2689document.viewport = {
2690 getDimensions: function() {
2691 var dimensions = { }, B = Prototype.Browser;
2692 $w('width height').each(function(d) {
2693 var D = d.capitalize();
2694 if (B.WebKit && !document.evaluate) {
2695 // Safari <3.0 needs self.innerWidth/Height
2696 dimensions[d] = self['inner' + D];
2697 } else if (B.Opera && parseFloat(window.opera.version()) < 9.5) {
2698 // Opera <9.5 needs document.body.clientWidth/Height
2699 dimensions[d] = document.body['client' + D]
2700 } else {
2701 dimensions[d] = document.documentElement['client' + D];
2702 }
2703 });
2704 return dimensions;
2705 },
2706
2707 getWidth: function() {
2708 return this.getDimensions().width;
2709 },
2710
2711 getHeight: function() {
2712 return this.getDimensions().height;
2713 },
2714
2715 getScrollOffsets: function() {
2716 return Element._returnOffset(
2717 window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft,
2718 window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop);
2719 }
2720};
2721/* Portions of the Selector class are derived from Jack Slocum's DomQuery,
2722 * part of YUI-Ext version 0.40, distributed under the terms of an MIT-style
2723 * license. Please see http://www.yui-ext.com/ for more information. */
2724
2725var Selector = Class.create({
2726 initialize: function(expression) {
2727 this.expression = expression.strip();
2728
2729 if (this.shouldUseSelectorsAPI()) {
2730 this.mode = 'selectorsAPI';
2731 } else if (this.shouldUseXPath()) {
2732 this.mode = 'xpath';
2733 this.compileXPathMatcher();
2734 } else {
2735 this.mode = "normal";
2736 this.compileMatcher();
2737 }
2738
2739 },
2740
2741 shouldUseXPath: function() {
2742 if (!Prototype.BrowserFeatures.XPath) return false;
2743
2744 var e = this.expression;
2745
2746 // Safari 3 chokes on :*-of-type and :empty
2747 if (Prototype.Browser.WebKit &&
2748 (e.include("-of-type") || e.include(":empty")))
2749 return false;
2750
2751 // XPath can't do namespaced attributes, nor can it read
2752 // the "checked" property from DOM nodes
2753 if ((/(\[[\w-]*?:|:checked)/).test(e))
2754 return false;
2755
2756 return true;
2757 },
2758
2759 shouldUseSelectorsAPI: function() {
2760 if (!Prototype.BrowserFeatures.SelectorsAPI) return false;
2761
2762 if (!Selector._div) Selector._div = new Element('div');
2763
2764 // Make sure the browser treats the selector as valid. Test on an
2765 // isolated element to minimize cost of this check.
2766 try {
2767 Selector._div.querySelector(this.expression);
2768 } catch(e) {
2769 return false;
2770 }
2771
2772 return true;
2773 },
2774
2775 compileMatcher: function() {
2776 var e = this.expression, ps = Selector.patterns, h = Selector.handlers,
2777 c = Selector.criteria, le, p, m;
2778
2779 if (Selector._cache[e]) {
2780 this.matcher = Selector._cache[e];
2781 return;
2782 }
2783
2784 this.matcher = ["this.matcher = function(root) {",
2785 "var r = root, h = Selector.handlers, c = false, n;"];
2786
2787 while (e && le != e && (/\S/).test(e)) {
2788 le = e;
2789 for (var i in ps) {
2790 p = ps[i];
2791 if (m = e.match(p)) {
2792 this.matcher.push(Object.isFunction(c[i]) ? c[i](m) :
2793 new Template(c[i]).evaluate(m));
2794 e = e.replace(m[0], '');
2795 break;
2796 }
2797 }
2798 }
2799
2800 this.matcher.push("return h.unique(n);\n}");
2801 eval(this.matcher.join('\n'));
2802 Selector._cache[this.expression] = this.matcher;
2803 },
2804
2805 compileXPathMatcher: function() {
2806 var e = this.expression, ps = Selector.patterns,
2807 x = Selector.xpath, le, m;
2808
2809 if (Selector._cache[e]) {
2810 this.xpath = Selector._cache[e]; return;
2811 }
2812
2813 this.matcher = ['.//*'];
2814 while (e && le != e && (/\S/).test(e)) {
2815 le = e;
2816 for (var i in ps) {
2817 if (m = e.match(ps[i])) {
2818 this.matcher.push(Object.isFunction(x[i]) ? x[i](m) :
2819 new Template(x[i]).evaluate(m));
2820 e = e.replace(m[0], '');
2821 break;
2822 }
2823 }
2824 }
2825
2826 this.xpath = this.matcher.join('');
2827 Selector._cache[this.expression] = this.xpath;
2828 },
2829
2830 findElements: function(root) {
2831 root = root || document;
2832 var e = this.expression, results;
2833
2834 switch (this.mode) {
2835 case 'selectorsAPI':
2836 // querySelectorAll queries document-wide, then filters to descendants
2837 // of the context element. That's not what we want.
2838 // Add an explicit context to the selector if necessary.
2839 if (root !== document) {
2840 var oldId = root.id, id = $(root).identify();
2841 e = "#" + id + " " + e;
2842 }
2843
2844 results = $A(root.querySelectorAll(e)).map(Element.extend);
2845 root.id = oldId;
2846
2847 return results;
2848 case 'xpath':
2849 return document._getElementsByXPath(this.xpath, root);
2850 default:
2851 return this.matcher(root);
2852 }
2853 },
2854
2855 match: function(element) {
2856 this.tokens = [];
2857
2858 var e = this.expression, ps = Selector.patterns, as = Selector.assertions;
2859 var le, p, m;
2860
2861 while (e && le !== e && (/\S/).test(e)) {
2862 le = e;
2863 for (var i in ps) {
2864 p = ps[i];
2865 if (m = e.match(p)) {
2866 // use the Selector.assertions methods unless the selector
2867 // is too complex.
2868 if (as[i]) {
2869 this.tokens.push([i, Object.clone(m)]);
2870 e = e.replace(m[0], '');
2871 } else {
2872 // reluctantly do a document-wide search
2873 // and look for a match in the array
2874 return this.findElements(document).include(element);
2875 }
2876 }
2877 }
2878 }
2879
2880 var match = true, name, matches;
2881 for (var i = 0, token; token = this.tokens[i]; i++) {
2882 name = token[0], matches = token[1];
2883 if (!Selector.assertions[name](element, matches)) {
2884 match = false; break;
2885 }
2886 }
2887
2888 return match;
2889 },
2890
2891 toString: function() {
2892 return this.expression;
2893 },
2894
2895 inspect: function() {
2896 return "#<Selector:" + this.expression.inspect() + ">";
2897 }
2898});
2899
2900Object.extend(Selector, {
2901 _cache: { },
2902
2903 xpath: {
2904 descendant: "//*",
2905 child: "/*",
2906 adjacent: "/following-sibling::*[1]",
2907 laterSibling: '/following-sibling::*',
2908 tagName: function(m) {
2909 if (m[1] == '*') return '';
2910 return "[local-name()='" + m[1].toLowerCase() +
2911 "' or local-name()='" + m[1].toUpperCase() + "']";
2912 },
2913 className: "[contains(concat(' ', @class, ' '), ' #{1} ')]",
2914 id: "[@id='#{1}']",
2915 attrPresence: function(m) {
2916 m[1] = m[1].toLowerCase();
2917 return new Template("[@#{1}]").evaluate(m);
2918 },
2919 attr: function(m) {
2920 m[1] = m[1].toLowerCase();
2921 m[3] = m[5] || m[6];
2922 return new Template(Selector.xpath.operators[m[2]]).evaluate(m);
2923 },
2924 pseudo: function(m) {
2925 var h = Selector.xpath.pseudos[m[1]];
2926 if (!h) return '';
2927 if (Object.isFunction(h)) return h(m);
2928 return new Template(Selector.xpath.pseudos[m[1]]).evaluate(m);
2929 },
2930 operators: {
2931 '=': "[@#{1}='#{3}']",
2932 '!=': "[@#{1}!='#{3}']",
2933 '^=': "[starts-with(@#{1}, '#{3}')]",
2934 '$=': "[substring(@#{1}, (string-length(@#{1}) - string-length('#{3}') + 1))='#{3}']",
2935 '*=': "[contains(@#{1}, '#{3}')]",
2936 '~=': "[contains(concat(' ', @#{1}, ' '), ' #{3} ')]",
2937 '|=': "[contains(concat('-', @#{1}, '-'), '-#{3}-')]"
2938 },
2939 pseudos: {
2940 'first-child': '[not(preceding-sibling::*)]',
2941 'last-child': '[not(following-sibling::*)]',
2942 'only-child': '[not(preceding-sibling::* or following-sibling::*)]',
2943 'empty': "[count(*) = 0 and (count(text()) = 0)]",
2944 'checked': "[@checked]",
2945 'disabled': "[(@disabled) and (@type!='hidden')]",
2946 'enabled': "[not(@disabled) and (@type!='hidden')]",
2947 'not': function(m) {
2948 var e = m[6], p = Selector.patterns,
2949 x = Selector.xpath, le, v;
2950
2951 var exclusion = [];
2952 while (e && le != e && (/\S/).test(e)) {
2953 le = e;
2954 for (var i in p) {
2955 if (m = e.match(p[i])) {
2956 v = Object.isFunction(x[i]) ? x[i](m) : new Template(x[i]).evaluate(m);
2957 exclusion.push("(" + v.substring(1, v.length - 1) + ")");
2958 e = e.replace(m[0], '');
2959 break;
2960 }
2961 }
2962 }
2963 return "[not(" + exclusion.join(" and ") + ")]";
2964 },
2965 'nth-child': function(m) {
2966 return Selector.xpath.pseudos.nth("(count(./preceding-sibling::*) + 1) ", m);
2967 },
2968 'nth-last-child': function(m) {
2969 return Selector.xpath.pseudos.nth("(count(./following-sibling::*) + 1) ", m);
2970 },
2971 'nth-of-type': function(m) {
2972 return Selector.xpath.pseudos.nth("position() ", m);
2973 },
2974 'nth-last-of-type': function(m) {
2975 return Selector.xpath.pseudos.nth("(last() + 1 - position()) ", m);
2976 },
2977 'first-of-type': function(m) {
2978 m[6] = "1"; return Selector.xpath.pseudos['nth-of-type'](m);
2979 },
2980 'last-of-type': function(m) {
2981 m[6] = "1"; return Selector.xpath.pseudos['nth-last-of-type'](m);
2982 },
2983 'only-of-type': function(m) {
2984 var p = Selector.xpath.pseudos; return p['first-of-type'](m) + p['last-of-type'](m);
2985 },
2986 nth: function(fragment, m) {
2987 var mm, formula = m[6], predicate;
2988 if (formula == 'even') formula = '2n+0';
2989 if (formula == 'odd') formula = '2n+1';
2990 if (mm = formula.match(/^(\d+)$/)) // digit only
2991 return '[' + fragment + "= " + mm[1] + ']';
2992 if (mm = formula.match(/^(-?\d*)?n(([+-])(\d+))?/)) { // an+b
2993 if (mm[1] == "-") mm[1] = -1;
2994 var a = mm[1] ? Number(mm[1]) : 1;
2995 var b = mm[2] ? Number(mm[2]) : 0;
2996 predicate = "[((#{fragment} - #{b}) mod #{a} = 0) and " +
2997 "((#{fragment} - #{b}) div #{a} >= 0)]";
2998 return new Template(predicate).evaluate({
2999 fragment: fragment, a: a, b: b });
3000 }
3001 }
3002 }
3003 },
3004
3005 criteria: {
3006 tagName: 'n = h.tagName(n, r, "#{1}", c); c = false;',
3007 className: 'n = h.className(n, r, "#{1}", c); c = false;',
3008 id: 'n = h.id(n, r, "#{1}", c); c = false;',
3009 attrPresence: 'n = h.attrPresence(n, r, "#{1}", c); c = false;',
3010 attr: function(m) {
3011 m[3] = (m[5] || m[6]);
3012 return new Template('n = h.attr(n, r, "#{1}", "#{3}", "#{2}", c); c = false;').evaluate(m);
3013 },
3014 pseudo: function(m) {
3015 if (m[6]) m[6] = m[6].replace(/"/g, '\\"');
3016 return new Template('n = h.pseudo(n, "#{1}", "#{6}", r, c); c = false;').evaluate(m);
3017 },
3018 descendant: 'c = "descendant";',
3019 child: 'c = "child";',
3020 adjacent: 'c = "adjacent";',
3021 laterSibling: 'c = "laterSibling";'
3022 },
3023
3024 patterns: {
3025 // combinators must be listed first
3026 // (and descendant needs to be last combinator)
3027 laterSibling: /^\s*~\s*/,
3028 child: /^\s*>\s*/,
3029 adjacent: /^\s*\+\s*/,
3030 descendant: /^\s/,
3031
3032 // selectors follow
3033 tagName: /^\s*(\*|[\w\-]+)(\b|$)?/,
3034 id: /^#([\w\-\*]+)(\b|$)/,
3035 className: /^\.([\w\-\*]+)(\b|$)/,
3036 pseudo:
3037/^:((first|last|nth|nth-last|only)(-child|-of-type)|empty|checked|(en|dis)abled|not)(\((.*?)\))?(\b|$|(?=\s|[:+~>]))/,
3038 attrPresence: /^\[((?:[\w]+:)?[\w]+)\]/,
3039 attr: /\[((?:[\w-]*:)?[\w-]+)\s*(?:([!^$*~|]?=)\s*((['"])([^\4]*?)\4|([^'"][^\]]*?)))?\]/
3040 },
3041
3042 // for Selector.match and Element#match
3043 assertions: {
3044 tagName: function(element, matches) {
3045 return matches[1].toUpperCase() == element.tagName.toUpperCase();
3046 },
3047
3048 className: function(element, matches) {
3049 return Element.hasClassName(element, matches[1]);
3050 },
3051
3052 id: function(element, matches) {
3053 return element.id === matches[1];
3054 },
3055
3056 attrPresence: function(element, matches) {
3057 return Element.hasAttribute(element, matches[1]);
3058 },
3059
3060 attr: function(element, matches) {
3061 var nodeValue = Element.readAttribute(element, matches[1]);
3062 return nodeValue && Selector.operators[matches[2]](nodeValue, matches[5] || matches[6]);
3063 }
3064 },
3065
3066 handlers: {
3067 // UTILITY FUNCTIONS
3068 // joins two collections
3069 concat: function(a, b) {
3070 for (var i = 0, node; node = b[i]; i++)
3071 a.push(node);
3072 return a;
3073 },
3074
3075 // marks an array of nodes for counting
3076 mark: function(nodes) {
3077 var _true = Prototype.emptyFunction;
3078 for (var i = 0, node; node = nodes[i]; i++)
3079 node._countedByPrototype = _true;
3080 return nodes;
3081 },
3082
3083 unmark: function(nodes) {
3084 for (var i = 0, node; node = nodes[i]; i++)
3085 node._countedByPrototype = undefined;
3086 return nodes;
3087 },
3088
3089 // mark each child node with its position (for nth calls)
3090 // "ofType" flag indicates whether we're indexing for nth-of-type
3091 // rather than nth-child
3092 index: function(parentNode, reverse, ofType) {
3093 parentNode._countedByPrototype = Prototype.emptyFunction;
3094 if (reverse) {
3095 for (var nodes = parentNode.childNodes, i = nodes.length - 1, j = 1; i >= 0; i--) {
3096 var node = nodes[i];
3097 if (node.nodeType == 1 && (!ofType || node._countedByPrototype)) node.nodeIndex = j++;
3098 }
3099 } else {
3100 for (var i = 0, j = 1, nodes = parentNode.childNodes; node = nodes[i]; i++)
3101 if (node.nodeType == 1 && (!ofType || node._countedByPrototype)) node.nodeIndex = j++;
3102 }
3103 },
3104
3105 // filters out duplicates and extends all nodes
3106 unique: function(nodes) {
3107 if (nodes.length == 0) return nodes;
3108 var results = [], n;
3109 for (var i = 0, l = nodes.length; i < l; i++)
3110 if (!(n = nodes[i])._countedByPrototype) {
3111 n._countedByPrototype = Prototype.emptyFunction;
3112 results.push(Element.extend(n));
3113 }
3114 return Selector.handlers.unmark(results);
3115 },
3116
3117 // COMBINATOR FUNCTIONS
3118 descendant: function(nodes) {
3119 var h = Selector.handlers;
3120 for (var i = 0, results = [], node; node = nodes[i]; i++)
3121 h.concat(results, node.getElementsByTagName('*'));
3122 return results;
3123 },
3124
3125 child: function(nodes) {
3126 var h = Selector.handlers;
3127 for (var i = 0, results = [], node; node = nodes[i]; i++) {
3128 for (var j = 0, child; child = node.childNodes[j]; j++)
3129 if (child.nodeType == 1 && child.tagName != '!') results.push(child);
3130 }
3131 return results;
3132 },
3133
3134 adjacent: function(nodes) {
3135 for (var i = 0, results = [], node; node = nodes[i]; i++) {
3136 var next = this.nextElementSibling(node);
3137 if (next) results.push(next);
3138 }
3139 return results;
3140 },
3141
3142 laterSibling: function(nodes) {
3143 var h = Selector.handlers;
3144 for (var i = 0, results = [], node; node = nodes[i]; i++)
3145 h.concat(results, Element.nextSiblings(node));
3146 return results;
3147 },
3148
3149 nextElementSibling: function(node) {
3150 while (node = node.nextSibling)
3151 if (node.nodeType == 1) return node;
3152 return null;
3153 },
3154
3155 previousElementSibling: function(node) {
3156 while (node = node.previousSibling)
3157 if (node.nodeType == 1) return node;
3158 return null;
3159 },
3160
3161 // TOKEN FUNCTIONS
3162 tagName: function(nodes, root, tagName, combinator) {
3163 var uTagName = tagName.toUpperCase();
3164 var results = [], h = Selector.handlers;
3165 if (nodes) {
3166 if (combinator) {
3167 // fastlane for ordinary descendant combinators
3168 if (combinator == "descendant") {
3169 for (var i = 0, node; node = nodes[i]; i++)
3170 h.concat(results, node.getElementsByTagName(tagName));
3171 return results;
3172 } else nodes = this[combinator](nodes);
3173 if (tagName == "*") return nodes;
3174 }
3175 for (var i = 0, node; node = nodes[i]; i++)
3176 if (node.tagName.toUpperCase() === uTagName) results.push(node);
3177 return results;
3178 } else return root.getElementsByTagName(tagName);
3179 },
3180
3181 id: function(nodes, root, id, combinator) {
3182 var targetNode = $(id), h = Selector.handlers;
3183 if (!targetNode) return [];
3184 if (!nodes && root == document) return [targetNode];
3185 if (nodes) {
3186 if (combinator) {
3187 if (combinator == 'child') {
3188 for (var i = 0, node; node = nodes[i]; i++)
3189 if (targetNode.parentNode == node) return [targetNode];
3190 } else if (combinator == 'descendant') {
3191 for (var i = 0, node; node = nodes[i]; i++)
3192 if (Element.descendantOf(targetNode, node)) return [targetNode];
3193 } else if (combinator == 'adjacent') {
3194 for (var i = 0, node; node = nodes[i]; i++)
3195 if (Selector.handlers.previousElementSibling(targetNode) == node)
3196 return [targetNode];
3197 } else nodes = h[combinator](nodes);
3198 }
3199 for (var i = 0, node; node = nodes[i]; i++)
3200 if (node == targetNode) return [targetNode];
3201 return [];
3202 }
3203 return (targetNode && Element.descendantOf(targetNode, root)) ? [targetNode] : [];
3204 },
3205
3206 className: function(nodes, root, className, combinator) {
3207 if (nodes && combinator) nodes = this[combinator](nodes);
3208 return Selector.handlers.byClassName(nodes, root, className);
3209 },
3210
3211 byClassName: function(nodes, root, className) {
3212 if (!nodes) nodes = Selector.handlers.descendant([root]);
3213 var needle = ' ' + className + ' ';
3214 for (var i = 0, results = [], node, nodeClassName; node = nodes[i]; i++) {
3215 nodeClassName = node.className;
3216 if (nodeClassName.length == 0) continue;
3217 if (nodeClassName == className || (' ' + nodeClassName + ' ').include(needle))
3218 results.push(node);
3219 }
3220 return results;
3221 },
3222
3223 attrPresence: function(nodes, root, attr, combinator) {
3224 if (!nodes) nodes = root.getElementsByTagName("*");
3225 if (nodes && combinator) nodes = this[combinator](nodes);
3226 var results = [];
3227 for (var i = 0, node; node = nodes[i]; i++)
3228 if (Element.hasAttribute(node, attr)) results.push(node);
3229 return results;
3230 },
3231
3232 attr: function(nodes, root, attr, value, operator, combinator) {
3233 if (!nodes) nodes = root.getElementsByTagName("*");
3234 if (nodes && combinator) nodes = this[combinator](nodes);
3235 var handler = Selector.operators[operator], results = [];
3236 for (var i = 0, node; node = nodes[i]; i++) {
3237 var nodeValue = Element.readAttribute(node, attr);
3238 if (nodeValue === null) continue;
3239 if (handler(nodeValue, value)) results.push(node);
3240 }
3241 return results;
3242 },
3243
3244 pseudo: function(nodes, name, value, root, combinator) {
3245 if (nodes && combinator) nodes = this[combinator](nodes);
3246 if (!nodes) nodes = root.getElementsByTagName("*");
3247 return Selector.pseudos[name](nodes, value, root);
3248 }
3249 },
3250
3251 pseudos: {
3252 'first-child': function(nodes, value, root) {
3253 for (var i = 0, results = [], node; node = nodes[i]; i++) {
3254 if (Selector.handlers.previousElementSibling(node)) continue;
3255 results.push(node);
3256 }
3257 return results;
3258 },
3259 'last-child': function(nodes, value, root) {
3260 for (var i = 0, results = [], node; node = nodes[i]; i++) {
3261 if (Selector.handlers.nextElementSibling(node)) continue;
3262 results.push(node);
3263 }
3264 return results;
3265 },
3266 'only-child': function(nodes, value, root) {
3267 var h = Selector.handlers;
3268 for (var i = 0, results = [], node; node = nodes[i]; i++)
3269 if (!h.previousElementSibling(node) && !h.nextElementSibling(node))
3270 results.push(node);
3271 return results;
3272 },
3273 'nth-child': function(nodes, formula, root) {
3274 return Selector.pseudos.nth(nodes, formula, root);
3275 },
3276 'nth-last-child': function(nodes, formula, root) {
3277 return Selector.pseudos.nth(nodes, formula, root, true);
3278 },
3279 'nth-of-type': function(nodes, formula, root) {
3280 return Selector.pseudos.nth(nodes, formula, root, false, true);
3281 },
3282 'nth-last-of-type': function(nodes, formula, root) {
3283 return Selector.pseudos.nth(nodes, formula, root, true, true);
3284 },
3285 'first-of-type': function(nodes, formula, root) {
3286 return Selector.pseudos.nth(nodes, "1", root, false, true);
3287 },
3288 'last-of-type': function(nodes, formula, root) {
3289 return Selector.pseudos.nth(nodes, "1", root, true, true);
3290 },
3291 'only-of-type': function(nodes, formula, root) {
3292 var p = Selector.pseudos;
3293 return p['last-of-type'](p['first-of-type'](nodes, formula, root), formula, root);
3294 },
3295
3296 // handles the an+b logic
3297 getIndices: function(a, b, total) {
3298 if (a == 0) return b > 0 ? [b] : [];
3299 return $R(1, total).inject([], function(memo, i) {
3300 if (0 == (i - b) % a && (i - b) / a >= 0) memo.push(i);
3301 return memo;
3302 });
3303 },
3304
3305 // handles nth(-last)-child, nth(-last)-of-type, and (first|last)-of-type
3306 nth: function(nodes, formula, root, reverse, ofType) {
3307 if (nodes.length == 0) return [];
3308 if (formula == 'even') formula = '2n+0';
3309 if (formula == 'odd') formula = '2n+1';
3310 var h = Selector.handlers, results = [], indexed = [], m;
3311 h.mark(nodes);
3312 for (var i = 0, node; node = nodes[i]; i++) {
3313 if (!node.parentNode._countedByPrototype) {
3314 h.index(node.parentNode, reverse, ofType);
3315 indexed.push(node.parentNode);
3316 }
3317 }
3318 if (formula.match(/^\d+$/)) { // just a number
3319 formula = Number(formula);
3320 for (var i = 0, node; node = nodes[i]; i++)
3321 if (node.nodeIndex == formula) results.push(node);
3322 } else if (m = formula.match(/^(-?\d*)?n(([+-])(\d+))?/)) { // an+b
3323 if (m[1] == "-") m[1] = -1;
3324 var a = m[1] ? Number(m[1]) : 1;
3325 var b = m[2] ? Number(m[2]) : 0;
3326 var indices = Selector.pseudos.getIndices(a, b, nodes.length);
3327 for (var i = 0, node, l = indices.length; node = nodes[i]; i++) {
3328 for (var j = 0; j < l; j++)
3329 if (node.nodeIndex == indices[j]) results.push(node);
3330 }
3331 }
3332 h.unmark(nodes);
3333 h.unmark(indexed);
3334 return results;
3335 },
3336
3337 'empty': function(nodes, value, root) {
3338 for (var i = 0, results = [], node; node = nodes[i]; i++) {
3339 // IE treats comments as element nodes
3340 if (node.tagName == '!' || node.firstChild) continue;
3341 results.push(node);
3342 }
3343 return results;
3344 },
3345
3346 'not': function(nodes, selector, root) {
3347 var h = Selector.handlers, selectorType, m;
3348 var exclusions = new Selector(selector).findElements(root);
3349 h.mark(exclusions);
3350 for (var i = 0, results = [], node; node = nodes[i]; i++)
3351 if (!node._countedByPrototype) results.push(node);
3352 h.unmark(exclusions);
3353 return results;
3354 },
3355
3356 'enabled': function(nodes, value, root) {
3357 for (var i = 0, results = [], node; node = nodes[i]; i++)
3358 if (!node.disabled && (!node.type || node.type !== 'hidden'))
3359 results.push(node);
3360 return results;
3361 },
3362
3363 'disabled': function(nodes, value, root) {
3364 for (var i = 0, results = [], node; node = nodes[i]; i++)
3365 if (node.disabled) results.push(node);
3366 return results;
3367 },
3368
3369 'checked': function(nodes, value, root) {
3370 for (var i = 0, results = [], node; node = nodes[i]; i++)
3371 if (node.checked) results.push(node);
3372 return results;
3373 }
3374 },
3375
3376 operators: {
3377 '=': function(nv, v) { return nv == v; },
3378 '!=': function(nv, v) { return nv != v; },
3379 '^=': function(nv, v) { return nv == v || nv && nv.startsWith(v); },
3380 '$=': function(nv, v) { return nv == v || nv && nv.endsWith(v); },
3381 '*=': function(nv, v) { return nv == v || nv && nv.include(v); },
3382 '$=': function(nv, v) { return nv.endsWith(v); },
3383 '*=': function(nv, v) { return nv.include(v); },
3384 '~=': function(nv, v) { return (' ' + nv + ' ').include(' ' + v + ' '); },
3385 '|=': function(nv, v) { return ('-' + (nv || "").toUpperCase() +
3386 '-').include('-' + (v || "").toUpperCase() + '-'); }
3387 },
3388
3389 split: function(expression) {
3390 var expressions = [];
3391 expression.scan(/(([\w#:.~>+()\s-]+|\*|\[.*?\])+)\s*(,|$)/, function(m) {
3392 expressions.push(m[1].strip());
3393 });
3394 return expressions;
3395 },
3396
3397 matchElements: function(elements, expression) {
3398 var matches = $$(expression), h = Selector.handlers;
3399 h.mark(matches);
3400 for (var i = 0, results = [], element; element = elements[i]; i++)
3401 if (element._countedByPrototype) results.push(element);
3402 h.unmark(matches);
3403 return results;
3404 },
3405
3406 findElement: function(elements, expression, index) {
3407 if (Object.isNumber(expression)) {
3408 index = expression; expression = false;
3409 }
3410 return Selector.matchElements(elements, expression || '*')[index || 0];
3411 },
3412
3413 findChildElements: function(element, expressions) {
3414 expressions = Selector.split(expressions.join(','));
3415 var results = [], h = Selector.handlers;
3416 for (var i = 0, l = expressions.length, selector; i < l; i++) {
3417 selector = new Selector(expressions[i].strip());
3418 h.concat(results, selector.findElements(element));
3419 }
3420 return (l > 1) ? h.unique(results) : results;
3421 }
3422});
3423
3424if (Prototype.Browser.IE) {
3425 Object.extend(Selector.handlers, {
3426 // IE returns comment nodes on getElementsByTagName("*").
3427 // Filter them out.
3428 concat: function(a, b) {
3429 for (var i = 0, node; node = b[i]; i++)
3430 if (node.tagName !== "!") a.push(node);
3431 return a;
3432 },
3433
3434 // IE improperly serializes _countedByPrototype in (inner|outer)HTML.
3435 unmark: function(nodes) {
3436 for (var i = 0, node; node = nodes[i]; i++)
3437 node.removeAttribute('_countedByPrototype');
3438 return nodes;
3439 }
3440 });
3441}
3442
3443function $$() {
3444 return Selector.findChildElements(document, $A(arguments));
3445}
3446var Form = {
3447 reset: function(form) {
3448 $(form).reset();
3449 return form;
3450 },
3451
3452 serializeElements: function(elements, options) {
3453 if (typeof options != 'object') options = { hash: !!options };
3454 else if (Object.isUndefined(options.hash)) options.hash = true;
3455 var key, value, submitted = false, submit = options.submit;
3456
3457 var data = elements.inject({ }, function(result, element) {
3458 if (!element.disabled && element.name) {
3459 key = element.name; value = $(element).getValue();
3460 if (value != null && element.type != 'file' && (element.type != 'submit' || (!submitted &&
3461 submit !== false && (!submit || key == submit) && (submitted = true)))) {
3462 if (key in result) {
3463 // a key is already present; construct an array of values
3464 if (!Object.isArray(result[key])) result[key] = [result[key]];
3465 result[key].push(value);
3466 }
3467 else result[key] = value;
3468 }
3469 }
3470 return result;
3471 });
3472
3473 return options.hash ? data : Object.toQueryString(data);
3474 }
3475};
3476
3477Form.Methods = {
3478 serialize: function(form, options) {
3479 return Form.serializeElements(Form.getElements(form), options);
3480 },
3481
3482 getElements: function(form) {
3483 return $A($(form).getElementsByTagName('*')).inject([],
3484 function(elements, child) {
3485 if (Form.Element.Serializers[child.tagName.toLowerCase()])
3486 elements.push(Element.extend(child));
3487 return elements;
3488 }
3489 );
3490 },
3491
3492 getInputs: function(form, typeName, name) {
3493 form = $(form);
3494 var inputs = form.getElementsByTagName('input');
3495
3496 if (!typeName && !name) return $A(inputs).map(Element.extend);
3497
3498 for (var i = 0, matchingInputs = [], length = inputs.length; i < length; i++) {
3499 var input = inputs[i];
3500 if ((typeName && input.type != typeName) || (name && input.name != name))
3501 continue;
3502 matchingInputs.push(Element.extend(input));
3503 }
3504
3505 return matchingInputs;
3506 },
3507
3508 disable: function(form) {
3509 form = $(form);
3510 Form.getElements(form).invoke('disable');
3511 return form;
3512 },
3513
3514 enable: function(form) {
3515 form = $(form);
3516 Form.getElements(form).invoke('enable');
3517 return form;
3518 },
3519
3520 findFirstElement: function(form) {
3521 var elements = $(form).getElements().findAll(function(element) {
3522 return 'hidden' != element.type && !element.disabled;
3523 });
3524 var firstByIndex = elements.findAll(function(element) {
3525 return element.hasAttribute('tabIndex') && element.tabIndex >= 0;
3526 }).sortBy(function(element) { return element.tabIndex }).first();
3527
3528 return firstByIndex ? firstByIndex : elements.find(function(element) {
3529 return ['input', 'select', 'textarea'].include(element.tagName.toLowerCase());
3530 });
3531 },
3532
3533 focusFirstElement: function(form) {
3534 form = $(form);
3535 form.findFirstElement().activate();
3536 return form;
3537 },
3538
3539 request: function(form, options) {
3540 form = $(form), options = Object.clone(options || { });
3541
3542 var params = options.parameters, action = form.readAttribute('action') || '';
3543 if (action.blank()) action = window.location.href;
3544 options.parameters = form.serialize(true);
3545
3546 if (params) {
3547 if (Object.isString(params)) params = params.toQueryParams();
3548 Object.extend(options.parameters, params);
3549 }
3550
3551 if (form.hasAttribute('method') && !options.method)
3552 options.method = form.method;
3553
3554 return new Ajax.Request(action, options);
3555 }
3556};
3557
3558/*--------------------------------------------------------------------------*/
3559
3560Form.Element = {
3561 focus: function(element) {
3562 $(element).focus();
3563 return element;
3564 },
3565
3566 select: function(element) {
3567 $(element).select();
3568 return element;
3569 }
3570};
3571
3572Form.Element.Methods = {
3573 serialize: function(element) {
3574 element = $(element);
3575 if (!element.disabled && element.name) {
3576 var value = element.getValue();
3577 if (value != undefined) {
3578 var pair = { };
3579 pair[element.name] = value;
3580 return Object.toQueryString(pair);
3581 }
3582 }
3583 return '';
3584 },
3585
3586 getValue: function(element) {
3587 element = $(element);
3588 var method = element.tagName.toLowerCase();
3589 return Form.Element.Serializers[method](element);
3590 },
3591
3592 setValue: function(element, value) {
3593 element = $(element);
3594 var method = element.tagName.toLowerCase();
3595 Form.Element.Serializers[method](element, value);
3596 return element;
3597 },
3598
3599 clear: function(element) {
3600 $(element).value = '';
3601 return element;
3602 },
3603
3604 present: function(element) {
3605 return $(element).value != '';
3606 },
3607
3608 activate: function(element) {
3609 element = $(element);
3610 try {
3611 element.focus();
3612 if (element.select && (element.tagName.toLowerCase() != 'input' ||
3613 !['button', 'reset', 'submit'].include(element.type)))
3614 element.select();
3615 } catch (e) { }
3616 return element;
3617 },
3618
3619 disable: function(element) {
3620 element = $(element);
3621 element.disabled = true;
3622 return element;
3623 },
3624
3625 enable: function(element) {
3626 element = $(element);
3627 element.disabled = false;
3628 return element;
3629 }
3630};
3631
3632/*--------------------------------------------------------------------------*/
3633
3634var Field = Form.Element;
3635var $F = Form.Element.Methods.getValue;
3636
3637/*--------------------------------------------------------------------------*/
3638
3639Form.Element.Serializers = {
3640 input: function(element, value) {
3641 switch (element.type.toLowerCase()) {
3642 case 'checkbox':
3643 case 'radio':
3644 return Form.Element.Serializers.inputSelector(element, value);
3645 default:
3646 return Form.Element.Serializers.textarea(element, value);
3647 }
3648 },
3649
3650 inputSelector: function(element, value) {
3651 if (Object.isUndefined(value)) return element.checked ? element.value : null;
3652 else element.checked = !!value;
3653 },
3654
3655 textarea: function(element, value) {
3656 if (Object.isUndefined(value)) return element.value;
3657 else element.value = value;
3658 },
3659
3660 select: function(element, value) {
3661 if (Object.isUndefined(value))
3662 return this[element.type == 'select-one' ?
3663 'selectOne' : 'selectMany'](element);
3664 else {
3665 var opt, currentValue, single = !Object.isArray(value);
3666 for (var i = 0, length = element.length; i < length; i++) {
3667 opt = element.options[i];
3668 currentValue = this.optionValue(opt);
3669 if (single) {
3670 if (currentValue == value) {
3671 opt.selected = true;
3672 return;
3673 }
3674 }
3675 else opt.selected = value.include(currentValue);
3676 }
3677 }
3678 },
3679
3680 selectOne: function(element) {
3681 var index = element.selectedIndex;
3682 return index >= 0 ? this.optionValue(element.options[index]) : null;
3683 },
3684
3685 selectMany: function(element) {
3686 var values, length = element.length;
3687 if (!length) return null;
3688
3689 for (var i = 0, values = []; i < length; i++) {
3690 var opt = element.options[i];
3691 if (opt.selected) values.push(this.optionValue(opt));
3692 }
3693 return values;
3694 },
3695
3696 optionValue: function(opt) {
3697 // extend element because hasAttribute may not be native
3698 return Element.extend(opt).hasAttribute('value') ? opt.value : opt.text;
3699 }
3700};
3701
3702/*--------------------------------------------------------------------------*/
3703
3704Abstract.TimedObserver = Class.create(PeriodicalExecuter, {
3705 initialize: function($super, element, frequency, callback) {
3706 $super(callback, frequency);
3707 this.element = $(element);
3708 this.lastValue = this.getValue();
3709 },
3710
3711 execute: function() {
3712 var value = this.getValue();
3713 if (Object.isString(this.lastValue) && Object.isString(value) ?
3714 this.lastValue != value : String(this.lastValue) != String(value)) {
3715 this.callback(this.element, value);
3716 this.lastValue = value;
3717 }
3718 }
3719});
3720
3721Form.Element.Observer = Class.create(Abstract.TimedObserver, {
3722 getValue: function() {
3723 return Form.Element.getValue(this.element);
3724 }
3725});
3726
3727Form.Observer = Class.create(Abstract.TimedObserver, {
3728 getValue: function() {
3729 return Form.serialize(this.element);
3730 }
3731});
3732
3733/*--------------------------------------------------------------------------*/
3734
3735Abstract.EventObserver = Class.create({
3736 initialize: function(element, callback) {
3737 this.element = $(element);
3738 this.callback = callback;
3739
3740 this.lastValue = this.getValue();
3741 if (this.element.tagName.toLowerCase() == 'form')
3742 this.registerFormCallbacks();
3743 else
3744 this.registerCallback(this.element);
3745 },
3746
3747 onElementEvent: function() {
3748 var value = this.getValue();
3749 if (this.lastValue != value) {
3750 this.callback(this.element, value);
3751 this.lastValue = value;
3752 }
3753 },
3754
3755 registerFormCallbacks: function() {
3756 Form.getElements(this.element).each(this.registerCallback, this);
3757 },
3758
3759 registerCallback: function(element) {
3760 if (element.type) {
3761 switch (element.type.toLowerCase()) {
3762 case 'checkbox':
3763 case 'radio':
3764 Event.observe(element, 'click', this.onElementEvent.bind(this));
3765 break;
3766 default:
3767 Event.observe(element, 'change', this.onElementEvent.bind(this));
3768 break;
3769 }
3770 }
3771 }
3772});
3773
3774Form.Element.EventObserver = Class.create(Abstract.EventObserver, {
3775 getValue: function() {
3776 return Form.Element.getValue(this.element);
3777 }
3778});
3779
3780Form.EventObserver = Class.create(Abstract.EventObserver, {
3781 getValue: function() {
3782 return Form.serialize(this.element);
3783 }
3784});
3785if (!window.Event) var Event = { };
3786
3787Object.extend(Event, {
3788 KEY_BACKSPACE: 8,
3789 KEY_TAB: 9,
3790 KEY_RETURN: 13,
3791 KEY_ESC: 27,
3792 KEY_LEFT: 37,
3793 KEY_UP: 38,
3794 KEY_RIGHT: 39,
3795 KEY_DOWN: 40,
3796 KEY_DELETE: 46,
3797 KEY_HOME: 36,
3798 KEY_END: 35,
3799 KEY_PAGEUP: 33,
3800 KEY_PAGEDOWN: 34,
3801 KEY_INSERT: 45,
3802
3803 cache: { },
3804
3805 relatedTarget: function(event) {
3806 var element;
3807 switch(event.type) {
3808 case 'mouseover': element = event.fromElement; break;
3809 case 'mouseout': element = event.toElement; break;
3810 default: return null;
3811 }
3812 return Element.extend(element);
3813 }
3814});
3815
3816Event.Methods = (function() {
3817 var isButton;
3818
3819 if (Prototype.Browser.IE) {
3820 var buttonMap = { 0: 1, 1: 4, 2: 2 };
3821 isButton = function(event, code) {
3822 return event.button == buttonMap[code];
3823 };
3824
3825 } else if (Prototype.Browser.WebKit) {
3826 isButton = function(event, code) {
3827 switch (code) {
3828 case 0: return event.which == 1 && !event.metaKey;
3829 case 1: return event.which == 1 && event.metaKey;
3830 default: return false;
3831 }
3832 };
3833
3834 } else {
3835 isButton = function(event, code) {
3836 return event.which ? (event.which === code + 1) : (event.button === code);
3837 };
3838 }
3839
3840 return {
3841 isLeftClick: function(event) { return isButton(event, 0) },
3842 isMiddleClick: function(event) { return isButton(event, 1) },
3843 isRightClick: function(event) { return isButton(event, 2) },
3844
3845 element: function(event) {
3846 event = Event.extend(event);
3847
3848 var node = event.target,
3849 type = event.type,
3850 currentTarget = event.currentTarget;
3851
3852 if (currentTarget && currentTarget.tagName) {
3853 // Firefox screws up the "click" event when moving between radio buttons
3854 // via arrow keys. It also screws up the "load" and "error" events on images,
3855 // reporting the document as the target instead of the original image.
3856 if (type === 'load' || type === 'error' ||
3857 (type === 'click' && currentTarget.tagName.toLowerCase() === 'input'
3858 && currentTarget.type === 'radio'))
3859 node = currentTarget;
3860 }
3861 if (node.nodeType == Node.TEXT_NODE) node = node.parentNode;
3862 return Element.extend(node);
3863 },
3864
3865 findElement: function(event, expression) {
3866 var element = Event.element(event);
3867 if (!expression) return element;
3868 var elements = [element].concat(element.ancestors());
3869 return Selector.findElement(elements, expression, 0);
3870 },
3871
3872 pointer: function(event) {
3873 var docElement = document.documentElement,
3874 body = document.body || { scrollLeft: 0, scrollTop: 0 };
3875 return {
3876 x: event.pageX || (event.clientX +
3877 (docElement.scrollLeft || body.scrollLeft) -
3878 (docElement.clientLeft || 0)),
3879 y: event.pageY || (event.clientY +
3880 (docElement.scrollTop || body.scrollTop) -
3881 (docElement.clientTop || 0))
3882 };
3883 },
3884
3885 pointerX: function(event) { return Event.pointer(event).x },
3886 pointerY: function(event) { return Event.pointer(event).y },
3887
3888 stop: function(event) {
3889 Event.extend(event);
3890 event.preventDefault();
3891 event.stopPropagation();
3892 event.stopped = true;
3893 }
3894 };
3895})();
3896
3897Event.extend = (function() {
3898 var methods = Object.keys(Event.Methods).inject({ }, function(m, name) {
3899 m[name] = Event.Methods[name].methodize();
3900 return m;
3901 });
3902
3903 if (Prototype.Browser.IE) {
3904 Object.extend(methods, {
3905 stopPropagation: function() { this.cancelBubble = true },
3906 preventDefault: function() { this.returnValue = false },
3907 inspect: function() { return "[object Event]" }
3908 });
3909
3910 return function(event) {
3911 if (!event) return false;
3912 if (event._extendedByPrototype) return event;
3913
3914 event._extendedByPrototype = Prototype.emptyFunction;
3915 var pointer = Event.pointer(event);
3916 Object.extend(event, {
3917 target: event.srcElement,
3918 relatedTarget: Event.relatedTarget(event),
3919 pageX: pointer.x,
3920 pageY: pointer.y
3921 });
3922 return Object.extend(event, methods);
3923 };
3924
3925 } else {
3926 Event.prototype = Event.prototype || document.createEvent("HTMLEvents")['__proto__'];
3927 Object.extend(Event.prototype, methods);
3928 return Prototype.K;
3929 }
3930})();
3931
3932Object.extend(Event, (function() {
3933 var cache = Event.cache;
3934
3935 function getEventID(element) {
3936 if (element._prototypeEventID) return element._prototypeEventID[0];
3937 arguments.callee.id = arguments.callee.id || 1;
3938 return element._prototypeEventID = [++arguments.callee.id];
3939 }
3940
3941 function getDOMEventName(eventName) {
3942 if (eventName && eventName.include(':')) return "dataavailable";
3943 return eventName;
3944 }
3945
3946 function getCacheForID(id) {
3947 return cache[id] = cache[id] || { };
3948 }
3949
3950 function getWrappersForEventName(id, eventName) {
3951 var c = getCacheForID(id);
3952 return c[eventName] = c[eventName] || [];
3953 }
3954
3955 function createWrapper(element, eventName, handler) {
3956 var id = getEventID(element);
3957 var c = getWrappersForEventName(id, eventName);
3958 if (c.pluck("handler").include(handler)) return false;
3959
3960 var wrapper = function(event) {
3961 if (!Event || !Event.extend ||
3962 (event.eventName && event.eventName != eventName))
3963 return false;
3964
3965 Event.extend(event);
3966 handler.call(element, event);
3967 };
3968
3969 wrapper.handler = handler;
3970 c.push(wrapper);
3971 return wrapper;
3972 }
3973
3974 function findWrapper(id, eventName, handler) {
3975 var c = getWrappersForEventName(id, eventName);
3976 return c.find(function(wrapper) { return wrapper.handler == handler });
3977 }
3978
3979 function destroyWrapper(id, eventName, handler) {
3980 var c = getCacheForID(id);
3981 if (!c[eventName]) return false;
3982 c[eventName] = c[eventName].without(findWrapper(id, eventName, handler));
3983 }
3984
3985 function destroyCache() {
3986 for (var id in cache)
3987 for (var eventName in cache[id])
3988 cache[id][eventName] = null;
3989 }
3990
3991
3992 // Internet Explorer needs to remove event handlers on page unload
3993 // in order to avoid memory leaks.
3994 if (window.attachEvent) {
3995 window.attachEvent("onunload", destroyCache);
3996 }
3997
3998 // Safari has a dummy event handler on page unload so that it won't
3999 // use its bfcache. Safari <= 3.1 has an issue with restoring the "document"
4000 // object when page is returned to via the back button using its bfcache.
4001 if (Prototype.Browser.WebKit) {
4002 window.addEventListener('unload', Prototype.emptyFunction, false);
4003 }
4004
4005 return {
4006 observe: function(element, eventName, handler) {
4007 element = $(element);
4008 var name = getDOMEventName(eventName);
4009
4010 var wrapper = createWrapper(element, eventName, handler);
4011 if (!wrapper) return element;
4012
4013 if (element.addEventListener) {
4014 element.addEventListener(name, wrapper, false);
4015 } else {
4016 element.attachEvent("on" + name, wrapper);
4017 }
4018
4019 return element;
4020 },
4021
4022 stopObserving: function(element, eventName, handler) {
4023 element = $(element);
4024 var id = getEventID(element), name = getDOMEventName(eventName);
4025
4026 if (!handler && eventName) {
4027 getWrappersForEventName(id, eventName).each(function(wrapper) {
4028 element.stopObserving(eventName, wrapper.handler);
4029 });
4030 return element;
4031
4032 } else if (!eventName) {
4033 Object.keys(getCacheForID(id)).each(function(eventName) {
4034 element.stopObserving(eventName);
4035 });
4036 return element;
4037 }
4038
4039 var wrapper = findWrapper(id, eventName, handler);
4040 if (!wrapper) return element;
4041
4042 if (element.removeEventListener) {
4043 element.removeEventListener(name, wrapper, false);
4044 } else {
4045 element.detachEvent("on" + name, wrapper);
4046 }
4047
4048 destroyWrapper(id, eventName, handler);
4049
4050 return element;
4051 },
4052
4053 fire: function(element, eventName, memo) {
4054 element = $(element);
4055 if (element == document && document.createEvent && !element.dispatchEvent)
4056 element = document.documentElement;
4057
4058 var event;
4059 if (document.createEvent) {
4060 event = document.createEvent("HTMLEvents");
4061 event.initEvent("dataavailable", true, true);
4062 } else {
4063 event = document.createEventObject();
4064 event.eventType = "ondataavailable";
4065 }
4066
4067 event.eventName = eventName;
4068 event.memo = memo || { };
4069
4070 if (document.createEvent) {
4071 element.dispatchEvent(event);
4072 } else {
4073 element.fireEvent(event.eventType, event);
4074 }
4075
4076 return Event.extend(event);
4077 }
4078 };
4079})());
4080
4081Object.extend(Event, Event.Methods);
4082
4083Element.addMethods({
4084 fire: Event.fire,
4085 observe: Event.observe,
4086 stopObserving: Event.stopObserving
4087});
4088
4089Object.extend(document, {
4090 fire: Element.Methods.fire.methodize(),
4091 observe: Element.Methods.observe.methodize(),
4092 stopObserving: Element.Methods.stopObserving.methodize(),
4093 loaded: false
4094});
4095
4096(function() {
4097 /* Support for the DOMContentLoaded event is based on work by Dan Webb,
4098 Matthias Miller, Dean Edwards and John Resig. */
4099
4100 var timer;
4101
4102 function fireContentLoadedEvent() {
4103 if (document.loaded) return;
4104 if (timer) window.clearInterval(timer);
4105 document.fire("dom:loaded");
4106 document.loaded = true;
4107 }
4108
4109 if (document.addEventListener) {
4110 if (Prototype.Browser.WebKit) {
4111 timer = window.setInterval(function() {
4112 if (/loaded|complete/.test(document.readyState))
4113 fireContentLoadedEvent();
4114 }, 0);
4115
4116 Event.observe(window, "load", fireContentLoadedEvent);
4117
4118 } else {
4119 document.addEventListener("DOMContentLoaded",
4120 fireContentLoadedEvent, false);
4121 }
4122
4123 } else {
4124 document.write("<script id=__onDOMContentLoaded defer src=//:><\/script>");
4125 $("__onDOMContentLoaded").onreadystatechange = function() {
4126 if (this.readyState == "complete") {
4127 this.onreadystatechange = null;
4128 fireContentLoadedEvent();
4129 }
4130 };
4131 }
4132})();
4133/*------------------------------- DEPRECATED -------------------------------*/
4134
4135Hash.toQueryString = Object.toQueryString;
4136
4137var Toggle = { display: Element.toggle };
4138
4139Element.Methods.childOf = Element.Methods.descendantOf;
4140
4141var Insertion = {
4142 Before: function(element, content) {
4143 return Element.insert(element, {before:content});
4144 },
4145
4146 Top: function(element, content) {
4147 return Element.insert(element, {top:content});
4148 },
4149
4150 Bottom: function(element, content) {
4151 return Element.insert(element, {bottom:content});
4152 },
4153
4154 After: function(element, content) {
4155 return Element.insert(element, {after:content});
4156 }
4157};
4158
4159var $continue = new Error('"throw $continue" is deprecated, use "return" instead');
4160
4161// This should be moved to script.aculo.us; notice the deprecated methods
4162// further below, that map to the newer Element methods.
4163var Position = {
4164 // set to true if needed, warning: firefox performance problems
4165 // NOT neeeded for page scrolling, only if draggable contained in
4166 // scrollable elements
4167 includeScrollOffsets: false,
4168
4169 // must be called before calling withinIncludingScrolloffset, every time the
4170 // page is scrolled
4171 prepare: function() {
4172 this.deltaX = window.pageXOffset
4173 || document.documentElement.scrollLeft
4174 || document.body.scrollLeft
4175 || 0;
4176 this.deltaY = window.pageYOffset
4177 || document.documentElement.scrollTop
4178 || document.body.scrollTop
4179 || 0;
4180 },
4181
4182 // caches x/y coordinate pair to use with overlap
4183 within: function(element, x, y) {
4184 if (this.includeScrollOffsets)
4185 return this.withinIncludingScrolloffsets(element, x, y);
4186 this.xcomp = x;
4187 this.ycomp = y;
4188 this.offset = Element.cumulativeOffset(element);
4189
4190 return (y >= this.offset[1] &&
4191 y < this.offset[1] + element.offsetHeight &&
4192 x >= this.offset[0] &&
4193 x < this.offset[0] + element.offsetWidth);
4194 },
4195
4196 withinIncludingScrolloffsets: function(element, x, y) {
4197 var offsetcache = Element.cumulativeScrollOffset(element);
4198
4199 this.xcomp = x + offsetcache[0] - this.deltaX;
4200 this.ycomp = y + offsetcache[1] - this.deltaY;
4201 this.offset = Element.cumulativeOffset(element);
4202
4203 return (this.ycomp >= this.offset[1] &&
4204 this.ycomp < this.offset[1] + element.offsetHeight &&
4205 this.xcomp >= this.offset[0] &&
4206 this.xcomp < this.offset[0] + element.offsetWidth);
4207 },
4208
4209 // within must be called directly before
4210 overlap: function(mode, element) {
4211 if (!mode) return 0;
4212 if (mode == 'vertical')
4213 return ((this.offset[1] + element.offsetHeight) - this.ycomp) /
4214 element.offsetHeight;
4215 if (mode == 'horizontal')
4216 return ((this.offset[0] + element.offsetWidth) - this.xcomp) /
4217 element.offsetWidth;
4218 },
4219
4220 // Deprecation layer -- use newer Element methods now (1.5.2).
4221
4222 cumulativeOffset: Element.Methods.cumulativeOffset,
4223
4224 positionedOffset: Element.Methods.positionedOffset,
4225
4226 absolutize: function(element) {
4227 Position.prepare();
4228 return Element.absolutize(element);
4229 },
4230
4231 relativize: function(element) {
4232 Position.prepare();
4233 return Element.relativize(element);
4234 },
4235
4236 realOffset: Element.Methods.cumulativeScrollOffset,
4237
4238 offsetParent: Element.Methods.getOffsetParent,
4239
4240 page: Element.Methods.viewportOffset,
4241
4242 clone: function(source, target, options) {
4243 options = options || { };
4244 return Element.clonePosition(target, source, options);
4245 }
4246};
4247
4248/*--------------------------------------------------------------------------*/
4249
4250if (!document.getElementsByClassName) document.getElementsByClassName = function(instanceMethods){
4251 function iter(name) {
4252 return name.blank() ? null : "[contains(concat(' ', @class, ' '), ' " + name + " ')]";
4253 }
4254
4255 instanceMethods.getElementsByClassName = Prototype.BrowserFeatures.XPath ?
4256 function(element, className) {
4257 className = className.toString().strip();
4258 var cond = /\s/.test(className) ? $w(className).map(iter).join('') : iter(className);
4259 return cond ? document._getElementsByXPath('.//*' + cond, element) : [];
4260 } : function(element, className) {
4261 className = className.toString().strip();
4262 var elements = [], classNames = (/\s/.test(className) ? $w(className) : null);
4263 if (!classNames && !className) return elements;
4264
4265 var nodes = $(element).getElementsByTagName('*');
4266 className = ' ' + className + ' ';
4267
4268 for (var i = 0, child, cn; child = nodes[i]; i++) {
4269 if (child.className && (cn = ' ' + child.className + ' ') && (cn.include(className) ||
4270 (classNames && classNames.all(function(name) {
4271 return !name.toString().blank() && cn.include(' ' + name + ' ');
4272 }))))
4273 elements.push(Element.extend(child));
4274 }
4275 return elements;
4276 };
4277
4278 return function(className, parentElement) {
4279 return $(parentElement || document.body).getElementsByClassName(className);
4280 };
4281}(Element.Methods);
4282
4283/*--------------------------------------------------------------------------*/
4284
4285Element.ClassNames = Class.create();
4286Element.ClassNames.prototype = {
4287 initialize: function(element) {
4288 this.element = $(element);
4289 },
4290
4291 _each: function(iterator) {
4292 this.element.className.split(/\s+/).select(function(name) {
4293 return name.length > 0;
4294 })._each(iterator);
4295 },
4296
4297 set: function(className) {
4298 this.element.className = className;
4299 },
4300
4301 add: function(classNameToAdd) {
4302 if (this.include(classNameToAdd)) return;
4303 this.set($A(this).concat(classNameToAdd).join(' '));
4304 },
4305
4306 remove: function(classNameToRemove) {
4307 if (!this.include(classNameToRemove)) return;
4308 this.set($A(this).without(classNameToRemove).join(' '));
4309 },
4310
4311 toString: function() {
4312 return $A(this).join(' ');
4313 }
4314};
4315
4316Object.extend(Element.ClassNames.prototype, Enumerable);
4317
4318/*--------------------------------------------------------------------------*/
4319
4320Element.addMethods(); \ No newline at end of file
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 0aa22f8..0e11747 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -37,10 +37,13 @@ td {
37 depend on this rule for vertical padding. table.node_table td resets 37 depend on this rule for vertical padding. table.node_table td resets
38 padding-top/bottom to 0 explicitly and does not depend on it. */ 38 padding-top/bottom to 0 explicitly and does not depend on it. */
39 39
40input[type=text], textarea { 40input[type=text],
41input[type=password],
42textarea {
41 font-size: 1rem; 43 font-size: 1rem;
42 font-family: Helvetica; 44 font-family: Helvetica;
43 border: 1px solid #989898; 45 border: 1px solid #989898;
46 border-radius: 2px;
44} 47}
45 48
46select { 49select {
@@ -881,6 +884,10 @@ form.button_to button[type="submit"] {
881 margin-top: -5px; 884 margin-top: -5px;
882} 885}
883 886
887.aligned_action_row .action_button.action_button_no_margin_top {
888 margin-top: 0;
889}
890
884/* ============================================================ 891/* ============================================================
885 Page editor / metadata forms 892 Page editor / metadata forms
886 ============================================================ */ 893 ============================================================ */
@@ -1499,10 +1506,6 @@ div#image_browser ul li {
1499 Action log 1506 Action log
1500 ============================================================ */ 1507 ============================================================ */
1501 1508
1502#dashboard_widget td.node_action_time {
1503 white-space: wrap;
1504}
1505
1506#node_action_list td.node_action_body { 1509#node_action_list td.node_action_body {
1507 border-bottom: 1px solid #f1f1f1; 1510 border-bottom: 1px solid #f1f1f1;
1508} 1511}
@@ -1510,12 +1513,20 @@ div#image_browser ul li {
1510#node_action_list td.node_action_time { 1513#node_action_list td.node_action_time {
1511 white-space: nowrap; 1514 white-space: nowrap;
1512 vertical-align: top; 1515 vertical-align: top;
1513 font-family: monospace;
1514 font-size: 0.8em; 1516 font-size: 0.8em;
1515 color: #777; 1517 color: #777;
1516 padding-right: 1em; 1518 padding-right: 1em;
1517} 1519}
1518 1520
1521#node_action_list .node_action_date {
1522 display: block;
1523}
1524
1525#node_action_list .node_action_clock {
1526 float: right;
1527 margin-top: 0.2rem;
1528}
1529
1519#node_action_list td.node_action_body { 1530#node_action_list td.node_action_body {
1520 vertical-align: top; 1531 vertical-align: top;
1521} 1532}
@@ -1532,7 +1543,50 @@ div#image_browser ul li {
1532} 1543}
1533 1544
1534.node_action_details summary { 1545.node_action_details summary {
1546 margin-top: 0.2rem;
1535 cursor: pointer; 1547 cursor: pointer;
1536 font-size: smaller; 1548 font-size: smaller;
1537 color: #777; 1549 color: #777;
1538} 1550}
1551
1552.node_action_icon {
1553 color: #777;
1554 margin-right: 0.35em;
1555 vertical-align: text-top;
1556}
1557
1558.revision_lifecycle {
1559 font-size: 0.85rem;
1560 color: #777;
1561}
1562
1563/* ============================================================
1564 Trash section
1565 ============================================================ */
1566
1567.restore_form {
1568 display: flex;
1569 align-items: center;
1570 gap: 8px;
1571}
1572
1573.restore_picker {
1574 position: relative;
1575}
1576
1577.restore_picker input[type=text] {
1578 width: 22em;
1579}
1580
1581.restore_picker .search_results {
1582 position: absolute;
1583 top: 100%;
1584 left: 0;
1585 right: 0;
1586 z-index: 10;
1587 background: #fff;
1588 border: 1px solid #989898;
1589 border-top: none;
1590 max-height: 20em;
1591 overflow-y: auto;
1592}
diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css
index cbeea17..a07cc4d 100644
--- a/public/stylesheets/ccc.css
+++ b/public/stylesheets/ccc.css
@@ -1,3 +1,55 @@
1/* ==========================================================================
2 ccc.css — annotated demolition survey (behavior-preserving rewrite)
3 ==========================================================================
4 Sorted into nine concerns; each banner carries an ASSUMES: line naming
5 the tokens and images it depends on. Within any equal-specificity
6 collision set, relative source order is preserved verbatim.
7 ========================================================================== */
8
9/* ==========================================================================
10 1. DESIGN TOKENS
11 ==========================================================================
12 The desktop layout is a fixed lattice drawn around two images:
13 header.png (909px wide, 145px tall) and left_column.png (continues the
14 header artwork down the left menu band via a wrapper-covering ::before).
15 Change these tokens and the two images must be regenerated — that IS
16 the redesign.
17 NOTE: custom properties cannot appear in @media conditions, so the
18 1015/1016 breakpoint below stays a literal pair. Its provenance is
19 unexplained (roughly --page-width plus a margin allowance).
20 ========================================================================== */
21
22:root {
23 --page-width: 909px; /* header.png's drawn width; the wrapper.
24 Decomposes: 185 left band (135 col + 50 pad)
25 + 490 center at left 200 + 210 right band
26 (155 col + 55 pad) at left 690. 690 + 210
27 = 900: 9px absorbed silently at the right
28 edge. */
29 --header-height: 145px; /* header.png's pixel height; search box and
30 light-mode toggle hang from its bottom
31 edge. */
32 --left-col-width: 135px; /* left band = this + --left-col-pad = 185px */
33 --left-col-pad: 50px;
34 --center-left: 200px; /* 15px gutter beyond the 185px left band */
35 --center-width: 490px;
36 --right-left: 690px; /* = --center-left + --center-width */
37 --right-width: 155px; /* right band = this + --right-pad = 210px */
38 --right-pad: 55px;
39 --search-left: 676px; /* provenance unknown; sits 14px left of
40 --right-left */
41 --toggle-left: 816px; /* provenance unknown; 140px right of
42 --search-left */
43 --burger-right: 54px; /* provenance unknown */
44}
45
46/* ==========================================================================
47 2. RESET & DOCUMENT SCAFFOLD
48 ==========================================================================
49 ASSUMES: nothing from the lattice. Element defaults and generic
50 utilities only.
51 ========================================================================== */
52
1html { 53html {
2 height: 100%; 54 height: 100%;
3 line-height: 1.6rem; 55 line-height: 1.6rem;
@@ -17,6 +69,54 @@ body {
17 height: 100%; 69 height: 100%;
18} 70}
19 71
72img {
73 max-width: 100%;
74}
75
76pre {
77 overflow: auto;
78}
79
80li {
81 line-height: 1.5rem;
82 margin-block-start: 1rem;
83 margin-block-end: 1rem;
84}
85
86dt {
87 font-weight: bold;
88 margin-bottom: 1em;
89}
90
91dd {
92 margin-bottom: 1em;
93}
94
95.pagination {
96 margin-bottom: .5rem;
97}
98
99+ /* Single caller: _headline_image.html.erb's image-count caption
100+ ("N Bilder"), shown only when a gallery has more than one image. */
101.right {
102 text-align: right;
103}
104
105.hide-me {
106 display: none;
107}
108
109/* ==========================================================================
110 3. COLOR SCHEME & LIGHT/DARK TOGGLE
111 ==========================================================================
112 ASSUMES: --header-height, --toggle-left (desktop toggle position);
113 header.png and left_column.png (the invert(50%) treatment); the nonced
114 inline restore script in the layout that re-checks #light-mode before
115 first paint (the JS contract: checkbox id "light-mode", state persisted
116 by the script). Symmetric blocks: each scheme inverts when the toggle
117 is checked.
118 ========================================================================== */
119
20@media (prefers-color-scheme: light) { 120@media (prefers-color-scheme: light) {
21 body:has(#light-mode:checked) { 121 body:has(#light-mode:checked) {
22 color-scheme: dark; 122 color-scheme: dark;
@@ -49,114 +149,221 @@ body {
49 } 149 }
50} 150}
51 151
52img { 152input#light-mode[type="checkbox"] {
53 max-width: 100%; 153 display: none;
54} 154}
55 155
56div#header img { 156label[for=light-mode] {
57 border: none; 157 font-size: 25px;
158 user-select: none;
159 cursor: pointer;
160 filter: grayscale(1) contrast(10%);
58} 161}
59 162
60@media(min-width:1016px) { 163/* Light and dark mode button magic */
61 div#wrapper { 164@media(max-width:1015px) {
62 position: relative; 165 div#light-mode-div {
63 width: 909px; 166 position: absolute;
64 margin: 0 auto 0 auto; 167 left: 170px; /* mobile toolbar offset; provenance unknown — anchors to
65 text-align: left; 168 the viewport, not #toolbox (see section 5) */
66 min-height: 100%;
67 }
68 .break-mobile {
69 display: block;
70 } 169 }
71} 170}
72 171
73pre { 172@media(min-width:1016px) {
74 overflow: auto; 173 div#light-mode-div {
75} 174 position: absolute;
76 175 top: var(--header-height); /* bottom edge of header.png */
77.right { 176 left: var(--toggle-left);
78 text-align: right; 177 }
79} 178}
80 179
81/*------------------links-------------------*/ 180/* ==========================================================================
181 4. LINKS & HEADINGS
182 ==========================================================================
183 ASSUMES: nothing from the lattice. Site-wide type and link colors;
184 the orange is the one brand color that survives theming.
185 ========================================================================== */
82 186
83a { 187a {
84 color: #F8921E; 188 color: #F8921E;
85 text-decoration: none; 189 text-decoration: none;
86} 190}
87 191
88a:visited {
89 color: #D1791A;
90 text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
91}
92
93a:hover { 192a:hover {
94 color: #5b8ca7; 193 color: #5b8ca7;
95} 194}
96 195
97/*------------------headlines-------------------*/
98
99h2 { 196h2 {
100 font-size: 1.5rem; 197 font-size: 1.5rem;
101 font-family: Helvetica, Arial, sans-serif; 198 font-family: Helvetica, Arial, sans-serif;
102 line-height: 1.75rem; 199 line-height: 1.75rem;
103} 200}
104 201
105div#center_column h2 a { 202h3 {
106 color: CanvasText; 203 font-size: 1.3rem;
107 text-decoration: none; 204 text-decoration: none;
205
108} 206}
109 207
110div#center_column h2 a:hover { 208h4 {
111 color: color-mix(in srgb, CanvasText, #808080 50%); 209 font-size: 1.0rem;
210 text-decoration: none;
112} 211}
113 212
114div#center_column h2.headline { 213h1, h2, h3 {
115 margin-top: 10px; 214 word-wrap: anywhere;
215 hyphens:auto;
116} 216}
117 217
118div#center_column .article_partial h2.headline { 218/* ==========================================================================
119 padding-top: 30px; 219 5. LAYOUT GRID
120 margin-bottom: 0.3rem; 220 ==========================================================================
121 border-top: 2px solid #cccccc; 221 ASSUMES: the full token lattice; header.png (drawn width = --page-width,
222 height = --header-height); left_column.png (continues the header art
223 down the left band via the ::before at z-index -10).
224 KNOWN STRUCTURAL DEBT: all three columns are position:absolute on
225 desktop, but #footer lives INSIDE #center_column — a long sidebar can
226 overflow past the footer. The column-height assumption is nowhere
227 enforced.
228 MOBILE ANCHORING: on max-width, #wrapper has no position, so the
229 absolutely positioned toolbox children (#burger-div, #search,
230 #light-mode-div) anchor to the viewport, not to #toolbox.
231 ========================================================================== */
232
233@media(min-width:1016px) {
234 div#wrapper {
235 position: relative;
236 width: var(--page-width); /* header.png's drawn width */
237 margin: 0 auto 0 auto;
238 text-align: left;
239 min-height: 100%;
240 }
241 .break-mobile {
242 display: block;
243 }
122} 244}
123 245
124/* except for erfa list */ 246/* Header */
125div#center_column .chapter_partial h2.headline { 247#header img,
126 border-top: none; 248#header > a {
249 display: block;
250 line-height: 0;
127} 251}
128 252
129div.article_partial h2 a { 253div#header img {
130 text-decoration: none; 254 border: none;
131} 255}
132 256
133h3 { 257#left_column {
134 font-size: 1.3rem; 258 line-height: 1.5em;
135 text-decoration: none; 259}
260
261@media(min-width:1016px) {
262 div#left_column::before {
263 content: " ";
264 position: absolute;
265 left: 0;
266 top: 0;
267 width: 100%;
268 height: 100%;
269 box-sizing: border-box;
270 background-image: url(/images/left_column.png);
271 background-position: top right;
272 background-repeat: no-repeat;
273 z-index: -10;
274 }
275 div#left_column {
276 position: absolute;
277 left: 0px;
278 width: var(--left-col-width);
279 min-height: 100px;
280 text-align: right;
281 padding-right: var(--left-col-pad); /* left band total: 185px */
282 }
136 283
284 div#left_column > a {
285 font-size: 0.8rem;
286 }
137} 287}
138 288
139h4 { 289div#center_column {
140 font-size: 1.0rem; 290 padding-bottom: 40px;
141 text-decoration: none;
142} 291}
143 292
144#left_column { 293@media(max-width: 1015px) {
145 line-height: 1.5em; 294div#center_column {
295 padding: 0 15px 40px 15px;
296}
146} 297}
147 298
148/*------------------main-navigation-------------------*/ 299@media(min-width: 1016px) {
300 div#center_column {
301 position: absolute;
302 background-color: Canvas;
303 left: var(--center-left);
304 width: var(--center-width);
305 }
306}
307
308@media(min-width:1016px) {
309 div#right_column {
310 position: absolute;
311 background-color: Canvas;
312 padding-left: var(--right-pad);
313 left: var(--right-left); /* = center-left + center-width */
314 width: var(--right-width); /* right band total: 210px; 690 + 210 = 900,
315 9px short of --page-width, absorbed
316 silently */
317 height: 100px;
318 }
319}
320
321/* Mobile toolbar strip. */
322@media(max-width:1015px) {
323#toolbox {
324 height: 30px;
325}
326}
327
328/* Footer */
329#footer {
330 border-bottom: 2px solid #aeadad;
331 border-top: 2px solid #aeadad;
332 bottom: 0;
333 color: CanvasText;
334}
335
336#footer p {
337 margin: .5rem auto;
338}
339
340#footer a {
341 margin-left: 1rem;
342 margin-right: 1rem;
343 color: CanvasText;
344}
345
346/* ==========================================================================
347 6. MAIN NAVIGATION & BURGER MENU
348 ==========================================================================
349 ASSUMES: --burger-right; the checkbox-hack markup order in the layout
350 (#toolbox precedes #left_column — the `#toolbox ~ #left_column`
351 sibling selectors break if the layout reorders them); left_column.png
352 behind the desktop nav. Mobile menu is pure CSS: .menu-checkbox state
353 drives both the burger-to-X morph and the menu slide.
354 COLLISION SET (order preserved): div.main_navigation ul base rule
355 before its min-width override — the override's `padding: 0` must win.
356 ========================================================================== */
149 357
150div.main_navigation ul { 358div.main_navigation ul {
151 margin-left: 0; 359 margin-left: 0;
152 padding-left: 0; 360 padding-left: 15px; /* dead duplicate `padding-left: 0` removed */
153 padding-left: 15px;
154 padding-right: 15px; 361 padding-right: 15px;
155 text-align: left; 362 text-align: left;
156} 363}
157 364
158 365
159@media(max-width:1016px) { 366@media(max-width:1015px) {
160 div.main_navigation li:not(:first-child):before { 367 div.main_navigation li:not(:first-child):before {
161 content: '•'; 368 content: '•';
162 margin-left: .3rem; 369 margin-left: .3rem;
@@ -203,7 +410,125 @@ div.main_navigation a.inactive, div.main_navigation span.inactive, div#left_colu
203 color: color-mix(in srgb, CanvasText, #808080 25%); 410 color: color-mix(in srgb, CanvasText, #808080 25%);
204} 411}
205 412
206/*------------------calendar-featured-tags-------------------*/ 413.menu-checkbox {
414 display: none;
415}
416
417.burger-menu {
418 display: none;
419 cursor: pointer;
420}
421
422#burger-div {
423 position: absolute;
424 right: var(--burger-right); /* anchors to the viewport on mobile,
425 see section 5 */
426}
427
428/* Mobile styles */
429@media (max-width: 1015px) {
430 .burger-menu {
431 position: absolute;
432 display: flex;
433 flex-direction: column;
434 transition: transform 0.3s ease, opacity 0.3s ease;
435 }
436
437 .burger-menu span {
438 display: inline-flex;
439 width: 26px;
440 height: 4px;
441 background: color-mix(in srgb, CanvasText, #808080 25%);
442 border-radius: 2px;
443 margin: 3px 0;
444 transition: max-height 0.5s ease-in-out, opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
445 }
446
447 #left_column .main_navigation {
448 width: 100%;
449 position: relative;
450 }
451
452 #toolbox ~ #left_column .main_navigation ul {
453 transition: max-height 0.2s ease-in-out, transform 0.4s ease-in-out, opacity 0.4s ease;
454 overflow: hidden;
455 text-align: center;
456 }
457
458 #toolbox ~ #left_column .main_navigation:first-of-type ul {
459 transform: translateY(-200px);
460 max-height: 0;
461 opacity: 0;
462 z-index: -10;
463 }
464
465 /* Show menu when checkbox is checked */
466 #toolbox:has(.menu-checkbox:checked) ~ #left_column .main_navigation ul {
467 max-height: 400px;
468 opacity: 1;
469 transform: translateY(0);
470 }
471
472 .menu-checkbox:checked + .burger-menu span:nth-child(1) { transform: translateY(10px) rotate(45deg); }
473 .menu-checkbox:checked + .burger-menu span:nth-child(2) { opacity: 0; }
474 .menu-checkbox:checked + .burger-menu span:nth-child(3) { transform: translateY(-10px) rotate(-45deg); }
475}
476
477/* ==========================================================================
478 7. SEARCH
479 ==========================================================================
480 ASSUMES: --header-height, --search-left (desktop box hangs from
481 header.png's bottom edge); viewport anchoring on mobile (section 5).
482 ========================================================================== */
483
484/* Search bar */
485@media(min-width:1016px) {
486 div#search {
487 position: absolute;
488 top: var(--header-height); /* bottom edge of header.png */
489 left: var(--search-left);
490 height: 25px;
491 vertical-align: top;
492 }
493}
494
495@media(max-width:1015px) {
496 div#search {
497 position: absolute;
498 left: 25px; /* mobile toolbar offset; anchors to the viewport */
499 height: 25px;
500 }
501}
502
503div#search input {
504 color: CanvasText !important;
505}
506div#search input[type=search],
507div#search input[type=text] {
508 display: block;
509 padding: 2px;
510 margin: 0px;
511 height: 25px;
512 width: 132px;
513 line-height: 20px;
514 border: solid #808080 1px;
515 background-color: Canvas;
516 border-radius: 5px;
517 margin-right: 5px;
518 text-indent: 0.5rem;
519}
520
521/* ==========================================================================
522 8. SIDEBAR WIDGETS (calendar, tags, featured, open erfas)
523 ==========================================================================
524 ASSUMES: right-column geometry from section 5; div.main_navigation h2
525 also picks up the grouped border rule below.
526 COLLISION SET (order preserved verbatim): the grouped h2 border rule,
527 then the same-specificity div#frontpage_calendar h2 override
528 (border-top: none), then the min-width block that re-adds a border-top
529 for desktop. Moving the override above the group rule would grow a
530 border on the calendar heading.
531 ========================================================================== */
207 532
208div#frontpage_calendar { 533div#frontpage_calendar {
209 display: none; 534 display: none;
@@ -246,7 +571,7 @@ div#open_erfas_today .event_time {
246 color: color-mix(in srgb, CanvasText, #808080); 571 color: color-mix(in srgb, CanvasText, #808080);
247} 572}
248 573
249@media(max-width:1016px) { 574@media(max-width:1015px) {
250 div#tags li { 575 div#tags li {
251 list-style-type: none; 576 list-style-type: none;
252 display: inline-block; 577 display: inline-block;
@@ -278,6 +603,8 @@ div#frontpage_calendar ul, div#tags ul, div#featured_articles ul, div#open_erfas
278 line-height: 1.5em; 603 line-height: 1.5em;
279} 604}
280 605
606/* ODDITY, kept: per-widget icon paddings — delete when these icons rotate
607 out of the featured box */
281div#featured_articles #ds_icon img { 608div#featured_articles #ds_icon img {
282 padding-top: 10px; 609 padding-top: 10px;
283} 610}
@@ -312,302 +639,74 @@ div#featured_articles img {
312 filter: grayscale(1); 639 filter: grayscale(1);
313} 640}
314 641
315dt { 642#tags ul li {
316 font-weight: bold; 643 margin-block-start: 0.5rem;
317 margin-bottom: 1em; 644 margin-block-end: 0.5rem;
318}
319
320dd {
321 margin-bottom: 1em;
322}
323
324/*--------------------------------------------------------------*/
325
326div.author_and_date {
327 font-style: italic;
328 font-family: Georgia;
329 color: color-mix(in srgb, CanvasText, #808080);
330 padding-top: .2rem;
331 padding-bottom: .8rem;
332}
333
334@media(min-width:1016px) {
335 div#left_column::before {
336 content: " ";
337 position: absolute;
338 left: 0;
339 top: 0;
340 width: 100%;
341 height: 100%;
342 box-sizing: border-box;
343 background-image: url(/images/left_column.png);
344 background-position: top right;
345 background-repeat: no-repeat;
346 z-index: -10;
347 }
348 div#left_column {
349 position: absolute;
350 left: 0px;
351 width: 135px;
352 min-height: 100px;
353 text-align: right;
354 padding-right: 50px;
355 }
356
357 div#left_column > a {
358 font-size: 0.8rem;
359 }
360}
361
362.menu-checkbox {
363 display: none;
364}
365
366.burger-menu {
367 display: none;
368 cursor: pointer;
369}
370
371#burger-div {
372 position: absolute;
373 right: 54px;
374}
375
376/* Mobile styles */
377@media (max-width: 1016px) {
378 .burger-menu {
379 position: absolute;
380 display: flex;
381 flex-direction: column;
382 transition: transform 0.3s ease, opacity 0.3s ease;
383 }
384
385 .burger-menu span {
386 display: inline-flex;
387 width: 26px;
388 height: 4px;
389 background: color-mix(in srgb, CanvasText, #808080 25%);
390 border-radius: 2px;
391 margin: 3px 0;
392 transition: max-height 0.5s ease-in-out, opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
393 }
394
395 #left_column .main_navigation {
396 width: 100%;
397 position: relative;
398 }
399
400 #toolbox ~ #left_column .main_navigation ul {
401 transition: max-height 0.2s ease-in-out, transform 0.4s ease-in-out, opacity 0.4s ease;
402 overflow: hidden;
403 text-align: center;
404 }
405
406 #toolbox ~ #left_column .main_navigation:first-of-type ul {
407 transform: translateY(-200px);
408 max-height: 0;
409 opacity: 0;
410 z-index: -10;
411 }
412
413 /* Show menu when checkbox is checked */
414 #toolbox:has(.menu-checkbox:checked) ~ #left_column .main_navigation ul {
415 max-height: 400px;
416 opacity: 1;
417 transform: translateY(0);
418 }
419
420 .menu-checkbox:checked + .burger-menu span:nth-child(1) { transform: translateY(10px) rotate(45deg); }
421 .menu-checkbox:checked + .burger-menu span:nth-child(2) { opacity: 0; }
422 .menu-checkbox:checked + .burger-menu span:nth-child(3) { transform: translateY(-10px) rotate(-45deg); }
423}
424
425div#center_column {
426 padding-bottom: 40px;
427}
428
429@media(max-width: 1016px) {
430div#center_column {
431 padding: 0 15px 40px 15px;
432}
433}
434
435@media(min-width: 1016px) {
436 div#center_column {
437 position: absolute;
438 background-color: Canvas;
439 left: 200px;
440 width: 490px;
441 }
442}
443
444@media(min-width:1016px) {
445 div#right_column {
446 position: absolute;
447 background-color: Canvas;
448 padding-left: 55px;
449 left: 690px;
450 width: 155px;
451 height: 100px;
452 }
453}
454
455div.teaser_ruler {
456 border-top: 1px solid #CCCCCC;
457 border-bottom: 1px dashed #CCCCCC;
458 height: 3px;
459 margin-top: 30px;
460}
461
462div.article_partial {
463 margin-bottom: 30px;
464}
465
466
467div.article_partial p.excerpt {
468 color: CanvasText;
469}
470
471/* Search bar */
472@media(min-width:1016px) {
473 div#search {
474 position: absolute;
475 top: 145px;
476 left: 676px;
477 height: 25px;
478 vertical-align: top;
479 }
480}
481
482@media(max-width:1016px) {
483 div#search {
484 position: absolute;
485 left: 25px;
486 height: 25px;
487 }
488}
489
490div#search input {
491 color: CanvasText !important;
492}
493div#search input[type=search],
494div#search input[type=text] {
495 display: block;
496 padding: 2px;
497 margin: 0px;
498 height: 25px;
499 width: 132px;
500 line-height: 20px;
501 border: solid #808080 1px;
502 background-color: Canvas;
503 border-radius: 5px;
504 margin-right: 5px;
505 text-indent: 0.5rem;
506} 645}
507 646
508/* Header */ 647/* ==========================================================================
509#header img, 648 9. ARTICLE CONTENT
510#header > a { 649 ==========================================================================
511 display: block; 650 ASSUMES: center-column geometry from section 5; TinyMCE emits the
512 line-height: 0; 651 inline-image classes (style-src unsafe-inline in the CSP exists partly
513} 652 for its img[style] output).
653 ========================================================================== */
514 654
515/* Main section */ 655/* Main section */
516.article, .article_partial { 656.article, .article_partial {
517 text-align: left; 657 text-align: left;
518} 658}
519 659
520h1, h2, h3 { 660div#center_column h2 a {
521 word-wrap: anywhere;
522 hyphens:auto;
523}
524
525.pagination {
526 margin-bottom: .5rem;
527}
528
529li {
530 line-height: 1.5rem;
531 margin-block-start: 1rem;
532 margin-block-end: 1rem;
533}
534
535#tags ul li {
536 margin-block-start: 0.5rem;
537 margin-block-end: 0.5rem;
538}
539
540/* Footer */
541#footer {
542 border-bottom: 2px solid #aeadad;
543 border-top: 2px solid #aeadad;
544 bottom: 0;
545 color: CanvasText; 661 color: CanvasText;
662 text-decoration: none;
546} 663}
547 664
548#footer > br { 665div#center_column h2 a:hover {
549 display: none; 666 color: color-mix(in srgb, CanvasText, #808080 50%);
550} 667}
551 668
552#footer p { 669div#center_column h2.headline {
553 margin: .5rem auto; 670 margin-top: 10px;
554} 671}
555 672
556#footer a { 673div#center_column .article_partial h2.headline {
557 margin-left: 1rem; 674 padding-top: 30px;
558 margin-right: 1rem; 675 margin-bottom: 0.3rem;
559 color: CanvasText; 676 border-top: 2px solid #cccccc;
560} 677}
561 678
562@media(max-width:1016px) { 679/* except for erfa list */
563#toolbox { 680div#center_column .chapter_partial h2.headline {
564 display: relative; 681 border-top: none;
565 height: 30px;
566}
567} 682}
568 683
569/* Light and dark mode button magic */ 684div.article_partial h2 a {
570@media(max-width:1016px) { 685 text-decoration: none;
571 div#light-mode-div {
572 position: absolute;
573 left: 170px;
574 }
575} 686}
576 687
577@media(min-width:1016px) { 688div.article_partial {
578 div#light-mode-div { 689 margin-bottom: 30px;
579 position: absolute;
580 top: 145px;
581 left: 816px;
582 }
583}
584input#light-mode[type="checkbox"] {
585 display: none;
586} 690}
587 691
588label[for=light-mode] {
589 font-size: 25px;
590 user-select: none;
591 cursor: pointer;
592 filter: grayscale(1) contrast(10%);
593}
594 692
595.hide-me { 693div.article_partial p.excerpt {
596 display: none; 694 color: CanvasText;
597} 695}
598 696
599/* Temporary glowing style for easterhegg 22 */ 697div.author_and_date {
600#eh22_icon img { 698 font-style: italic;
601 animation: animate 3s linear infinite; 699 font-family: Georgia;
700 color: color-mix(in srgb, CanvasText, #808080);
701 padding-top: .2rem;
702 padding-bottom: .8rem;
602} 703}
603 704
604@keyframes animate { 705div.teaser_ruler {
605 from { 706 border-top: 1px solid #CCCCCC;
606 filter: sepia(100%) hue-rotate(0deg); 707 border-bottom: 1px dashed #CCCCCC;
607 } 708 height: 3px;
608 to { 709 margin-top: 30px;
609 filter: sepia(100%) hue-rotate(360deg);
610 }
611} 710}
612 711
613.chapter_partial_layout { 712.chapter_partial_layout {
diff --git a/public/stylesheets/ccc.css_ b/public/stylesheets/ccc.css_
deleted file mode 100644
index 9c5dbba..0000000
--- a/public/stylesheets/ccc.css_
+++ /dev/null
@@ -1,579 +0,0 @@
1html {
2 height: 100%;
3 line-height: 1.6rem;
4}
5
6body {
7 margin: 0;
8 padding: 0;
9 text-align: center;
10 font-family: Verdana, Helvetica, Arial, sans-serif;
11 background-color: Canvas;
12 color: color-mix(in srgb, CanvasText, #808080 25%);
13 hyphens: auto;
14 color-scheme: light dark;
15
16 min-height: 100%;
17 height: 100%;
18}
19
20@media (prefers-color-scheme: light) {
21 body:has(#light-mode:checked) {
22 color-scheme: dark;
23 }
24 #light-mode + label[for=light-mode]:before { content: '🌙'; }
25 #light-mode:checked + label[for=light-mode]:before { content: '☀️'; }
26}
27
28@media (prefers-color-scheme: dark) {
29 body:has(#light-mode:checked) {
30 color-scheme: light;
31 }
32 #light-mode + label[for=light-mode]:before { content: '☀️'; }
33 #light-mode:checked + label[for=light-mode]:before { content: '🌙'; }
34
35 /* The header images were set in the before times. Let them shine in dark
36 mode, too */
37 body:not(:has(#light-mode:checked)) #header img,
38 body:not(:has(#light-mode:checked)) div#left_column::before
39 {
40 filter: invert(50%);
41 }
42}
43
44img {
45 max-width: 100%;
46}
47
48div#header img {
49 border: none;
50}
51
52@media(min-width:1016px) {
53 div#wrapper {
54 position: relative;
55 width: 909px;
56 margin: 0 auto 0 auto;
57 text-align: left;
58 min-height: 100%;
59 }
60 .break-mobile {
61 display: block;
62 }
63}
64
65pre {
66 overflow: auto;
67}
68
69.right {
70 text-align: right;
71}
72
73/*------------------links-------------------*/
74
75a {
76 color: #F8921E;
77 text-decoration: none;
78}
79
80a:visited {
81 color: #D1791A;
82 text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
83}
84
85a:hover {
86 color: #5b8ca7;
87}
88
89/*------------------headlines-------------------*/
90
91h2 {
92 font-size: 1.5rem;
93 font-family: Helvetica, Arial, sans-serif;
94 line-height: 1.75rem;
95}
96
97div#center_column h2 a {
98 color: CanvasText;
99 text-decoration: none;
100}
101
102div#center_column h2 a:hover {
103 color: color-mix(in srgb, CanvasText, #808080 50%);
104}
105
106div#center_column h2.headline {
107 margin-top: 10px;
108}
109
110div#center_column .article_partial h2.headline {
111 padding-top: 30px;
112 margin-bottom: 0.3rem;
113 border-top: 2px solid #cccccc;
114}
115
116div.article_partial h2 a {
117 text-decoration: none;
118}
119
120h3 {
121 font-size: 1.3rem;
122 text-decoration: none;
123
124}
125
126h4 {
127 font-size: 1.0rem;
128 text-decoration: none;
129}
130
131#left_column {
132 line-height: 1.5em;
133}
134
135/*------------------main-navigation-------------------*/
136
137div.main_navigation ul {
138 margin-left: 0;
139 padding-left: 0;
140 padding-left: 15px;
141 padding-right: 15px;
142 text-align: left;
143}
144
145
146@media(max-width:1016px) {
147 div.main_navigation li:not(:first-child):before {
148 content: '•';
149 margin-left: .3rem;
150 margin-right: .6rem;
151 }
152
153 div.main_navigation li {
154 list-style-type: none;
155 display: inline-block;
156 line-height: .75rem;
157 }
158}
159
160@media(min-width:1016px) {
161 div.main_navigation ul {
162 padding: 0;
163 text-align: right;
164 }
165
166 div.main_navigation li {
167 list-style-type: none;
168 line-height: 1.45em;
169 }
170}
171
172div.main_navigation a {
173 text-decoration: none;
174}
175
176div.main_navigation a.inactive:hover, div#left_column span.inactive:hover {
177 color: color-mix(in srgb, CanvasText, #808080 50%);
178}
179
180div.main_navigation a.active:before {
181 content: "▸";
182}
183
184div.main_navigation a.active {
185 color: CanvasText;
186 text-decoration: none;
187}
188
189div.main_navigation a.inactive, div.main_navigation span.inactive, div#left_column span.inactive {
190 color: color-mix(in srgb, CanvasText, #808080 25%);
191}
192
193/*------------------calendar-featured-tags-------------------*/
194
195div#frontpage_calendar {
196 display: none;
197 margin-top: 10px;
198}
199
200@media(min-width:1016px) {
201 div#frontpage_calendar {
202 margin-top: 30px;
203 }
204}
205
206div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div.main_navigation h2 {
207 border-top: 2px solid;
208 border-bottom: 2px solid;
209 font-size: 1.1em;
210 padding-top: 2px;
211 padding-bottom: 2px;
212}
213
214div#frontpage_calendar h2 {
215 display: none;
216 border-top: none;
217 margin-left: auto;
218 margin-right: auto;
219 padding-left: 0.5em;
220 padding-right: 0.5em;
221 margin-top: 0;
222}
223
224@media(max-width:1016px) {
225 div#tags li {
226 list-style-type: none;
227 display: inline-block;
228 }
229
230 div#tags li:not(:first-child):before {
231 content: '•';
232 margin-left: .3em;
233 margin-right: .6em;
234 }
235}
236
237@media(min-width:1016px) {
238 div#frontpage_calendar h2, div#tags h2, div#featured_articles h2 {
239 font-size: 1rem;
240 }
241
242 div#frontpage_calendar h2 {
243 display: block;
244 border-top: 2px solid #aeadad;
245 padding: 2px 0;
246 }
247
248}
249
250div#frontpage_calendar ul, div#tags ul, div#featured_articles ul {
251 padding: 0px;
252 font-size: 1rem;
253 line-height: 1.5em;
254}
255
256div#featured_articles #ds_icon img {
257 padding-top: 10px;
258}
259
260div#featured_articles #events_icon img {
261 padding-left: 10px;
262}
263
264div#frontpage_calendar li {
265 margin-bottom: 20px;
266}
267
268div#frontpage_calendar li, div#tags li, div#featured_articles li {
269 list-style-type: none;
270}
271
272div#frontpage_calendar li a, div#tags li a, div#featured_articles li a {
273 text-decoration: none;
274 color: color-mix(in srgb, CanvasText, #808080 25%);
275}
276
277div#frontpage_calendar li a:hover,
278div#tags li a:hover,
279div#featured_articles li a:hover {
280 text-decoration: none;
281 color: color-mix(in srgb, CanvasText, #808080 50%);
282}
283
284/* We don't want the only colourful thing on the site to be pointing
285 * somewhere else */
286div#featured_articles img {
287 filter: grayscale(1);
288}
289
290dt {
291 font-weight: bold;
292 margin-bottom: 1em;
293}
294
295dd {
296 margin-bottom: 1em;
297}
298
299/*--------------------------------------------------------------*/
300
301div.author_and_date {
302 font-style: italic;
303 font-family: Georgia;
304 color: color-mix(in srgb, CanvasText, #808080);
305 padding-top: 0;
306 padding-bottom: 1rem;
307}
308
309@media(min-width:1016px) {
310 div#left_column::before {
311 content: " ";
312 position: absolute;
313 left: 0;
314 top: 0;
315 width: 100%;
316 height: 100%;
317 box-sizing: border-box;
318 background-image: url(/images/left_column.png);
319 background-position: top right;
320 background-repeat: no-repeat;
321 z-index: -10;
322 }
323 div#left_column {
324 position: absolute;
325 left: 0px;
326 width: 135px;
327 min-height: 100px;
328 text-align: right;
329 padding-right: 50px;
330 }
331
332 div#left_column > a {
333 font-size: 0.8rem;
334 }
335}
336
337.menu-checkbox {
338 display: none;
339}
340
341.burger-menu {
342 display: none;
343 cursor: pointer;
344}
345
346/* Mobile styles */
347@media (max-width: 1016px) {
348 .main_navigation {
349 width: 100%;
350 position: relative;
351 }
352
353 .main_navigation ul {
354 transition: max-height 0.5s ease-in-out, transform .4s ease, opacity 0.4s ease;
355 overflow: hidden;
356 text-align: center;
357 }
358
359
360 .burger-menu {
361 position: absolute;
362 top: -48px;
363 right: 30px;
364 display: flex;
365 flex-direction: column;
366 transition: transform 0.3s ease, opacity 0.3s ease;
367 }
368
369 .burger-menu span {
370 display: inline-flex;
371 width: 30px;
372 height: 4px;
373 background: color-mix(in srgb, CanvasText, #808080 25%);
374 border-radius: 2px;
375 margin: 3px 0;
376 transition: max-height 0.5s ease-in-out, opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
377 }
378 .main_navigation .menu-checkbox:not(:checked) ~ ul {
379 transform: translateY(-60px);
380 max-height: 0;
381 opacity: 0;
382 }
383
384 /* Show menu when checkbox is checked */
385 .main_navigation .menu-checkbox ~ ul {
386 max-height: 400px;
387 opacity: 1;
388 transform: translateY(0);
389 text-align: center;
390 }
391
392 .menu-checkbox:checked + .burger-menu span:nth-child(1) { transform: translateY(10px) rotate(45deg); }
393 .menu-checkbox:checked + .burger-menu span:nth-child(2) { opacity: 0; }
394 .menu-checkbox:checked + .burger-menu span:nth-child(3) { transform: translateY(-10px) rotate(-45deg); }
395}
396
397div#center_column {
398 padding-bottom: 40px;
399}
400
401@media(max-width: 1016px) {
402div#center_column {
403 padding: 0 15px 40px 15px;
404}
405}
406
407@media(min-width: 1016px) {
408 div#center_column {
409 position: absolute;
410 background-color: Canvas;
411 left: 200px;
412 width: 490px;
413 }
414}
415
416@media(min-width:1016px) {
417 div#right_column {
418 position: absolute;
419 background-color: Canvas;
420 padding-left: 55px;
421 left: 690px;
422 width: 155px;
423 height: 100px;
424 }
425}
426
427div.teaser_ruler {
428 border-top: 1px solid #CCCCCC;
429 border-bottom: 1px dashed #CCCCCC;
430 height: 3px;
431 margin-top: 30px;
432}
433
434div.article_partial {
435 margin-bottom: 30px;
436}
437
438
439div.article_partial p.excerpt {
440 color: CanvasText;
441}
442
443/* Search bar */
444@media(min-width:1016px) {
445 div#search {
446 position: absolute;
447 top: 145px;
448 left: 676px;
449 height: 25px;
450 vertical-align: top;
451 }
452}
453
454@media(max-width:1016px) {
455 div#search {
456 position: relative;
457 bottom: 5px;
458 left: 0px;
459 margin-left: 25px;
460 height: 25px;
461 vertical-align: top;
462 }
463}
464
465div#search input {
466 color: CanvasText !important;
467}
468div#search input[type=search],
469div#search input[type=text] {
470 display: block;
471 padding: 2px;
472 margin: 0px;
473 height: 25px;
474 width: 132px;
475 line-height: 20px;
476 border: solid #808080 1px;
477 background-color: Canvas;
478 border-radius: 5px;
479 margin-right: 5px;
480 text-indent: 0.5rem;
481}
482
483/* Header */
484#header img,
485#header > a {
486 display: block;
487 line-height: 0;
488}
489
490/* Main section */
491.article, .article_partial {
492 text-align: left;
493}
494
495h1, h2, h3 {
496 word-wrap: anywhere;
497 hyphens:auto;
498}
499
500.pagination {
501 margin-bottom: .5rem;
502}
503
504li {
505 line-height: 1.5rem;
506 margin-block-start: 1rem;
507 margin-block-end: 1rem;
508}
509
510#tags ul li {
511 margin-block-start: 0.5rem;
512 margin-block-end: 0.5rem;
513}
514
515/* Footer */
516#footer {
517 border-bottom: 2px solid #aeadad;
518 border-top: 2px solid #aeadad;
519 bottom: 0;
520 color: CanvasText;
521}
522
523#footer > br {
524 display: none;
525}
526
527#footer p {
528 margin: .5rem auto;
529}
530
531#footer a {
532 margin-left: 1rem;
533 margin-right: 1rem;
534 color: CanvasText;
535}
536
537/* Light and dark mode button magic */
538@media(max-width:1016px) {
539 div#light-mode-li {
540 position: absolute;
541 top: 145px;
542 left: 25px;
543 }
544}
545
546@media(min-width:1016px) {
547 div#light-mode-li {
548 position: absolute;
549 top: 145px;
550 left: 816px;
551 }
552}
553input#light-mode[type="checkbox"] {
554 display: none;
555}
556
557label[for=light-mode] {
558 font-size: 25px;
559 user-select: none;
560 filter: grayscale(1);
561}
562
563.hide-me {
564 display: none;
565}
566
567/* Temporary glowing style for easterhegg 22 */
568#eh22_icon img {
569 animation: animate 3s linear infinite;
570}
571
572@keyframes animate {
573 from {
574 filter: sepia(100%) hue-rotate(0deg);
575 }
576 to {
577 filter: sepia(100%) hue-rotate(360deg);
578 }
579}
diff --git a/public/stylesheets/ccc.css_old b/public/stylesheets/ccc.css_old
deleted file mode 100644
index 93651b9..0000000
--- a/public/stylesheets/ccc.css_old
+++ /dev/null
@@ -1,257 +0,0 @@
1body {
2 background-color: #FFFFFF;
3 margin: 0;
4 padding: 0;
5 text-align: center;
6 font-family: Verdana, Helvetica, Arial, sans-serif;
7 font-size: 11px;
8 line-height: 16px;
9 background-color: #ffffff;
10 color: #535353;
11}
12
13div#header {
14 height: 224px;
15}
16
17div#header img {
18 border: none;
19}
20
21div#wrapper {
22 position: relative;
23 width: 909px;
24 margin-left: 90px;
25 text-align: left;
26}
27
28pre {
29 overflow: auto;
30}
31
32.right {
33 text-align: right;
34}
35
36/*------------------links-------------------*/
37
38a {
39 color: #F8921E;
40 text-decoration: none;
41}
42
43a:visited {
44 color: #5b8ca7;
45}
46
47a:hover {
48 color: #5b8ca7;
49}
50
51/*------------------headlines-------------------*/
52
53h2 {
54 font-size: 15px;
55 line-height: 20px;
56 font-family: Helvetica;
57}
58
59div#center_column h2 a {
60 color: #535353;
61 text-decoration: none;
62}
63
64div#center_column h2 a:hover {
65 color: #8e8e8e;
66}
67
68div#center_column h2.headline {
69 margin-top: 10px;
70 padding-top: 30px;
71 border-top: 2px solid #cccccc;
72}
73
74div.article_partial h2 a {
75 text-decoration: none;
76}
77
78h3 {
79 font-size: 13px;
80 text-decoration: none;
81
82}
83
84h4 {
85 font-size: 11px;
86 text-decoration: bold;
87}
88
89/*------------------main-navigation-------------------*/
90
91div.main_navigation ul {
92 margin-left: 0;
93 padding-left: 0;
94 line-height: 18px;
95}
96
97div.main_navigation li {
98 list-style-type: none;
99}
100
101div.main_navigation a {
102 text-decoration: none;
103 font-size: 14px;
104}
105
106div.main_navigation a.inactive:hover {
107 color: #F8921E;
108}
109
110div.main_navigation a.active {
111 color: #000000;
112 text-decoration: none;
113 font-size: 13px;
114}
115
116div.main_navigation a.inactive {
117 color: #aeadad;
118 font-size: 13px;
119}
120
121/*------------------calendar-featured-tags-------------------*/
122
123div#frontpage_calendar {
124 margin-top: 30px;
125}
126
127div#frontpage_calendar h2, div#tags h2, div#featured_articles h2 {
128 color: #aeadad;
129 border-top: 2px solid #aeadad;
130 border-bottom: 2px solid #aeadad;
131 font-size: 16px;
132 padding-top: 2px;
133 padding-bottom: 2px;
134}
135
136div#frontpage_calendar ul, div#tags ul, div#featured_articles ul {
137 padding: 0px;
138 font-size: 11px;
139}
140
141div#featured_articles #ds_icon img {
142 padding-top: 10px;
143}
144
145div#featured_articles #events_icon img {
146 padding-left: 10px;
147}
148
149div#frontpage_calendar li {
150 margin-bottom: 20px;
151}
152
153div#frontpage_calendar li, div#tags li, div#featured_articles li {
154 list-style-type: none;
155}
156
157div#frontpage_calendar li a, div#tags li a, div#featured_articles li a {
158 text-decoration: none;
159 color: #535353;
160}
161
162div#frontpage_calendar li a:hover,
163div#tags li a:hover,
164div#featured_articles li a:hover {
165 text-decoration: none;
166 color: #ff9600;
167}
168
169/*--------------------------------------------------------------*/
170
171div.author_and_date {
172 font-style: italic;
173 padding-left: 15px;
174 font-family: Georgia;
175}
176
177div#left_column {
178 position: absolute;
179 background-image: url(/images/left_column.png);
180 background-position: top right;
181 background-repeat: no-repeat;
182 left: 0px;
183 width: 115px;
184 min-height: 100px;
185 text-align: right;
186 padding-right: 70px;
187}
188
189
190div#center_column {
191 position: absolute;
192 background-color: #ffffff;
193 left: 200px;
194 width: 460px;
195 padding-left: 15px;
196 padding-right: 15px;
197 padding-bottom: 40px;
198}
199
200div#right_column {
201 position: absolute;
202 background-color: #ffffff;
203 padding-left: 70px;
204 left: 675px;
205 width: 155px;
206 height: 100px;
207}
208
209div.teaser_ruler {
210 border-top: 1px solid #CCCCCC;
211 border-bottom: 1px dashed #CCCCCC;
212 height: 3px;
213 margin-top: 30px;
214}
215
216div.article_partial {
217 margin-bottom: 30px;
218}
219
220
221div.article_partial p.excerpt {
222 color: #404040;
223}
224
225div#center_column div.body,
226div#center_column div.abstract {
227 font-size: 11px;
228 line-height: 16px;
229}
230
231div#search {
232 position: absolute;
233 top: 145px;
234 left: 676px;
235 height: 20px;
236 vertical-align: top;
237}
238
239div#search input[type=button] {
240 display: block;
241 height: 20px;
242}
243
244div#search input[type=text] {
245 display: block;
246 padding: 0px;
247 margin: 0px;
248 height: 20px;
249 width: 132px;
250 line-height: 20px;
251 border: none;
252 background-image: url(/images/search_field.png);
253 background-repeat:no-repeat;
254 padding-right: 5px;
255 margin-right: 5px;
256 background-position: top top;
257}
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb
index e8bc55f..9731d08 100644
--- a/test/controllers/content_controller_test.rb
+++ b/test/controllers/content_controller_test.rb
@@ -73,8 +73,7 @@ class ContentControllerTest < ActionController::TestCase
73 def test_nonexistant_custom_template_defaults_to_standard_template 73 def test_nonexistant_custom_template_defaults_to_standard_template
74 new_node = create_node_under_root "fnord" 74 new_node = create_node_under_root "fnord"
75 draft = find_or_create_draft(new_node, @user1) 75 draft = find_or_create_draft(new_node, @user1)
76 draft.template_name = "huchibu" 76 draft.update_column(:template_name, "huchibu")
77 draft.save
78 new_node.publish_draft! 77 new_node.publish_draft!
79 78
80 get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } 79 get :render_page, params: { :locale => 'de', :page_path => ["fnord"] }
@@ -105,6 +104,16 @@ class ContentControllerTest < ActionController::TestCase
105 assert_response :success 104 assert_response :success
106 File.write("/tmp/no_fill_response.html", @response.body) 105 File.write("/tmp/no_fill_response.html", @response.body)
107 end 106 end
107
108 test "render_gallery renders for a published page" do
109 node = Node.root.children.create!(:slug => "gallery_render_test")
110 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Galerie") }
111 node.publish_draft!
112
113 get :render_gallery, params: { :page_path => "gallery_render_test", :locale => "de" }
114
115 assert_response :success
116 end
108 117
109 protected 118 protected
110 119
diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb
new file mode 100644
index 0000000..7dd8c9e
--- /dev/null
+++ b/test/controllers/csp_reports_controller_test.rb
@@ -0,0 +1,8 @@
1require 'test_helper'
2
3class CspReportsControllerTest < ActionController::TestCase
4 test "accepts anonymous reports without CSRF" do
5 post :create, body: '{"csp-report":{"violated-directive":"script-src"}}'
6 assert_response :no_content
7 end
8end
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 5b66bd3..ddc4565 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -140,7 +140,7 @@ class NodesControllerTest < ActionController::TestCase
140 :page => { 140 :page => {
141 :title => "Hello", 141 :title => "Hello",
142 :body => "There", 142 :body => "There",
143 :template_name => "Foobar" 143 :template_name => "title_only"
144 } 144 }
145 } 145 }
146 146
@@ -148,7 +148,17 @@ class NodesControllerTest < ActionController::TestCase
148 test_node.reload 148 test_node.reload
149 assert_equal "Hello", test_node.head.title 149 assert_equal "Hello", test_node.head.title
150 assert_equal "There", test_node.head.body 150 assert_equal "There", test_node.head.body
151 assert_equal "Foobar", test_node.head.template_name 151 assert_equal "title_only", test_node.head.template_name
152 end
153
154 def test_update_rejects_a_template_name_not_on_disk
155 test_node = Node.root.children.create! :slug => "test_node"
156 login_as :quentin
157 put :update, params: { :id => test_node.id,
158 :page => { :title => "Hello", :template_name => "Foobar" } }
159
160 test_node.reload
161 assert_not_equal "Foobar", test_node.draft&.template_name
152 end 162 end
153 163
154 test "publish draft with staged_slug unqueal slug" do 164 test "publish draft with staged_slug unqueal slug" do
@@ -417,11 +427,11 @@ class NodesControllerTest < ActionController::TestCase
417 test "show never renders a destroy link for events" do 427 test "show never renders a destroy link for events" do
418 login_as :quentin 428 login_as :quentin
419 node = create_node_with_published_page 429 node = create_node_with_published_page
420 Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) 430 event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour)
421 431
422 get :show, params: { id: node.id } 432 get :show, params: { id: node.id }
423 assert_response :success 433 assert_response :success
424 assert_select "form.button_to.destructive", count: 0 434 assert_select "form[action=?]", event_path(event), count: 0
425 end 435 end
426 436
427 test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do 437 test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do
@@ -469,7 +479,8 @@ class NodesControllerTest < ActionController::TestCase
469 login_as :quentin 479 login_as :quentin
470 get :show, params: { :id => node.id } 480 get :show, params: { :id => node.id }
471 assert_response :success 481 assert_response :success
472 assert_select "form.destructive", :count => 0 482 assert_select "form[action=?]", revert_node_path(node), count: 0
483 assert_select "form[action=?]", trash_node_path(node), count: 1
473 end 484 end
474 485
475 test "drafts includes a never-published node with only a draft" do 486 test "drafts includes a never-published node with only a draft" do
@@ -662,4 +673,88 @@ class NodesControllerTest < ActionController::TestCase
662 assert_equal "Brand New", action.metadata["title"] 673 assert_equal "Brand New", action.metadata["title"]
663 assert_equal Node.last.unique_name, action.metadata["path"] 674 assert_equal Node.last.unique_name, action.metadata["path"]
664 end 675 end
676
677 test "trash moves the node and redirects to the Trash" do
678 login_as :quentin
679 node = Node.root.children.create!(:slug => "trash_me")
680
681 put :trash, params: { :id => node.id }
682
683 assert_redirected_to trashed_nodes_path
684 assert node.reload.in_trash?
685 end
686
687 test "trashing the Trash node itself is refused" do
688 login_as :quentin
689
690 put :trash, params: { :id => Node.trash.id }
691
692 assert_redirected_to node_path(Node.trash)
693 assert flash[:error].present?
694 end
695
696 test "restore_from_trash reparents to the given parent" do
697 login_as :quentin
698 node = Node.root.children.create!(:slug => "restore_me")
699 node.trash!(users(:quentin))
700 target = Node.root.children.create!(:slug => "restore_home")
701
702 put :restore_from_trash, params: { :id => node.id, :parent_id => target.id }
703
704 assert_redirected_to node_path(node)
705 assert_equal target, node.reload.parent
706 end
707
708 test "destroy refuses a node outside the Trash" do
709 login_as :quentin
710 node = Node.root.children.create!(:slug => "not_deletable_here")
711
712 delete :destroy, params: { :id => node.id }
713
714 assert Node.exists?(node.id)
715 assert flash[:error].present?
716 end
717
718 test "destroy deletes a trashed node and redirects to the Trash" do
719 login_as :quentin
720 node = Node.root.children.create!(:slug => "deletable")
721 node.trash!(users(:quentin))
722
723 delete :destroy, params: { :id => node.id }
724
725 assert_not Node.exists?(node.id)
726 assert_redirected_to trashed_nodes_path
727 end
728
729 test "trash lists trashed subtree roots" do
730 login_as :quentin
731 node = Node.root.children.create!(:slug => "listed_in_trash")
732 node.trash!(users(:quentin))
733
734 get :trashed
735 assert_response :success
736 assert_select "a[href=?]", node_path(node)
737 end
738
739 test "trashed rows carry provenance and a delete for childless roots" do
740 login_as :quentin
741 node = Node.root.children.create!(:slug => "provenance_test")
742 node.trash!(users(:quentin))
743
744 get :trashed
745 assert_select "td", /quentin/
746 assert_select "form[action=?]", node_path(node), count: 1
747 end
748
749test "show annotates history rows with their lifecycle" do
750 login_as :quentin
751 node = Node.root.children.create!(:slug => "history_annotation_test")
752 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Annotated") }
753 node.publish_draft!(users(:quentin))
754
755 get :show, params: { :id => node.id }
756
757 assert_response :success
758 assert_select "span.revision_lifecycle", /quentin/
759 end
665end 760end
diff --git a/test/fixtures/nodes.yml b/test/fixtures/nodes.yml
index 9ca325e..e94f10f 100644
--- a/test/fixtures/nodes.yml
+++ b/test/fixtures/nodes.yml
@@ -2,8 +2,6 @@
2 2
3root: 3root:
4 id: 1 4 id: 1
5 lft: 1
6 rgt: 10
7 parent_id: 5 parent_id:
8 slug: 6 slug:
9 unique_name: 7 unique_name:
@@ -11,8 +9,6 @@ root:
11 9
12first_child: 10first_child:
13 id: 2 11 id: 2
14 lft: 2
15 rgt: 3
16 parent_id: 1 12 parent_id: 1
17 draft_id: 100 13 draft_id: 100
18 slug: first_child 14 slug: first_child
@@ -20,8 +16,6 @@ first_child:
20 16
21second_child: 17second_child:
22 id: 3 18 id: 3
23 lft: 4
24 rgt: 5
25 parent_id: 1 19 parent_id: 1
26 draft_id: 101 20 draft_id: 101
27 slug: second_child 21 slug: second_child
@@ -29,8 +23,6 @@ second_child:
29 23
30third_child: 24third_child:
31 id: 4 25 id: 4
32 lft: 6
33 rgt: 7
34 parent_id: 1 26 parent_id: 1
35 draft_id: 102 27 draft_id: 102
36 slug: third_child 28 slug: third_child
@@ -38,8 +30,6 @@ third_child:
38 30
39fourth_child: 31fourth_child:
40 id: 5 32 id: 5
41 lft: 8
42 rgt: 9
43 parent_id: 1 33 parent_id: 1
44 slug: fourth_child 34 slug: fourth_child
45 unique_name: fourth_child 35 unique_name: fourth_child
diff --git a/test/integration/csp_header_test.rb b/test/integration/csp_header_test.rb
new file mode 100644
index 0000000..73707ed
--- /dev/null
+++ b/test/integration/csp_header_test.rb
@@ -0,0 +1,12 @@
1require 'test_helper'
2
3class CspHeaderTest < ActionDispatch::IntegrationTest
4 test "public responses carry the report-only CSP header with a nonce" do
5 get "/"
6
7 header = response.headers["Content-Security-Policy-Report-Only"]
8 assert header.present?
9 assert_includes header, "script-src"
10 assert_includes header, "nonce-"
11 end
12end
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb
index 54c4c22..d747171 100644
--- a/test/models/concerns/rrule_humanizer_test.rb
+++ b/test/models/concerns/rrule_humanizer_test.rb
@@ -114,4 +114,11 @@ class RruleHumanizerTest < ActiveSupport::TestCase
114 test "wday_abbr falls back to :de for an unrecognized locale" do 114 test "wday_abbr falls back to :de for an unrecognized locale" do
115 assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) 115 assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr)
116 end 116 end
117
118 test "monthly two selected weeks" do
119 assert_equal "Jeden ersten und dritten Dienstag im Monat",
120 humanize("FREQ=MONTHLY;BYDAY=1TU,3TU")
121 assert_equal "Every first and third Tuesday of the month",
122 humanize("FREQ=MONTHLY;BYDAY=1TU,3TU", :en)
123 end
117end 124end
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
index 5244c19..1b72ec9 100644
--- a/test/models/helpers/node_actions_helper_test.rb
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -10,6 +10,10 @@ class NodeActionsHelperTest < ActionView::TestCase
10 { :locale => nil } 10 { :locale => nil }
11 end 11 end
12 12
13 def icon(_name, **)
14 '<svg class="stub-icon"></svg>'.html_safe
15 end
16
13 def teardown 17 def teardown
14 I18n.locale = @original_locale 18 I18n.locale = @original_locale
15 end 19 end
@@ -104,4 +108,39 @@ class NodeActionsHelperTest < ActionView::TestCase
104 assert_includes action_summary(e), "a/b" 108 assert_includes action_summary(e), "a/b"
105 assert_not action_details?(e) 109 assert_not action_details?(e)
106 end 110 end
111
112 test "trash, restore, and destroy render their paths" do
113 trash = entry("trash", { "path" => { "from" => "club/old", "to" => "trash/old" } })
114 restore = entry("restore_from_trash", { "path" => { "from" => "trash/old", "to" => "club/new" } })
115 doomed = entry("destroy", { "path" => "trash/old" })
116
117 assert_includes action_summary(trash), "club/old"
118 assert_includes action_summary(restore), "club/new"
119 assert_includes action_summary(doomed), "trash/old"
120 end
121
122 test "revision lifecycle badges cover create, publish, rollback, and inference" do
123 created = entry("create", { "title" => "x", "path" => "a/b" })
124 published = entry("publish", { "via" => "draft", "title" => { "from" => nil, "to" => "x" } })
125 restored = entry("publish", { "via" => "revision", "title" => { "from" => "y", "to" => "x" } })
126 restored.update!(:inferred_from => "from_page_revision")
127
128 out = revision_lifecycle_badges([created, published, restored])
129
130 assert_includes out, "created"
131 assert_includes out, "published"
132 assert_includes out, "restored"
133 assert_includes out, "quentin"
134 assert_includes out, "node_action_inferred"
135 assert_includes out, "<span"
136 assert_not_includes out, "&lt;span"
137
138 assert_equal "", revision_lifecycle_badges(nil)
139 end
140
141 test "verb icons map known verbs, distinguish rollbacks, and fall back" do
142 assert_includes verb_icon(entry("trash", { "path" => { "from" => "a", "to" => "t/a" } })), "node_action_icon--trash"
143 assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history"
144 assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed"
145 end
107end 146end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index c8e49e5..ab66f81 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -761,4 +761,105 @@ class NodeTest < ActiveSupport::TestCase
761 assert_equal "Second", action.metadata.dig("title", "from") 761 assert_equal "Second", action.metadata.dig("title", "from")
762 assert_equal "First", action.metadata.dig("title", "to") 762 assert_equal "First", action.metadata.dig("title", "to")
763 end 763 end
764
765 test "default_template_name rejects values not present in the template directory" do
766 node = Node.root.children.build(:slug => "template_guard_test",
767 :default_template_name => "../../layouts/admin")
768 assert_not node.valid?
769
770 node.default_template_name = "standard_template"
771 assert node.valid?
772 end
773
774 test "destroying a node with children is refused" do
775 parent = Node.root.children.create!(:slug => "destroy_guard_parent")
776 parent.children.create!(:slug => "destroy_guard_child")
777
778 assert_no_difference "Node.count" do
779 assert_not parent.destroy
780 end
781 assert parent.errors[:base].any?
782 end
783
784 test "destroying a childless node leaves no orphaned pages or asset links" do
785 node = create_node_with_published_page
786 page_ids = node.pages.pluck(:id)
787 asset = Asset.create!(:name => "destroy cascade probe",
788 :upload_file_name => "test_image.png",
789 :upload_content_type => "image/png",
790 :upload_file_size => 49854,
791 :upload_updated_at => Time.current)
792 node.head.related_assets.create!(:asset_id => asset.id, :position => 1)
793
794 node.destroy
795
796 assert_equal 0, Page.where(:id => page_ids).count
797 assert_equal 0, RelatedAsset.where(:page_id => page_ids).count
798 end
799
800 test "destroying a node also removes its autosave page" do
801 node = create_node_with_published_page
802 node.lock_for_editing!(@user1)
803 node.autosave!({:title => "in flight"}, @user1)
804 autosave_id = node.autosave_id
805
806 node.destroy
807
808 assert_equal 0, Page.where(:id => autosave_id).count
809 end
810
811 test "Node.trash lazily creates the container exactly once" do
812 assert_difference "Node.count", 1 do
813 Node.trash
814 end
815 assert_no_difference "Node.count" do
816 assert_equal Node.trash, Node.trash
817 end
818 assert Node.trash.trash_node?
819 assert_not Node.trash.in_trash?
820 assert_equal "Trash", Globalize.with_locale(I18n.default_locale) { Node.trash.draft.title }
821 end
822
823 test "in_trash? walks the whole parent chain" do
824 child = Node.trash.children.create!(:slug => "trashed_thing")
825 grandchild = child.children.create!(:slug => "trashed_deeper")
826
827 assert child.in_trash?
828 assert grandchild.in_trash?
829 assert_not Node.root.children.create!(:slug => "living_thing").in_trash?
830 end
831
832 test "the reserved slug is refused for other root children, live and staged" do
833 Node.trash
834
835 assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid?
836 assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid?
837 assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid?
838 end
839
840 test "the Trash node refuses rename, move, and destroy" do
841 trash = Node.trash
842 other = Node.root.children.create!(:slug => "not_the_trash")
843
844 trash.slug = "recycling"
845 assert_not trash.valid?
846
847 trash.reload.parent_id = other.id
848 assert_not trash.valid?
849
850 assert_no_difference "Node.count" do
851 assert_not trash.reload.destroy
852 end
853 end
854
855 test "a head cannot exist inside the Trash, and publishing there is refused" do
856 node = Node.trash.children.create!(:slug => "no_publish_here")
857 page = node.pages.create!
858
859 node.head = page
860 assert_not node.valid?
861
862 node.reload
863 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! }
864 end
764end 865end
diff --git a/test/models/node_trash_test.rb b/test/models/node_trash_test.rb
new file mode 100644
index 0000000..52069d7
--- /dev/null
+++ b/test/models/node_trash_test.rb
@@ -0,0 +1,168 @@
1require "test_helper"
2
3class NodeTrashTest < ActiveSupport::TestCase
4
5 def setup
6 @user1 = User.create!(:login => "trasher", :email => "t@example.com",
7 :password => "foobar", :password_confirmation => "foobar")
8 end
9
10 test "trash! demotes the head into the draft slot when no draft exists" do
11 node = create_node_with_published_page
12 former_head = node.head
13
14 node.trash!(@user1)
15 node.reload
16
17 assert_nil node.head_id
18 assert_equal former_head, node.draft
19 assert node.in_trash?
20 end
21
22 test "trash! keeps an existing draft; the former head stays a plain revision" do
23 node = create_node_with_published_page
24 draft = find_or_create_draft(node, @user1)
25 former_head = node.head
26
27 node.trash!(@user1)
28 node.reload
29
30 assert_nil node.head_id
31 assert_equal draft, node.draft
32 assert_includes node.pages, former_head
33 end
34
35 test "trash! demotes descendant heads and counts them in the log entry" do
36 parent = create_node_with_published_page
37 child = parent.children.create!(:slug => "trashed_child")
38 child.draft.update!(:title => "Child")
39 child.publish_draft!(@user1)
40
41 parent.trash!(@user1)
42
43 action = NodeAction.where(:action => "trash").last
44 assert_equal parent, action.node
45 assert_equal 2, action.metadata["demoted_heads"]
46 assert action.metadata["was_published"]
47 assert_nil child.reload.head_id
48 assert child.in_trash?
49 end
50
51 test "trash! logs one entry with the path pair and updates unique names" do
52 node = create_node_with_published_page
53 path_before = node.unique_name
54
55 assert_difference "NodeAction.count", 1 do
56 node.trash!(@user1)
57 end
58
59 action = NodeAction.last
60 assert_equal path_before, action.metadata.dig("path", "from")
61 assert_equal node.reload.unique_name, action.metadata.dig("path", "to")
62 assert action.metadata.dig("path", "to").start_with?(CccConventions::TRASH_SLUG)
63 end
64
65 test "trash! on an already trashed node is a logged-nothing no-op" do
66 node = create_node_with_published_page
67 node.trash!(@user1)
68
69 assert_no_difference "NodeAction.count" do
70 assert_nil node.reload.trash!(@user1)
71 end
72 end
73
74 test "restore_from_trash! reparents as drafts without republishing" do
75 node = create_node_with_published_page
76 node.trash!(@user1)
77 target = Node.root.children.create!(:slug => "restore_target")
78
79 node.reload.restore_from_trash!(target, @user1)
80 node.reload
81
82 assert_equal target, node.parent
83 assert_not node.in_trash?
84 assert_nil node.head_id
85 assert_not_nil node.draft_id
86 assert_equal "restore_from_trash", NodeAction.last.action
87 end
88
89 test "restore_from_trash! refuses targets inside the Trash and the Trash itself" do
90 node = create_node_with_published_page
91 node.trash!(@user1)
92 other_trashed = Node.trash.children.create!(:slug => "also_trashed")
93
94 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(Node.trash, @user1) }
95 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(other_trashed, @user1) }
96 end
97
98 test "destroy_from_trash! refuses nodes outside the Trash" do
99 node = create_node_with_published_page
100
101 assert_raises(ActiveRecord::RecordInvalid) { node.destroy_from_trash!(@user1) }
102 assert Node.exists?(node.id)
103 end
104
105 test "destroy_from_trash! removes the whole subtree bottom-up with one entry" do
106 parent = create_node_with_published_page
107 child = parent.children.create!(:slug => "doomed_child")
108 grandchild = child.children.create!(:slug => "doomed_grandchild")
109 page_ids = [parent, child, grandchild].flat_map { |n| n.pages.pluck(:id) }
110 parent.trash!(@user1)
111
112 assert_difference "NodeAction.count", 1 do
113 parent.reload.destroy_from_trash!(@user1)
114 end
115
116 assert_not Node.exists?(parent.id)
117 assert_not Node.exists?(child.id)
118 assert_not Node.exists?(grandchild.id)
119 assert_equal 0, Page.where(:id => page_ids).count
120
121 action = NodeAction.where(:action => "destroy").last
122 assert_equal 2, action.metadata["destroyed_descendants"]
123 assert_nil action.node_id
124 end
125
126 test "destroy_from_trash! logs an entry that survives the node" do
127 node = create_node_with_published_page
128 Globalize.with_locale(I18n.default_locale) { node.head.update!(:title => "Doomed") }
129 node.trash!(@user1)
130 final_path = node.reload.unique_name
131
132 node.destroy_from_trash!(@user1)
133
134 action = NodeAction.where(:action => "destroy").last
135 assert_nil action.node_id
136 assert_equal final_path, action.metadata["path"]
137 assert_equal "Doomed", action.subject_name
138 assert_equal @user1, action.user
139 end
140
141 test "suggested_restore_parent finds the old parent while it lives" do
142 parent = Node.root.children.create!(:slug => "old_home")
143 node = parent.children.create!(:slug => "comes_back")
144 node.trash!(@user1)
145
146 assert_equal parent, node.reload.suggested_restore_parent
147
148 parent.trash!(@user1)
149 assert_nil node.reload.suggested_restore_parent
150 end
151
152 test "suggested_restore_parent is root for trashed top-level nodes" do
153 node = Node.root.children.create!(:slug => "was_top_level")
154 node.trash!(@user1)
155
156 assert_equal Node.root, node.reload.suggested_restore_parent
157 end
158
159 test "trashed nodes and the Trash itself stay out of the drafts surfaces" do
160 Node.trash
161 node = create_node_with_published_page
162 node.trash!(@user1)
163
164 ids = Node.drafts_and_autosaves.pluck(:id)
165 assert_not_includes ids, Node.trash.id
166 assert_not_includes ids, node.id
167 end
168end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index edb7c37..1f924f9 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -370,4 +370,39 @@ class PageTest < ActiveSupport::TestCase
370 assert en_entry[:changed] 370 assert en_entry[:changed]
371 refute en_entry[:exists_there] 371 refute en_entry[:exists_there]
372 end 372 end
373
374 test "aggregate ignores order_by values outside the allowlist" do
375 sql = Page.aggregate(:order_by => "pages.id; DROP TABLE pages--").to_sql
376
377 assert_not_includes sql, "DROP"
378 assert_includes sql, "pages.id ASC"
379 end
380
381 test "aggregate accepts allowlisted order columns, bare or prefixed" do
382 assert_includes Page.aggregate(:order_by => "published_at").to_sql,
383 "pages.published_at ASC"
384 assert_includes Page.aggregate(:order_by => "pages.published_at").to_sql,
385 "pages.published_at ASC"
386 end
387
388 test "template_name rejects values not present in the template directory" do
389 page = Page.create!(:title => "Template guard")
390
391 page.template_name = "../../partials/_article"
392 assert_not page.valid?
393
394 page.template_name = "standard_template"
395 assert page.valid?
396
397 page.template_name = ""
398 assert page.valid?
399 end
400
401 test "a stale legacy template_name does not block unrelated saves" do
402 page = Page.create!(:title => "Stale template")
403 page.update_column(:template_name, "long_deleted_template")
404
405 page.reload
406 assert page.update(:abstract => "still saveable")
407 end
373end 408end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 6e4d2d7..feccce2 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -54,6 +54,78 @@ class UserTest < ActiveSupport::TestCase
54 def test_should_authenticate_user 54 def test_should_authenticate_user
55 assert_equal users(:quentin), User.authenticate('quentin', 'monkey') 55 assert_equal users(:quentin), User.authenticate('quentin', 'monkey')
56 end 56 end
57
58 def test_should_not_authenticate_wrong_password
59 assert_nil User.authenticate("quentin", "wrong password")
60 end
61
62 def test_should_not_authenticate_unknown_user
63 assert_nil User.authenticate("nosuchuser", "monkey")
64 end
65
66 def test_user_with_crypted_password_is_migrated_on_login
67 user = users(:quentin)
68
69 assert_nil user.password_digest
70
71 assert User.authenticate("quentin", "monkey")
72
73 user.reload
74
75 assert_not_nil user.password_digest
76 assert_nil user.crypted_password
77 assert_nil user.salt
78 end
79
80 def test_new_user_uses_password_digest
81 user = create_user
82
83 assert_not_nil user.password_digest
84 assert_nil user.crypted_password
85 assert_nil user.salt
86
87 assert_equal user, User.authenticate("quire", "quire69")
88 end
89
90 def test_legacy_user_is_migrated_on_login
91 user = users(:quentin)
92
93 assert_nil user.password_digest
94 assert_not_nil user.crypted_password
95 assert_not_nil user.salt
96
97 assert_equal user, User.authenticate("quentin", "monkey")
98
99 user.reload
100
101 assert_not_nil user.password_digest
102 assert_nil user.crypted_password
103 assert_nil user.salt
104 end
105
106 def test_migrated_user_authenticates_using_password_digest
107 user = users(:quentin)
108
109 # Trigger automatic migration.
110 assert_equal user, User.authenticate("quentin", "monkey")
111
112 user.reload
113
114 assert_not_nil user.password_digest
115 assert_nil user.crypted_password
116 assert_nil user.salt
117
118 # Second login should now use password_digest.
119 assert_equal user, User.authenticate("quentin", "monkey")
120 end
121
122 def test_migrated_user_can_be_updated_without_password
123 user = users(:quentin)
124 assert_equal user, User.authenticate("quentin", "monkey")
125 user.reload
126
127 assert user.update(:email => "quentin@example.org")
128 end
57 129
58protected 130protected
59 def create_user(options = {}) 131 def create_user(options = {})