# encoding: utf-8 # # = RSS Log Model # # This model handles the RSS feed. Every object we care about gets an RssLog # instance to report changes in that object. Going forward, every new object # gets assigned one; historically, there are loads of objects without, but we # don't really care, so they stay that way until they are modified. # # There is a separate #{object}_id field for each kind of object that # can own an RssLog. I thought it would be cleaner to use a polymorphic # association, however that makes it impossible to eager-load different # associations for the different types of owners. The resulting performance # hit was significant. # # Possible owners are currently: # # * Location # * Name # * Observation # * Project # * SpeciesList # # == Adding RssLog to Model # # I think this is relatively easy. Try following these steps: # # 1) Add columns to rss_logs and new model tables via migration: # # class AddRssLogToModel < ActiveRecord::Migration # def self.up # add_column(:rss_logs, :model_id, :integer) # add_column(:models, :rss_log_id, :integer) # end # def self.down # remove_column(:rss_logs, :model_id) # remove_column(:models, :rss_log_id) # end # end # # 2) Inform model of the new association: (automatically inherits +log+ # method from AbstractModel) # # belongs_to :rss_log # # 3) Inform RssLog of the new association: # # (just search for "location" in this file) # # 4) Add partial view for +list_rss_logs+: # # (just clone, e.g., app/views/observer/_location.rhtml) # # 5) Add "show log" link at bottom of model's show page: # # <%= show_object_footer(@object) %> # # 6) Add +by_rss_log+ flavor to Query for your model: # # self.allowed_model_flavors = { # :Model => [ # :by_rss_log, # Models with RSS logs, in RSS order. # ] # } # # == Usage # # AbstractModel provides a standardized interface for all models that handle # RssLog (see the list above). These are inherited automatically by any model # that contains an "rss_log_id" column. # # rss_log = observation.rss_log # rss_log.add("Made some change.") # rss_log.orphan("Deleting observation.") # # *NOTE*: After an object is deleted, no one will ever be able to change that # RssLog again -- i.e. it is orphaned. # # == Log Syntax # # The log is kept in a variable-length text field, +notes+. Each entry is # stored as a single line, with newest entries first. Each line has time # stamp, localization string and any arguments required. If the underlying # object is destroyed, the log becomes orphaned, and the object's last known # title string is stored at the very top of the log. # # Here is an example of an Observation's log with five entries created by two # high-level actions: it is first created along with two images and a naming; # then it is destroyed, orphaning the log: # # **__Russula%20chloroides__**%20Krbh. # 20091214035011 log_observation_destroyed user douglas # 20090722075919 log_image_created name 51164 user douglas # 20090722075919 log_image_created name 51163 user douglas # 20090722075919 log_consensus_changed new **__Russula%20chloroides__**%20Krbh. old **__Fungi%20sp.__**%20L. # 20090722075918 log_observation_created user douglas # # *NOTE*: All non-alphanumeric characters are escaped via private class # methods +escape+ and +unescape+. # # *NOTE*: Somewhere in 2008/2009 we changed the syntax of the logs so we could # translate them. We made the deliberate decision _not_ to convert all the # pre-existing logs. Thus you will see various syntaxes prior to this # switchover. These are processed specially in +parse_log+. # # == Attributes # # id:: Locally unique numerical id, starting at 1. # modified:: Date/time it was last modified. # notes:: Log of changes. # location:: Owning Location (or nil). # name:: Owning Name (or nil). # observation:: Owning Observation (or nil). # project:: Owning Project (or nil). # species_list:: Owning SpeciesList (or nil). # # == Class methods # # all_types:: Object types with RssLog's (Array of Symbol's). # # == Instance methods # # add_with_date:: Same, but adds timestamp. # orphan:: About to delete object: add notes, clear association. # orphan_title:: Get old title from top line of orphaned log. # target:: Return owner object: Observation, Name, etc. # text_name:: Return title string of associated object. # format_name:: Return formatted title string of associated object. # unique_text_name:: (same, with id tacked on to make unique) # unique_format_name:: (same, with id tacked on to make unique) # url:: Return "show_blah/id" URL for associated object. # parse_log:: Parse log, see method for description of return value. # # == Callbacks # # None. # ################################################################################ class RssLog < AbstractModel belongs_to :location belongs_to :name belongs_to :observation belongs_to :project belongs_to :species_list # List of all object types that can have RssLog's. def self.all_types ['observation', 'name', 'location', 'project', 'species_list'] end # Returns the associated object, or nil if it's an orphan. def target location || name || observation || project || species_list end # Handy for prev/next handler. Any object that responds to rss_log has an # attached RssLog. In this case, it *is* the RssLog itself, meaning it is # an orphan log for a deleted object. def rss_log self end # Get title from top line of orphaned log. (Should be the +format_name+.) def orphan_title RssLog.unescape(notes.to_s.split("\n", 2).first) end # Returns plain text title of the associated object. def text_name if target target.text_name else orphan_title.t.html_to_ascii.sub(/ (\d+)$/, '') end end # Returns plain text title of the associated object, with id tacked on. def unique_text_name if target target.unique_text_name else orphan_title.t.html_to_ascii end end # Returns formatted title of the associated object. def format_name if target target.format_name else orphan_title.sub(/ (\d+)$/, '') end end # Returns formatted title of the associated object, with id tacked on. def unique_format_name if target target.unique_format_name else orphan_title end end # Returns URL of show_#{object} action for the associated object. # That is, the RssLog for an Observation would return # "/observer/show_observation/#{id}", and so on. If the RssLog is # an orphan, it returns the generic "/observer/show_rss_log/#{id}" # URL. def url result = '' if location_id result = sprintf("/location/show_location/%d?time=%d", location_id, self.modified.tv_sec) elsif name_id result = sprintf("/name/show_name/%d?time=%d", name_id, self.modified.tv_sec) elsif observation_id result = sprintf("/observer/show_observation/%d?time=%d", observation_id, self.modified.tv_sec) elsif project_id result = sprintf("/project/show_project/%d?time=%d", project_id, self.modified.tv_sec) elsif species_list_id result = sprintf("/observer/show_species_list/%d?time=%d", species_list_id, self.modified.tv_sec) else result = sprintf("/observer/show_rss_log/%d?time=%d", id, self.modified.tv_sec) end result end # Add entry to top of notes and save. Pass in a localization key and a hash # of arguments it requires. Changes +modified+ unless args[:touch] # is false. (Changing +modified+ has the effect of pushing it to the front # of the RSS feed.) # # name.rss_log.add(:log_name_updated, # :user => user.login, # :touch => false # ) # # *NOTE*: By default it includes these in args: # # :user => User.current # Which user is responsible? # :touch => true # Bring to top of RSS feed? # :time => Time.now # Timestamp to use. # :save => true # Save changes? # def add_with_date(tag, args={}) args = { :user => (User.current ? User.current.login : :UNKNOWN.l), :touch => true, :time => Time.now, :save => true, }.merge(args) args2 = args.dup args2.delete(:touch) args2.delete(:time) args2.delete(:save) entry = RssLog.encode(tag, args2, args[:time]) self.notes = entry + "\n" + notes.to_s self.modified = args[:time] if args[:touch] self.save_without_our_callbacks if args[:save] end # Add line with timestamp and +title+ to notes, clear references to # associated object, and save. Once this is done and the owner has been # deleted, this RssLog will be "orphaned" and will never change again. # # obs.rss_log.orphan(observation.format_name, :log_observation_destroyed) # def orphan(title, key, args={}) args = args.merge(:save => false) add_with_date(key, args) self.notes = RssLog.escape(title) + "\n" + notes.to_s self.location = nil self.name = nil self.observation = nil self.project = nil self.species_list = nil self.save_without_our_callbacks end # Parse the log, returning a list of triplets, one for each line, newest # first: # # for tag, args, time in rss_log.parse_log # puts "#{time.web_time}: #{key.t(args)}" # end # def parse_log(cutoff_time=nil) first = true results = [] for line in notes.to_s.split("\n") if first && !line.match(/^\d{14}/) tag = :log_orphan args = { :title => self.class.unescape(line) } time = modified elsif !line.blank? tag, args, time = self.class.decode(line) end break if cutoff_time && time < cutoff_time results << [tag, args, time] first = false end return results end ################################################################################ private # Encode a line of the log. Pass in a triplet: # tag:: Symbol # args:: Hash # time:: TimeWithZone def self.encode(tag, args, time) time = time.utc.strftime('%Y%m%d%H%M%S') tag = tag.to_s raise "Invalid rss log tag: #{tag}" if tag.blank? args = args.keys.sort_by(&:to_s).map do |key| [key.to_s, escape(args[key])] end.flatten [time, tag, *args].map do |x| # Make *absolutely* sure no logs are ever created with fields missing, # since this can royally f--- up the parser and crash things. x.blank? ? '.' : x.gsub(/\s+/, '_') end.join(' ') end # Decode a line from the log. Returns a triplet: # tag:: Symbol # args:: Hash # time:: TimeWithZone def self.decode(line) time, tag, *args = line.split odd = false args.map! do |x| odd = !odd odd ? x.to_sym : unescape(x) end args << '' if odd begin time2 = Time.utc(time[0,4], time[4,2], time[6,2], time[8,2], time[10,2], time[12,2]).in_time_zone time = time2 rescue => e # Caught this error in the log, not sure how/why. raise "rss_log timestamp corrupt: time=#{time.inspect}, err=#{e}" end [tag.to_sym, Hash[*args], time] end # Protect special characters (whitespace) in string for log encoder/decoder. def self.escape(str) str.to_s.gsub(/[%\s]/) { '%%%02X' % $&[0].ord } end # Reverse protection of special characters in string for log encoder/decoder. def self.unescape(str) str.to_s.gsub(/%(..)/) { $1.hex.chr } end end