ptimelog 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ptimelog
4
+ module Command
5
+ # Extract and show entries from a Obsidian+DayPlanner list
6
+ class Info < CmdParse::Command
7
+ def initialize
8
+ super('info', takes_commands: false)
9
+ @configuration = Ptimelog::Configuration.instance
10
+ end
11
+
12
+ def execute
13
+ puts <<~TXT
14
+ Configuration
15
+ -------------
16
+ base_url: #{@configuration['base_url']}
17
+ rounding: #{@configuration['rounding']}
18
+ dir: #{@configuration['dir']}
19
+
20
+ #{obsidian_config}#{timelog_config}
21
+ TXT
22
+ end
23
+
24
+ def obsidian_config
25
+ return nil unless @configuration['obsidian']
26
+
27
+ <<~TXT
28
+ Datasource: Obsidian (Operon)
29
+ -----------------------------
30
+ vault: #{@configuration['obsidian_vault']}
31
+ custom keys:
32
+ ticket: #{Ptimelog::Operon.custom_keys['ticket']}
33
+ inferer: #{Ptimelog::Operon.custom_keys['inferer']}
34
+ TXT
35
+ end
36
+
37
+ def timelog_config
38
+ return nil unless @configuration['timelog']
39
+
40
+ <<~TXT
41
+ Datasource: Timelog
42
+ -------------------
43
+ file: #{@configuration['timelog_txt']}
44
+ TXT
45
+ end
46
+ end
47
+ end
48
+ end
@@ -3,48 +3,65 @@
3
3
  module Ptimelog
4
4
  module Command
5
5
  # show entries of one day or all of them
6
- class Show < Base
7
- def initialize(*args)
8
- @durations = Hash.new(0)
6
+ class Show < CmdParse::Command
7
+ attr_reader :entries
9
8
 
10
- super
11
- end
9
+ def initialize
10
+ super('show', takes_commands: false)
11
+ @debug = false
12
+ @durations = false
13
+ @entries = {}
14
+ @config = Configuration.instance
12
15
 
13
- def needs_entries?
14
- true
16
+ options.on('-d', '--durations', 'Summarize durations by ticket') { @durations = true }
17
+ options.on('--debug', 'Show debugging output') { @debug = true }
15
18
  end
16
19
 
17
- def run
18
- @entries.each do |date, list|
19
- puts date,
20
- '----------'
20
+ def execute(maybe_named_day) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
21
+ debug("Requested Day: #{maybe_named_day}")
22
+ @day = Ptimelog::NamedDate.new.named_date(maybe_named_day)
23
+ debug("Resolved Day: #{@day}")
24
+ data_source = Ptimelog::DataSource.new(@config, @day)
25
+ debug("Data Source: #{data_source.backend}")
26
+ @entries = data_source.entries
27
+ debug("Found Entries: #{@entries.values.sum(&:size)}")
21
28
 
29
+ @entries.each do |date, list|
22
30
  next if list.empty?
23
31
 
24
- list.each do |entry|
25
- puts entry
26
- end
27
- puts '----------',
28
- "Total work done: #{duration(date)} hours",
29
- '----------------------------',
30
- nil
32
+ valids = Ptimelog::Joiner.new(compact_on_ticket_only: @durations).join_similar(
33
+ list.select(&:valid?).sort_by { |entry| entry.ticket.to_s }
34
+ ).sort_by(&:start_time)
35
+ total_duration = duration(valids.sum(&:duration))
36
+
37
+ output(date, valids, total_duration)
31
38
  end
32
39
  end
33
40
 
34
- def entries=(entries)
35
- entries.each do |date, list|
36
- @entries[date] = []
41
+ private
37
42
 
38
- list.each do |entry|
39
- @durations[date] += entry.duration
40
- @entries[date] << entry.to_s
41
- end
43
+ def output(date, entries, total_duration)
44
+ puts date,
45
+ '----------'
46
+
47
+ entries.each do |entry|
48
+ debug(entry.instance_variable_get(:@inferer_cmd))
49
+ puts entry
42
50
  end
51
+
52
+ puts '----------',
53
+ "Total work done: #{total_duration} hours",
54
+ '----------------------------',
55
+ nil
43
56
  end
44
57
 
45
- def duration(date)
46
- Time.at(@durations[date]).utc.strftime('%H:%M')
58
+ def debug(msg)
59
+ return unless @debug
60
+
61
+ warn msg
47
62
  end
63
+
64
+ def duration(durations) = Time.at(durations).utc.strftime('%H:%M')
48
65
  end
49
66
  end
50
67
  end
@@ -5,24 +5,39 @@ require 'erb'
5
5
  module Ptimelog
6
6
  module Command
7
7
  # Upload entries to puzzletime
8
- class Upload < Base
9
- attr_writer :entries
8
+ class Upload < CmdParse::Command
9
+ def initialize
10
+ super('upload', takes_commands: false)
10
11
 
11
- def needs_entries?
12
- true
12
+ @durations = false
13
+ @entries = {}
14
+ @config = Configuration.instance
15
+
16
+ options.on('-d', '--durations', 'Only upload durations') { @durations = true }
13
17
  end
14
18
 
15
- def run
19
+ def execute(maybe_named_day)
20
+ @day = Ptimelog::NamedDate.new.named_date(maybe_named_day)
21
+ @entries = Ptimelog::DataSource.new(@config, @day).entries
22
+
16
23
  @entries.each do |date, list|
17
- puts "Uploading #{date}"
18
- list.each do |entry|
19
- open_browser(entry)
20
- end
24
+ upload_date(date, list)
21
25
  end
22
26
  end
23
27
 
24
28
  private
25
29
 
30
+ def upload_date(date, list)
31
+ return if list.empty?
32
+
33
+ valids = Ptimelog::Joiner.new(compact_on_ticket_only: @durations).join_similar(
34
+ list.select(&:valid?).sort_by { |entry| entry.ticket.to_s }
35
+ )
36
+
37
+ puts "Uploading #{date}"
38
+ valids.each { |entry| open_browser(entry) }
39
+ end
40
+
26
41
  def open_browser(entry)
27
42
  xdg_open "'#{@config[:base_url]}/ordertimes/new?#{url_options(entry)}'", silent: true
28
43
  end
@@ -47,18 +62,34 @@ module Ptimelog
47
62
  end
48
63
 
49
64
  def url_options(entry)
50
- {
51
- work_date: entry.date,
52
- 'ordertime[ticket]': entry.ticket,
53
- 'ordertime[description]': entry.description,
54
- 'ordertime[from_start_time]': entry.start_time,
55
- 'ordertime[to_end_time]': entry.finish_time,
56
- 'ordertime[account_id]': entry.account,
57
- 'ordertime[billable]': entry.billable,
58
- }
65
+ base_params(entry)
66
+ .merge(duration_params(entry))
59
67
  .map { |key, value| [key, ERB::Util.url_encode(value)].join('=') }
60
68
  .join('&')
61
69
  end
70
+
71
+ def base_params(entry)
72
+ {
73
+ work_date: entry.date,
74
+ 'ordertime[ticket]': entry.ticket,
75
+ 'ordertime[description]': entry.description,
76
+ 'ordertime[account_id]': entry.account,
77
+ 'ordertime[billable]': entry.billable,
78
+ }
79
+ end
80
+
81
+ def duration_params(entry)
82
+ if @durations
83
+ {
84
+ 'ordertime[hours]': entry.duration_hours,
85
+ }
86
+ else
87
+ {
88
+ 'ordertime[from_start_time]': entry.start_time,
89
+ 'ordertime[to_end_time]': entry.finish_time,
90
+ }
91
+ end
92
+ end
62
93
  end
63
94
  end
64
95
  end
@@ -10,10 +10,14 @@ module Ptimelog
10
10
  include Singleton
11
11
 
12
12
  CONFIGURATION_DEFAULTS = {
13
- 'base_url' => 'https://time.puzzle.ch',
14
- 'rounding' => 15,
15
- 'dir' => '~/.config/ptimelog',
16
- 'timelog' => '~/.local/share/gtimelog/timelog.txt',
13
+ 'base_url' => 'https://time.puzzle.ch',
14
+ 'rounding' => 15,
15
+ 'dir' => '~/.config/ptimelog',
16
+ 'timelog' => true,
17
+ 'timelog_txt' => '~/.local/share/gtimelog/timelog.txt',
18
+ 'obsidian' => false,
19
+ 'obsidian_vault' => '~/path/to/obsidian-vault',
20
+ 'operon_custom_keys' => { 'ticket' => 'ticket', 'inferer' => 'ptimelog' }.freeze,
17
21
  }.freeze
18
22
 
19
23
  def initialize
@@ -27,7 +31,9 @@ module Ptimelog
27
31
  .join('config')
28
32
  )
29
33
  wrap_with_pathname('dir')
30
- wrap_with_pathname('timelog')
34
+
35
+ wrap_with_pathname('timelog_txt') if self['timelog']
36
+ wrap_with_pathname('obsidian_vault') if self['obsidian']
31
37
  end
32
38
 
33
39
  def load_config(fn)
@@ -43,13 +49,14 @@ module Ptimelog
43
49
  def []=(key, value)
44
50
  @config[key.to_s] = value
45
51
 
46
- wrap_with_pathname(key.to_s) if %w[dir timelog].include?(key.to_s)
52
+ wrap_with_pathname(key.to_s) if %w[dir obsidian_vault timelog_txt].include?(key.to_s)
47
53
  end
48
54
 
49
55
  private
50
56
 
51
57
  def wrap_with_pathname(key)
52
58
  return unless @config.key?(key)
59
+ return unless @config[key].is_a? String
53
60
  return @config[key] if @config[key].is_a? Pathname
54
61
 
55
62
  @config[key] = Pathname.new(@config[key]).expand_path
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ptimelog
4
+ # Facade around several datasources, currently intended to cover the
5
+ # timelog.txt and the tasks of an Obsidian vault
6
+ class DataSource
7
+ def initialize(configuration, day)
8
+ @configuration = configuration
9
+ @day = day
10
+ end
11
+
12
+ def type
13
+ if @configuration['timelog']
14
+ :timelog
15
+ elsif @configuration['obsidian']
16
+ :obsidian
17
+ end
18
+ end
19
+
20
+ def entries
21
+ case type
22
+ when :obsidian then { @day => backend.entries }
23
+ when :timelog then Ptimelog::Day.new(@day).entries
24
+ else
25
+ []
26
+ end
27
+ end
28
+
29
+ def file
30
+ case type
31
+ when :obsidian then backend.file
32
+ when :timelog then backend.timelog_txt
33
+ end
34
+ end
35
+
36
+ def backend
37
+ @backend ||= case type
38
+ when :obsidian then Operon.new(@day)
39
+ when :timelog then Timelog.instance
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/ptimelog/day.rb CHANGED
@@ -12,7 +12,8 @@ module Ptimelog
12
12
  next unless date # guard against the machine
13
13
  next unless @date.to_s == 'all' || @date == date # limit to one day if passed
14
14
 
15
- entries[date] = join_similar(entries_of_day(lines)) # lines |> entries_of_day |> join_similar
15
+ # lines |> entries_of_day |> join_similar
16
+ entries[date] = join_similar(entries_of_day(lines))
16
17
  end
17
18
  end
18
19
 
@@ -22,6 +23,10 @@ module Ptimelog
22
23
  Timelog.load
23
24
  end
24
25
 
26
+ def join_similar(list)
27
+ Ptimelog::Joiner.new.join_similar(list)
28
+ end
29
+
25
30
  def entries_of_day(lines)
26
31
  entries = []
27
32
  start = nil # at the start of the day, we have no previous end
@@ -30,42 +35,12 @@ module Ptimelog
30
35
  entry = Entry.from_timelog(line)
31
36
  entry.start_time = start
32
37
 
33
- entries << entry if entry.valid?
38
+ entries << entry if entry.valid? && entry.selected?
34
39
 
35
40
  start = entry.finish_time # store previous ending for nice display of next entry
36
41
  end
37
42
 
38
43
  entries
39
44
  end
40
-
41
- def join_similar(list)
42
- return [] if list.empty?
43
- return list if list.one?
44
-
45
- one, *tail = list
46
- two, *rest = tail
47
-
48
- joined = maybe_join(one, two)
49
-
50
- if joined.one?
51
- join_similar(joined + rest)
52
- else
53
- [one] + join_similar(tail)
54
- end
55
- end
56
-
57
- def maybe_join(one, two)
58
- if one.ticket == two.ticket &&
59
- one.description == two.description &&
60
- one.finish_time == two.start_time
61
-
62
- joined = one.dup
63
- joined.finish_time = two.finish_time
64
-
65
- [joined]
66
- else
67
- [one, two]
68
- end
69
- end
70
45
  end
71
46
  end
@@ -4,16 +4,21 @@ require 'time'
4
4
 
5
5
  module Ptimelog
6
6
  # Dataclass to wrap an entry
7
- class Entry
7
+ class Entry # rubocop:disable Metrics/ClassLength
8
+ SEPARATOR = '|'
9
+
8
10
  # define only trivial writers, omit special and derived values
9
11
  attr_accessor :date, :ticket, :description
10
12
 
11
13
  # allow to read everything else
12
- attr_reader :start_time, :finish_time, :tags, :billable, :account
14
+ attr_reader :start_time, :finish_time, :tags, :billable, :account, :publishable
13
15
 
14
16
  BILLABLE = 1
15
17
  NON_BILLABLE = 0
16
18
 
19
+ class AdditionError < ::StandardError
20
+ end
21
+
17
22
  def initialize(config = Configuration.instance)
18
23
  @config = config
19
24
  @script = Script.new(@config[:dir])
@@ -37,6 +42,34 @@ module Ptimelog
37
42
  infer_ptime_settings
38
43
  end
39
44
 
45
+ def +(other) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
46
+ raise AdditionError, "Tickets don't match or empty" if @ticket != other.ticket || @ticket.to_s == ''
47
+ raise AdditionError, 'O RLY? Date is different' if @date != other.date
48
+ raise AdditionError, 'At least one is invalid' if [self, other].reject(&:valid?).any?
49
+
50
+ both_have_tags = [self, other].all? { |entry| !entry.tags.nil? }
51
+ raise AdditionError, 'Tags do not match' if both_have_tags && tag_list.sort != other.tag_list.sort
52
+
53
+ joined = self.class.new
54
+ joined.ticket = @ticket
55
+ joined.date = @date
56
+ joined.description = [@description, other.description].uniq.join(', ')
57
+ joined.tags = [tag_list, other.tag_list].uniq.join(' ')
58
+
59
+ joined.infer_ptime_settings
60
+
61
+ earlier, later = [self, other].sort
62
+
63
+ joined.start_time = earlier.start_time
64
+ joined.finish_time = if earlier.finish_time == later.start_time
65
+ later.finish_time
66
+ else
67
+ (Time.parse(earlier.finish_time) + later.duration).strftime('%H:%M')
68
+ end
69
+
70
+ joined
71
+ end
72
+
40
73
  def start_time=(time)
41
74
  @start_time = round_time(time, @config[:rounding])
42
75
  end
@@ -46,56 +79,81 @@ module Ptimelog
46
79
  end
47
80
 
48
81
  def tags=(tags)
82
+ raise 'HELL' unless tags.is_a?(String) || tags.nil?
83
+
49
84
  @tags = case tags
50
85
  when '', nil then nil
51
86
  else tags.split.compact
52
87
  end
53
88
  end
54
89
 
55
- def valid?
56
- @start_time && duration.positive? && !hidden?
57
- end
90
+ def valid? = @start_time && duration.positive? && !hidden?
58
91
 
59
- def hidden?
60
- @description.to_s.end_with?('**') # hide lunch and breaks
61
- end
92
+ # hide lunch and breaks
93
+ def hidden? = @description.to_s.end_with?('**') || !publishable?
62
94
 
63
- def billable?
64
- @billable == BILLABLE
65
- end
95
+ # or something like @tags.to_a.include?('special-team'), still needs to be implemented...
96
+ def selected? = true
97
+
98
+ def billable? = @billable == BILLABLE
99
+
100
+ def publishable? = @publishable != false
66
101
 
67
102
  def infer_ptime_settings
68
103
  return if hidden?
69
104
  return unless @script.inferer(script_name).exist?
70
105
 
71
- @account, @billable = infer_account_and_billable
106
+ @account, @billable, @publishable = infer_entry_attributes
72
107
  end
73
108
 
74
109
  def duration
75
110
  (Time.parse(@finish_time) - Time.parse(@start_time)).to_i
76
111
  end
77
112
 
113
+ def duration_hours
114
+ format('%.02f', duration / 3600.0)
115
+ end
116
+
78
117
  def to_s
79
118
  billable = billable? ? '($)' : nil
80
- tag_list = Array(@tags).compact
81
119
 
82
120
  tags = tag_list.join(' ') if tag_list.any?
83
121
  desc = [@ticket, @description].compact.join(': ')
84
122
  acc = [@account, billable].compact.join(' ') if @account
85
123
 
86
124
  [
87
- @start_time, '-', @finish_time, '∴',
88
- [desc, tags, acc].compact.join(' ∴ '),
125
+ @start_time, '-', @finish_time, SEPARATOR,
126
+ "#{duration_hours}h", SEPARATOR,
127
+ [desc, tags, acc].compact.join(" #{SEPARATOR} "),
89
128
  ].compact.join(' ')
90
129
  end
91
130
 
92
- # make sortable/def <=>
131
+ def <=>(other)
132
+ @start_time <=> other.start_time
133
+ end
134
+
135
+ def ==(other)
136
+ comparable_attrs = %i[date ticket description start_time finish_time tags]
137
+
138
+ self.class == other.class &&
139
+ comparable_attrs.map { |attr| send(attr) == other.send(attr) }
140
+ end
141
+
142
+ protected
143
+
144
+ def tag_list
145
+ list = [first_tag] + tail_tags.split
146
+ list.reject(&:empty?).compact
147
+ end
148
+
149
+ def first_tag = @tags.to_a.first.to_s
150
+ def tail_tags = @tags.to_a[1..].to_a.join(' ')
93
151
 
94
152
  private
95
153
 
96
154
  def round_time(time, interval)
97
155
  return time unless interval
98
- return unless time.to_s =~ /\d\d:\d\d/
156
+ return unless /\d\d:\d\d/.match?(time.to_s)
99
157
 
100
158
  hour, minute = time.split(':')
101
159
  minute = (minute.to_i / interval.to_f).round * interval.to_i
@@ -108,21 +166,25 @@ module Ptimelog
108
166
  end
109
167
 
110
168
  def script_name
111
- @script_name ||= @tags.to_a.first.to_s
169
+ @script_name ||= first_tag
112
170
  end
113
171
 
114
172
  def script_args
115
- @script_args ||= @tags.to_a[1..].to_a.map(&:inspect).join(' ')
173
+ @script_args ||= tail_tags.split.map(&:inspect).join(' ')
116
174
  end
117
175
 
118
- def infer_account_and_billable
176
+ def infer_entry_attributes
119
177
  script = @script.inferer(script_name)
120
178
 
121
179
  cmd = %(#{script} "#{@ticket}" "#{@description}" #{script_args})
180
+ @inferer_cmd = cmd
122
181
 
123
- account, billable = `#{cmd}`.chomp.split
182
+ results = `#{cmd}`.chomp.split
183
+ account = results[0]
184
+ billable = ((results[1] || 'false').to_s == 'true' ? BILLABLE : NON_BILLABLE)
185
+ publishable = ((results[2] || 'true').to_s == 'true')
124
186
 
125
- [account, (billable == 'true' ? BILLABLE : NON_BILLABLE)]
187
+ [account, billable, publishable]
126
188
  end
127
189
  end
128
190
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ptimelog
4
+ # Superclass for all Errors that are somewhat expected
5
+ class Error < StandardError
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ptimelog
4
+ # Join two directly adjacent entries or adjacent entries with the same ticket
5
+ class Joiner
6
+ def initialize(compact_on_ticket_only: false)
7
+ @compact_on_ticket_only = compact_on_ticket_only
8
+ end
9
+
10
+ def join_similar(list)
11
+ return [] if list.empty?
12
+ return list if list.one?
13
+
14
+ one, *tail = list
15
+ two, *rest = tail
16
+
17
+ joined = maybe_join(one, two)
18
+
19
+ if joined.one?
20
+ join_similar(joined + rest)
21
+ else
22
+ [one] + join_similar(tail)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def maybe_join(one, two)
29
+ return [one, two] if one.ticket != two.ticket || one.ticket.to_s == ''
30
+ return [one, two] if tags_are_present_but_differ(one, two)
31
+
32
+ if @compact_on_ticket_only ||
33
+ (one.description == two.description && one.finish_time == two.start_time)
34
+ [one + two]
35
+ else
36
+ [one, two]
37
+ end
38
+ end
39
+
40
+ def tags_are_present_but_differ(one, two)
41
+ both_have_tags = [one, two].all? { |entry| !entry.tags.nil? }
42
+ tags_differ = (one.tags.sort != two.tags.sort)
43
+
44
+ both_have_tags && tags_differ
45
+ end
46
+ end
47
+ end
@@ -13,7 +13,7 @@ module Ptimelog
13
13
  case date.to_s
14
14
  when 'yesterday' then yesterday
15
15
  when 'today' then Date.today.to_s
16
- when 'last', '' then last_entry.to_s || yesterday
16
+ when 'last', '' then (last_entry || yesterday).to_s
17
17
  when 'mon', 'monday' then previous_weekday('monday')
18
18
  when 'tue', 'tuesday' then previous_weekday('tuesday')
19
19
  when 'wed', 'wednesday' then previous_weekday('wednesday')