File: Query.rb

package info (click to toggle)
tj3 3.8.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 5,048 kB
  • sloc: ruby: 36,481; javascript: 1,113; sh: 19; makefile: 17
file content (417 lines) | stat: -rw-r--r-- 14,670 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = Query.rb -- The TaskJuggler III Project Management Software
#
# Copyright (c) 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014
#               by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'taskjuggler/TjException'

class TaskJuggler

  # A query can be used to retrieve any property attribute after the scheduling
  # run has been completed. It is possible to make a Query before the scheduling
  # run has been completed, but it only produces good results for static
  # attributes. And for such queries, the PropertyTreeNode.get and [] functions
  # are a lot more efficient.
  #
  # When constructing a Query, a set of variables need to be set that is
  # sufficient enough to identify a unique attribute. Some attribute are
  # computed dynamically and further variables such as a start and end time will
  # be incorporated into the result computation.
  #
  # The result is returned as String (Query#result), in numerical form
  # (Query#numericalResult) if available as number, and as an entity that can be
  # used for sorting (Query#sortableResult). To get the result, Query#process
  # needs to be called. In case an error occured, Query#ok is set to false and
  # Query#errorMessage contains an error message.
  class Query

    @@ps = %w( project propertyType propertyId property
               scopePropertyType scopePropertyId scopeProperty
               attributeId scenario scenarioIdx
               loadUnit numberFormat currencyFormat timeFormat
               listItem listType hideJournalEntry
               journalMode journalAttributes sortJournalEntries
               costAccount revenueAccount selfContained )
    @@ps.each do |p|
      attr_accessor p.to_sym
    end
    attr_accessor :ok, :errorMessage
    attr_reader :end, :endIdx, :start, :startIdx
    attr_writer :sortable, :numerical, :string, :rti

    # Create a new Query object. The _parameters_ need to be sufficent to
    # uniquely identify an attribute.
    def initialize(parameters = { })
      @selfContained = false
      @@ps.each do |p|
        instance_variable_set('@' + p, parameters[p] ? parameters[p] : nil)
      end

      # instance_variable_set does not call writer functions. So we need to
      # handle @start, @end, @startIdx and @endIdx separately.
      %w( end endIdx start startIdx ).each do |p|
        send(p + '=', parameters[p]) if parameters[p]
      end
      # The custom data hash can be filled with results to be returned for
      # special attributes that are not directly property attributes or
      # computed attributes.
      @customData = {}

      reset
    end

    # We probably need the start and end dates as TjTime and Scoreboard index.
    # We store both, but we need to assure they are always in sync.

    def start=(date)
      if date.is_a?(TjTime)
        @start = date
      else
        raise "Unsupported type #{date.class}"
      end
      @startIdx = @project.dateToIdx(@start)
    end

    def startIdx=(idx)
      if idx.is_a?(Integer)
        @startIdx = idx
        @start = @project.idxToDate(idx)
      else
        raise "Unsupported type #{idx.class}"
      end
    end

    def end=(date)
      if date.is_a?(TjTime)
        @end = date
      else
        raise "Unsupported type #{date.class}"
      end
      @endIdx = @project.dateToIdx(@end)
    end

    def endIdx=(idx)
      if idx.is_a?(Integer)
        @endIdx = idx
        @end = @project.idxToDate(idx)
      else
        raise "Unsupported type #{idx.class}"
      end
    end

    # Set a custom data entry. _name_ is the name of the pseudo attribute.
    # _data_ must be a Hash that contains the value for :numberical, :string,
    # :sortable or :rti results.
    def setCustomData(name, data)
      @customData[name] = data
    end

    # This method tries to resolve the query and return a result. In case it
    # finds an attribute that matches the query, it returns true; false
    # otherwise. The actual result data is stored in the Query object. It can
    # then be retrieved by the caller with the methods to_s(), to_num(),
    # to_sort() and result().
    def process
      reset
      begin
        # Resolve property reference from property ID.
        if @propertyId && (@property.nil? || @propertyId[0] == '!')
          @property = resolvePropertyId(@propertyType, @propertyId)
          unless @property
            @errorMessage = "Unknown property '#{@propertyId}' queried"
            return @ok = false
          end
        end

        unless @property
          # No property was provided. We are looking for a project attribute.
          supportedAttrs = %w( copyright currency end journal name now projectid
                               start version )
          unless supportedAttrs.include?(@attributeId)
            @errorMessage = "Unsupported project attribute '#{@attributeId}'"
            return @ok = false
          end
          if @project.respond_to?(attributeId)
            @project.send(attributeId, self)
          else
            attr = @project[@attributeId]
          end
          if attr.is_a?(TjTime)
            @sortable = @numerical = attr
            @string = attr.to_s(@timeFormat)
          else
            @sortable = @string = attr
          end
          return @ok = true
        end

        # Same for the scope property.
        if !@scopeProperty.nil? && !@scopePropertyId.nil?
          @scopeProperty = resolvePropertyId(@scopePropertyType,
                                             @scopePropertyId)
          unless @scopeProperty
            @errorMessage = "Unknown scope property #{@scopePropertyId} queried"
            return @ok = false
          end
        end
        # Make sure the have a reference to the project.
        @project = @property.project unless @project

        if @scenario && !@scenarioIdx
          @scenarioIdx = @project.scenarioIdx(@scenario)
          unless @scenarioIdx
            raise "Query cannot resolve scenario '#{@scenario}'"
          end
        end

        queryMethodName = 'query_' + @attributeId
        # First we check for non-scenario-specific query functions.
        if (data = @customData[@attributeId])
          @sortable = data[:sortable]
          @numerical = data[:numerical]
          @string = data[:string]
          @rti = data[:rti]
        elsif @property.respond_to?(queryMethodName)
          @property.send(queryMethodName, self)
        elsif @scenarioIdx && @property.data &&
              @property.data[@scenarioIdx].respond_to?(queryMethodName)
          # Then we check for scenario-specific ones via the @data member.
          @property.send(queryMethodName, @scenarioIdx, self)
        else
          # The result is a BaseAttribute
          begin
            # The user may also provide a scenario index for
            # non-scenario-specific values. We need to check if the attribute
            # is really scenario specific or not because
            # PropertyTreeNode::getAttribute can only handle an index for
            # scenario-specific attributs.
            aType = @property.attributeDefinition(@attributeId)
            raise ArgumentError unless aType
            scIdx = aType.scenarioSpecific ? @scenarioIdx : nil
            @attr = @property.getAttribute(@attributeId, scIdx)
            if @attr.nil? && @attr.is_a?(DateAttribute)
              @errorMessage = "Attribute '#{@attributeId}' of property " +
                "'#{@property.fullId}' has undefined value."
              return @ok = false
            end
          rescue ArgumentError
            @errorMessage = "Unknown attribute '#{@attributeId}' queried"
            return @ok = false
          end
        end
      rescue TjException
        @errorMessage = $!.message
        return @ok = false
      end
      @ok = true
    end

    # Converts the String items in _listItems_ into a RichTextIntermediate
    # objects and assigns it as result of the query.
    def assignList(listItems)
      list = ''
      listItems.each do |item|
        case @listType
        when nil, :comma
          list += ', ' unless list.empty?
          list += item
        when :bullets
          list += "* #{item}\n"
        when :numbered
          list += "# #{item}\n"
        end
      end
      @sortable = @string = list
      rText = RichText.new(list)
      @rti = rText.generateIntermediateFormat
    end

    # Return the result of the Query as String. The result may be nil.
    def to_s
      @attr ? @attr.to_s(self) : (@rti ? @rti.to_s : (@string || ''))
    end

    # Return the result of the Query as Integer or Float. The result may be
    # nil.
    def to_num
      @attr ? @attr.to_num : @numerical
    end

    # Return the result in the best suited type and format for sorting. The
    # result may be nil.
    def to_sort
      @attr ? @attr.to_sort : @sortable
    end

    # Return the result as RichTextIntermediate object. The result may be nil.
    def to_rti
      return @attr.value if @attr.is_a?(RichTextAttribute)

      @attr ? @attr.to_rti(self) : @rti
    end

    # Return the result in the orginal form. It may be nil.
    def result
      if @attr
        if @attr.value && @attr.is_a?(ReferenceAttribute)
          @attr.value[0]
        else
          @attr.value
        end
      elsif @numerical
        @numerical
      elsif @rti
        @rti
      else
        @string
      end
    end

    # Convert a duration to the format specified by @loadUnit.  _value_ is the
    # duration effort in days. The return value is the converted value with
    # optional unit as a String.
    def scaleDuration(value)
      scaleValue(value, [ 24 * 60, 24, 1, 1.0 / 7, 1.0 / 30.42,
                          1.0 / 91.25, 1.0 / 365 ])
    end

    # Convert a load or effort value to the format specified by @loadUnit.
    # _work_ is the effort in man days. The return value is the converted value
    # with optional unit as a String.
    def scaleLoad(value)
      scaleValue(value, [ @project.dailyWorkingHours * 60,
                          @project.dailyWorkingHours,
                          1.0,
                          1.0 / @project.weeklyWorkingDays,
                          1.0 / @project.monthlyWorkingDays,
                          1.0 / (@project.yearlyWorkingDays / 4),
                          1.0 / @project.yearlyWorkingDays ])
    end

  private

    def resolvePropertyId(pType, pId)
      unless @project
        raise "Need Project reference to process the query"
      end
      if pId[0] == '!'
        # This is the case where the property ID is just a sequence of
        # exclamation marks. Each one moves the scope 1 level up from the
        # current level.
        pId.each_utf8_char do |c|
          if c == '!'
            @property = @property.parent
          end
          break unless @property
        end
        @property
      else
        case pType
        when :Account
          @project.account(pId)
        when :Task
          @project.task(pId)
        when:Resource
          @project.resource(pId)
        else
          raise "Unknown property type #{pType}"
        end
      end
    end

    # This function converts number to strings that may include a unit. The
    # unit is determined by @loadUnit. In the automatic modes, the shortest
    # possible result is shown and the unit is always appended. _value_ is the
    # value to convert. _factors_ determines the conversion factors for the
    # different units.
    def scaleValue(value, factors)
      if @loadUnit == :shortauto || @loadUnit == :longauto
        # We try all possible units and store the resulting strings here.
        options = []
        # For each option we also save the delta between the String value and
        # the original value.
        delta = []
        # For each of the units we can define a maximum value that the value
        # should not exceed. nil means no limit. Never use quarters since it's
        # pretty uncommon to use.
        max = [ 60, 48, nil, 8, 24, 0, nil ]
        stdFormat = RealFormat.new([ '-', '', '', '.',
                                     @numberFormat.fractionDigits ])

        i = 0
        fSep = @numberFormat.fractionSeparator
        factors.each do |factor|
          scaledValue = value * factor
          str = @numberFormat.format(scaledValue)
          stdStr = stdFormat.format(scaledValue)
          delta[i] = (scaledValue - stdStr.to_f).abs
          # We ignore results that are 0 or exceed the maximum. To ensure that
          # we have at least one result the unscaled value is always taken.
          if (factor != 1.0 && /^[0.]*$/ =~ stdStr) ||
             (max[i] && scaledValue > max[i])
            options << nil
          else
            options << str
          end
          i += 1
        end

        # Find the value that is the closest to the original value. This will be
        # the default if all values have the same length.
        shortest = 2
        delta.length.times do |j|
          shortest = j if options[j] && delta[j] < delta[shortest]
        end

        # Find the shortest option.
        6.times do |j|
          shortest = j if options[j] && options[j][0, 2] != '0' + fSep &&
                          options[j].length < options[shortest].length
        end

        str = options[shortest]
        if @loadUnit == :longauto
          # For the long units we handle singular and plural properly. For
          # English we just need to append an 's', but this code will work for
          # other languages as well.
          units = []
          if str == "1"
            units = %w( minute hour day week month quarter year )
          else
            units = %w( minutes hours days weeks months quarters years )
          end
          str += ' ' + units[shortest]
        else
          str += %w( min h d w m q y )[shortest]
        end
      else
        # For fixed units we just need to do the conversion. No unit is
        # included.
        units = [ :minutes, :hours, :days, :weeks, :months, :quarters, :years ]
        str = @numberFormat.format(value * factors[units.index(@loadUnit)])
      end
      str
    end

    private

    # Queries object can be reused. Calling this function will clear the query
    # result data.
    def reset
      @attr = @numerical = @sortable = @string = @rti = nil
      @ok = true
      @errorMessage = nil
    end

  end

end