ptimelog 0.9.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,45 +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
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)}")
28
+
18
29
  @entries.each do |date, list|
19
- puts date,
20
- '----------'
21
- list.each do |entry|
22
- puts entry
23
- end
24
- puts '----------',
25
- "Total work done: #{duration(date)} hours",
26
- '----------------------------',
27
- nil
30
+ next if list.empty?
31
+
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)
28
38
  end
29
39
  end
30
40
 
31
- def entries=(entries)
32
- entries.each do |date, list|
33
- @entries[date] = []
41
+ private
34
42
 
35
- list.each do |entry|
36
- @durations[date] += entry.duration
37
- @entries[date] << entry.to_s
38
- 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
39
50
  end
51
+
52
+ puts '----------',
53
+ "Total work done: #{total_duration} hours",
54
+ '----------------------------',
55
+ nil
40
56
  end
41
57
 
42
- def duration(date)
43
- Time.at(@durations[date]).utc.strftime('%H:%M')
58
+ def debug(msg)
59
+ return unless @debug
60
+
61
+ warn msg
44
62
  end
63
+
64
+ def duration(durations) = Time.at(durations).utc.strftime('%H:%M')
45
65
  end
46
66
  end
47
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'singleton'
4
4
  require 'pathname'
5
+ require 'yaml'
5
6
 
6
7
  module Ptimelog
7
8
  # Wrapper around configuration-options and -loading
@@ -9,10 +10,14 @@ module Ptimelog
9
10
  include Singleton
10
11
 
11
12
  CONFIGURATION_DEFAULTS = {
12
- base_url: 'https://time.puzzle.ch',
13
- rounding: 15,
14
- dir: '~/.config/ptimelog',
15
- 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,
16
21
  }.freeze
17
22
 
18
23
  def initialize
@@ -21,10 +26,14 @@ module Ptimelog
21
26
 
22
27
  def reset
23
28
  @config = load_config(
24
- Pathname.new(CONFIGURATION_DEFAULTS[:dir]).join('config')
29
+ Pathname.new(CONFIGURATION_DEFAULTS['dir'])
30
+ .expand_path
31
+ .join('config')
25
32
  )
26
- wrap_with_pathname(:dir)
27
- wrap_with_pathname(:timelog)
33
+ wrap_with_pathname('dir')
34
+
35
+ wrap_with_pathname('timelog_txt') if self['timelog']
36
+ wrap_with_pathname('obsidian_vault') if self['obsidian']
28
37
  end
29
38
 
30
39
  def load_config(fn)
@@ -34,19 +43,20 @@ module Ptimelog
34
43
  end
35
44
 
36
45
  def [](key)
37
- @config[key.to_sym]
46
+ @config[key.to_s]
38
47
  end
39
48
 
40
49
  def []=(key, value)
41
- @config[key.to_sym] = value
50
+ @config[key.to_s] = value
42
51
 
43
- wrap_with_pathname(key.to_sym) 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)
44
53
  end
45
54
 
46
55
  private
47
56
 
48
57
  def wrap_with_pathname(key)
49
58
  return unless @config.key?(key)
59
+ return unless @config[key].is_a? String
50
60
  return @config[key] if @config[key].is_a? Pathname
51
61
 
52
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
@@ -9,10 +9,11 @@ module Ptimelog
9
9
 
10
10
  def entries
11
11
  timelog.each_with_object({}) do |(date, lines), entries|
12
- next unless date # guard against the machine
13
- next unless @date == :all || @date == date # limit to one day if passed
12
+ next unless date # guard against the machine
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,17 +4,21 @@ require 'time'
4
4
 
5
5
  module Ptimelog
6
6
  # Dataclass to wrap an entry
7
- class Entry
8
- # allow to read everything
9
- attr_reader :date, :start_time, :finish_time, :ticket, :description,
10
- :tags, :billable, :account
7
+ class Entry # rubocop:disable Metrics/ClassLength
8
+ SEPARATOR = '|'
11
9
 
12
10
  # define only trivial writers, omit special and derived values
13
- attr_writer :date, :ticket, :description
11
+ attr_accessor :date, :ticket, :description
12
+
13
+ # allow to read everything else
14
+ attr_reader :start_time, :finish_time, :tags, :billable, :account, :publishable
14
15
 
15
16
  BILLABLE = 1
16
17
  NON_BILLABLE = 0
17
18
 
19
+ class AdditionError < ::StandardError
20
+ end
21
+
18
22
  def initialize(config = Configuration.instance)
19
23
  @config = config
20
24
  @script = Script.new(@config[:dir])
@@ -38,6 +42,34 @@ module Ptimelog
38
42
  infer_ptime_settings
39
43
  end
40
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
+
41
73
  def start_time=(time)
42
74
  @start_time = round_time(time, @config[:rounding])
43
75
  end
@@ -47,50 +79,81 @@ module Ptimelog
47
79
  end
48
80
 
49
81
  def tags=(tags)
82
+ raise 'HELL' unless tags.is_a?(String) || tags.nil?
83
+
50
84
  @tags = case tags
51
85
  when '', nil then nil
52
86
  else tags.split.compact
53
87
  end
54
88
  end
55
89
 
56
- def valid?
57
- @start_time && !hidden?
58
- end
90
+ def valid? = @start_time && duration.positive? && !hidden?
59
91
 
60
- def hidden?
61
- @description.to_s.end_with?('**') # hide lunch and breaks
62
- end
92
+ # hide lunch and breaks
93
+ def hidden? = @description.to_s.end_with?('**') || !publishable?
63
94
 
64
- def billable?
65
- @billable == BILLABLE
66
- 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
67
101
 
68
102
  def infer_ptime_settings
69
103
  return if hidden?
70
104
  return unless @script.inferer(script_name).exist?
71
105
 
72
- @account, @billable = infer_account_and_billable
106
+ @account, @billable, @publishable = infer_entry_attributes
73
107
  end
74
108
 
75
109
  def duration
76
110
  (Time.parse(@finish_time) - Time.parse(@start_time)).to_i
77
111
  end
78
112
 
113
+ def duration_hours
114
+ format('%.02f', duration / 3600.0)
115
+ end
116
+
79
117
  def to_s
80
- billable = billable? ? '$' : nil
118
+ billable = billable? ? '($)' : nil
119
+
120
+ tags = tag_list.join(' ') if tag_list.any?
121
+ desc = [@ticket, @description].compact.join(': ')
122
+ acc = [@account, billable].compact.join(' ') if @account
123
+
81
124
  [
82
- @start_time, '-', @finish_time,
83
- [@ticket, @description, @tags, @account, billable].compact.join(' ∴ '),
125
+ @start_time, '-', @finish_time, SEPARATOR,
126
+ "#{duration_hours}h", SEPARATOR,
127
+ [desc, tags, acc].compact.join(" #{SEPARATOR} "),
84
128
  ].compact.join(' ')
85
129
  end
86
130
 
87
- # 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(' ')
88
151
 
89
152
  private
90
153
 
91
154
  def round_time(time, interval)
92
155
  return time unless interval
93
- return unless time.to_s =~ /\d\d:\d\d/
156
+ return unless /\d\d:\d\d/.match?(time.to_s)
94
157
 
95
158
  hour, minute = time.split(':')
96
159
  minute = (minute.to_i / interval.to_f).round * interval.to_i
@@ -103,21 +166,25 @@ module Ptimelog
103
166
  end
104
167
 
105
168
  def script_name
106
- @script_name ||= @tags.to_a.first.to_s
169
+ @script_name ||= first_tag
107
170
  end
108
171
 
109
172
  def script_args
110
- @script_args ||= @tags.to_a[1..-1].to_a.map(&:inspect).join(' ')
173
+ @script_args ||= tail_tags.split.map(&:inspect).join(' ')
111
174
  end
112
175
 
113
- def infer_account_and_billable
176
+ def infer_entry_attributes
114
177
  script = @script.inferer(script_name)
115
178
 
116
179
  cmd = %(#{script} "#{@ticket}" "#{@description}" #{script_args})
180
+ @inferer_cmd = cmd
117
181
 
118
- 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')
119
186
 
120
- [account, (billable == 'true' ? BILLABLE : NON_BILLABLE)]
187
+ [account, billable, publishable]
121
188
  end
122
189
  end
123
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