mpp_reader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ require "set"
2
+ module MppReader
3
+ # A parsed MS Project file. Tasks and resources are read lazily on first
4
+ # access and memoized.
5
+ class Project
6
+ attr_reader :file_format, :application_name
7
+
8
+ def initialize(cfbf, comp_obj)
9
+ @cfbf = cfbf
10
+ @file_format = comp_obj.file_format
11
+ @application_name = comp_obj.application_name
12
+ end
13
+
14
+ def tasks
15
+ @tasks ||= reader.read_tasks.tap do |list|
16
+ reader.read_relations(list.to_h { |t| [t.unique_id, t] })
17
+ end
18
+ end
19
+
20
+ # Calendars carry the resource->calendar links, so reading them also
21
+ # fills Resource#calendar_unique_id; force that before handing out
22
+ # resources.
23
+ def resources
24
+ calendars
25
+ raw_resources
26
+ end
27
+
28
+ def assignments
29
+ @assignments ||= reader.read_assignments(tasks.map(&:unique_id).to_set)
30
+ end
31
+
32
+ def calendars
33
+ @calendars ||= begin
34
+ list = reader.read_calendars
35
+
36
+ # Derived calendars not used by any resource are leftovers MS
37
+ # Project does not show; prune them (as MPXJ does). Surviving
38
+ # resource links are mirrored onto the resources, and unnamed
39
+ # resource calendars inherit the resource name.
40
+ by_uid = raw_resources.to_h { |r| [r.unique_id, r] }
41
+ list.reject! do |cal|
42
+ cal.base_calendar_unique_id && by_uid[cal.resource_unique_id].nil?
43
+ end
44
+ list.each do |cal|
45
+ res = by_uid[cal.resource_unique_id]
46
+ next if res.nil?
47
+
48
+ res.calendar_unique_id = cal.unique_id
49
+ cal.name = res.name || "Unnamed Resource" if cal.name.nil? || cal.name.empty?
50
+ end
51
+ end
52
+ end
53
+
54
+ def task(unique_id) = tasks_by_uid[unique_id]
55
+
56
+ def calendar(unique_id) = calendars.find { |c| c.unique_id == unique_id }
57
+
58
+ def resource(unique_id) = resources_by_uid[unique_id]
59
+
60
+ private
61
+
62
+ def raw_resources = @resources ||= reader.read_resources
63
+
64
+ def reader = @reader ||= Reader14.new(@cfbf)
65
+
66
+ def tasks_by_uid = @tasks_by_uid ||= tasks.to_h { |t| [t.unique_id, t] }
67
+
68
+ def resources_by_uid = @resources_by_uid ||= resources.to_h { |r| [r.unique_id, r] }
69
+ end
70
+ end
@@ -0,0 +1,455 @@
1
+ module MppReader
2
+ # Reads tasks and resources from an MPP14 project directory. Ported from
3
+ # MPXJ MPP14Reader (processTaskData/createTaskMap, processResourceData/
4
+ # createResourceMap).
5
+ class Reader14
6
+ PROJECT_DIR = " 114"
7
+
8
+ TASK_FIXED_META_ITEM_SIZE = 47
9
+ TASK_FIXED2_META_ITEM_SIZES = [92, 93, 94, 95, 96].freeze
10
+ RESOURCE_FIXED_META_ITEM_SIZE = 37
11
+
12
+ NULL_TASK_BLOCK_SIZE = 16
13
+ DELETED_FLAG = 0x02
14
+ KEEP_EXISTING_FLAG = 0x04
15
+
16
+ ASSIGNMENT_FIXED_META_ITEM_SIZE = 34
17
+ ASSIGNMENT_FIXED_DATA_ITEM_SIZE = 110
18
+ ASSIGNMENT_FIXED2_DATA_ITEM_SIZE = 48
19
+ NULL_RESOURCE_ID = -65_535
20
+
21
+ # Project 2016 task meta-data bit flags (MPXJ
22
+ # PROJECT2016_TASK_META_DATA_BIT_FLAGS / *_META_DATA2_BIT_FLAGS, core
23
+ # subset). Files written by older Project versions use slightly
24
+ # different locations; not yet ported (the corpus this gem targets is
25
+ # written by Project 2016+).
26
+ MILESTONE_FLAG = [10, 0x02].freeze # in FixedMeta records
27
+ ACTIVE_FLAG = [8, 0x40].freeze # in Fixed2Meta records
28
+ MANUALLY_SCHEDULED_FLAG = [8, 0x80].freeze
29
+
30
+ def initialize(cfbf)
31
+ @cfbf = cfbf
32
+ props_stream = stream("Props")
33
+ raise InvalidFormatError, "missing project Props stream" if props_stream.nil?
34
+
35
+ @props = Blocks::Props.new(props_stream)
36
+ @field_reader = FieldReader.new(@props)
37
+ end
38
+
39
+ def read_tasks
40
+ field_map = FieldMap.for_tasks(@props)
41
+ var_meta = Blocks::VarMeta.new(stream("TBkndTask/VarMeta"))
42
+ var_data = Blocks::Var2Data.new(var_meta, stream("TBkndTask/Var2Data"))
43
+ fixed_meta = Blocks::FixedMeta.new(stream("TBkndTask/FixedMeta"), TASK_FIXED_META_ITEM_SIZE)
44
+ fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndTask/FixedData"),
45
+ max_expected_size: field_map.max_fixed_data_size(0))
46
+ fixed2_meta = Blocks::FixedMeta.with_derived_item_size(
47
+ stream("TBkndTask/Fixed2Meta"), TASK_FIXED2_META_ITEM_SIZES, fixed_data.item_count
48
+ )
49
+ fixed2_data = Blocks::FixedData.new(fixed2_meta, stream("TBkndTask/Fixed2Data"))
50
+
51
+ uid_offset = field_map[:unique_id]&.fixed_offset
52
+ raise InvalidFormatError, "task field map lacks a unique_id location" if uid_offset.nil?
53
+
54
+ task_map = build_task_map(field_map, fixed_meta, fixed_data, fixed2_data, var_meta, uid_offset)
55
+
56
+ tasks = task_map.sort.filter_map do |unique_id, index|
57
+ next if index.nil?
58
+
59
+ record = fixed_data[index]
60
+ next if record.nil? || record.bytesize == NULL_TASK_BLOCK_SIZE
61
+
62
+ build_task(field_map, unique_id, [record, fixed2_data[index]],
63
+ fixed_meta[index], fixed2_meta[index], var_data)
64
+ end
65
+
66
+ link_hierarchy(tasks.sort_by! { |t| t.id || 0 })
67
+ tasks
68
+ end
69
+
70
+ def read_resources
71
+ field_map = FieldMap.for_resources(@props)
72
+ var_meta = Blocks::VarMeta.new(stream("TBkndRsc/VarMeta"))
73
+ var_data = Blocks::Var2Data.new(var_meta, stream("TBkndRsc/Var2Data"))
74
+ fixed_meta = Blocks::FixedMeta.new(stream("TBkndRsc/FixedMeta"), RESOURCE_FIXED_META_ITEM_SIZE)
75
+ fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndRsc/FixedData"))
76
+
77
+ uid_offset = field_map[:unique_id]&.fixed_offset
78
+ raise InvalidFormatError, "resource field map lacks a unique_id location" if uid_offset.nil?
79
+
80
+ resource_map = build_resource_map(field_map, fixed_data, uid_offset)
81
+
82
+ var_meta.unique_ids.filter_map do |unique_id|
83
+ index = resource_map[unique_id]
84
+ next if index.nil?
85
+
86
+ build_resource(field_map, unique_id, [fixed_data[index]], var_data)
87
+ end
88
+ end
89
+
90
+ # Ported from MPXJ ResourceAssignmentFactory#process. task_uids gates
91
+ # assignments to known tasks (MPXJ skips assignments whose task is
92
+ # absent from the file).
93
+ def read_assignments(task_uids)
94
+ field_map = FieldMap.for_assignments(@props)
95
+ var_meta = Blocks::VarMeta.new(stream("TBkndAssn/VarMeta"))
96
+ var_data = Blocks::Var2Data.new(var_meta, stream("TBkndAssn/Var2Data"))
97
+ fixed_meta = Blocks::FixedMeta.new(stream("TBkndAssn/FixedMeta"), ASSIGNMENT_FIXED_META_ITEM_SIZE)
98
+ fixed_data = Blocks::FixedData.fixed_size(stream("TBkndAssn/FixedData"), ASSIGNMENT_FIXED_DATA_ITEM_SIZE)
99
+ fixed2_data = Blocks::FixedData.fixed_size(stream("TBkndAssn/Fixed2Data"), ASSIGNMENT_FIXED2_DATA_ITEM_SIZE)
100
+
101
+ uid_offset = field_map[:unique_id]&.fixed_offset
102
+ raise InvalidFormatError, "assignment field map lacks a unique_id location" if uid_offset.nil?
103
+
104
+ assignments = []
105
+ fixed_meta.item_count.times do |meta_index|
106
+ meta = fixed_meta[meta_index]
107
+ next if meta.nil? || meta.getbyte(0) != 0 # deleted
108
+
109
+ index = fixed_data.index_from_offset(meta.byteslice(4, 4).unpack1("V"))
110
+ next if index.nil?
111
+
112
+ record = fixed_data[index]
113
+ record = record.ljust(field_map.max_fixed_data_size(0), "\0") if record.bytesize < field_map.max_fixed_data_size(0)
114
+ unique_id = record.byteslice(uid_offset, 4).unpack1("l<")
115
+ next unless var_meta.entries?(unique_id)
116
+
117
+ assignment = build_assignment(field_map, unique_id, [record, fixed2_data[index]], var_data)
118
+ next unless task_uids.include?(assignment.task_unique_id)
119
+
120
+ assignments << assignment
121
+ end
122
+ assignments
123
+ end
124
+
125
+ # Reads predecessor links from TBkndCons and wires them into the given
126
+ # tasks (by unique id). Ported from MPXJ ConstraintFactory. Layout: uid
127
+ # u32@0, predecessor u32@4, successor u32@8, type u16@12; lag location
128
+ # depends on writer version (Project 2010 vs 2013+).
129
+ def read_relations(tasks_by_uid)
130
+ meta_stream = stream("TBkndCons/FixedMeta")
131
+ return if meta_stream.nil?
132
+
133
+ fixed_meta = Blocks::FixedMeta.new(meta_stream, 10)
134
+ fixed_data = Blocks::FixedData.meta_offsets_with_size(fixed_meta, stream("TBkndCons/FixedData"), 20)
135
+ project15 = application_version > 14
136
+ lag_offset = project15 ? 14 : 16
137
+ lag_units_offset = project15 ? 18 : 14
138
+
139
+ seen = {}
140
+ # NOTE: the header count, not the block-derived count - MS Project
141
+ # leaves stale link records past the header count (MPXJ iterates
142
+ # getItemCount() here, unlike for tasks).
143
+ [fixed_meta.header_item_count, fixed_meta.item_count].min.times do |index|
144
+ meta = fixed_meta[index]
145
+ next if meta.nil? || meta.byteslice(0, 2).unpack1("v") != 0 # deleted
146
+
147
+ data_index = fixed_data.index_from_offset(meta.byteslice(4, 4).unpack1("V"))
148
+ next if data_index.nil?
149
+
150
+ record = fixed_data[data_index]
151
+ next if record.nil? || record.bytesize < 14
152
+
153
+ predecessor_uid = record.byteslice(4, 4).unpack1("V")
154
+ successor_uid = record.byteslice(8, 4).unpack1("V")
155
+ # Links to the project summary task and self-links are not valid.
156
+ next if predecessor_uid.zero? || successor_uid.zero? || predecessor_uid == successor_uid
157
+
158
+ predecessor = tasks_by_uid[predecessor_uid]
159
+ successor = tasks_by_uid[successor_uid]
160
+ next if predecessor.nil? || successor.nil?
161
+
162
+ relation = Relation.new
163
+ relation.unique_id = record.byteslice(0, 4).unpack1("V")
164
+ relation.predecessor_task_unique_id = predecessor_uid
165
+ relation.successor_task_unique_id = successor_uid
166
+ relation.type = Relation::TYPES.fetch(record.byteslice(12, 2).unpack1("v"), :finish_start)
167
+ units = Decode.duration_units(record.byteslice(lag_units_offset, 2).to_s.unpack1("v").to_i)
168
+ lag = record.byteslice(lag_offset, 4).to_s.unpack1("l<")
169
+ relation.lag = lag.nil? ? nil : @field_reader.adjusted_duration(lag, units)
170
+
171
+ # Only one relation per (successor, predecessor, type, lag), as in
172
+ # MPXJ RelationContainer#addPredecessor.
173
+ next if seen[[successor_uid, predecessor_uid, relation.type, relation.lag]]
174
+
175
+ seen[[successor_uid, predecessor_uid, relation.type, relation.lag]] = true
176
+ successor.predecessors << relation
177
+ predecessor.successors << relation
178
+ end
179
+ end
180
+
181
+ # Reads calendars from TBkndCal (ported from MPXJ AbstractCalendarFactory
182
+ # and AbstractCalendarAndExceptionFactory; Project 2013+ record layout).
183
+ # Calendar var data: seven 60-byte day blocks then exception records.
184
+ def read_calendars
185
+ var_meta = Blocks::VarMeta.new(stream("TBkndCal/VarMeta"))
186
+ var_data = Blocks::Var2Data.new(var_meta, stream("TBkndCal/Var2Data"))
187
+ fixed_meta = Blocks::FixedMeta.new(stream("TBkndCal/FixedMeta"), 10)
188
+ fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndCal/FixedData"), max_expected_size: 12)
189
+
190
+ default_data = @props[DEFAULT_CALENDAR_HOURS_KEY]
191
+ project15 = application_version > 14
192
+ id_offset = project15 ? 8 : 0
193
+ base_offset = project15 ? 0 : 4
194
+ resource_offset = project15 ? 4 : 8
195
+
196
+ calendars = {}
197
+ fixed_data.item_count.times do |index|
198
+ record = fixed_data[index]
199
+ next if record.nil? || record.bytesize < 8
200
+
201
+ offset = 0
202
+ while offset + 12 <= record.bytesize
203
+ unique_id = record.byteslice(offset + id_offset, 4).unpack1("V")
204
+ base_id = record.byteslice(offset + base_offset, 4).unpack1("l<")
205
+ resource_id = record.byteslice(offset + resource_offset, 4).unpack1("l<")
206
+ offset += 12
207
+ next if unique_id <= 0 || calendars.key?(unique_id)
208
+
209
+ data = var_data.bytes(unique_id, CALENDAR_DATA_KEY)
210
+ calendar = Calendar.new
211
+ calendar.unique_id = unique_id
212
+ is_base = base_id <= 0 || base_id == unique_id
213
+ # Only base calendars take their name from the file; derived
214
+ # (resource) calendar name entries are often stale, so MPXJ
215
+ # ignores them and names the calendar after its resource.
216
+ calendar.name = var_data.string(unique_id, CALENDAR_NAME_KEY) if is_base
217
+ calendar.base_calendar_unique_id = base_id unless is_base
218
+ # Derived (resource) calendars keep the link even for resource id
219
+ # 0 - the unnamed placeholder resource is a real, linkable entry.
220
+ calendar.resource_unique_id = resource_id if !is_base || resource_id.positive?
221
+ data = default_data if data.nil? && is_base
222
+ read_calendar_days(data, calendar, is_base)
223
+ read_calendar_exceptions(data, calendar)
224
+ calendars[unique_id] = calendar
225
+ end
226
+ end
227
+ calendars.values
228
+ end
229
+
230
+ private
231
+
232
+ CALENDAR_NAME_KEY = 1
233
+ CALENDAR_DATA_KEY = 8
234
+ DEFAULT_CALENDAR_HOURS_KEY = 37_753_736 # PropsKey.DEFAULT_CALENDAR_HOURS
235
+ CALENDAR_EXCEPTIONS_OFFSET = 420 # after seven 60-byte day blocks
236
+
237
+ # Standard week used when a base calendar leaves a day on "default":
238
+ # Monday-Friday 08:00-12:00 and 13:00-17:00.
239
+ DEFAULT_WORKING_DAYS = %i[monday tuesday wednesday thursday friday].freeze
240
+ DEFAULT_WORKING_HOURS = [[8 * 3600, 12 * 3600], [13 * 3600, 17 * 3600]].freeze
241
+
242
+ def read_calendar_days(data, calendar, is_base)
243
+ Calendar::DAY_NAMES.each_with_index do |day, index|
244
+ offset = 60 * index
245
+ default_flag = data.nil? ? 1 : data.byteslice(offset, 2).to_s.unpack1("v")
246
+ calendar.days[day] =
247
+ if default_flag == 1
248
+ if is_base
249
+ if DEFAULT_WORKING_DAYS.include?(day)
250
+ { type: :working, hours: DEFAULT_WORKING_HOURS.map(&:dup) }
251
+ else
252
+ { type: :non_working, hours: [] }
253
+ end
254
+ else
255
+ { type: :default, hours: [] }
256
+ end
257
+ else
258
+ hours = read_time_ranges(data, count_offset: offset + 2,
259
+ starts_at: offset + 8, durations_at: offset + 20)
260
+ { type: hours.empty? ? :non_working : :working, hours: hours }
261
+ end
262
+ end
263
+ end
264
+
265
+ def read_calendar_exceptions(data, calendar)
266
+ return if data.nil? || data.bytesize <= CALENDAR_EXCEPTIONS_OFFSET
267
+
268
+ offset = CALENDAR_EXCEPTIONS_OFFSET
269
+ count = data.byteslice(offset, 2).unpack1("v")
270
+ offset += 4
271
+ count.times do
272
+ break if offset + 92 > data.bytesize
273
+
274
+ hours = read_time_ranges(data, count_offset: offset + 14,
275
+ starts_at: offset + 20, durations_at: offset + 32)
276
+ name_length = data.byteslice(offset + 88, 4).unpack1("V")
277
+ name_length = ((name_length / 4) + 1) * 4 unless (name_length % 4).zero?
278
+ name = if name_length.zero?
279
+ nil
280
+ else
281
+ data.byteslice(offset + 92, name_length)
282
+ .force_encoding(Encoding::UTF_16LE)
283
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
284
+ .sub(/\0.*\z/m, "")
285
+ end
286
+ calendar.exceptions << {
287
+ from: Decode.date(data, offset),
288
+ to: Decode.date(data, offset + 2),
289
+ name: name,
290
+ hours: hours
291
+ }
292
+ offset += 92 + name_length
293
+ end
294
+ end
295
+
296
+ # Time ranges as stored in day blocks and exceptions: a u16 period
297
+ # count, u16 start times (tenths of a minute since midnight) and u16
298
+ # durations (tenths of a minute), returned as [start_sec, end_sec].
299
+ def read_time_ranges(data, count_offset:, starts_at:, durations_at:)
300
+ count = data.byteslice(count_offset, 2).to_s.unpack1("v").to_i
301
+ (0...count).filter_map do |i|
302
+ start_tenths = data.byteslice(starts_at + i * 2, 2).to_s.unpack1("v")
303
+ duration_tenths = data.byteslice(durations_at + i * 4, 2).to_s.unpack1("v")
304
+ next if start_tenths.nil? || duration_tenths.nil?
305
+
306
+ start_seconds = (start_tenths / 10) * 60
307
+ [start_seconds, start_seconds + duration_tenths * 6]
308
+ end
309
+ end
310
+
311
+ # Major version of the Project release that wrote the file, from the
312
+ # CompObj application name (e.g. "Microsoft.Project 16.0" -> 16).
313
+ def application_version
314
+ @application_version ||= @cfbf.stream("\x01CompObj")
315
+ .then { |s| CompObj.new(s).application_name }
316
+ .to_s[/(\d+)\.\d+\z/, 1].to_i
317
+ end
318
+
319
+ def build_assignment(field_map, unique_id, fixed_records, var_data)
320
+ read = ->(field) { @field_reader.read(field_map, field, unique_id, fixed_records, var_data) }
321
+ assignment = Assignment.new
322
+ assignment.unique_id = unique_id
323
+ assignment.task_unique_id = read.call(:task_unique_id)
324
+ assignment.resource_unique_id = read.call(:resource_unique_id)
325
+ assignment.resource_unique_id = nil if assignment.resource_unique_id == NULL_RESOURCE_ID
326
+ assignment.start = read.call(:start)
327
+ assignment.finish = read.call(:finish)
328
+ assignment.units = read.call(:assignment_units)
329
+ assignment.work = read.call(:work)
330
+ assignment.notes = read.call(:notes)
331
+ assignment
332
+ end
333
+
334
+ def stream(name) = @cfbf.stream("#{PROJECT_DIR}/#{name}")
335
+
336
+ # Ported from MPP14Reader#createTaskMap: the first three fixed items are
337
+ # not tasks; we work backwards because with duplicates the later
338
+ # occurrences are the correct ones; deleted and suspect records are
339
+ # filtered out.
340
+ def build_task_map(field_map, fixed_meta, fixed_data, fixed2_data, var_meta, uid_offset)
341
+ task_map = {}
342
+ max_size = field_map.max_fixed_data_size(0)
343
+
344
+ (fixed_meta.item_count - 1).downto(3) do |index|
345
+ record = fixed_data[index]
346
+ next if record.nil? || fixed2_data[index].nil?
347
+
348
+ flags = fixed_meta[index].byteslice(0, 4).to_s.unpack1("V").to_i
349
+ if (flags & DELETED_FLAG) != 0
350
+ # Deleted tasks store only a short unique id; remember it so a
351
+ # phantom duplicate later in the block is not resurrected.
352
+ unique_id = record.byteslice(0, 2).to_s.unpack1("v")
353
+ task_map[unique_id] = nil unless task_map.key?(unique_id)
354
+ elsif record.bytesize == NULL_TASK_BLOCK_SIZE
355
+ unique_id = record.byteslice(0, 4).to_s.unpack1("V")
356
+ task_map[unique_id] = index unless task_map.key?(unique_id)
357
+ elsif max_size.zero? || (record.bytesize * 100) / max_size > 75
358
+ unique_id = record.byteslice(uid_offset, 4).to_s.unpack1("V")
359
+ next unless unique_id
360
+
361
+ if (!task_map.key?(unique_id) || var_meta.entries?(unique_id)) &&
362
+ (!task_map.key?(unique_id) || (flags & KEEP_EXISTING_FLAG).zero?)
363
+ task_map[unique_id] = index
364
+ end
365
+ end
366
+ end
367
+
368
+ task_map
369
+ end
370
+
371
+ # Ported from MPP14Reader#createResourceMap.
372
+ def build_resource_map(field_map, fixed_data, uid_offset)
373
+ resource_map = {}
374
+ max_size = field_map.max_fixed_data_size(0)
375
+
376
+ fixed_data.item_count.times do |index|
377
+ record = fixed_data[index]
378
+ next if record.nil? || record.bytesize < max_size
379
+
380
+ unique_id = record.byteslice(uid_offset, 2).to_s.unpack1("v")
381
+ resource_map[unique_id] = index unless resource_map.key?(unique_id)
382
+ end
383
+
384
+ resource_map
385
+ end
386
+
387
+ def build_task(field_map, unique_id, fixed_records, meta_record, meta2_record, var_data)
388
+ read = ->(field) { @field_reader.read(field_map, field, unique_id, fixed_records, var_data) }
389
+ task = Task.new
390
+ task.unique_id = unique_id
391
+ task.id = read.call(:id)
392
+ task.name = read.call(:name)
393
+ task.start = read.call(:start)
394
+ task.finish = read.call(:finish)
395
+ task.duration = read.call(:duration)
396
+ task.outline_level = read.call(:outline_level)
397
+ task.percent_complete = read.call(:percent_complete)
398
+ task.notes = read.call(:notes)
399
+ task.milestone = meta_bit?(meta_record, MILESTONE_FLAG)
400
+ task.active = meta_bit?(meta2_record, ACTIVE_FLAG)
401
+ task.manual = meta_bit?(meta2_record, MANUALLY_SCHEDULED_FLAG)
402
+
403
+ # In MPP14 the start/finish/duration slots hold manually-scheduled
404
+ # values; auto-scheduled tasks carry theirs in the scheduled_* fields
405
+ # (ported from MPP14Reader#processTaskData).
406
+ apply_scheduled_fallbacks(task, read)
407
+ task
408
+ end
409
+
410
+ def apply_scheduled_fallbacks(task, read)
411
+ scheduled_start = read.call(:scheduled_start)
412
+ scheduled_finish = read.call(:scheduled_finish)
413
+ scheduled_duration = read.call(:scheduled_duration)
414
+ auto = !task.manual
415
+ task.start = scheduled_start if task.start.nil? || (scheduled_start && auto)
416
+ task.finish = scheduled_finish if task.finish.nil? || (scheduled_finish && auto)
417
+ task.duration = scheduled_duration if task.duration.nil? || (scheduled_duration && auto)
418
+ # The duration attribute is unreliable for manually scheduled tasks;
419
+ # MS Project displays the manual duration value, so prefer it.
420
+ task.duration = read.call(:manual_duration) if task.manual
421
+ end
422
+
423
+ def build_resource(field_map, unique_id, fixed_records, var_data)
424
+ read = ->(field) { @field_reader.read(field_map, field, unique_id, fixed_records, var_data) }
425
+ resource = Resource.new
426
+ resource.unique_id = unique_id
427
+ resource.id = read.call(:id)
428
+ resource.name = read.call(:name)
429
+ resource.notes = read.call(:notes)
430
+ resource
431
+ end
432
+
433
+ def meta_bit?(meta_record, (offset, mask))
434
+ byte = meta_record && meta_record.getbyte(offset)
435
+ !byte.nil? && (byte & mask) != 0
436
+ end
437
+
438
+ # Builds parent/child links from outline levels over the id-ordered
439
+ # task list (MPXJ updateStructure does the equivalent).
440
+ def link_hierarchy(tasks)
441
+ stack = []
442
+ tasks.each do |task|
443
+ level = task.outline_level
444
+ next if level.nil?
445
+
446
+ stack.pop while stack.any? && stack.last.outline_level >= level
447
+ if (parent = stack.last)
448
+ task.parent = parent
449
+ parent.children << task
450
+ end
451
+ stack << task
452
+ end
453
+ end
454
+ end
455
+ end
@@ -0,0 +1,11 @@
1
+ module MppReader
2
+ # A dependency link between two tasks. type is one of :finish_start,
3
+ # :start_start, :finish_finish, :start_finish; lag is a Duration.
4
+ class Relation
5
+ TYPES = { 0 => :finish_finish, 1 => :finish_start,
6
+ 2 => :start_finish, 3 => :start_start }.freeze
7
+
8
+ attr_accessor :unique_id, :predecessor_task_unique_id,
9
+ :successor_task_unique_id, :type, :lag
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module MppReader
2
+ class Resource
3
+ attr_accessor :unique_id, :id, :name, :calendar_unique_id, :notes
4
+ end
5
+ end
@@ -0,0 +1,92 @@
1
+ module MppReader
2
+ # Minimal RTF-to-plain-text conversion for notes fields. MS Project
3
+ # stores notes as simple RTF (font/color tables, \par breaks, hex and
4
+ # unicode escapes); anything not starting with {\rtf is returned as-is.
5
+ module RtfText
6
+ # Destination groups whose text is not document content.
7
+ SKIP_GROUPS = %w[fonttbl colortbl stylesheet info pict object header
8
+ footer generator].freeze
9
+
10
+ module_function
11
+
12
+ def strip(text)
13
+ return text if text.nil? || text.empty? || !text.start_with?("{\\rtf")
14
+
15
+ out = +""
16
+ pos = 0
17
+ bytes = text.b
18
+ # Each entry mirrors the group nesting: true while inside a skipped
19
+ # destination group.
20
+ skip_stack = [false]
21
+ pending_unicode_skip = 0
22
+
23
+ while pos < bytes.bytesize
24
+ ch = bytes[pos]
25
+ case ch
26
+ when "{"
27
+ skip_stack.push(skip_stack.last)
28
+ pos += 1
29
+ when "}"
30
+ skip_stack.pop if skip_stack.size > 1
31
+ pos += 1
32
+ when "\\"
33
+ pos = control(bytes, pos, out, skip_stack) { pending_unicode_skip = _1 }
34
+ else
35
+ if pending_unicode_skip.positive?
36
+ pending_unicode_skip -= 1
37
+ elsif !skip_stack.last && ch != "\r" && ch != "\n"
38
+ out << ch
39
+ end
40
+ pos += 1
41
+ end
42
+ end
43
+ out.sub!(/\n\z/, "") # formal RTF always ends with one extra \par
44
+ out.force_encoding(Encoding::UTF_8).valid_encoding? ? out.force_encoding(Encoding::UTF_8) : out
45
+ end
46
+
47
+ def control(bytes, pos, out, skip_stack)
48
+ suppressed = skip_stack.last
49
+ rest = bytes.byteslice(pos + 1, 12).to_s
50
+
51
+ case rest
52
+ when /\A([{}\\])/ # escaped literal
53
+ out << Regexp.last_match(1) unless suppressed
54
+ yield 0
55
+ pos + 2
56
+ when /\A'([0-9a-fA-F]{2})/ # hex escape, Windows-1252
57
+ unless suppressed
58
+ out << Regexp.last_match(1).to_i(16).chr
59
+ .force_encoding(Encoding::WINDOWS_1252)
60
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace).b
61
+ end
62
+ yield 0
63
+ pos + 4
64
+ when /\Au(-?\d+)/ # unicode escape; following char is the fallback
65
+ out << [Regexp.last_match(1).to_i].pack("U").b unless suppressed
66
+ yield 1
67
+ pos + 2 + Regexp.last_match(1).length
68
+ when /\A([a-z]+)(-?\d*) ?/i # control word
69
+ word = Regexp.last_match(1)
70
+ consumed = 1 + Regexp.last_match(0).length
71
+ if SKIP_GROUPS.include?(word)
72
+ skip_stack[-1] = true
73
+ elsif !suppressed
74
+ case word
75
+ when "par", "line" then out << "\n"
76
+ when "tab" then out << "\t"
77
+ end
78
+ end
79
+ yield 0
80
+ pos + consumed
81
+ when /\A\*/ # \* marks an optional destination group - skip it
82
+ skip_stack[-1] = true
83
+ yield 0
84
+ pos + 2
85
+ else
86
+ yield 0
87
+ pos + 1
88
+ end
89
+ end
90
+ private_class_method :control
91
+ end
92
+ end
@@ -0,0 +1,21 @@
1
+ module MppReader
2
+ class Task
3
+ attr_accessor :unique_id, :id, :name, :start, :finish, :duration,
4
+ :outline_level, :percent_complete, :milestone, :parent,
5
+ :active, :manual, :notes
6
+ attr_reader :children, :predecessors, :successors
7
+
8
+ def initialize
9
+ @children = []
10
+ @predecessors = []
11
+ @successors = []
12
+ end
13
+
14
+ def milestone? = !!@milestone
15
+
16
+ # A summary task is one with subtasks (MS Project derives this the
17
+ # same way).
18
+ def summary = !@children.empty?
19
+ alias summary? summary
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module MppReader
2
+ VERSION = "0.1.0"
3
+ end