aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_controller/upload_progress.rb
diff options
context:
space:
mode:
Diffstat (limited to 'actionpack/lib/action_controller/upload_progress.rb')
-rw-r--r--actionpack/lib/action_controller/upload_progress.rb410
1 files changed, 410 insertions, 0 deletions
diff --git a/actionpack/lib/action_controller/upload_progress.rb b/actionpack/lib/action_controller/upload_progress.rb
new file mode 100644
index 0000000000..ce8d51c67d
--- /dev/null
+++ b/actionpack/lib/action_controller/upload_progress.rb
@@ -0,0 +1,410 @@
+# Unfortunately we need to require multipart_progress here and not in
+# uplaod_status_for because if the upload happens to hit a fresh FCGI instance
+# the upload_status_for method will be called after the CGI object is created
+# Requiring here means that multipart progress will be enabled for all multipart
+# postings.
+require 'action_controller/cgi_ext/multipart_progress'
+
+module ActionController #:nodoc:
+ # == Action Pack Upload Progress for multipart uploads
+ #
+ # The UploadProgress module aids in the process of viewing an Ajax driven
+ # upload status when working with multipart forms. It offers a macro that
+ # will prepare an action for handling the cleanup of the Ajax updating including
+ # passing the redirect URL and custom parameters to the Javascript finish handler.
+ #
+ # UploadProgress is available for all multipart uploads when the +upload_status_for+
+ # macro is called in one of your controllers.
+ #
+ # The progress is stored as an UploadProgress::Progress object in the session and
+ # is accessible in the controller and view with the +upload_progress+ method.
+ #
+ # For help rendering the UploadProgress enabled form and supported elements, see
+ # ActionView::Helpers::UploadProgressHelper.
+ #
+ # === Automatic updating on upload actions
+ #
+ # class DocumentController < ApplicationController
+ # upload_status_for :create
+ #
+ # def create
+ # # ... Your document creation action
+ # end
+ # end
+ #
+ # The +upload_status_for+ macro will override the rendering of the action passed
+ # if +upload_id+ is found in the query string. This allows for default
+ # behavior if Javascript is disabled. If you are tracking the upload progress
+ # then +create+ will now return the cleanup scripts that will terminate the polling
+ # of the upload status.
+ #
+ # === Customized status rendering
+ #
+ # class DocumentController < ApplicationController
+ # upload_status_for :create, :status => :custom_status
+ #
+ # def create
+ # # ... Your document creation action
+ # end
+ #
+ # def custom_status
+ # # ... Override this action to return content to be replaced in
+ # # the status container
+ # render :inline => "<%= upload_progress.completed_percent rescue 0 %> % complete", :layout => false
+ # end
+ #
+ # The default status action is +upload_status+. The results of this action
+ # are added used to replace the contents of the HTML elements defined in
+ # +upload_status_tag+. Within +upload_status+, you can load the Progress
+ # object from the session with the +upload_progress+ method and display your own
+ # results.
+ #
+ # Completion of the upload status updating occurs automatically with an +after_filter+ call to
+ # +finish_upload_status+. Because the upload must be posted into a hidden IFRAME to enable
+ # Ajax updates during the upload, +finish_upload_status+ overwrites the results of any previous
+ # +render+ or +redirect_to+ so it can render the necessary Javascript that will properly terminate
+ # the status updating loop, trigger the completion callback or redirect to the appropriate URL.
+ #
+ # ==== Basic Example (View):
+ #
+ # <%= form_tag_with_upload_progress({:action => 'create'}, {:finish => 'alert("Document Uploaded")'}) %>
+ # <%= upload_status_tag %>
+ # <%= file_field 'document', 'file' %>
+ # <%= end_form_tag %>
+ #
+ # ==== Basic Example (Controller):
+ #
+ # class DocumentController < ApplicationController
+ # upload_status_for :create
+ #
+ # def create
+ # @document = Document.create(params[:document])
+ # end
+ # end
+ #
+ # ==== Extended Example (View):
+ #
+ # <%= form_tag_with_upload_progress({:action => 'create'}, {}, {:action => :custom_status}) %>
+ # <%= upload_status_tag %>
+ # <%= file_field 'document', 'file' %>
+ # <%= submit_tag "Upload" %>
+ # <%= end_form_tag %>
+ #
+ # <%= form_tag_with_upload_progress({:action => 'add_preview'}, {:finish => 'alert(arguments[0])'}, {:action => :custom_status}) %>
+ # <%= upload_status_tag %>
+ # <%= submit_tag "Upload" %>
+ # <%= file_field 'preview', 'file' %>
+ # <%= end_form_tag %>
+ #
+ # ==== Extended Example (Controller):
+ #
+ # class DocumentController < ApplicationController
+ # upload_status_for :add_preview, :create, {:status => :custom_status}
+ #
+ # def add_preview
+ # @document = Document.find(params[:id])
+ # @document.preview = Preview.create(params[:preview])
+ # if @document.save
+ # finish_upload_status "'Preview added'"
+ # else
+ # finish_upload_status "'Preview not added'"
+ # end
+ # end
+ #
+ # def create
+ # @document = Document.new(params[:document])
+ #
+ # upload_progress.message = "Processing document..."
+ # session.update
+ #
+ # @document.save
+ # redirect_to :action => 'show', :id => @document.id
+ # end
+ #
+ # def custom_status
+ # render :inline => '<%= upload_progress_status %> <div>Updated at <%= Time.now %></div>', :layout => false
+ # end
+ #
+ #
+ module UploadProgress
+
+ def self.append_features(base) #:nodoc:
+ super
+ base.extend(ClassMethods)
+ base.helper_method :upload_progress, :next_upload_id, :last_upload_id, :current_upload_id
+ end
+
+ module ClassMethods
+ # Creates an +after_filter+ which will call +finish_upload_status+
+ # creating the document that will be loaded into the hidden IFRAME, terminating
+ # the status polling forms created with +form_with_upload_progress+.
+ #
+ # Also defines an action +upload_status+ or a action name passed as
+ # the <tt>:status</tt> option. This status action must match the one expected
+ # in the +form_tag_with_upload_progress+ helper.
+ #
+ def upload_status_for(*actions)
+ after_filter :finish_upload_status, :only => actions
+
+ define_method(actions.last.is_a?(Hash) && actions.last[:status] || :upload_status) do
+ render(:inline => '<%= upload_progress_status %>', :layout => false)
+ end
+ end
+ end
+
+ # Overwrites the body rendered if the upload comes from a form that tracks
+ # the progress of the upload. After clearing the body and any redirects, this
+ # method then renders the helper +finish_upload_status+
+ #
+ # This method only needs to be called if you wish to pass a
+ # javascript parameter to your finish event handler that you optionally
+ # define in +form_with_upload_progress+
+ #
+ # === Parameter:
+ #
+ # client_js_argument:: a string containing a Javascript expression that will
+ # be evaluated and passed to your +finish+ handler of
+ # +form_tag_with_upload_progress+.
+ #
+ # You can pass a String, Number or Boolean.
+ #
+ # === Strings
+ #
+ # Strings contain Javascript code that will be evaluated on the client. If you
+ # wish to pass a string to the client finish callback, you will need to include
+ # quotes in the +client_js_argument+ you pass to this method.
+ #
+ # ==== Example
+ #
+ # finish_upload_status("\"Finished\"")
+ # finish_upload_status("'Finished #{@document.title}'")
+ # finish_upload_status("{success: true, message: 'Done!'}")
+ # finish_upload_status("function() { alert('Uploaded!'); }")
+ #
+ # === Numbers / Booleans
+ #
+ # Numbers and Booleans can either be passed as Number objects or string versions
+ # of number objects as they are evaluated by Javascript the same way as in Ruby.
+ #
+ # ==== Example
+ #
+ # finish_upload_status(0)
+ # finish_upload_status(@document.file.size)
+ # finish_upload_status("10")
+ #
+ # === Nil
+ #
+ # To pass +nil+ to the finish callback, use a string "undefined"
+ #
+ # ==== Example
+ #
+ # finish_upload_status(@message || "undefined")
+ #
+ # == Redirection
+ #
+ # If you action performs a redirection then +finish_upload_status+ will recognize
+ # the redirection and properly create the Javascript to perform the redirection in
+ # the proper location.
+ #
+ # It is possible to redirect and pass a parameter to the finish callback.
+ #
+ # ==== Example
+ #
+ # redirect_to :action => 'show', :id => @document.id
+ # finish_upload_status("'Redirecting you to your new file'")
+ #
+ #
+ def finish_upload_status(client_js_argument='')
+ if not @rendered_finish_upload_status and params[:upload_id]
+ @rendered_finish_upload_status = true
+
+ erase_render_results
+ location = erase_redirect_results || ''
+
+ ## TODO determine if #inspect is the appropriate way to marshall values
+ ## in inline templates
+
+ template = "<%= finish_upload_status({"
+ template << ":client_js_argument => #{client_js_argument.inspect}, "
+ template << ":redirect_to => #{location.to_s.inspect}, "
+ template << "}) %>"
+
+ render({ :inline => template, :layout => false })
+ end
+ end
+
+ # Returns and saves the next unique +upload_id+ in the instance variable
+ # <tt>@upload_id</tt>
+ def next_upload_id
+ @upload_id = last_upload_id.succ
+ end
+
+ # Either returns the last saved +upload_id+ or looks in the session
+ # for the last used +upload_id+ and saves it as the intance variable
+ # <tt>@upload_id</tt>
+ def last_upload_id
+ @upload_id ||= ((session[:uploads] || {}).keys.map{|k| k.to_i}.sort.last || 0).to_s
+ end
+
+ # Returns the +upload_id+ from the query parameters or if it cannot be found
+ # in the query parameters, then return the +last_upload_id+
+ def current_upload_id
+ params[:upload_id] or last_upload_id
+ end
+
+ # Get the UploadProgress::Progress object for the supplied +upload_id+ from the
+ # session. If no +upload_id+ is given, then use the +current_upload_id+
+ #
+ # If an UploadProgress::Progress object cannot be found, a new instance will be
+ # returned with <code>total_bytes == 0</code>, <code>started? == false</code>,
+ # and <code>finished? == true</code>.
+ def upload_progress(upload_id = nil)
+ upload_id ||= current_upload_id
+ session[:uploads] && session[:uploads][upload_id] || UploadProgress::Progress.new(0)
+ end
+
+ # Upload Progress abstracts the progress of an upload. It's used by the
+ # multipart progress IO that keeps track of the upload progress and creating
+ # the application depends on. It contians methods to update the progress
+ # during an upload and read the statistics such as +received_bytes+,
+ # +total_bytes+, +completed_percent+, +bitrate+, and
+ # +remaining_seconds+
+ #
+ # You can get the current +Progress+ object by calling +upload_progress+ instance
+ # method in your controller or view.
+ #
+ class Progress
+ unless const_defined? :MIN_SAMPLE_TIME
+ # Number of seconds between bitrate samples. Updates that occur more
+ # frequently than +MIN_SAMPLE_TIME+ will not be queued until this
+ # time passes. This behavior gives a good balance of accuracy and load
+ # for both fast and slow transfers.
+ MIN_SAMPLE_TIME = 0.150
+
+ # Number of seconds between updates before giving up to try and calculate
+ # bitrate anymore
+ MIN_STALL_TIME = 10.0
+
+ # Number of samples used to calculate bitrate
+ MAX_SAMPLES = 20
+ end
+
+ # Number bytes received from the multipart post
+ attr_reader :received_bytes
+
+ # Total number of bytes expected from the mutlipart post
+ attr_reader :total_bytes
+
+ # The last time the upload history was updated
+ attr_reader :last_update_time
+
+ # A message you can set from your controller or view to be rendered in the
+ # +upload_status_text+ helper method. If you set a messagein a controller
+ # then call <code>session.update</code> to make that message available to
+ # your +upload_status+ action.
+ attr_accessor :message
+
+ # Create a new Progress object passing the expected number of bytes to receive
+ def initialize(total)
+ @total_bytes = total
+ reset!
+ end
+
+ # Resets the received_bytes, last_update_time, message and bitrate, but
+ # but maintains the total expected bytes
+ def reset!
+ @received_bytes, @last_update_time, @stalled, @message = 0, 0, false, ''
+ reset_history
+ end
+
+ # Number of bytes left for this upload
+ def remaining_bytes
+ @total_bytes - @received_bytes
+ end
+
+ # Completed percent in integer form from 0..100
+ def completed_percent
+ (@received_bytes * 100 / @total_bytes).to_i rescue 0
+ end
+
+ # Updates this UploadProgress object with the number of bytes received
+ # since last update time and the absolute number of seconds since the
+ # beginning of the upload.
+ #
+ # This method is used by the +MultipartProgress+ module and should
+ # not be called directly.
+ def update!(bytes, elapsed_seconds)#:nodoc:
+ if @received_bytes + bytes > @total_bytes
+ #warn "Progress#update received bytes exceeds expected bytes"
+ bytes = @total_bytes - @received_bytes
+ end
+
+ @received_bytes += bytes
+
+ # Age is the duration of time since the last update to the history
+ age = elapsed_seconds - @last_update_time
+
+ # Record the bytes received in the first element of the history
+ # in case the sample rate is exceeded and we shouldn't record at this
+ # time
+ @history.first[0] += bytes
+ @history.first[1] += age
+
+ history_age = @history.first[1]
+
+ @history.pop while @history.size > MAX_SAMPLES
+ @history.unshift([0,0]) if history_age > MIN_SAMPLE_TIME
+
+ if history_age > MIN_STALL_TIME
+ @stalled = true
+ reset_history
+ else
+ @stalled = false
+ end
+
+ @last_update_time = elapsed_seconds
+
+ self
+ end
+
+ # Calculates the bitrate in bytes/second. If the transfer is stalled or
+ # just started, the bitrate will be 0
+ def bitrate
+ history_bytes, history_time = @history.transpose.map { |vals| vals.inject { |sum, v| sum + v } }
+ history_bytes / history_time rescue 0
+ end
+
+ # Number of seconds elapsed since the start of the upload
+ def elapsed_seconds
+ @last_update_time
+ end
+
+ # Calculate the seconds remaining based on the current bitrate. Returns
+ # O seconds if stalled or if no bytes have been received
+ def remaining_seconds
+ remaining_bytes / bitrate rescue 0
+ end
+
+ # Returns true if there are bytes pending otherwise returns false
+ def finished?
+ remaining_bytes <= 0
+ end
+
+ # Returns true if some bytes have been received
+ def started?
+ @received_bytes > 0
+ end
+
+ # Returns true if there has been a delay in receiving bytes. The delay
+ # is set by the constant MIN_STALL_TIME
+ def stalled?
+ @stalled
+ end
+
+ private
+ def reset_history
+ @history = [[0,0]]
+ end
+ end
+ end
+end