scriptplan 0.9.0__py3-none-any.whl
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.
- scriptplan/__init__.py +22 -0
- scriptplan/cli/__init__.py +7 -0
- scriptplan/cli/main.py +546 -0
- scriptplan/core/__init__.py +0 -0
- scriptplan/core/account.py +125 -0
- scriptplan/core/allocation.py +69 -0
- scriptplan/core/booking.py +39 -0
- scriptplan/core/journal.py +377 -0
- scriptplan/core/leave.py +14 -0
- scriptplan/core/limits.py +354 -0
- scriptplan/core/project.py +924 -0
- scriptplan/core/property.py +1290 -0
- scriptplan/core/resource.py +198 -0
- scriptplan/core/resource_scenario.py +711 -0
- scriptplan/core/scenario.py +5 -0
- scriptplan/core/scenario_data.py +39 -0
- scriptplan/core/shift.py +71 -0
- scriptplan/core/task.py +77 -0
- scriptplan/core/task_scenario.py +1515 -0
- scriptplan/core/timesheet.py +457 -0
- scriptplan/core/working_hours.py +231 -0
- scriptplan/parser/__init__.py +0 -0
- scriptplan/parser/macro_processor.py +264 -0
- scriptplan/parser/tjp.lark +412 -0
- scriptplan/parser/tjp_parser.py +1904 -0
- scriptplan/py.typed +0 -0
- scriptplan/report/__init__.py +75 -0
- scriptplan/report/html_generator.py +477 -0
- scriptplan/report/report.py +466 -0
- scriptplan/report/report_base.py +397 -0
- scriptplan/report/report_context.py +248 -0
- scriptplan/report/resource_report.py +341 -0
- scriptplan/report/table_report.py +693 -0
- scriptplan/report/task_report.py +362 -0
- scriptplan/report/text_report.py +172 -0
- scriptplan/scheduler/__init__.py +0 -0
- scriptplan/scheduler/batch_processor.py +238 -0
- scriptplan/scheduler/scoreboard.py +120 -0
- scriptplan/utils/__init__.py +0 -0
- scriptplan/utils/data_cache.py +46 -0
- scriptplan/utils/logger.py +243 -0
- scriptplan/utils/message_handler.py +515 -0
- scriptplan/utils/time.py +195 -0
- scriptplan-0.9.0.dist-info/METADATA +161 -0
- scriptplan-0.9.0.dist-info/RECORD +49 -0
- scriptplan-0.9.0.dist-info/WHEEL +5 -0
- scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
- scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
- scriptplan-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
"""TJP Parser for TaskJuggler project files."""
|
|
2
|
+
|
|
3
|
+
from lark import Lark, Transformer, v_args, Token, Tree
|
|
4
|
+
from scriptplan.core.project import Project
|
|
5
|
+
from scriptplan.core.task import Task
|
|
6
|
+
from scriptplan.core.resource import Resource
|
|
7
|
+
from scriptplan.parser.macro_processor import preprocess_tjp
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TJPTransformer(Transformer):
|
|
13
|
+
"""Transform the parse tree into a dictionary structure."""
|
|
14
|
+
|
|
15
|
+
def start(self, items):
|
|
16
|
+
return items[0] if items else {}
|
|
17
|
+
|
|
18
|
+
def statements(self, items):
|
|
19
|
+
result = {
|
|
20
|
+
'project': None,
|
|
21
|
+
'global_attributes': [],
|
|
22
|
+
'property_declarations': [],
|
|
23
|
+
'reports': [],
|
|
24
|
+
'navigators': []
|
|
25
|
+
}
|
|
26
|
+
for item in items:
|
|
27
|
+
if isinstance(item, dict):
|
|
28
|
+
if item.get('type') == 'project':
|
|
29
|
+
result['project'] = item
|
|
30
|
+
elif item.get('type') in ['resource', 'task', 'account', 'shift']:
|
|
31
|
+
result['property_declarations'].append(item)
|
|
32
|
+
elif item.get('type') in ['taskreport', 'resourcereport', 'textreport']:
|
|
33
|
+
result['reports'].append(item)
|
|
34
|
+
elif item.get('type') == 'navigator':
|
|
35
|
+
result['navigators'].append(item)
|
|
36
|
+
elif isinstance(item, tuple):
|
|
37
|
+
result['global_attributes'].append(item)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def statement(self, items):
|
|
41
|
+
return items[0] if items else None
|
|
42
|
+
|
|
43
|
+
# Project definition
|
|
44
|
+
def project(self, items):
|
|
45
|
+
# items[0] is always project_id
|
|
46
|
+
# items[1] might be project_name (if present) or project_timeframe
|
|
47
|
+
# We need to check the type to determine
|
|
48
|
+
p_id = self._get_value(items[0])
|
|
49
|
+
|
|
50
|
+
idx = 1
|
|
51
|
+
# Check if items[1] is a string (project_name) or a dict (timeframe)
|
|
52
|
+
if len(items) > idx and isinstance(items[idx], str):
|
|
53
|
+
p_name = items[idx]
|
|
54
|
+
idx += 1
|
|
55
|
+
else:
|
|
56
|
+
p_name = p_id # Use id as name if not specified
|
|
57
|
+
|
|
58
|
+
timeframe = items[idx] if len(items) > idx else {}
|
|
59
|
+
idx += 1
|
|
60
|
+
attrs = items[idx] if len(items) > idx else []
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
'type': 'project',
|
|
64
|
+
'id': p_id,
|
|
65
|
+
'name': p_name,
|
|
66
|
+
'timeframe': timeframe,
|
|
67
|
+
'attributes': attrs
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def project_id(self, items):
|
|
71
|
+
return self._get_value(items[0])
|
|
72
|
+
|
|
73
|
+
def project_name(self, items):
|
|
74
|
+
return self._get_value(items[0])
|
|
75
|
+
|
|
76
|
+
def project_timeframe(self, items):
|
|
77
|
+
result = {'start': items[0]}
|
|
78
|
+
if len(items) > 1 and items[1]:
|
|
79
|
+
result['duration'] = items[1]
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def duration_spec(self, items):
|
|
83
|
+
return self._get_value(items[0]) if items else None
|
|
84
|
+
|
|
85
|
+
def project_attributes(self, items):
|
|
86
|
+
return list(items)
|
|
87
|
+
|
|
88
|
+
def project_attribute(self, items):
|
|
89
|
+
return items[0] if items else None
|
|
90
|
+
|
|
91
|
+
def project_scheduling(self, items):
|
|
92
|
+
mode = self._get_value(items[0]).lower()
|
|
93
|
+
# forward=True means ASAP, forward=False means ALAP
|
|
94
|
+
return ('scheduling', mode)
|
|
95
|
+
|
|
96
|
+
# Global attributes
|
|
97
|
+
def global_attribute(self, items):
|
|
98
|
+
return items[0] if items else None
|
|
99
|
+
|
|
100
|
+
def copyright(self, items):
|
|
101
|
+
return ('copyright', self._get_value(items[0]))
|
|
102
|
+
|
|
103
|
+
def rate(self, items):
|
|
104
|
+
return ('rate', float(self._get_value(items[0])))
|
|
105
|
+
|
|
106
|
+
def leaves_global(self, items):
|
|
107
|
+
return ('leaves', {
|
|
108
|
+
'type': self._get_value(items[0]),
|
|
109
|
+
'name': self._get_value(items[1]),
|
|
110
|
+
'start': items[2],
|
|
111
|
+
'end': items[3] if len(items) > 3 else None
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
def flags_global(self, items):
|
|
115
|
+
return ('flags', [self._get_value(i) for i in items])
|
|
116
|
+
|
|
117
|
+
def balance(self, items):
|
|
118
|
+
return ('balance', (self._get_value(items[0]), self._get_value(items[1])))
|
|
119
|
+
|
|
120
|
+
def vacation_global(self, items):
|
|
121
|
+
# vacation_global: "vacation" STRING? date ("-" date)?
|
|
122
|
+
# After transformation, items contains: optional string name, datetime(s) from date rule
|
|
123
|
+
name = None
|
|
124
|
+
start_date = None
|
|
125
|
+
end_date = None
|
|
126
|
+
for item in items:
|
|
127
|
+
if isinstance(item, datetime):
|
|
128
|
+
if start_date is None:
|
|
129
|
+
start_date = item
|
|
130
|
+
else:
|
|
131
|
+
end_date = item
|
|
132
|
+
elif isinstance(item, str):
|
|
133
|
+
name = item
|
|
134
|
+
return ('vacation', {'name': name, 'start': start_date, 'end': end_date or start_date})
|
|
135
|
+
|
|
136
|
+
# Project attribute handlers
|
|
137
|
+
def timezone(self, items):
|
|
138
|
+
return ('timezone', self._get_value(items[0]))
|
|
139
|
+
|
|
140
|
+
def timeformat(self, items):
|
|
141
|
+
return ('timeformat', self._get_value(items[0]))
|
|
142
|
+
|
|
143
|
+
def numberformat(self, items):
|
|
144
|
+
return ('numberformat', [self._get_value(i) for i in items])
|
|
145
|
+
|
|
146
|
+
def currencyformat(self, items):
|
|
147
|
+
return ('currencyformat', [self._get_value(i) for i in items])
|
|
148
|
+
|
|
149
|
+
def currency(self, items):
|
|
150
|
+
return ('currency', self._get_value(items[0]))
|
|
151
|
+
|
|
152
|
+
def now(self, items):
|
|
153
|
+
return ('now', items[0])
|
|
154
|
+
|
|
155
|
+
def dailyworkinghours(self, items):
|
|
156
|
+
return ('dailyworkinghours', float(self._get_value(items[0])))
|
|
157
|
+
|
|
158
|
+
def yearlyworkingdays(self, items):
|
|
159
|
+
return ('yearlyworkingdays', float(self._get_value(items[0])))
|
|
160
|
+
|
|
161
|
+
# Scenario
|
|
162
|
+
def scenario_def(self, items):
|
|
163
|
+
s_id = self._get_value(items[0])
|
|
164
|
+
s_name = self._get_value(items[1])
|
|
165
|
+
body = items[2] if len(items) > 2 else []
|
|
166
|
+
return ('scenario', {'id': s_id, 'name': s_name, 'children': body})
|
|
167
|
+
|
|
168
|
+
def scenario_body(self, items):
|
|
169
|
+
return list(items)
|
|
170
|
+
|
|
171
|
+
# Extend
|
|
172
|
+
def extend(self, items):
|
|
173
|
+
e_type = self._get_value(items[0])
|
|
174
|
+
attrs = items[1] if len(items) > 1 else []
|
|
175
|
+
return ('extend', {'type': e_type, 'attributes': attrs})
|
|
176
|
+
|
|
177
|
+
def extend_body(self, items):
|
|
178
|
+
return list(items)
|
|
179
|
+
|
|
180
|
+
def extend_attribute(self, items):
|
|
181
|
+
# Grammar: "text" ID STRING - "text" is literal, so only ID and STRING in items
|
|
182
|
+
return {
|
|
183
|
+
'type': 'text',
|
|
184
|
+
'name': self._get_value(items[0]),
|
|
185
|
+
'label': self._get_value(items[1])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Property declarations
|
|
189
|
+
def property_declaration(self, items):
|
|
190
|
+
return items[0] if items else None
|
|
191
|
+
|
|
192
|
+
# Resource
|
|
193
|
+
def resource(self, items):
|
|
194
|
+
r_id = self._get_value(items[0])
|
|
195
|
+
r_name = self._get_value(items[1])
|
|
196
|
+
body = items[2] if len(items) > 2 else []
|
|
197
|
+
return {
|
|
198
|
+
'type': 'resource',
|
|
199
|
+
'id': r_id,
|
|
200
|
+
'name': r_name,
|
|
201
|
+
'attributes': body
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def resource_body(self, items):
|
|
205
|
+
return list(items)
|
|
206
|
+
|
|
207
|
+
def resource_attr(self, items):
|
|
208
|
+
return items[0] if items else None
|
|
209
|
+
|
|
210
|
+
def resource_email(self, items):
|
|
211
|
+
return ('email', self._get_value(items[0]))
|
|
212
|
+
|
|
213
|
+
def resource_rate(self, items):
|
|
214
|
+
return ('rate', float(self._get_value(items[0])))
|
|
215
|
+
|
|
216
|
+
def resource_efficiency(self, items):
|
|
217
|
+
return ('efficiency', float(self._get_value(items[0])))
|
|
218
|
+
|
|
219
|
+
def resource_timezone(self, items):
|
|
220
|
+
return ('timezone', self._get_value(items[0]))
|
|
221
|
+
|
|
222
|
+
def resource_managers(self, items):
|
|
223
|
+
return ('managers', [self._get_value(i) for i in items])
|
|
224
|
+
|
|
225
|
+
def resource_limits(self, items):
|
|
226
|
+
return ('limits', items[0] if items else [])
|
|
227
|
+
|
|
228
|
+
def limits_body(self, items):
|
|
229
|
+
"""Parse limits body containing limit_attr items."""
|
|
230
|
+
return list(items) if items else []
|
|
231
|
+
|
|
232
|
+
def limit_attr(self, items):
|
|
233
|
+
"""Parse a single limit attribute - pass through the dailymax/weeklymax result."""
|
|
234
|
+
return items[0] if items else None
|
|
235
|
+
|
|
236
|
+
def limit_dailymax(self, items):
|
|
237
|
+
"""Parse dailymax limit."""
|
|
238
|
+
duration = items[0] if items else '0h'
|
|
239
|
+
resources = items[1] if len(items) > 1 else None
|
|
240
|
+
# Store value in hours - conversion to slots happens in Limits class
|
|
241
|
+
hours = self._parse_duration_to_hours(duration, round_to_slots=False) if isinstance(duration, str) else float(duration)
|
|
242
|
+
return {
|
|
243
|
+
'type': 'dailymax',
|
|
244
|
+
'value': hours,
|
|
245
|
+
'resources': resources
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def limit_weeklymax(self, items):
|
|
249
|
+
"""Parse weeklymax limit."""
|
|
250
|
+
duration = items[0] if items else '0h'
|
|
251
|
+
resources = items[1] if len(items) > 1 else None
|
|
252
|
+
# Store value in hours - conversion to slots happens in Limits class
|
|
253
|
+
hours = self._parse_duration_to_hours(duration, round_to_slots=False) if isinstance(duration, str) else float(duration)
|
|
254
|
+
return {
|
|
255
|
+
'type': 'weeklymax',
|
|
256
|
+
'value': hours,
|
|
257
|
+
'resources': resources
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def limits_resources(self, items):
|
|
261
|
+
"""Parse limits resources: { resources id1, id2, ... }."""
|
|
262
|
+
return [self._get_value(i) for i in items]
|
|
263
|
+
|
|
264
|
+
def _parse_duration_to_hours(self, duration_str, round_to_slots=False):
|
|
265
|
+
"""Parse duration string to hours.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
duration_str: Duration string like '6.4h', '2d', '1w'
|
|
269
|
+
round_to_slots: If True, round to integer hours (for limits)
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Duration in hours (float or int depending on round_to_slots)
|
|
273
|
+
"""
|
|
274
|
+
import re
|
|
275
|
+
match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmy]?)', str(duration_str))
|
|
276
|
+
if match:
|
|
277
|
+
value = float(match.group(1))
|
|
278
|
+
unit = match.group(2) or 'h'
|
|
279
|
+
if unit == 'h':
|
|
280
|
+
hours = value
|
|
281
|
+
elif unit == 'd':
|
|
282
|
+
hours = value * 8 # 8 hours per day
|
|
283
|
+
elif unit == 'w':
|
|
284
|
+
hours = value * 40 # 40 hours per week
|
|
285
|
+
elif unit == 'm':
|
|
286
|
+
hours = value * 160 # ~160 hours per month
|
|
287
|
+
elif unit == 'y':
|
|
288
|
+
hours = value * 2000 # ~2000 hours per year
|
|
289
|
+
else:
|
|
290
|
+
hours = 0
|
|
291
|
+
|
|
292
|
+
# TaskJuggler rounds limit values to integer slots
|
|
293
|
+
if round_to_slots:
|
|
294
|
+
return round(hours)
|
|
295
|
+
return hours
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
def resource_leaves(self, items):
|
|
299
|
+
"""Handle resource leaves: leaves type start_date [- end_date]."""
|
|
300
|
+
leave_type = items[0] if items else 'annual'
|
|
301
|
+
start_date = items[1] if len(items) > 1 else None
|
|
302
|
+
end_date = items[2] if len(items) > 2 else start_date
|
|
303
|
+
return ('leaves', {
|
|
304
|
+
'type': leave_type,
|
|
305
|
+
'start': start_date,
|
|
306
|
+
'end': end_date
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
def resource_flags(self, items):
|
|
310
|
+
return ('flags', [self._get_value(i) for i in items])
|
|
311
|
+
|
|
312
|
+
def resource_vacation(self, items):
|
|
313
|
+
"""Handle resource vacation: vacation start_date [- end_date]."""
|
|
314
|
+
start_date = items[0] if items else None
|
|
315
|
+
end_date = items[1] if len(items) > 1 else start_date
|
|
316
|
+
return ('vacation', {
|
|
317
|
+
'start': start_date,
|
|
318
|
+
'end': end_date
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
def resource_booking(self, items):
|
|
322
|
+
"""Handle resource booking: booking STRING date duration_value."""
|
|
323
|
+
name = self._get_value(items[0])
|
|
324
|
+
start = items[1] if len(items) > 1 else None
|
|
325
|
+
duration = self._get_value(items[2]) if len(items) > 2 else '0h'
|
|
326
|
+
return ('booking', {
|
|
327
|
+
'name': name,
|
|
328
|
+
'start': start,
|
|
329
|
+
'duration': duration
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
def resource_workinghours(self, items):
|
|
333
|
+
"""Handle resource workinghours: workinghours mon, tue, ... 08:00 - 17:00 or shift_id."""
|
|
334
|
+
if not items:
|
|
335
|
+
return ('workinghours', [])
|
|
336
|
+
# Check if it's a shift reference (ID) or a workinghours_spec (list)
|
|
337
|
+
item = items[0]
|
|
338
|
+
if isinstance(item, str):
|
|
339
|
+
# It's a shift ID reference
|
|
340
|
+
return ('workinghours_shift', item)
|
|
341
|
+
elif hasattr(item, 'type') and item.type == 'ID':
|
|
342
|
+
# It's a Token ID
|
|
343
|
+
return ('workinghours_shift', str(item))
|
|
344
|
+
else:
|
|
345
|
+
# It's a workinghours_spec
|
|
346
|
+
return ('workinghours', item)
|
|
347
|
+
|
|
348
|
+
def resource_chargeset(self, items):
|
|
349
|
+
"""Handle resource chargeset: chargeset account_id."""
|
|
350
|
+
return ('chargeset', self._get_value(items[0]))
|
|
351
|
+
|
|
352
|
+
def timingresolution(self, items):
|
|
353
|
+
"""Handle timingresolution: timingresolution duration_value."""
|
|
354
|
+
# Parse duration to seconds
|
|
355
|
+
duration = items[0] if items else '1h'
|
|
356
|
+
import re
|
|
357
|
+
match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmymin]+)', str(duration))
|
|
358
|
+
if match:
|
|
359
|
+
value = float(match.group(1))
|
|
360
|
+
unit = match.group(2) or 'h'
|
|
361
|
+
if unit == 'min':
|
|
362
|
+
seconds = int(value * 60)
|
|
363
|
+
elif unit == 'h':
|
|
364
|
+
seconds = int(value * 3600)
|
|
365
|
+
elif unit == 'd':
|
|
366
|
+
seconds = int(value * 86400)
|
|
367
|
+
else:
|
|
368
|
+
seconds = 3600 # default 1 hour
|
|
369
|
+
return ('timingresolution', seconds)
|
|
370
|
+
return ('timingresolution', 3600)
|
|
371
|
+
|
|
372
|
+
def workinghours(self, items):
|
|
373
|
+
"""Handle workinghours at project or shift level."""
|
|
374
|
+
return ('workinghours', items[0] if items else [])
|
|
375
|
+
|
|
376
|
+
def workinghours_spec(self, items):
|
|
377
|
+
"""Parse workinghours specification: mon, tue, ... 08:00 - 17:00, 13:00 - 14:00.
|
|
378
|
+
|
|
379
|
+
Returns a dict mapping day names to list of (start_time, end_time) tuples.
|
|
380
|
+
"""
|
|
381
|
+
# items[0] is day_list (list of days)
|
|
382
|
+
# items[1:] are duration_range tuples
|
|
383
|
+
days = items[0] if items else []
|
|
384
|
+
ranges = list(items[1:]) if len(items) > 1 else []
|
|
385
|
+
|
|
386
|
+
return {'days': days, 'ranges': ranges}
|
|
387
|
+
|
|
388
|
+
def day_list(self, items):
|
|
389
|
+
"""Parse day list: day_spec, day_spec, ..."""
|
|
390
|
+
all_days = []
|
|
391
|
+
for item in items:
|
|
392
|
+
if isinstance(item, list):
|
|
393
|
+
all_days.extend(item)
|
|
394
|
+
else:
|
|
395
|
+
all_days.append(item)
|
|
396
|
+
return all_days
|
|
397
|
+
|
|
398
|
+
def day_spec(self, items):
|
|
399
|
+
"""Parse day spec: single day or day range like mon - fri."""
|
|
400
|
+
day_order = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
|
401
|
+
|
|
402
|
+
if len(items) == 1:
|
|
403
|
+
# Single day
|
|
404
|
+
day = self._get_value(items[0]).lower()
|
|
405
|
+
return [day]
|
|
406
|
+
else:
|
|
407
|
+
# Day range like mon - fri
|
|
408
|
+
start_day = self._get_value(items[0]).lower()
|
|
409
|
+
end_day = self._get_value(items[1]).lower()
|
|
410
|
+
|
|
411
|
+
start_idx = day_order.index(start_day)
|
|
412
|
+
end_idx = day_order.index(end_day)
|
|
413
|
+
|
|
414
|
+
# Handle wrap-around if needed (e.g., fri - mon)
|
|
415
|
+
if start_idx <= end_idx:
|
|
416
|
+
return day_order[start_idx:end_idx + 1]
|
|
417
|
+
else:
|
|
418
|
+
# Wrap around (unusual but supported)
|
|
419
|
+
return day_order[start_idx:] + day_order[:end_idx + 1]
|
|
420
|
+
|
|
421
|
+
def duration_range(self, items):
|
|
422
|
+
"""Parse duration range: TIME - TIME."""
|
|
423
|
+
start_time = self._get_value(items[0]) if items else '09:00'
|
|
424
|
+
end_time = self._get_value(items[1]) if len(items) > 1 else '17:00'
|
|
425
|
+
return (start_time, end_time)
|
|
426
|
+
|
|
427
|
+
def leaves_type(self, items):
|
|
428
|
+
"""Handle leaves type: annual, sick, holiday, special, unpaid."""
|
|
429
|
+
return self._get_value(items[0]) if items else 'annual'
|
|
430
|
+
|
|
431
|
+
# Task
|
|
432
|
+
def task(self, items):
|
|
433
|
+
t_id = self._get_value(items[0])
|
|
434
|
+
t_name = self._get_value(items[1])
|
|
435
|
+
body = items[2] if len(items) > 2 else []
|
|
436
|
+
return {
|
|
437
|
+
'type': 'task',
|
|
438
|
+
'id': t_id,
|
|
439
|
+
'name': t_name,
|
|
440
|
+
'attributes': body
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
def task_body(self, items):
|
|
444
|
+
return list(items)
|
|
445
|
+
|
|
446
|
+
def task_attr(self, items):
|
|
447
|
+
return items[0] if items else None
|
|
448
|
+
|
|
449
|
+
# Named task attribute rules
|
|
450
|
+
def task_start(self, items):
|
|
451
|
+
return ('start', items[0])
|
|
452
|
+
|
|
453
|
+
def task_end(self, items):
|
|
454
|
+
return ('end', items[0])
|
|
455
|
+
|
|
456
|
+
def task_effort(self, items):
|
|
457
|
+
return items[0] # effort_value returns a tuple
|
|
458
|
+
|
|
459
|
+
def task_duration(self, items):
|
|
460
|
+
return ('duration', items[0])
|
|
461
|
+
|
|
462
|
+
def task_length(self, items):
|
|
463
|
+
return ('length', items[0])
|
|
464
|
+
|
|
465
|
+
def task_milestone(self, items):
|
|
466
|
+
return ('milestone', True)
|
|
467
|
+
|
|
468
|
+
def task_scheduling(self, items):
|
|
469
|
+
mode = self._get_value(items[0]).lower()
|
|
470
|
+
# forward=True means ASAP, forward=False means ALAP
|
|
471
|
+
return ('forward', mode == 'asap')
|
|
472
|
+
|
|
473
|
+
def task_depends(self, items):
|
|
474
|
+
return items[0] # depends_list returns a tuple
|
|
475
|
+
|
|
476
|
+
def task_precedes(self, items):
|
|
477
|
+
return ('precedes', items[0][1] if isinstance(items[0], tuple) else items[0])
|
|
478
|
+
|
|
479
|
+
def task_allocate(self, items):
|
|
480
|
+
return items[0] # allocate_spec returns a tuple
|
|
481
|
+
|
|
482
|
+
def task_responsible(self, items):
|
|
483
|
+
return ('responsible', self._get_value(items[0]))
|
|
484
|
+
|
|
485
|
+
def task_priority(self, items):
|
|
486
|
+
return ('priority', int(self._get_value(items[0])))
|
|
487
|
+
|
|
488
|
+
def task_complete(self, items):
|
|
489
|
+
return ('complete', float(self._get_value(items[0])))
|
|
490
|
+
|
|
491
|
+
def task_note(self, items):
|
|
492
|
+
return ('note', self._get_value(items[0]))
|
|
493
|
+
|
|
494
|
+
def task_chargeset(self, items):
|
|
495
|
+
return ('chargeset', self._get_value(items[0]))
|
|
496
|
+
|
|
497
|
+
def task_purge_chargeset(self, items):
|
|
498
|
+
return ('purge_chargeset', True)
|
|
499
|
+
|
|
500
|
+
def task_charge(self, items):
|
|
501
|
+
return ('charge', (float(self._get_value(items[0])), self._get_value(items[1]) if len(items) > 1 else None))
|
|
502
|
+
|
|
503
|
+
def task_limits(self, items):
|
|
504
|
+
return ('limits', items[0] if items else [])
|
|
505
|
+
|
|
506
|
+
def task_journalentry(self, items):
|
|
507
|
+
# items: date, optional headline (STRING), journal_body
|
|
508
|
+
date = items[0] if items else None
|
|
509
|
+
headline = None
|
|
510
|
+
body = {}
|
|
511
|
+
|
|
512
|
+
for item in items[1:]:
|
|
513
|
+
if isinstance(item, str) or (hasattr(item, 'type') and item.type == 'STRING'):
|
|
514
|
+
headline = self._get_value(item)
|
|
515
|
+
elif isinstance(item, dict):
|
|
516
|
+
body = item
|
|
517
|
+
|
|
518
|
+
return ('journalentry', {'date': date, 'headline': headline, 'body': body})
|
|
519
|
+
|
|
520
|
+
def journal_body(self, items):
|
|
521
|
+
# Collect all journal attributes into a dict
|
|
522
|
+
result = {'author': None, 'alert': 'green', 'summary': None, 'details': None}
|
|
523
|
+
for item in items:
|
|
524
|
+
if isinstance(item, tuple):
|
|
525
|
+
key, value = item
|
|
526
|
+
result[key] = value
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
def journal_attr(self, items):
|
|
530
|
+
# Pass through the inner journal_* rule result
|
|
531
|
+
return items[0] if items else None
|
|
532
|
+
|
|
533
|
+
def journal_author(self, items):
|
|
534
|
+
return ('author', self._get_value(items[0]))
|
|
535
|
+
|
|
536
|
+
def journal_alert(self, items):
|
|
537
|
+
return ('alert', items[0] if items else 'green')
|
|
538
|
+
|
|
539
|
+
def journal_summary(self, items):
|
|
540
|
+
value = items[0] if items else None
|
|
541
|
+
return ('summary', self._extract_text(value))
|
|
542
|
+
|
|
543
|
+
def journal_details(self, items):
|
|
544
|
+
value = items[0] if items else None
|
|
545
|
+
return ('details', self._extract_text(value))
|
|
546
|
+
|
|
547
|
+
def rich_text(self, items):
|
|
548
|
+
# Rich text is wrapped in -8<- ... ->8-
|
|
549
|
+
# The RICH_TEXT_BLOCK token contains the delimiters and content
|
|
550
|
+
if items:
|
|
551
|
+
text = self._get_value(items[0])
|
|
552
|
+
# Strip the -8<- and ->8- markers
|
|
553
|
+
if text.startswith('-8<-'):
|
|
554
|
+
text = text[4:]
|
|
555
|
+
if text.endswith('->8-'):
|
|
556
|
+
text = text[:-4]
|
|
557
|
+
return text.strip()
|
|
558
|
+
return ''
|
|
559
|
+
|
|
560
|
+
def _extract_text(self, value):
|
|
561
|
+
"""Extract text from a string or rich_text result."""
|
|
562
|
+
if value is None:
|
|
563
|
+
return None
|
|
564
|
+
if isinstance(value, str):
|
|
565
|
+
return value
|
|
566
|
+
if hasattr(value, 'type') and value.type == 'STRING':
|
|
567
|
+
return self._get_value(value)
|
|
568
|
+
# If it's already processed rich_text
|
|
569
|
+
return str(value) if value else None
|
|
570
|
+
|
|
571
|
+
def alert_level(self, items):
|
|
572
|
+
# items[0] is a Token with the alert level value (green/yellow/red)
|
|
573
|
+
return self._get_value(items[0]) if items else 'green'
|
|
574
|
+
|
|
575
|
+
def task_flags(self, items):
|
|
576
|
+
return ('flags', [self._get_value(i) for i in items])
|
|
577
|
+
|
|
578
|
+
def scenario_attr(self, items):
|
|
579
|
+
"""Handle scenario-specific attribute like 'delayed:effort 40d'."""
|
|
580
|
+
scenario_id = self._get_value(items[0])
|
|
581
|
+
attr_data = items[1] # scenario_specific_attr result
|
|
582
|
+
return ('scenario_attr', (scenario_id, attr_data))
|
|
583
|
+
|
|
584
|
+
def scenario_specific_attr(self, items):
|
|
585
|
+
"""Handle the attribute part of scenario-specific attribute."""
|
|
586
|
+
# items[0] is the result from scenario_start/end/effort/etc
|
|
587
|
+
return items[0] if items else None
|
|
588
|
+
|
|
589
|
+
def scenario_start(self, items):
|
|
590
|
+
"""Handle scenario-specific start attribute."""
|
|
591
|
+
return ('start', items[0]) # items[0] is the date
|
|
592
|
+
|
|
593
|
+
def scenario_end(self, items):
|
|
594
|
+
"""Handle scenario-specific end attribute."""
|
|
595
|
+
return ('end', items[0])
|
|
596
|
+
|
|
597
|
+
def scenario_effort(self, items):
|
|
598
|
+
"""Handle scenario-specific effort attribute."""
|
|
599
|
+
return items[0] # effort_value already returns ('effort', value)
|
|
600
|
+
|
|
601
|
+
def scenario_duration(self, items):
|
|
602
|
+
"""Handle scenario-specific duration attribute."""
|
|
603
|
+
return ('duration', items[0])
|
|
604
|
+
|
|
605
|
+
def scenario_length(self, items):
|
|
606
|
+
"""Handle scenario-specific length attribute."""
|
|
607
|
+
return ('length', items[0])
|
|
608
|
+
|
|
609
|
+
# Task attribute helpers
|
|
610
|
+
def effort_value(self, items):
|
|
611
|
+
num = float(self._get_value(items[0]))
|
|
612
|
+
unit = self._get_value(items[1])
|
|
613
|
+
# Convert to hours (the base unit internally)
|
|
614
|
+
# d=day (8h), w=week (40h), h=hour, m=minute, y=year (2080h)
|
|
615
|
+
multipliers = {
|
|
616
|
+
'd': 8,
|
|
617
|
+
'w': 40,
|
|
618
|
+
'h': 1,
|
|
619
|
+
'm': 1/60,
|
|
620
|
+
'y': 2080,
|
|
621
|
+
'min': 1/60
|
|
622
|
+
}
|
|
623
|
+
hours = num * multipliers.get(unit.lower(), 1)
|
|
624
|
+
return ('effort', hours)
|
|
625
|
+
|
|
626
|
+
def duration_value(self, items):
|
|
627
|
+
num = self._get_value(items[0])
|
|
628
|
+
unit = self._get_value(items[1])
|
|
629
|
+
return f"{num}{unit}"
|
|
630
|
+
|
|
631
|
+
def depends_list(self, items):
|
|
632
|
+
# Items are now dependency dicts with ref and optional gap
|
|
633
|
+
return ('depends', list(items))
|
|
634
|
+
|
|
635
|
+
def depends_item(self, items):
|
|
636
|
+
# First item is the DEPENDS_REF, optional second is depends_options dict
|
|
637
|
+
ref = self._get_value(items[0])
|
|
638
|
+
dep = {'ref': ref}
|
|
639
|
+
if len(items) > 1 and items[1]:
|
|
640
|
+
dep.update(items[1])
|
|
641
|
+
return dep
|
|
642
|
+
|
|
643
|
+
def depends_options(self, items):
|
|
644
|
+
result = {}
|
|
645
|
+
for item in items:
|
|
646
|
+
if isinstance(item, dict):
|
|
647
|
+
result.update(item)
|
|
648
|
+
return result
|
|
649
|
+
|
|
650
|
+
def dep_gapduration(self, items):
|
|
651
|
+
return {'gapduration': self._get_value(items[0])}
|
|
652
|
+
|
|
653
|
+
def dep_gaplength(self, items):
|
|
654
|
+
return {'gaplength': self._get_value(items[0])}
|
|
655
|
+
|
|
656
|
+
def dep_maxgapduration(self, items):
|
|
657
|
+
return {'maxgapduration': self._get_value(items[0])}
|
|
658
|
+
|
|
659
|
+
def dep_onend(self, items):
|
|
660
|
+
return {'onend': True}
|
|
661
|
+
|
|
662
|
+
def dep_onstart(self, items):
|
|
663
|
+
return {'onstart': True}
|
|
664
|
+
|
|
665
|
+
def allocate_spec(self, items):
|
|
666
|
+
resources = []
|
|
667
|
+
options = {}
|
|
668
|
+
for item in items:
|
|
669
|
+
if isinstance(item, Token):
|
|
670
|
+
resources.append(item.value)
|
|
671
|
+
elif isinstance(item, dict):
|
|
672
|
+
# allocate_options dict
|
|
673
|
+
options.update(item)
|
|
674
|
+
# If there are alternatives, include them in the allocation structure
|
|
675
|
+
if options:
|
|
676
|
+
return ('allocate', {'resources': resources, 'options': options})
|
|
677
|
+
return ('allocate', resources)
|
|
678
|
+
|
|
679
|
+
def allocate_options(self, items):
|
|
680
|
+
"""Process allocation options like persistent, mandatory, alternative."""
|
|
681
|
+
result = {}
|
|
682
|
+
for item in items:
|
|
683
|
+
if isinstance(item, dict):
|
|
684
|
+
result.update(item)
|
|
685
|
+
elif isinstance(item, list):
|
|
686
|
+
# Flatten nested lists from allocate_option
|
|
687
|
+
for subitem in item:
|
|
688
|
+
if isinstance(subitem, dict):
|
|
689
|
+
result.update(subitem)
|
|
690
|
+
return result
|
|
691
|
+
|
|
692
|
+
def allocate_option(self, items):
|
|
693
|
+
"""
|
|
694
|
+
Process a single allocation option.
|
|
695
|
+
|
|
696
|
+
The grammar literals ("alternative", "persistent", etc.) are filtered out,
|
|
697
|
+
so we detect the option type by the structure:
|
|
698
|
+
- Empty items -> persistent or mandatory (no IDs)
|
|
699
|
+
- ID tokens -> alternative (list of resource IDs)
|
|
700
|
+
- Dict with limits -> limits
|
|
701
|
+
"""
|
|
702
|
+
if not items:
|
|
703
|
+
# This shouldn't happen for well-formed input
|
|
704
|
+
return {}
|
|
705
|
+
|
|
706
|
+
# Check if it's a limits block (dict)
|
|
707
|
+
if isinstance(items[0], dict):
|
|
708
|
+
return items[0]
|
|
709
|
+
|
|
710
|
+
# Check if items are ID tokens -> alternative resources
|
|
711
|
+
alternatives = []
|
|
712
|
+
for item in items:
|
|
713
|
+
if isinstance(item, Token) and item.type == 'ID':
|
|
714
|
+
alternatives.append(item.value)
|
|
715
|
+
|
|
716
|
+
if alternatives:
|
|
717
|
+
return {'alternative': alternatives}
|
|
718
|
+
|
|
719
|
+
# Persistent and mandatory have no child items in the parse tree
|
|
720
|
+
# They're handled at the grammar level as literals
|
|
721
|
+
return {}
|
|
722
|
+
|
|
723
|
+
# Account
|
|
724
|
+
def account(self, items):
|
|
725
|
+
a_id = self._get_value(items[0])
|
|
726
|
+
a_name = self._get_value(items[1])
|
|
727
|
+
body = items[2] if len(items) > 2 else []
|
|
728
|
+
return {
|
|
729
|
+
'type': 'account',
|
|
730
|
+
'id': a_id,
|
|
731
|
+
'name': a_name,
|
|
732
|
+
'attributes': body
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
def account_body(self, items):
|
|
736
|
+
return list(items)
|
|
737
|
+
|
|
738
|
+
def account_attr(self, items):
|
|
739
|
+
return items[0] if items else None
|
|
740
|
+
|
|
741
|
+
# Shift
|
|
742
|
+
def shift(self, items):
|
|
743
|
+
s_id = self._get_value(items[0])
|
|
744
|
+
# Name is optional (STRING?)
|
|
745
|
+
# Body is the last item (a list)
|
|
746
|
+
if len(items) >= 2 and isinstance(items[-1], list):
|
|
747
|
+
body = items[-1]
|
|
748
|
+
s_name = self._get_value(items[1]) if len(items) > 2 else s_id
|
|
749
|
+
else:
|
|
750
|
+
body = []
|
|
751
|
+
s_name = self._get_value(items[1]) if len(items) > 1 else s_id
|
|
752
|
+
return {
|
|
753
|
+
'type': 'shift',
|
|
754
|
+
'id': s_id,
|
|
755
|
+
'name': s_name,
|
|
756
|
+
'attributes': body
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
def shift_body(self, items):
|
|
760
|
+
return list(items)
|
|
761
|
+
|
|
762
|
+
def shift_attr(self, items):
|
|
763
|
+
# shift_attr comes from workinghours workinghours_spec or leaves
|
|
764
|
+
# The workinghours handler returns ('workinghours', spec)
|
|
765
|
+
# But for shifts, the grammar directly uses workinghours_spec
|
|
766
|
+
if items and isinstance(items[0], dict):
|
|
767
|
+
# It's a workinghours_spec dict - wrap it as a tuple
|
|
768
|
+
return ('workinghours', items[0])
|
|
769
|
+
return items[0] if items else None
|
|
770
|
+
|
|
771
|
+
# Reports
|
|
772
|
+
def report_definition(self, items):
|
|
773
|
+
return items[0] if items else None
|
|
774
|
+
|
|
775
|
+
def textreport(self, items):
|
|
776
|
+
return self._parse_report('textreport', items)
|
|
777
|
+
|
|
778
|
+
def taskreport(self, items):
|
|
779
|
+
return self._parse_report('taskreport', items)
|
|
780
|
+
|
|
781
|
+
def resourcereport(self, items):
|
|
782
|
+
return self._parse_report('resourcereport', items)
|
|
783
|
+
|
|
784
|
+
def _parse_report(self, report_type, items):
|
|
785
|
+
r_id = None
|
|
786
|
+
r_name = None
|
|
787
|
+
body = []
|
|
788
|
+
for item in items:
|
|
789
|
+
if isinstance(item, Token):
|
|
790
|
+
if item.type == 'ID':
|
|
791
|
+
r_id = item.value
|
|
792
|
+
elif item.type == 'STRING':
|
|
793
|
+
r_name = item.value.strip('"')
|
|
794
|
+
elif isinstance(item, list):
|
|
795
|
+
body = item
|
|
796
|
+
return {
|
|
797
|
+
'type': report_type,
|
|
798
|
+
'id': r_id,
|
|
799
|
+
'name': r_name,
|
|
800
|
+
'attributes': body
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
def textreport_body(self, items):
|
|
804
|
+
return list(items)
|
|
805
|
+
|
|
806
|
+
def textreport_attr(self, items):
|
|
807
|
+
return items[0] if items else None
|
|
808
|
+
|
|
809
|
+
def textreport_header(self, items):
|
|
810
|
+
return ('header', self._get_value(items[0]))
|
|
811
|
+
|
|
812
|
+
def textreport_footer(self, items):
|
|
813
|
+
return ('footer', self._get_value(items[0]))
|
|
814
|
+
|
|
815
|
+
def textreport_center(self, items):
|
|
816
|
+
return ('center', self._get_value(items[0]))
|
|
817
|
+
|
|
818
|
+
def textreport_left(self, items):
|
|
819
|
+
return ('left', self._get_value(items[0]))
|
|
820
|
+
|
|
821
|
+
def textreport_right(self, items):
|
|
822
|
+
return ('right', self._get_value(items[0]))
|
|
823
|
+
|
|
824
|
+
def textreport_formats(self, items):
|
|
825
|
+
# items[0] is the result from format_list which is already ('formats', [...])
|
|
826
|
+
return items[0] if items else ('formats', [])
|
|
827
|
+
|
|
828
|
+
def textreport_title(self, items):
|
|
829
|
+
return ('title', self._get_value(items[0]))
|
|
830
|
+
|
|
831
|
+
def taskreport_body(self, items):
|
|
832
|
+
return list(items)
|
|
833
|
+
|
|
834
|
+
def taskreport_attr(self, items):
|
|
835
|
+
return items[0] if items else None
|
|
836
|
+
|
|
837
|
+
def taskreport_header(self, items):
|
|
838
|
+
return ('header', self._get_value(items[0]))
|
|
839
|
+
|
|
840
|
+
def taskreport_footer(self, items):
|
|
841
|
+
return ('footer', self._get_value(items[0]))
|
|
842
|
+
|
|
843
|
+
def taskreport_headline(self, items):
|
|
844
|
+
return ('headline', self._get_value(items[0]))
|
|
845
|
+
|
|
846
|
+
def taskreport_caption(self, items):
|
|
847
|
+
return ('caption', self._get_value(items[0]))
|
|
848
|
+
|
|
849
|
+
def taskreport_columns(self, items):
|
|
850
|
+
return items[0] if items else ('columns', [])
|
|
851
|
+
|
|
852
|
+
def taskreport_timeformat(self, items):
|
|
853
|
+
return ('timeFormat', self._get_value(items[0]))
|
|
854
|
+
|
|
855
|
+
def taskreport_loadunit(self, items):
|
|
856
|
+
return ('loadUnit', self._get_value(items[0]))
|
|
857
|
+
|
|
858
|
+
def taskreport_hideresource(self, items):
|
|
859
|
+
return ('hideResource', self._get_value(items[0]))
|
|
860
|
+
|
|
861
|
+
def taskreport_hidetask(self, items):
|
|
862
|
+
return ('hideTask', self._get_value(items[0]))
|
|
863
|
+
|
|
864
|
+
def taskreport_sorttasks(self, items):
|
|
865
|
+
return items[0] if items else ('sort', [])
|
|
866
|
+
|
|
867
|
+
def taskreport_sortresources(self, items):
|
|
868
|
+
return items[0] if items else ('sort', [])
|
|
869
|
+
|
|
870
|
+
def taskreport_scenarios(self, items):
|
|
871
|
+
return ('scenarios', [self._get_value(i) for i in items])
|
|
872
|
+
|
|
873
|
+
def taskreport_taskroot(self, items):
|
|
874
|
+
return ('taskRoot', self._get_value(items[0]))
|
|
875
|
+
|
|
876
|
+
def taskreport_period(self, items):
|
|
877
|
+
return items[0] if items else ('period', None)
|
|
878
|
+
|
|
879
|
+
def taskreport_balance(self, items):
|
|
880
|
+
return ('balance', [self._get_value(i) for i in items])
|
|
881
|
+
|
|
882
|
+
def taskreport_journalmode(self, items):
|
|
883
|
+
return ('journalMode', self._get_value(items[0]))
|
|
884
|
+
|
|
885
|
+
def taskreport_journalattributes(self, items):
|
|
886
|
+
return ('journalAttributes', [self._get_value(i) for i in items])
|
|
887
|
+
|
|
888
|
+
def taskreport_formats(self, items):
|
|
889
|
+
# items[0] is the result from format_list: ('formats', [...])
|
|
890
|
+
if items and isinstance(items[0], tuple) and items[0][0] == 'formats':
|
|
891
|
+
return items[0] # Already a properly formatted tuple
|
|
892
|
+
return ('formats', [self._get_value(i) for i in items])
|
|
893
|
+
|
|
894
|
+
def taskreport_leaftasksonly(self, items):
|
|
895
|
+
val = self._get_value(items[0])
|
|
896
|
+
# Convert string to boolean
|
|
897
|
+
if isinstance(val, str):
|
|
898
|
+
val = val.lower() in ('true', 'yes', '1')
|
|
899
|
+
return ('leafTasksOnly', val)
|
|
900
|
+
|
|
901
|
+
def resourcereport_body(self, items):
|
|
902
|
+
return list(items)
|
|
903
|
+
|
|
904
|
+
def resourcereport_attr(self, items):
|
|
905
|
+
return items[0] if items else None
|
|
906
|
+
|
|
907
|
+
def resourcereport_header(self, items):
|
|
908
|
+
return ('header', self._get_value(items[0]))
|
|
909
|
+
|
|
910
|
+
def resourcereport_footer(self, items):
|
|
911
|
+
return ('footer', self._get_value(items[0]))
|
|
912
|
+
|
|
913
|
+
def resourcereport_headline(self, items):
|
|
914
|
+
return ('headline', self._get_value(items[0]))
|
|
915
|
+
|
|
916
|
+
def resourcereport_columns(self, items):
|
|
917
|
+
return items[0] if items else ('columns', [])
|
|
918
|
+
|
|
919
|
+
def resourcereport_loadunit(self, items):
|
|
920
|
+
return ('loadUnit', self._get_value(items[0]))
|
|
921
|
+
|
|
922
|
+
def resourcereport_hideresource(self, items):
|
|
923
|
+
return ('hideResource', self._get_value(items[0]))
|
|
924
|
+
|
|
925
|
+
def resourcereport_hidetask(self, items):
|
|
926
|
+
return ('hideTask', self._get_value(items[0]))
|
|
927
|
+
|
|
928
|
+
def resourcereport_sorttasks(self, items):
|
|
929
|
+
return items[0] if items else ('sort', [])
|
|
930
|
+
|
|
931
|
+
def resourcereport_sortresources(self, items):
|
|
932
|
+
return items[0] if items else ('sort', [])
|
|
933
|
+
|
|
934
|
+
def resourcereport_scenarios(self, items):
|
|
935
|
+
return ('scenarios', [self._get_value(i) for i in items])
|
|
936
|
+
|
|
937
|
+
# Column specifications
|
|
938
|
+
def column_list(self, items):
|
|
939
|
+
"""Parse column list into list of column specs."""
|
|
940
|
+
return ('columns', [item for item in items if item])
|
|
941
|
+
|
|
942
|
+
def column_spec(self, items):
|
|
943
|
+
"""Parse a single column specification."""
|
|
944
|
+
col_id = self._get_value(items[0])
|
|
945
|
+
options = {}
|
|
946
|
+
if len(items) > 1 and items[1]:
|
|
947
|
+
options = items[1]
|
|
948
|
+
return {'id': col_id, 'options': options}
|
|
949
|
+
|
|
950
|
+
def column_options(self, items):
|
|
951
|
+
"""Parse column options into a dict."""
|
|
952
|
+
result = {}
|
|
953
|
+
for item in items:
|
|
954
|
+
if isinstance(item, tuple):
|
|
955
|
+
result[item[0]] = item[1]
|
|
956
|
+
elif isinstance(item, Token):
|
|
957
|
+
# Macro reference or similar
|
|
958
|
+
result['macro'] = self._get_value(item)
|
|
959
|
+
return result
|
|
960
|
+
|
|
961
|
+
def column_option(self, items):
|
|
962
|
+
"""Parse a single column option."""
|
|
963
|
+
if not items:
|
|
964
|
+
return None
|
|
965
|
+
first = items[0]
|
|
966
|
+
if isinstance(first, Token):
|
|
967
|
+
if first.type == 'MACRO_REF':
|
|
968
|
+
return ('macro', self._get_value(first))
|
|
969
|
+
# Token is often the keyword like 'title', 'width' etc
|
|
970
|
+
key = self._get_value(first)
|
|
971
|
+
value = self._get_value(items[1]) if len(items) > 1 else None
|
|
972
|
+
return (key, value)
|
|
973
|
+
return items[0] if items else None
|
|
974
|
+
|
|
975
|
+
# Sort specifications
|
|
976
|
+
def sort_list(self, items):
|
|
977
|
+
"""Parse sort list."""
|
|
978
|
+
return ('sort', [item for item in items if item])
|
|
979
|
+
|
|
980
|
+
def sort_item(self, items):
|
|
981
|
+
"""Parse a single sort item."""
|
|
982
|
+
return self._get_value(items[0]) if items else None
|
|
983
|
+
|
|
984
|
+
# Format list
|
|
985
|
+
def format_list(self, items):
|
|
986
|
+
"""Parse formats list."""
|
|
987
|
+
return ('formats', [self._get_value(i) for i in items])
|
|
988
|
+
|
|
989
|
+
# Period specification
|
|
990
|
+
def period_spec(self, items):
|
|
991
|
+
"""Parse period specification."""
|
|
992
|
+
return ('period', self._get_value(items[0]) if items else None)
|
|
993
|
+
|
|
994
|
+
# Navigator
|
|
995
|
+
def navigator(self, items):
|
|
996
|
+
n_id = self._get_value(items[0])
|
|
997
|
+
body = items[1] if len(items) > 1 else []
|
|
998
|
+
return {
|
|
999
|
+
'type': 'navigator',
|
|
1000
|
+
'id': n_id,
|
|
1001
|
+
'attributes': body
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
def navigator_body(self, items):
|
|
1005
|
+
return list(items)
|
|
1006
|
+
|
|
1007
|
+
def navigator_attr(self, items):
|
|
1008
|
+
return items[0] if items else None
|
|
1009
|
+
|
|
1010
|
+
# Common
|
|
1011
|
+
def date(self, items):
|
|
1012
|
+
val = self._get_value(items[0])
|
|
1013
|
+
try:
|
|
1014
|
+
return datetime.strptime(val, "%Y-%m-%d")
|
|
1015
|
+
except ValueError:
|
|
1016
|
+
return datetime.strptime(val, "%Y-%m-%d-%H:%M")
|
|
1017
|
+
|
|
1018
|
+
def _get_value(self, item):
|
|
1019
|
+
"""Extract value from Token or string."""
|
|
1020
|
+
if isinstance(item, Token):
|
|
1021
|
+
val = item.value
|
|
1022
|
+
if val.startswith('"') and val.endswith('"'):
|
|
1023
|
+
return val[1:-1]
|
|
1024
|
+
return val
|
|
1025
|
+
elif isinstance(item, str):
|
|
1026
|
+
if item.startswith('"') and item.endswith('"'):
|
|
1027
|
+
return item[1:-1]
|
|
1028
|
+
return item
|
|
1029
|
+
return item
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
class ModelBuilder:
|
|
1033
|
+
"""Build the Project model from the parsed data."""
|
|
1034
|
+
|
|
1035
|
+
def __init__(self):
|
|
1036
|
+
self._pending_depends = [] # Store (task, depends_list) for later resolution
|
|
1037
|
+
self._pending_precedes = [] # Store (task, precedes_list) for later resolution
|
|
1038
|
+
|
|
1039
|
+
def build(self, data):
|
|
1040
|
+
"""Build a Project from parsed data."""
|
|
1041
|
+
if not data or not data.get('project'):
|
|
1042
|
+
raise ValueError("No project definition found")
|
|
1043
|
+
|
|
1044
|
+
proj_data = data['project']
|
|
1045
|
+
timeframe = proj_data.get('timeframe', {})
|
|
1046
|
+
start_date = timeframe.get('start')
|
|
1047
|
+
duration_str = timeframe.get('duration')
|
|
1048
|
+
|
|
1049
|
+
# Create project
|
|
1050
|
+
project = Project(
|
|
1051
|
+
proj_data['id'],
|
|
1052
|
+
proj_data['name'],
|
|
1053
|
+
None # version is not used like this
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Set project start and end dates
|
|
1057
|
+
if start_date:
|
|
1058
|
+
project['start'] = start_date
|
|
1059
|
+
# Calculate end date from duration if provided
|
|
1060
|
+
if duration_str:
|
|
1061
|
+
from dateutil.relativedelta import relativedelta
|
|
1062
|
+
import re
|
|
1063
|
+
match = re.match(r'(\d+)([dwmy])', duration_str)
|
|
1064
|
+
if match:
|
|
1065
|
+
amount = int(match.group(1))
|
|
1066
|
+
unit = match.group(2)
|
|
1067
|
+
if unit == 'd':
|
|
1068
|
+
end_date = start_date + relativedelta(days=amount)
|
|
1069
|
+
elif unit == 'w':
|
|
1070
|
+
end_date = start_date + relativedelta(weeks=amount)
|
|
1071
|
+
elif unit == 'm':
|
|
1072
|
+
end_date = start_date + relativedelta(months=amount)
|
|
1073
|
+
elif unit == 'y':
|
|
1074
|
+
end_date = start_date + relativedelta(years=amount)
|
|
1075
|
+
else:
|
|
1076
|
+
end_date = start_date
|
|
1077
|
+
project['end'] = end_date
|
|
1078
|
+
|
|
1079
|
+
# Apply project attributes
|
|
1080
|
+
self._apply_project_attributes(project, proj_data.get('attributes', []))
|
|
1081
|
+
|
|
1082
|
+
# Apply global attributes
|
|
1083
|
+
self._apply_global_attributes(project, data.get('global_attributes', []))
|
|
1084
|
+
|
|
1085
|
+
# Apply property declarations (resources, tasks, accounts)
|
|
1086
|
+
for prop in data.get('property_declarations', []):
|
|
1087
|
+
self._create_property(project, prop)
|
|
1088
|
+
|
|
1089
|
+
# Resolve dependencies after all tasks are created
|
|
1090
|
+
self._resolve_dependencies(project)
|
|
1091
|
+
|
|
1092
|
+
# Resolve precedes relationships (convert to dependencies on target tasks)
|
|
1093
|
+
self._resolve_precedes(project)
|
|
1094
|
+
|
|
1095
|
+
# Inherit attributes from parents for all tasks
|
|
1096
|
+
self._inherit_all_attributes(project)
|
|
1097
|
+
|
|
1098
|
+
# Create reports
|
|
1099
|
+
for report_data in data.get('reports', []):
|
|
1100
|
+
self._create_report(project, report_data)
|
|
1101
|
+
|
|
1102
|
+
return project
|
|
1103
|
+
|
|
1104
|
+
def _inherit_all_attributes(self, project):
|
|
1105
|
+
"""Inherit attributes from parent nodes for all tasks and resources."""
|
|
1106
|
+
# Process tasks in tree order (parents before children)
|
|
1107
|
+
def inherit_recursive(node):
|
|
1108
|
+
node.inheritAttributes()
|
|
1109
|
+
for child in node.children:
|
|
1110
|
+
inherit_recursive(child)
|
|
1111
|
+
|
|
1112
|
+
# Get top-level items (no parent)
|
|
1113
|
+
for task in project.tasks:
|
|
1114
|
+
if not task.parent:
|
|
1115
|
+
inherit_recursive(task)
|
|
1116
|
+
|
|
1117
|
+
for resource in project.resources:
|
|
1118
|
+
if not resource.parent:
|
|
1119
|
+
inherit_recursive(resource)
|
|
1120
|
+
|
|
1121
|
+
def _resolve_dependencies(self, project):
|
|
1122
|
+
"""Resolve task dependency references to actual Task objects."""
|
|
1123
|
+
for task, depends_list in self._pending_depends:
|
|
1124
|
+
resolved = []
|
|
1125
|
+
for dep_item in depends_list:
|
|
1126
|
+
# dep_item can be a dict with 'ref' key or a string (for backwards compat)
|
|
1127
|
+
if isinstance(dep_item, dict):
|
|
1128
|
+
dep_ref = dep_item.get('ref', '')
|
|
1129
|
+
gapduration = dep_item.get('gapduration')
|
|
1130
|
+
gaplength = dep_item.get('gaplength')
|
|
1131
|
+
maxgapduration = dep_item.get('maxgapduration')
|
|
1132
|
+
onstart = dep_item.get('onstart', False)
|
|
1133
|
+
onend = dep_item.get('onend', False)
|
|
1134
|
+
else:
|
|
1135
|
+
dep_ref = dep_item
|
|
1136
|
+
gapduration = None
|
|
1137
|
+
gaplength = None
|
|
1138
|
+
maxgapduration = None
|
|
1139
|
+
onstart = False
|
|
1140
|
+
onend = False
|
|
1141
|
+
|
|
1142
|
+
dep_task = self._resolve_task_reference(project, task, dep_ref)
|
|
1143
|
+
if dep_task:
|
|
1144
|
+
# Store as dict if we have gap info or onstart/onend, else just the task
|
|
1145
|
+
if gapduration or gaplength or maxgapduration or onstart or onend:
|
|
1146
|
+
resolved.append({
|
|
1147
|
+
'task': dep_task,
|
|
1148
|
+
'gapduration': gapduration,
|
|
1149
|
+
'gaplength': gaplength,
|
|
1150
|
+
'maxgapduration': maxgapduration,
|
|
1151
|
+
'onstart': onstart,
|
|
1152
|
+
'onend': onend
|
|
1153
|
+
})
|
|
1154
|
+
else:
|
|
1155
|
+
resolved.append(dep_task)
|
|
1156
|
+
if resolved:
|
|
1157
|
+
# Set dependencies for all scenarios
|
|
1158
|
+
for scIdx in range(project.scenarioCount()):
|
|
1159
|
+
task[('depends', scIdx)] = resolved
|
|
1160
|
+
|
|
1161
|
+
def _resolve_precedes(self, project):
|
|
1162
|
+
"""Resolve precedes relationships by adding dependencies to target tasks.
|
|
1163
|
+
|
|
1164
|
+
If task A precedes task B, then B depends on A.
|
|
1165
|
+
This is the inverse of the 'depends' relationship.
|
|
1166
|
+
"""
|
|
1167
|
+
for source_task, precedes_list in self._pending_precedes:
|
|
1168
|
+
for prec_item in precedes_list:
|
|
1169
|
+
# prec_item can be a dict with 'ref' key or a string
|
|
1170
|
+
if isinstance(prec_item, dict):
|
|
1171
|
+
prec_ref = prec_item.get('ref', '')
|
|
1172
|
+
else:
|
|
1173
|
+
prec_ref = prec_item
|
|
1174
|
+
|
|
1175
|
+
target_task = self._resolve_task_reference(project, source_task, prec_ref)
|
|
1176
|
+
if target_task:
|
|
1177
|
+
# Add source_task as a dependency of target_task
|
|
1178
|
+
for scIdx in range(project.scenarioCount()):
|
|
1179
|
+
existing_deps = target_task.get('depends', scIdx) or []
|
|
1180
|
+
if not isinstance(existing_deps, list):
|
|
1181
|
+
existing_deps = [existing_deps] if existing_deps else []
|
|
1182
|
+
# Check if source_task is already in dependencies
|
|
1183
|
+
already_exists = False
|
|
1184
|
+
for dep in existing_deps:
|
|
1185
|
+
dep_task = dep.get('task') if isinstance(dep, dict) else dep
|
|
1186
|
+
if dep_task is source_task:
|
|
1187
|
+
already_exists = True
|
|
1188
|
+
break
|
|
1189
|
+
if not already_exists:
|
|
1190
|
+
existing_deps.append(source_task)
|
|
1191
|
+
target_task[('depends', scIdx)] = existing_deps
|
|
1192
|
+
|
|
1193
|
+
def _resolve_task_reference(self, project, from_task, ref):
|
|
1194
|
+
"""Resolve a task reference string to a Task object.
|
|
1195
|
+
|
|
1196
|
+
Reference formats:
|
|
1197
|
+
- "!taskid" - sibling (same parent)
|
|
1198
|
+
- "!!taskid" - uncle (parent's sibling)
|
|
1199
|
+
- "taskid" - from project root
|
|
1200
|
+
- "parent.child" - path from root
|
|
1201
|
+
"""
|
|
1202
|
+
if not ref:
|
|
1203
|
+
return None
|
|
1204
|
+
|
|
1205
|
+
# Count leading exclamation marks to determine scope
|
|
1206
|
+
level = 0
|
|
1207
|
+
while ref.startswith('!'):
|
|
1208
|
+
level += 1
|
|
1209
|
+
ref = ref[1:]
|
|
1210
|
+
|
|
1211
|
+
# Find base task to search from
|
|
1212
|
+
if level > 0:
|
|
1213
|
+
# Go up level times from current task's parent
|
|
1214
|
+
base = from_task.parent
|
|
1215
|
+
for _ in range(level - 1):
|
|
1216
|
+
if base and base.parent:
|
|
1217
|
+
base = base.parent
|
|
1218
|
+
else:
|
|
1219
|
+
base = None
|
|
1220
|
+
break
|
|
1221
|
+
else:
|
|
1222
|
+
base = None # Search from root
|
|
1223
|
+
|
|
1224
|
+
# Now find the task by ID
|
|
1225
|
+
# Handle path references like "parent.child"
|
|
1226
|
+
parts = ref.split('.')
|
|
1227
|
+
|
|
1228
|
+
if base:
|
|
1229
|
+
# Search in base's children
|
|
1230
|
+
current = base
|
|
1231
|
+
for part in parts:
|
|
1232
|
+
found = None
|
|
1233
|
+
for child in current.children:
|
|
1234
|
+
if child.id == part:
|
|
1235
|
+
found = child
|
|
1236
|
+
break
|
|
1237
|
+
if found:
|
|
1238
|
+
current = found
|
|
1239
|
+
else:
|
|
1240
|
+
return None
|
|
1241
|
+
return current
|
|
1242
|
+
else:
|
|
1243
|
+
# Search from project root
|
|
1244
|
+
for task in project.tasks:
|
|
1245
|
+
if task.id == parts[0]:
|
|
1246
|
+
if len(parts) == 1:
|
|
1247
|
+
return task
|
|
1248
|
+
# Navigate path
|
|
1249
|
+
current = task
|
|
1250
|
+
for part in parts[1:]:
|
|
1251
|
+
found = None
|
|
1252
|
+
for child in current.children:
|
|
1253
|
+
if child.id == part:
|
|
1254
|
+
found = child
|
|
1255
|
+
break
|
|
1256
|
+
if found:
|
|
1257
|
+
current = found
|
|
1258
|
+
else:
|
|
1259
|
+
return None
|
|
1260
|
+
return current
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
def _apply_project_attributes(self, project, attributes):
|
|
1264
|
+
"""Apply attributes to the project."""
|
|
1265
|
+
for attr in attributes:
|
|
1266
|
+
if attr is None:
|
|
1267
|
+
continue
|
|
1268
|
+
if isinstance(attr, tuple):
|
|
1269
|
+
key, value = attr
|
|
1270
|
+
if key == 'scenario':
|
|
1271
|
+
self._create_scenario(project, value)
|
|
1272
|
+
elif key == 'extend':
|
|
1273
|
+
pass # Handle extensions later
|
|
1274
|
+
else:
|
|
1275
|
+
try:
|
|
1276
|
+
project[key] = value
|
|
1277
|
+
except (ValueError, KeyError):
|
|
1278
|
+
pass
|
|
1279
|
+
|
|
1280
|
+
def _apply_global_attributes(self, project, attributes):
|
|
1281
|
+
"""Apply global attributes to the project."""
|
|
1282
|
+
from scriptplan.core.leave import Leave
|
|
1283
|
+
from scriptplan.utils.time import TimeInterval
|
|
1284
|
+
|
|
1285
|
+
for attr in attributes:
|
|
1286
|
+
if attr is None:
|
|
1287
|
+
continue
|
|
1288
|
+
if isinstance(attr, tuple):
|
|
1289
|
+
key, value = attr
|
|
1290
|
+
if key == 'leaves':
|
|
1291
|
+
# Global leaves - convert dict to Leave object
|
|
1292
|
+
leave_type = value.get('type', 'holiday')
|
|
1293
|
+
start_date = value.get('start')
|
|
1294
|
+
# For single-day holidays, end_date might be None
|
|
1295
|
+
end_date = value.get('end')
|
|
1296
|
+
if end_date is None:
|
|
1297
|
+
# Single day - end is start + 1 day
|
|
1298
|
+
from datetime import timedelta
|
|
1299
|
+
end_date = start_date + timedelta(days=1)
|
|
1300
|
+
|
|
1301
|
+
if start_date:
|
|
1302
|
+
interval = TimeInterval(start_date, end_date)
|
|
1303
|
+
type_idx = Leave.Types.get(leave_type, 1) # Default to 'holiday' (1)
|
|
1304
|
+
leave = Leave(interval, type_idx)
|
|
1305
|
+
|
|
1306
|
+
# Add to project's leaves list
|
|
1307
|
+
existing = project.attributes.get('leaves', [])
|
|
1308
|
+
if not isinstance(existing, list):
|
|
1309
|
+
existing = [existing] if existing else []
|
|
1310
|
+
existing.append(leave)
|
|
1311
|
+
project.attributes['leaves'] = existing
|
|
1312
|
+
elif key == 'vacation':
|
|
1313
|
+
# Global vacation - similar to leaves but always 'holiday' type
|
|
1314
|
+
start_date = value.get('start')
|
|
1315
|
+
end_date = value.get('end')
|
|
1316
|
+
# If end_date equals start_date (single day vacation), extend to next day
|
|
1317
|
+
from datetime import timedelta
|
|
1318
|
+
if end_date is None or end_date == start_date:
|
|
1319
|
+
end_date = start_date + timedelta(days=1)
|
|
1320
|
+
|
|
1321
|
+
if start_date:
|
|
1322
|
+
interval = TimeInterval(start_date, end_date)
|
|
1323
|
+
type_idx = Leave.Types.get('holiday', 1)
|
|
1324
|
+
leave = Leave(interval, type_idx)
|
|
1325
|
+
|
|
1326
|
+
# Add to project's vacations/leaves list
|
|
1327
|
+
existing = project.attributes.get('vacations', [])
|
|
1328
|
+
if not isinstance(existing, list):
|
|
1329
|
+
existing = [existing] if existing else []
|
|
1330
|
+
existing.append(leave)
|
|
1331
|
+
project.attributes['vacations'] = existing
|
|
1332
|
+
else:
|
|
1333
|
+
try:
|
|
1334
|
+
project[key] = value
|
|
1335
|
+
except (ValueError, KeyError):
|
|
1336
|
+
pass
|
|
1337
|
+
|
|
1338
|
+
def _create_scenario(self, project, scenario_data, parent=None):
|
|
1339
|
+
"""Create a scenario in the project.
|
|
1340
|
+
|
|
1341
|
+
Args:
|
|
1342
|
+
project: The project
|
|
1343
|
+
scenario_data: Dict with 'id', 'name', 'children'
|
|
1344
|
+
parent: Parent scenario for nested scenarios
|
|
1345
|
+
"""
|
|
1346
|
+
from scriptplan.core.scenario import Scenario
|
|
1347
|
+
|
|
1348
|
+
s_id = scenario_data.get('id')
|
|
1349
|
+
s_name = scenario_data.get('name', '').strip('"')
|
|
1350
|
+
children = scenario_data.get('children', [])
|
|
1351
|
+
|
|
1352
|
+
# Clear default scenario on first scenario definition
|
|
1353
|
+
if parent is None and not hasattr(self, '_scenarios_cleared'):
|
|
1354
|
+
# Remove the default 'plan' scenario
|
|
1355
|
+
default_plan = project.scenarios['plan']
|
|
1356
|
+
if default_plan:
|
|
1357
|
+
project.scenarios.removeProperty(default_plan)
|
|
1358
|
+
self._scenarios_cleared = True
|
|
1359
|
+
|
|
1360
|
+
# Create the scenario
|
|
1361
|
+
scenario = Scenario(project, s_id, s_name, parent)
|
|
1362
|
+
|
|
1363
|
+
# Create nested child scenarios
|
|
1364
|
+
for child in children:
|
|
1365
|
+
if isinstance(child, tuple) and child[0] == 'scenario':
|
|
1366
|
+
self._create_scenario(project, child[1], scenario)
|
|
1367
|
+
|
|
1368
|
+
def _create_property(self, parent, prop_data):
|
|
1369
|
+
"""Create a property (resource, task, account) in the parent."""
|
|
1370
|
+
if not isinstance(prop_data, dict):
|
|
1371
|
+
return
|
|
1372
|
+
|
|
1373
|
+
prop_type = prop_data.get('type')
|
|
1374
|
+
prop_id = prop_data.get('id')
|
|
1375
|
+
prop_name = prop_data.get('name')
|
|
1376
|
+
attributes = prop_data.get('attributes', [])
|
|
1377
|
+
|
|
1378
|
+
# Determine project reference
|
|
1379
|
+
project = parent if isinstance(parent, Project) else parent.project
|
|
1380
|
+
|
|
1381
|
+
if prop_type == 'task':
|
|
1382
|
+
obj = Task(
|
|
1383
|
+
project,
|
|
1384
|
+
prop_id,
|
|
1385
|
+
prop_name,
|
|
1386
|
+
parent if isinstance(parent, Task) else None
|
|
1387
|
+
)
|
|
1388
|
+
elif prop_type == 'resource':
|
|
1389
|
+
obj = Resource(
|
|
1390
|
+
project,
|
|
1391
|
+
prop_id,
|
|
1392
|
+
prop_name,
|
|
1393
|
+
parent if isinstance(parent, Resource) else None
|
|
1394
|
+
)
|
|
1395
|
+
elif prop_type == 'account':
|
|
1396
|
+
# Skip accounts for now - need Account class
|
|
1397
|
+
return
|
|
1398
|
+
elif prop_type == 'shift':
|
|
1399
|
+
from scriptplan.core.shift import Shift
|
|
1400
|
+
obj = Shift(
|
|
1401
|
+
project,
|
|
1402
|
+
prop_id,
|
|
1403
|
+
prop_name,
|
|
1404
|
+
parent if isinstance(parent, Shift) else None
|
|
1405
|
+
)
|
|
1406
|
+
else:
|
|
1407
|
+
return
|
|
1408
|
+
|
|
1409
|
+
# Apply attributes to the created object
|
|
1410
|
+
self._apply_property_attributes(obj, attributes, prop_type)
|
|
1411
|
+
|
|
1412
|
+
def _apply_property_attributes(self, obj, attributes, prop_type):
|
|
1413
|
+
"""Apply attributes to a property object."""
|
|
1414
|
+
for attr in attributes:
|
|
1415
|
+
if attr is None:
|
|
1416
|
+
continue
|
|
1417
|
+
if isinstance(attr, dict):
|
|
1418
|
+
# Nested property (e.g., nested resource or task)
|
|
1419
|
+
self._create_property(obj, attr)
|
|
1420
|
+
elif isinstance(attr, tuple):
|
|
1421
|
+
key, value = attr
|
|
1422
|
+
if key == 'email':
|
|
1423
|
+
obj['email'] = value
|
|
1424
|
+
elif key == 'rate':
|
|
1425
|
+
# Set for all scenarios
|
|
1426
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1427
|
+
obj[('rate', scIdx)] = value
|
|
1428
|
+
elif key == 'efficiency':
|
|
1429
|
+
# Set for all scenarios
|
|
1430
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1431
|
+
obj[('efficiency', scIdx)] = value
|
|
1432
|
+
elif key == 'timezone':
|
|
1433
|
+
# Set for all scenarios
|
|
1434
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1435
|
+
obj[('timezone', scIdx)] = value
|
|
1436
|
+
elif key == 'effort':
|
|
1437
|
+
# Set for all scenarios (no prefix means apply to all)
|
|
1438
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1439
|
+
obj[('effort', scIdx)] = value
|
|
1440
|
+
elif key == 'depends':
|
|
1441
|
+
# Store for later resolution (after all tasks created)
|
|
1442
|
+
self._pending_depends.append((obj, value))
|
|
1443
|
+
elif key == 'precedes':
|
|
1444
|
+
# Store for later resolution - precedes creates reverse dependencies
|
|
1445
|
+
# If A precedes B, then B depends on A
|
|
1446
|
+
self._pending_precedes.append((obj, value))
|
|
1447
|
+
elif key == 'allocate':
|
|
1448
|
+
# Set for all scenarios
|
|
1449
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1450
|
+
obj[('allocate', scIdx)] = value
|
|
1451
|
+
elif key == 'start':
|
|
1452
|
+
# Set for all scenarios
|
|
1453
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1454
|
+
obj[('start', scIdx)] = value
|
|
1455
|
+
elif key == 'end':
|
|
1456
|
+
# Set for all scenarios
|
|
1457
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1458
|
+
obj[('end', scIdx)] = value
|
|
1459
|
+
elif key == 'milestone':
|
|
1460
|
+
# Set for all scenarios
|
|
1461
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1462
|
+
obj[('milestone', scIdx)] = value
|
|
1463
|
+
elif key == 'flags':
|
|
1464
|
+
# Set flags for all scenarios (list of flag strings)
|
|
1465
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1466
|
+
obj[('flags', scIdx)] = value
|
|
1467
|
+
elif key == 'priority':
|
|
1468
|
+
# Set for all scenarios
|
|
1469
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1470
|
+
obj[('priority', scIdx)] = value
|
|
1471
|
+
elif key == 'forward':
|
|
1472
|
+
# Set scheduling direction for all scenarios
|
|
1473
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1474
|
+
obj[('forward', scIdx)] = value
|
|
1475
|
+
# Mark that this task has explicit scheduling (not inherited from project)
|
|
1476
|
+
obj._explicit_scheduling = True
|
|
1477
|
+
elif key == 'scenario_attr':
|
|
1478
|
+
# Handle scenario-specific attributes like ('delayed', ('effort', 320))
|
|
1479
|
+
scenario_id, attr_data = value
|
|
1480
|
+
scenario_idx = self._get_scenario_index(obj.project, scenario_id)
|
|
1481
|
+
if scenario_idx is not None and attr_data:
|
|
1482
|
+
if isinstance(attr_data, tuple):
|
|
1483
|
+
attr_key, attr_value = attr_data
|
|
1484
|
+
obj[(attr_key, scenario_idx)] = attr_value
|
|
1485
|
+
elif key == 'journalentry':
|
|
1486
|
+
# Create a journal entry for this task
|
|
1487
|
+
self._create_journal_entry(obj, value)
|
|
1488
|
+
elif key == 'charge':
|
|
1489
|
+
# charge is a tuple (amount, mode) where mode is 'onstart', 'onend', or 'perday'
|
|
1490
|
+
amount, mode = value
|
|
1491
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1492
|
+
obj[('charge', scIdx)] = amount
|
|
1493
|
+
# Note: mode (onstart/onend/perday) affects when charge is applied
|
|
1494
|
+
# For now we store just the amount; mode handling can be added later
|
|
1495
|
+
elif key == 'chargeset':
|
|
1496
|
+
# chargeset specifies which account to charge to
|
|
1497
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1498
|
+
obj[('chargeset', scIdx)] = value
|
|
1499
|
+
elif key == 'purge_chargeset':
|
|
1500
|
+
# Clear inherited chargeset
|
|
1501
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1502
|
+
obj[('chargeset', scIdx)] = []
|
|
1503
|
+
elif key == 'leaves':
|
|
1504
|
+
# Resource leaves - create Leave objects and store on resource
|
|
1505
|
+
from scriptplan.core.leave import Leave
|
|
1506
|
+
from scriptplan.utils.time import TimeInterval
|
|
1507
|
+
|
|
1508
|
+
leave_type = value.get('type', 'annual')
|
|
1509
|
+
start_date = value.get('start')
|
|
1510
|
+
end_date = value.get('end', start_date)
|
|
1511
|
+
|
|
1512
|
+
if start_date and end_date:
|
|
1513
|
+
interval = TimeInterval(start_date, end_date)
|
|
1514
|
+
type_idx = Leave.Types.get(leave_type, 5) # Default to 'annual' (5)
|
|
1515
|
+
leave = Leave(interval, type_idx)
|
|
1516
|
+
|
|
1517
|
+
# Store leaves as a list for all scenarios
|
|
1518
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1519
|
+
existing = obj.get('leaves', scIdx) or []
|
|
1520
|
+
if not isinstance(existing, list):
|
|
1521
|
+
existing = [existing]
|
|
1522
|
+
existing.append(leave)
|
|
1523
|
+
obj[('leaves', scIdx)] = existing
|
|
1524
|
+
elif key == 'limits':
|
|
1525
|
+
# Task or resource limits - create Limits object
|
|
1526
|
+
from scriptplan.core.limits import Limits
|
|
1527
|
+
|
|
1528
|
+
limits_obj = Limits()
|
|
1529
|
+
limits_obj.setProject(obj.project)
|
|
1530
|
+
|
|
1531
|
+
# value is a list of limit dicts from parsing
|
|
1532
|
+
for limit_def in value:
|
|
1533
|
+
limit_type = limit_def.get('type', 'dailymax')
|
|
1534
|
+
limit_value = limit_def.get('value', 0)
|
|
1535
|
+
limit_resources = limit_def.get('resources')
|
|
1536
|
+
|
|
1537
|
+
if limit_resources:
|
|
1538
|
+
# Resource-specific limits
|
|
1539
|
+
for res_id in limit_resources:
|
|
1540
|
+
limits_obj.setLimit(limit_type, limit_value, resource=res_id)
|
|
1541
|
+
else:
|
|
1542
|
+
# General limit
|
|
1543
|
+
limits_obj.setLimit(limit_type, limit_value)
|
|
1544
|
+
|
|
1545
|
+
# Store limits on task/resource for all scenarios
|
|
1546
|
+
# IMPORTANT: Each scenario needs its own copy of the limits
|
|
1547
|
+
# because they track usage counters independently
|
|
1548
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1549
|
+
obj[('limits', scIdx)] = limits_obj.copy()
|
|
1550
|
+
elif key == 'workinghours':
|
|
1551
|
+
# Working hours for resource or shift
|
|
1552
|
+
from scriptplan.core.working_hours import WorkingHours
|
|
1553
|
+
|
|
1554
|
+
# Check if working hours already exist
|
|
1555
|
+
existing_wh = obj.get('workinghours', 0)
|
|
1556
|
+
if existing_wh and hasattr(existing_wh, 'set_hours'):
|
|
1557
|
+
wh = existing_wh
|
|
1558
|
+
else:
|
|
1559
|
+
wh = WorkingHours(obj.project)
|
|
1560
|
+
|
|
1561
|
+
if isinstance(value, dict):
|
|
1562
|
+
days = value.get('days', [])
|
|
1563
|
+
ranges = value.get('ranges', [])
|
|
1564
|
+
wh.set_hours(days, ranges)
|
|
1565
|
+
|
|
1566
|
+
# Store working hours for all scenarios
|
|
1567
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1568
|
+
obj[('workinghours', scIdx)] = wh
|
|
1569
|
+
elif key == 'workinghours_shift':
|
|
1570
|
+
# Resource references a shift for its working hours
|
|
1571
|
+
# Lookup the shift and copy its working hours
|
|
1572
|
+
shift_id = value
|
|
1573
|
+
shift = obj.project.shifts[shift_id] if hasattr(obj.project, 'shifts') else None
|
|
1574
|
+
if shift:
|
|
1575
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1576
|
+
# Store the shift reference - ResourceScenario.onShift will use it
|
|
1577
|
+
obj[('shifts', scIdx)] = shift
|
|
1578
|
+
elif key == 'vacation':
|
|
1579
|
+
# Resource vacation - similar to leaves but type is always vacation
|
|
1580
|
+
from scriptplan.core.leave import Leave
|
|
1581
|
+
from scriptplan.utils.time import TimeInterval
|
|
1582
|
+
|
|
1583
|
+
start_date = value.get('start')
|
|
1584
|
+
end_date = value.get('end', start_date)
|
|
1585
|
+
|
|
1586
|
+
if start_date and end_date:
|
|
1587
|
+
interval = TimeInterval(start_date, end_date)
|
|
1588
|
+
type_idx = Leave.Types.get('annual', 5) # Vacation treated as annual leave
|
|
1589
|
+
leave = Leave(interval, type_idx)
|
|
1590
|
+
|
|
1591
|
+
# Store leaves as a list for all scenarios
|
|
1592
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1593
|
+
existing = obj.get('leaves', scIdx) or []
|
|
1594
|
+
if not isinstance(existing, list):
|
|
1595
|
+
existing = [existing]
|
|
1596
|
+
existing.append(leave)
|
|
1597
|
+
obj[('leaves', scIdx)] = existing
|
|
1598
|
+
elif key == 'booking':
|
|
1599
|
+
# Resource booking - blocks resource during a time period
|
|
1600
|
+
# booking "name" date +duration (e.g., "Maintenance" 2025-05-12-09:00 +6h)
|
|
1601
|
+
from scriptplan.core.leave import Leave
|
|
1602
|
+
from scriptplan.utils.time import TimeInterval
|
|
1603
|
+
from datetime import timedelta
|
|
1604
|
+
import re
|
|
1605
|
+
|
|
1606
|
+
start_date = value.get('start')
|
|
1607
|
+
duration_str = value.get('duration', '0h')
|
|
1608
|
+
|
|
1609
|
+
if start_date:
|
|
1610
|
+
# Parse duration to compute end date
|
|
1611
|
+
match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmymin]+)', str(duration_str))
|
|
1612
|
+
if match:
|
|
1613
|
+
num = float(match.group(1))
|
|
1614
|
+
unit = match.group(2)
|
|
1615
|
+
if unit == 'h':
|
|
1616
|
+
delta = timedelta(hours=num)
|
|
1617
|
+
elif unit == 'min':
|
|
1618
|
+
delta = timedelta(minutes=num)
|
|
1619
|
+
elif unit == 'd':
|
|
1620
|
+
delta = timedelta(days=num)
|
|
1621
|
+
else:
|
|
1622
|
+
delta = timedelta(hours=num)
|
|
1623
|
+
else:
|
|
1624
|
+
delta = timedelta(hours=0)
|
|
1625
|
+
|
|
1626
|
+
end_date = start_date + delta
|
|
1627
|
+
interval = TimeInterval(start_date, end_date)
|
|
1628
|
+
# Use special type to mark as booking (treated as unavailable)
|
|
1629
|
+
type_idx = Leave.Types.get('special', 3)
|
|
1630
|
+
leave = Leave(interval, type_idx)
|
|
1631
|
+
|
|
1632
|
+
# Store as leaves (blocks resource availability)
|
|
1633
|
+
for scIdx in range(obj.project.scenarioCount()):
|
|
1634
|
+
existing = obj.get('leaves', scIdx) or []
|
|
1635
|
+
if not isinstance(existing, list):
|
|
1636
|
+
existing = [existing]
|
|
1637
|
+
existing.append(leave)
|
|
1638
|
+
obj[('leaves', scIdx)] = existing
|
|
1639
|
+
else:
|
|
1640
|
+
try:
|
|
1641
|
+
obj[key] = value
|
|
1642
|
+
except (ValueError, KeyError, AttributeError):
|
|
1643
|
+
pass
|
|
1644
|
+
|
|
1645
|
+
def _get_scenario_index(self, project, scenario_id):
|
|
1646
|
+
"""Get the index of a scenario by its ID."""
|
|
1647
|
+
for i, scenario in enumerate(project.scenarios):
|
|
1648
|
+
if scenario.id == scenario_id:
|
|
1649
|
+
return i
|
|
1650
|
+
return None
|
|
1651
|
+
|
|
1652
|
+
def _create_journal_entry(self, task, entry_data):
|
|
1653
|
+
"""Create a journal entry for a task.
|
|
1654
|
+
|
|
1655
|
+
Args:
|
|
1656
|
+
task: The Task object this entry belongs to
|
|
1657
|
+
entry_data: Dict with 'date', 'headline', and 'body' keys
|
|
1658
|
+
"""
|
|
1659
|
+
from scriptplan.core.journal import JournalEntry, AlertLevel
|
|
1660
|
+
|
|
1661
|
+
journal = task.project.attributes.get('journal')
|
|
1662
|
+
if journal is None:
|
|
1663
|
+
return
|
|
1664
|
+
|
|
1665
|
+
date = entry_data.get('date')
|
|
1666
|
+
headline = entry_data.get('headline', '')
|
|
1667
|
+
body = entry_data.get('body', {})
|
|
1668
|
+
|
|
1669
|
+
# Create the journal entry
|
|
1670
|
+
entry = journal.create_entry(date, headline, task)
|
|
1671
|
+
|
|
1672
|
+
# Set body attributes
|
|
1673
|
+
if body.get('author'):
|
|
1674
|
+
# Look up the author resource
|
|
1675
|
+
author_id = body['author']
|
|
1676
|
+
author = task.project.resources[author_id] if author_id else None
|
|
1677
|
+
entry.author = author
|
|
1678
|
+
|
|
1679
|
+
alert_str = body.get('alert', 'green')
|
|
1680
|
+
if alert_str == 'red':
|
|
1681
|
+
entry.alert_level = AlertLevel.RED
|
|
1682
|
+
elif alert_str == 'yellow':
|
|
1683
|
+
entry.alert_level = AlertLevel.YELLOW
|
|
1684
|
+
else:
|
|
1685
|
+
entry.alert_level = AlertLevel.GREEN
|
|
1686
|
+
|
|
1687
|
+
entry.summary = body.get('summary')
|
|
1688
|
+
entry.details = body.get('details')
|
|
1689
|
+
|
|
1690
|
+
def _create_report(self, project, report_data, parent=None):
|
|
1691
|
+
"""Create a Report from parsed data.
|
|
1692
|
+
|
|
1693
|
+
Args:
|
|
1694
|
+
project: The Project object
|
|
1695
|
+
report_data: Dict with 'type', 'id', 'name', 'attributes'
|
|
1696
|
+
parent: Optional parent report for nested reports
|
|
1697
|
+
"""
|
|
1698
|
+
from scriptplan.report.report import Report, ReportType, ReportFormat
|
|
1699
|
+
|
|
1700
|
+
report_type = report_data.get('type')
|
|
1701
|
+
r_id = report_data.get('id') or ''
|
|
1702
|
+
r_name = report_data.get('name') or r_id
|
|
1703
|
+
|
|
1704
|
+
# Create the report
|
|
1705
|
+
report = Report(project, r_id, r_name, parent)
|
|
1706
|
+
|
|
1707
|
+
# Set report type
|
|
1708
|
+
if report_type == 'taskreport':
|
|
1709
|
+
report.type_spec = ReportType.TASK_REPORT
|
|
1710
|
+
elif report_type == 'resourcereport':
|
|
1711
|
+
report.type_spec = ReportType.RESOURCE_REPORT
|
|
1712
|
+
elif report_type == 'textreport':
|
|
1713
|
+
report.type_spec = ReportType.TEXT_REPORT
|
|
1714
|
+
elif report_type == 'accountreport':
|
|
1715
|
+
report.type_spec = ReportType.ACCOUNT_REPORT
|
|
1716
|
+
|
|
1717
|
+
# Default to HTML format if not specified
|
|
1718
|
+
default_formats = [ReportFormat.HTML]
|
|
1719
|
+
|
|
1720
|
+
# Process attributes
|
|
1721
|
+
attributes = report_data.get('attributes', [])
|
|
1722
|
+
for attr in attributes:
|
|
1723
|
+
self._apply_report_attribute(report, attr, default_formats)
|
|
1724
|
+
|
|
1725
|
+
# If no formats were set, use defaults
|
|
1726
|
+
if not report.get('formats'):
|
|
1727
|
+
report['formats'] = default_formats
|
|
1728
|
+
|
|
1729
|
+
return report
|
|
1730
|
+
|
|
1731
|
+
def _apply_report_attribute(self, report, attr, default_formats):
|
|
1732
|
+
"""Apply a single attribute to a report.
|
|
1733
|
+
|
|
1734
|
+
Args:
|
|
1735
|
+
report: The Report object
|
|
1736
|
+
attr: The attribute (can be tuple, dict, Token, Tree, or string)
|
|
1737
|
+
default_formats: List to accumulate format types
|
|
1738
|
+
"""
|
|
1739
|
+
from lark import Token, Tree
|
|
1740
|
+
from scriptplan.report.report import ReportFormat
|
|
1741
|
+
|
|
1742
|
+
if attr is None:
|
|
1743
|
+
return
|
|
1744
|
+
|
|
1745
|
+
if isinstance(attr, dict):
|
|
1746
|
+
# Nested report definition
|
|
1747
|
+
attr_type = attr.get('type')
|
|
1748
|
+
if attr_type in ['taskreport', 'resourcereport', 'textreport', 'accountreport']:
|
|
1749
|
+
self._create_report(report.project, attr, report)
|
|
1750
|
+
return
|
|
1751
|
+
|
|
1752
|
+
if isinstance(attr, tuple):
|
|
1753
|
+
key, value = attr
|
|
1754
|
+
if key == 'columns':
|
|
1755
|
+
# value is list of column specs
|
|
1756
|
+
report['columns'] = value
|
|
1757
|
+
elif key == 'formats':
|
|
1758
|
+
# value is list of format strings
|
|
1759
|
+
formats = []
|
|
1760
|
+
for fmt_str in value:
|
|
1761
|
+
fmt_str = fmt_str.lower()
|
|
1762
|
+
if fmt_str == 'html':
|
|
1763
|
+
formats.append(ReportFormat.HTML)
|
|
1764
|
+
elif fmt_str == 'csv':
|
|
1765
|
+
formats.append(ReportFormat.CSV)
|
|
1766
|
+
elif fmt_str == 'ical':
|
|
1767
|
+
formats.append(ReportFormat.ICAL)
|
|
1768
|
+
elif fmt_str == 'tjp':
|
|
1769
|
+
formats.append(ReportFormat.TJP)
|
|
1770
|
+
elif fmt_str == 'niku':
|
|
1771
|
+
formats.append(ReportFormat.NIKU)
|
|
1772
|
+
report['formats'] = formats
|
|
1773
|
+
elif key == 'sort':
|
|
1774
|
+
report['sort'] = value
|
|
1775
|
+
elif key == 'period':
|
|
1776
|
+
report['period'] = value
|
|
1777
|
+
else:
|
|
1778
|
+
# Generic attribute
|
|
1779
|
+
try:
|
|
1780
|
+
report[key] = value
|
|
1781
|
+
except (ValueError, KeyError, AttributeError):
|
|
1782
|
+
pass
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
if isinstance(attr, Token):
|
|
1786
|
+
# Tokens are typically attribute values that need context
|
|
1787
|
+
# They represent things like scenarios, hideresource, etc.
|
|
1788
|
+
token_type = attr.type
|
|
1789
|
+
token_val = attr.value
|
|
1790
|
+
if token_val.startswith('"') and token_val.endswith('"'):
|
|
1791
|
+
token_val = token_val[1:-1]
|
|
1792
|
+
|
|
1793
|
+
if token_type == 'STRING':
|
|
1794
|
+
# This could be timeformat, title, headline, etc.
|
|
1795
|
+
# Without knowing the context, we can't assign it properly
|
|
1796
|
+
# These are usually preceded by a keyword in the grammar
|
|
1797
|
+
pass
|
|
1798
|
+
elif token_type == 'ID':
|
|
1799
|
+
# Could be scenarios, loadunit, etc.
|
|
1800
|
+
# Common IDs in reports
|
|
1801
|
+
if token_val in ['plan', 'delayed']:
|
|
1802
|
+
# scenarios attribute
|
|
1803
|
+
existing = report.get('scenarios') or []
|
|
1804
|
+
for i, scenario in enumerate(report.project.scenarios):
|
|
1805
|
+
if scenario.id == token_val:
|
|
1806
|
+
existing.append(i)
|
|
1807
|
+
break
|
|
1808
|
+
report['scenarios'] = existing
|
|
1809
|
+
elif token_val in ['days', 'hours', 'weeks', 'months', 'shortauto', 'longauto']:
|
|
1810
|
+
# loadUnit
|
|
1811
|
+
report['loadUnit'] = token_val
|
|
1812
|
+
elif token_type == 'FILTER_EXPR':
|
|
1813
|
+
# Filter expression for hideResource, hideTask, etc.
|
|
1814
|
+
# Store as filter
|
|
1815
|
+
if token_val.startswith('@') or token_val.startswith('~'):
|
|
1816
|
+
# Could be hideResource or hideTask - store both
|
|
1817
|
+
if not report.get('hideResource'):
|
|
1818
|
+
report['hideResource'] = token_val
|
|
1819
|
+
elif not report.get('hideTask'):
|
|
1820
|
+
report['hideTask'] = token_val
|
|
1821
|
+
elif token_type == 'TASK_PATH':
|
|
1822
|
+
# taskRoot
|
|
1823
|
+
report['taskRoot'] = token_val
|
|
1824
|
+
return
|
|
1825
|
+
|
|
1826
|
+
if isinstance(attr, Tree):
|
|
1827
|
+
# Handle Tree objects (shouldn't happen now that we transform them)
|
|
1828
|
+
tree_data = attr.data
|
|
1829
|
+
if tree_data == 'column_list':
|
|
1830
|
+
columns = []
|
|
1831
|
+
for child in attr.children:
|
|
1832
|
+
if isinstance(child, Tree) and child.data == 'column_spec':
|
|
1833
|
+
col_id = None
|
|
1834
|
+
col_opts = {}
|
|
1835
|
+
for cc in child.children:
|
|
1836
|
+
if isinstance(cc, Token) and cc.type == 'ID':
|
|
1837
|
+
col_id = cc.value
|
|
1838
|
+
elif isinstance(cc, Tree) and cc.data == 'column_options':
|
|
1839
|
+
# Parse options
|
|
1840
|
+
pass
|
|
1841
|
+
if col_id:
|
|
1842
|
+
columns.append({'id': col_id, 'options': col_opts})
|
|
1843
|
+
report['columns'] = columns
|
|
1844
|
+
elif tree_data == 'sort_list':
|
|
1845
|
+
sorts = []
|
|
1846
|
+
for child in attr.children:
|
|
1847
|
+
if isinstance(child, Tree) and child.data == 'sort_item':
|
|
1848
|
+
for cc in child.children:
|
|
1849
|
+
if isinstance(cc, Token):
|
|
1850
|
+
sorts.append(cc.value)
|
|
1851
|
+
report['sort'] = sorts
|
|
1852
|
+
return
|
|
1853
|
+
|
|
1854
|
+
if isinstance(attr, str):
|
|
1855
|
+
# Rich text content (header, footer, headline, etc.)
|
|
1856
|
+
# Without context, we try to determine what it is
|
|
1857
|
+
if '----' in attr:
|
|
1858
|
+
report['footer'] = attr
|
|
1859
|
+
elif '====' in attr or '===' in attr:
|
|
1860
|
+
# Contains headlines, could be header
|
|
1861
|
+
if not report.get('header'):
|
|
1862
|
+
report['header'] = attr
|
|
1863
|
+
elif not report.get('headline'):
|
|
1864
|
+
report['headline'] = attr
|
|
1865
|
+
else:
|
|
1866
|
+
# Generic rich text, could be any of header/footer/headline/caption
|
|
1867
|
+
if not report.get('header'):
|
|
1868
|
+
report['header'] = attr
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
class ProjectFileParser:
|
|
1872
|
+
"""Parser for TJP project files."""
|
|
1873
|
+
|
|
1874
|
+
def __init__(self):
|
|
1875
|
+
grammar_path = os.path.join(os.path.dirname(__file__), 'tjp.lark')
|
|
1876
|
+
with open(grammar_path, 'r') as f:
|
|
1877
|
+
self.grammar = f.read()
|
|
1878
|
+
self.parser = Lark(self.grammar, start='start', parser='lalr')
|
|
1879
|
+
|
|
1880
|
+
def parse(self, text, preprocess_macros=True, schedule=True):
|
|
1881
|
+
"""Parse TJP text and return a Project object.
|
|
1882
|
+
|
|
1883
|
+
Args:
|
|
1884
|
+
text: The TJP file content
|
|
1885
|
+
preprocess_macros: If True, expand macros before parsing
|
|
1886
|
+
schedule: If True, schedule the project after parsing to compute task dates
|
|
1887
|
+
|
|
1888
|
+
Returns:
|
|
1889
|
+
A Project object
|
|
1890
|
+
"""
|
|
1891
|
+
# Preprocess macros
|
|
1892
|
+
if preprocess_macros:
|
|
1893
|
+
text = preprocess_tjp(text)
|
|
1894
|
+
|
|
1895
|
+
tree = self.parser.parse(text)
|
|
1896
|
+
data = TJPTransformer().transform(tree)
|
|
1897
|
+
builder = ModelBuilder()
|
|
1898
|
+
project = builder.build(data)
|
|
1899
|
+
|
|
1900
|
+
# Schedule the project to compute task dates
|
|
1901
|
+
if schedule:
|
|
1902
|
+
project.schedule()
|
|
1903
|
+
|
|
1904
|
+
return project
|