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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +504 -0
- data/README.md +77 -0
- data/exe/mpp_reader +5 -0
- data/lib/mpp_reader/assignment.rb +8 -0
- data/lib/mpp_reader/blocks/fixed_data.rb +83 -0
- data/lib/mpp_reader/blocks/fixed_meta.rb +62 -0
- data/lib/mpp_reader/blocks/props.rb +47 -0
- data/lib/mpp_reader/blocks/var2_data.rb +39 -0
- data/lib/mpp_reader/blocks/var_meta.rb +38 -0
- data/lib/mpp_reader/calendar.rb +19 -0
- data/lib/mpp_reader/cfbf/directory.rb +84 -0
- data/lib/mpp_reader/cfbf/fat.rb +75 -0
- data/lib/mpp_reader/cfbf/file.rb +114 -0
- data/lib/mpp_reader/cfbf/header.rb +40 -0
- data/lib/mpp_reader/cli.rb +110 -0
- data/lib/mpp_reader/comp_obj.rb +37 -0
- data/lib/mpp_reader/decode.rb +105 -0
- data/lib/mpp_reader/errors.rb +13 -0
- data/lib/mpp_reader/field_map.rb +106 -0
- data/lib/mpp_reader/field_reader.rb +119 -0
- data/lib/mpp_reader/field_tables.rb +4156 -0
- data/lib/mpp_reader/project.rb +70 -0
- data/lib/mpp_reader/reader14.rb +455 -0
- data/lib/mpp_reader/relation.rb +11 -0
- data/lib/mpp_reader/resource.rb +5 -0
- data/lib/mpp_reader/rtf_text.rb +92 -0
- data/lib/mpp_reader/task.rb +21 -0
- data/lib/mpp_reader/version.rb +3 -0
- data/lib/mpp_reader.rb +66 -0
- metadata +77 -0
|
@@ -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,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
|