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.
- checksums.yaml +4 -4
- data/AGENTS.md +42 -0
- data/PLAN-ADD-COMMAND.md +140 -0
- data/README.md +117 -12
- data/exe/ptimelog +26 -2
- data/lib/ptimelog/command/add.rb +50 -11
- data/lib/ptimelog/command/confirm.rb +82 -0
- data/lib/ptimelog/command/edit.rb +47 -7
- data/lib/ptimelog/command/info.rb +48 -0
- data/lib/ptimelog/command/show.rb +46 -26
- data/lib/ptimelog/command/upload.rb +49 -18
- data/lib/ptimelog/configuration.rb +20 -10
- data/lib/ptimelog/data_source.rb +43 -0
- data/lib/ptimelog/day.rb +9 -34
- data/lib/ptimelog/entry.rb +92 -25
- data/lib/ptimelog/error.rb +7 -0
- data/lib/ptimelog/joiner.rb +47 -0
- data/lib/ptimelog/named_date.rb +26 -4
- data/lib/ptimelog/operon.rb +170 -0
- data/lib/ptimelog/script.rb +1 -1
- data/lib/ptimelog/timelog.rb +66 -1
- data/lib/ptimelog/version.rb +9 -1
- data/lib/ptimelog.rb +14 -3
- data/opencode.json +37 -0
- metadata +136 -47
- data/.envrc +0 -1
- data/.gitignore +0 -10
- data/.overcommit.yml +0 -26
- data/.rspec +0 -2
- data/.rubocop.yml +0 -33
- data/.rubocop_todo.yml +0 -16
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/.travis.yml +0 -7
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/lib/ptimelog/app.rb +0 -21
- data/lib/ptimelog/command/base.rb +0 -29
- data/lib/ptimelog/command/version.rb +0 -16
- data/ptimelog.gemspec +0 -34
|
@@ -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 <
|
|
7
|
-
|
|
8
|
-
@durations = Hash.new(0)
|
|
6
|
+
class Show < CmdParse::Command
|
|
7
|
+
attr_reader :entries
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def initialize
|
|
10
|
+
super('show', takes_commands: false)
|
|
11
|
+
@debug = false
|
|
12
|
+
@durations = false
|
|
13
|
+
@entries = {}
|
|
14
|
+
@config = Configuration.instance
|
|
12
15
|
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
entries.each do |date, list|
|
|
33
|
-
@entries[date] = []
|
|
41
|
+
private
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
43
|
-
|
|
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 <
|
|
9
|
-
|
|
8
|
+
class Upload < CmdParse::Command
|
|
9
|
+
def initialize
|
|
10
|
+
super('upload', takes_commands: false)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
rounding
|
|
14
|
-
dir
|
|
15
|
-
|
|
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[
|
|
29
|
+
Pathname.new(CONFIGURATION_DEFAULTS['dir'])
|
|
30
|
+
.expand_path
|
|
31
|
+
.join('config')
|
|
25
32
|
)
|
|
26
|
-
wrap_with_pathname(
|
|
27
|
-
|
|
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.
|
|
46
|
+
@config[key.to_s]
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def []=(key, value)
|
|
41
|
-
@config[key.
|
|
50
|
+
@config[key.to_s] = value
|
|
42
51
|
|
|
43
|
-
wrap_with_pathname(key.
|
|
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
|
|
13
|
-
next unless @date ==
|
|
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
|
-
|
|
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
|
data/lib/ptimelog/entry.rb
CHANGED
|
@@ -4,17 +4,21 @@ require 'time'
|
|
|
4
4
|
|
|
5
5
|
module Ptimelog
|
|
6
6
|
# Dataclass to wrap an entry
|
|
7
|
-
class Entry
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
end
|
|
92
|
+
# hide lunch and breaks
|
|
93
|
+
def hidden? = @description.to_s.end_with?('**') || !publishable?
|
|
63
94
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ||=
|
|
169
|
+
@script_name ||= first_tag
|
|
107
170
|
end
|
|
108
171
|
|
|
109
172
|
def script_args
|
|
110
|
-
@script_args ||=
|
|
173
|
+
@script_args ||= tail_tags.split.map(&:inspect).join(' ')
|
|
111
174
|
end
|
|
112
175
|
|
|
113
|
-
def
|
|
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
|
-
|
|
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,
|
|
187
|
+
[account, billable, publishable]
|
|
121
188
|
end
|
|
122
189
|
end
|
|
123
190
|
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
|