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.

Methods

Attributes

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] 

Public Class methods

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().

[Source]

     # 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.

[Source]

    # 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

Public Instance methods

[Source]

    # File app/models/task_import.rb, line 60
60:   def collapse=( val )
61:     @collapse = val.to_i
62:   end

[Source]

    # File app/models/task_import.rb, line 85
85:   def filtered_parent_titles=( titles )
86:     @filtered_parent_titles = to_nested_array( titles )
87:   end

[Source]

    # File app/models/task_import.rb, line 82
82:   def filtered_parent_uids=( uids )
83:     @filtered_parent_uids = to_nested_array( uids )
84:   end

[Source]

    # 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").

[Source]

     # 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.

[Source]

    # 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

[Source]

    # File app/models/task_import.rb, line 76
76:   def parent_titles=( titles )
77:     @parent_titles = to_nested_array( titles )
78:   end

[Source]

    # 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.

[Source]

    # 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.

[Source]

    # File app/models/task_import.rb, line 69
69:   def tasks=( tasks )
70:     @max_level = nil
71:     @tasks     = to_task_array( tasks )
72:   end

[Validate]