Class | TaskImport |
In: |
app/models/task_import.rb
|
Parent: | Object |
File: | task_import.rb |
(C): | Hipposoft 2008, 2009 |
Purpose: | Encapsulate data required for a task import session. |
16-May-2008 (ADH): Created. 28-Nov-2009 (ADH): Made more model-like with lots of extra processing code to rationalise unusual data types used in property assignments.
collapse | [R] | |
collapse | [R] | |
filtered_parent_titles | [R] | |
filtered_parent_uids | [R] | |
filtered_tasks | [R] | |
parent_titles | [R] | |
parent_uids | [R] | |
project_id | [R] | |
project_id | [R] | |
tasks | [R] |
Obtain a task list from the given parsed XML data (a REXML document). Returns a hash with key :tasks, containing an array of task objects with titles, codes and durations set up; :parent_uids, containing an array of arrays of UIDs of the parent tasks (if any) for each entry in the :tasks array; and :parent_titles, the same thing but holding parent strings for display purposes.
All strings are HTML-safe because they come from XML, so must use character entities for sensitive characters just as in HTML. Views should not further escape them with ERB::Util.h().
# File app/models/task_import.rb, line 186 186: def self.get_tasks_from_xml( doc ) 187: 188: # Extract details of every task into a flat array 189: 190: tasks = [] 191: 192: doc.each_element( 'Project/Tasks/Task' ) do | task | 193: begin 194: tasks << OpenStruct.new( { 195: :level => task.get_elements( 'OutlineLevel' )[ 0 ].text.to_i, 196: :tid => task.get_elements( 'ID' )[ 0 ].text, 197: :uid => task.get_elements( 'UID' )[ 0 ].text, 198: :title => task.get_elements( 'Name' )[ 0 ].text 199: } ) 200: rescue 201: # Ignore errors; they tend to indicate malformed tasks, or at least, 202: # XML file task entries that we do not understand. 203: end 204: end 205: 206: # Step through the sorted tasks. Each time we find one where the 207: # *next* task has an outline level greater than the current task, 208: # then the current task MUST be a summary. Record its name and 209: # blank out the task from the array. Otherwise, use whatever 210: # summary name was most recently found (if any) as a name prefix. 211: # 212: # At each step, we keep a note of the titles and UIDs in the branch 213: # leading up to a given task and push this "history" into a parent 214: # title and UID array so that we can implement the 'collapse to level' 215: # function later on, without having to refer back to the original 216: # file (which is temporary and will have been deleted by then). 217: 218: prefix = '' 219: branch_uids = [] 220: branch_titles = [] 221: parent_uids = [] 222: parent_titles = [] 223: filled_to = 0 224: 225: tasks.each_index do | index | 226: task = tasks[ index ] 227: next_task = tasks[ index + 1 ] 228: 229: branch_uids[ task.level ] = task.uid 230: branch_titles[ task.level ] = task.title 231: 232: # Some XML files only contain tasks at strange levels with gaps - 233: # particularly with nothing at level 0. Make sure we always fill these 234: # in, else the parent title and collapse/filtering code will generate 235: # strange task titles due to nil parent title entries. 236: 237: if ( task.level > filled_to ) 238: filled_to = task.level 239: 240: branch_titles.each_index do | level | 241: title = branch_titles[ level ] 242: if ( title.nil? || title.empty? ) 243: branch_titles[ level ] = "Untitled task at level #{ level }" 244: end 245: end 246: end 247: 248: if ( next_task and next_task.level > task.level ) 249: prefix = task.title 250: tasks[ index ] = nil 251: else 252: top_level = task.level - 1 253: top_level = 0 if ( top_level < 0 ) 254: 255: parent_uids[ index ] = branch_uids[ 0..top_level ] 256: parent_titles[ index ] = branch_titles[ 0..top_level ] 257: 258: task.title = "#{ branch_titles[ top_level ] }: #{ task.title }" unless ( task.level.zero? ) 259: end 260: end 261: 262: # Remove any 'nil' items we ended up with above. 263: 264: tasks.compact! 265: parent_uids.compact! 266: parent_titles.compact! 267: 268: # Now create a secondary array, where the UID of any given task is 269: # the array index at which it can be found. This is just to make 270: # looking up tasks by UID really easy, rather than faffing around 271: # with "tasks.find { | task | task.uid = <whatever> }". 272: # 273: # By keeping track of the index in the original array too, we can 274: # make sure the ordering (which has relevance in terms of input file 275: # structure and the parent UID and title arrays) is maintained later. 276: 277: uid_tasks = {} # Using a hash means UIDs don't have to be numeric. 278: 279: tasks.each_index do | index | 280: task = tasks[ index ] 281: uid_tasks[ task.uid ] = { :task => task, :index => index } 282: end 283: 284: # OK, now it's time to parse the assignments into some meaningful 285: # array. These will become our timesheet system tasks. Assignments 286: # which relate to empty elements in "uid_tasks" or which have zero 287: # work are associated with tasks which are either summaries or 288: # milestones. Ignore both types. 289: 290: real_tasks = [] 291: real_parent_uids = [] 292: real_parent_titles = [] 293: 294: doc.each_element( 'Project/Assignments/Assignment' ) do | assignment | 295: task_uid = assignment.get_elements( 'TaskUID' )[ 0 ].text 296: data = uid_tasks[ task_uid ] 297: 298: next if ( data.nil? ) 299: 300: task = data[ :task ] 301: index = data[ :index ] 302: work = assignment.get_elements( 'Work' )[ 0 ].text 303: 304: # Parse the "Work" string: "PT<num>H<num>M<num>S", but with some 305: # leniency to allow any data before or after the H/M/S stuff. 306: 307: strs = work.scan(/.*?(\d+)H(\d+)M(\d+)S.*?/).flatten 308: hours, mins, secs = strs.map { | str | str.to_i } 309: 310: next if ( hours == 0 and mins == 0 and secs == 0 ) 311: 312: # Woohoo, real task! Store it in 'real_tasks' at the same array index 313: # as the item used to hold in the raw 'tasks' array. 314: # 315: # The divide by 3600.0 is VITAL to perform a floating point calculation 316: # rather than rounding everything with integer maths. 317: 318: task.code = Task.generate_xml_code( task.tid ) 319: task.duration = ( ( ( hours * 3600 ) + ( mins * 60 ) + secs ) / 3600.0 ).precision( 2 ) 320: 321: real_tasks[ index ] = task 322: real_parent_uids[ index ] = parent_uids[ index ] 323: real_parent_titles[ index ] = parent_titles[ index ] 324: end 325: 326: # Remove "nil" entries which exist beacuse of any tasks in the original 327: # 'tasks' array which were discarded for some reason (e.g. no duration). 328: 329: real_tasks.compact! 330: real_parent_uids.compact! 331: real_parent_titles.compact! 332: 333: return { 334: :tasks => real_tasks, 335: :parent_uids => real_parent_uids, 336: :parent_titles => real_parent_titles 337: } 338: end
Create a new Import object, optionally from form submission parameters.
# File app/models/task_import.rb, line 24 24: def initialize( params = nil ) 25: @project_id = nil 26: 27: @tasks = [] 28: @parent_uids = nil 29: @parent_titles = nil 30: 31: @filtered_tasks = [] 32: @filtered_parent_uids = nil 33: @filtered_parent_titles = nil 34: @collapse = nil 35: 36: @max_level = nil 37: 38: unless ( params.nil? ) 39: 40: # Adapted from ActiveRecord::Base "attributes=", Rails 2.1.0 41: # on 29-Jun-2008. 42: 43: attributes = params.dup 44: attributes.stringify_keys! 45: attributes.each do | key, value | 46: if ( key.include?( '(' ) ) 47: raise( "Multi-parameter attributes are not supported." ) 48: else 49: send( key + "=", value ) 50: end 51: end 52: end 53: end
# File app/models/task_import.rb, line 85 85: def filtered_parent_titles=( titles ) 86: @filtered_parent_titles = to_nested_array( titles ) 87: end
# File app/models/task_import.rb, line 82 82: def filtered_parent_uids=( uids ) 83: @filtered_parent_uids = to_nested_array( uids ) 84: end
# File app/models/task_import.rb, line 79 79: def filtered_tasks=( tasks ) 80: @filtered_tasks = to_task_array( tasks ) 81: end
Take this object‘s (fully set up) task array along with the associated parent title and UID arrays; then collapse these using this object‘s ‘collapse’ value and update the internal filtered task, parent title and parent UID arrays ("filtered_tasks", "filtered_parent_uids" and "filtered_parent_titles").
# File app/models/task_import.rb, line 106 106: def generate_filtered_task_list 107: collapse_level = self.collapse.to_i 108: collapsed_tasks = {} 109: collapsed_uid = nil 110: 111: filtered_tasks = [] 112: filtered_parent_uids = [] 113: filtered_parent_titles = [] 114: 115: self.tasks.each_index do | index | 116: 117: task = self.tasks[ index ] 118: collapsed_uid = self.parent_uids[ index ][ 0..collapse_level ].join( ',' ) 119: 120: if ( task.level <= collapse_level ) 121: 122: # If this real task is already at the collapsing level, then just copy 123: # it into the collapsed task hash. 124: 125: collapsed_task = task.dup 126: collapsed_tasks[ task.uid ] = collapsed_task 127: 128: # We rely on Ruby storing a reference to the same task structure here, 129: # else later changes to the 'duration' field via the "collapsed_tasks" 130: # hash won't be reflected in the "filtered_tasks" array. 131: 132: filtered_tasks << collapsed_task 133: filtered_parent_uids << self.parent_uids[ index ].dup 134: filtered_parent_titles << self.parent_titles[ index ].dup 135: 136: elsif ( collapsed_tasks.has_key?( collapsed_uid ) ) 137: 138: # Have we generated this collapsed task UID before? Yes - just add 139: # the current child task's duration to it. 140: 141: collapsed_tasks[ collapsed_uid ].duration += task.duration 142: 143: else 144: 145: # Generate a new collapsed task. Use the last two entries in the 146: # titles array (noting Ruby array reference syntax meaning we have to 147: # be careful of negative indices) for the new collapsed task's title. 148: 149: previous_level = collapse_level - 1 150: previous_level = 0 if ( previous_level < 0 ) 151: 152: collapsed_task = OpenStruct.new( { 153: :level => collapse_level, 154: :uid => collapsed_uid, # NB: See above - this is a string of one or more integers joined with a comma 155: :tid => task.tid, 156: :title => self.parent_titles[ index ][ previous_level..collapse_level ].join( ': ' ), 157: :code => Task.generate_xml_code( task.tid ), 158: :duration => task.duration 159: } ) 160: 161: collapsed_tasks[ collapsed_uid ] = collapsed_task 162: 163: filtered_tasks << collapsed_task 164: filtered_parent_uids << self.parent_uids[ index ][ 0..previous_level ] 165: filtered_parent_titles << self.parent_titles[ index ][ 0..previous_level ] 166: 167: end 168: end 169: 170: self.filtered_tasks = filtered_tasks 171: self.filtered_parent_uids = filtered_parent_uids 172: self.filtered_parent_titles = filtered_parent_titles 173: end
Read the maximum level stored in the ‘tasks’ array. Generated on the fly and cached until ‘tasks’ gets reset.
# File app/models/task_import.rb, line 92 92: def max_level 93: if ( @max_level.nil? ) 94: @max_level = @tasks.collect { | t | t.level }.max 95: end 96: 97: return @max_level 98: end
# File app/models/task_import.rb, line 76 76: def parent_titles=( titles ) 77: @parent_titles = to_nested_array( titles ) 78: end
# File app/models/task_import.rb, line 73 73: def parent_uids=( uids ) 74: @parent_uids = to_nested_array( uids ) 75: end
Coerce strings to integers for certain properties.
# File app/models/task_import.rb, line 57 57: def project_id=( val ) 58: @project_id = val.to_i 59: end
Due to the way the forms are constructed, arrays will usually be encoded as a HashWithIndifferentAccess set up with keys containing a string version of the index at which we should store the entry and the second element with the hash describing the object to store there.
# File app/models/task_import.rb, line 69 69: def tasks=( tasks ) 70: @max_level = nil 71: @tasks = to_task_array( tasks ) 72: end