Class: MppReader::Reader14

Inherits:
Object
  • Object
show all
Defined in:
lib/mpp_reader/reader14.rb

Overview

Reads tasks and resources from an MPP14 project directory. Ported from MPXJ MPP14Reader (processTaskData/createTaskMap, processResourceData/ createResourceMap).

Constant Summary collapse

PROJECT_DIR =
"   114"
TASK_FIXED_META_ITEM_SIZE =
47
TASK_FIXED2_META_ITEM_SIZES =
[92, 93, 94, 95, 96].freeze
RESOURCE_FIXED_META_ITEM_SIZE =
37
NULL_TASK_BLOCK_SIZE =
16
DELETED_FLAG =
0x02
KEEP_EXISTING_FLAG =
0x04
ASSIGNMENT_FIXED_META_ITEM_SIZE =
34
ASSIGNMENT_FIXED_DATA_ITEM_SIZE =
110
ASSIGNMENT_FIXED2_DATA_ITEM_SIZE =
48
NULL_RESOURCE_ID =
-65_535
MILESTONE_FLAG =

Project 2016 task meta-data bit flags (MPXJ PROJECT2016_TASK_META_DATA_BIT_FLAGS / *_META_DATA2_BIT_FLAGS, core subset). Files written by older Project versions use slightly different locations; not yet ported (the corpus this gem targets is written by Project 2016+).

[10, 0x02].freeze
ACTIVE_FLAG =

in Fixed2Meta records

[8, 0x40].freeze
MANUALLY_SCHEDULED_FLAG =
[8, 0x80].freeze

Instance Method Summary collapse

Constructor Details

#initialize(cfbf) ⇒ Reader14

Returns a new instance of Reader14.

Raises:



30
31
32
33
34
35
36
37
# File 'lib/mpp_reader/reader14.rb', line 30

def initialize(cfbf)
  @cfbf = cfbf
  props_stream = stream("Props")
  raise InvalidFormatError, "missing project Props stream" if props_stream.nil?

  @props = Blocks::Props.new(props_stream)
  @field_reader = FieldReader.new(@props)
end

Instance Method Details

#read_assignments(task_uids) ⇒ Object

Ported from MPXJ ResourceAssignmentFactory#process. task_uids gates assignments to known tasks (MPXJ skips assignments whose task is absent from the file).

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/mpp_reader/reader14.rb', line 93

def read_assignments(task_uids)
  field_map = FieldMap.for_assignments(@props)
  var_meta = Blocks::VarMeta.new(stream("TBkndAssn/VarMeta"))
  var_data = Blocks::Var2Data.new(var_meta, stream("TBkndAssn/Var2Data"))
  fixed_meta = Blocks::FixedMeta.new(stream("TBkndAssn/FixedMeta"), ASSIGNMENT_FIXED_META_ITEM_SIZE)
  fixed_data = Blocks::FixedData.fixed_size(stream("TBkndAssn/FixedData"), ASSIGNMENT_FIXED_DATA_ITEM_SIZE)
  fixed2_data = Blocks::FixedData.fixed_size(stream("TBkndAssn/Fixed2Data"), ASSIGNMENT_FIXED2_DATA_ITEM_SIZE)

  uid_offset = field_map[:unique_id]&.fixed_offset
  raise InvalidFormatError, "assignment field map lacks a unique_id location" if uid_offset.nil?

  assignments = []
  fixed_meta.item_count.times do |meta_index|
    meta = fixed_meta[meta_index]
    next if meta.nil? || meta.getbyte(0) != 0 # deleted

    index = fixed_data.index_from_offset(meta.byteslice(4, 4).unpack1("V"))
    next if index.nil?

    record = fixed_data[index]
    record = record.ljust(field_map.max_fixed_data_size(0), "\0") if record.bytesize < field_map.max_fixed_data_size(0)
    unique_id = record.byteslice(uid_offset, 4).unpack1("l<")
    next unless var_meta.entries?(unique_id)

    assignment = build_assignment(field_map, unique_id, [record, fixed2_data[index]], var_data)
    next unless task_uids.include?(assignment.task_unique_id)

    assignments << assignment
  end
  assignments
end

#read_calendarsObject

Reads calendars from TBkndCal (ported from MPXJ AbstractCalendarFactory and AbstractCalendarAndExceptionFactory; Project 2013+ record layout). Calendar var data: seven 60-byte day blocks then exception records.



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/mpp_reader/reader14.rb', line 184

def read_calendars
  var_meta = Blocks::VarMeta.new(stream("TBkndCal/VarMeta"))
  var_data = Blocks::Var2Data.new(var_meta, stream("TBkndCal/Var2Data"))
  fixed_meta = Blocks::FixedMeta.new(stream("TBkndCal/FixedMeta"), 10)
  fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndCal/FixedData"), max_expected_size: 12)

  default_data = @props[DEFAULT_CALENDAR_HOURS_KEY]
  project15 = application_version > 14
  id_offset = project15 ? 8 : 0
  base_offset = project15 ? 0 : 4
  resource_offset = project15 ? 4 : 8

  calendars = {}
  fixed_data.item_count.times do |index|
    record = fixed_data[index]
    next if record.nil? || record.bytesize < 8

    offset = 0
    while offset + 12 <= record.bytesize
      unique_id = record.byteslice(offset + id_offset, 4).unpack1("V")
      base_id = record.byteslice(offset + base_offset, 4).unpack1("l<")
      resource_id = record.byteslice(offset + resource_offset, 4).unpack1("l<")
      offset += 12
      next if unique_id <= 0 || calendars.key?(unique_id)

      data = var_data.bytes(unique_id, CALENDAR_DATA_KEY)
      calendar = Calendar.new
      calendar.unique_id = unique_id
      is_base = base_id <= 0 || base_id == unique_id
      # Only base calendars take their name from the file; derived
      # (resource) calendar name entries are often stale, so MPXJ
      # ignores them and names the calendar after its resource.
      calendar.name = var_data.string(unique_id, CALENDAR_NAME_KEY) if is_base
      calendar.base_calendar_unique_id = base_id unless is_base
      # Derived (resource) calendars keep the link even for resource id
      # 0 - the unnamed placeholder resource is a real, linkable entry.
      calendar.resource_unique_id = resource_id if !is_base || resource_id.positive?
      data = default_data if data.nil? && is_base
      read_calendar_days(data, calendar, is_base)
      read_calendar_exceptions(data, calendar)
      calendars[unique_id] = calendar
    end
  end
  calendars.values
end

#read_relations(tasks_by_uid) ⇒ Object

Reads predecessor links from TBkndCons and wires them into the given tasks (by unique id). Ported from MPXJ ConstraintFactory. Layout: uid u32@0, predecessor u32@4, successor u32@8, type u16@12; lag location depends on writer version (Project 2010 vs 2013+).



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/mpp_reader/reader14.rb', line 129

def read_relations(tasks_by_uid)
  meta_stream = stream("TBkndCons/FixedMeta")
  return if meta_stream.nil?

  fixed_meta = Blocks::FixedMeta.new(meta_stream, 10)
  fixed_data = Blocks::FixedData.meta_offsets_with_size(fixed_meta, stream("TBkndCons/FixedData"), 20)
  project15 = application_version > 14
  lag_offset = project15 ? 14 : 16
  lag_units_offset = project15 ? 18 : 14

  seen = {}
  # NOTE: the header count, not the block-derived count - MS Project
  # leaves stale link records past the header count (MPXJ iterates
  # getItemCount() here, unlike for tasks).
  [fixed_meta.header_item_count, fixed_meta.item_count].min.times do |index|
    meta = fixed_meta[index]
    next if meta.nil? || meta.byteslice(0, 2).unpack1("v") != 0 # deleted

    data_index = fixed_data.index_from_offset(meta.byteslice(4, 4).unpack1("V"))
    next if data_index.nil?

    record = fixed_data[data_index]
    next if record.nil? || record.bytesize < 14

    predecessor_uid = record.byteslice(4, 4).unpack1("V")
    successor_uid = record.byteslice(8, 4).unpack1("V")
    # Links to the project summary task and self-links are not valid.
    next if predecessor_uid.zero? || successor_uid.zero? || predecessor_uid == successor_uid

    predecessor = tasks_by_uid[predecessor_uid]
    successor = tasks_by_uid[successor_uid]
    next if predecessor.nil? || successor.nil?

    relation = Relation.new
    relation.unique_id = record.byteslice(0, 4).unpack1("V")
    relation.predecessor_task_unique_id = predecessor_uid
    relation.successor_task_unique_id = successor_uid
    relation.type = Relation::TYPES.fetch(record.byteslice(12, 2).unpack1("v"), :finish_start)
    units = Decode.duration_units(record.byteslice(lag_units_offset, 2).to_s.unpack1("v").to_i)
    lag = record.byteslice(lag_offset, 4).to_s.unpack1("l<")
    relation.lag = lag.nil? ? nil : @field_reader.adjusted_duration(lag, units)

    # Only one relation per (successor, predecessor, type, lag), as in
    # MPXJ RelationContainer#addPredecessor.
    next if seen[[successor_uid, predecessor_uid, relation.type, relation.lag]]

    seen[[successor_uid, predecessor_uid, relation.type, relation.lag]] = true
    successor.predecessors << relation
    predecessor.successors << relation
  end
end

#read_resourcesObject

Raises:



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/mpp_reader/reader14.rb', line 70

def read_resources
  field_map = FieldMap.for_resources(@props)
  var_meta = Blocks::VarMeta.new(stream("TBkndRsc/VarMeta"))
  var_data = Blocks::Var2Data.new(var_meta, stream("TBkndRsc/Var2Data"))
  fixed_meta = Blocks::FixedMeta.new(stream("TBkndRsc/FixedMeta"), RESOURCE_FIXED_META_ITEM_SIZE)
  fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndRsc/FixedData"))

  uid_offset = field_map[:unique_id]&.fixed_offset
  raise InvalidFormatError, "resource field map lacks a unique_id location" if uid_offset.nil?

  resource_map = build_resource_map(field_map, fixed_data, uid_offset)

  var_meta.unique_ids.filter_map do |unique_id|
    index = resource_map[unique_id]
    next if index.nil?

    build_resource(field_map, unique_id, [fixed_data[index]], var_data)
  end
end

#read_tasksObject

Raises:



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/mpp_reader/reader14.rb', line 39

def read_tasks
  field_map = FieldMap.for_tasks(@props)
  var_meta = Blocks::VarMeta.new(stream("TBkndTask/VarMeta"))
  var_data = Blocks::Var2Data.new(var_meta, stream("TBkndTask/Var2Data"))
  fixed_meta = Blocks::FixedMeta.new(stream("TBkndTask/FixedMeta"), TASK_FIXED_META_ITEM_SIZE)
  fixed_data = Blocks::FixedData.new(fixed_meta, stream("TBkndTask/FixedData"),
                                     max_expected_size: field_map.max_fixed_data_size(0))
  fixed2_meta = Blocks::FixedMeta.with_derived_item_size(
    stream("TBkndTask/Fixed2Meta"), TASK_FIXED2_META_ITEM_SIZES, fixed_data.item_count
  )
  fixed2_data = Blocks::FixedData.new(fixed2_meta, stream("TBkndTask/Fixed2Data"))

  uid_offset = field_map[:unique_id]&.fixed_offset
  raise InvalidFormatError, "task field map lacks a unique_id location" if uid_offset.nil?

  task_map = build_task_map(field_map, fixed_meta, fixed_data, fixed2_data, var_meta, uid_offset)

  tasks = task_map.sort.filter_map do |unique_id, index|
    next if index.nil?

    record = fixed_data[index]
    next if record.nil? || record.bytesize == NULL_TASK_BLOCK_SIZE

    build_task(field_map, unique_id, [record, fixed2_data[index]],
               fixed_meta[index], fixed2_meta[index], var_data)
  end

  link_hierarchy(tasks.sort_by! { |t| t.id || 0 })
  tasks
end