muon 0.0.2
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.
- data/.gitignore +1 -0
- data/Gemfile +6 -0
- data/README.md +52 -0
- data/Rakefile +11 -0
- data/bin/muon +103 -0
- data/extras/muon-completion.bash +1 -0
- data/extras/muon-prompt.sh +20 -0
- data/lib/muon.rb +1 -0
- data/lib/muon/app.rb +136 -0
- data/lib/muon/config.rb +66 -0
- data/lib/muon/entry.rb +33 -0
- data/lib/muon/format.rb +21 -0
- data/lib/muon/history.rb +67 -0
- data/lib/muon/project.rb +128 -0
- data/lib/muon/version.rb +3 -0
- data/muon.gemspec +23 -0
- data/test/cli_test.rb +174 -0
- data/test/test_helper.rb +2 -0
- metadata +129 -0
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
Gemfile.lock
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
muon
|
2
|
+
=================
|
3
|
+
|
4
|
+
muon is going to be a distributed time tracking tool. It already tracks time, but it's not distributed yet.
|
5
|
+
|
6
|
+
Installation
|
7
|
+
------------
|
8
|
+
```
|
9
|
+
gem install muon
|
10
|
+
```
|
11
|
+
|
12
|
+
Usage
|
13
|
+
------------
|
14
|
+
```
|
15
|
+
cd ~/myproject
|
16
|
+
muon init
|
17
|
+
muon start
|
18
|
+
# do some work
|
19
|
+
muon stop
|
20
|
+
muon log
|
21
|
+
```
|
22
|
+
|
23
|
+
You'll find more details via `muon help`.
|
24
|
+
|
25
|
+
Configuration
|
26
|
+
-------------
|
27
|
+
You can set up some handy aliases:
|
28
|
+
```
|
29
|
+
muon config --global alias.a start
|
30
|
+
muon config --global alias.z stop
|
31
|
+
muon config --global alias.st status
|
32
|
+
```
|
33
|
+
|
34
|
+
Bash completion
|
35
|
+
------------
|
36
|
+
```
|
37
|
+
source /path/to/muon/extras/muon-completion.bash
|
38
|
+
```
|
39
|
+
|
40
|
+
Bash prompt
|
41
|
+
------------
|
42
|
+
```
|
43
|
+
source /path/to/muon/extras/muon-prompt.sh
|
44
|
+
```
|
45
|
+
Then add `$(__muon_ps1)` somewhere in your $PS1, for example:
|
46
|
+
```
|
47
|
+
export PS1='\w$(__muon_ps1) \$ '
|
48
|
+
```
|
49
|
+
Or with some colors:
|
50
|
+
```
|
51
|
+
export PS1='\w\[\033[31m\]$(__muon_ps1) \[\033[00m\]\$ '
|
52
|
+
```
|
data/Rakefile
ADDED
data/bin/muon
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.unshift File.expand_path(__FILE__ + '/../../lib')
|
3
|
+
|
4
|
+
require 'muon'
|
5
|
+
require 'muon/app'
|
6
|
+
require 'gli'
|
7
|
+
|
8
|
+
include GLI
|
9
|
+
|
10
|
+
program_desc 'muon tracks your working time'
|
11
|
+
version Muon::VERSION
|
12
|
+
|
13
|
+
desc 'Set up current directory as muon project'
|
14
|
+
command :init do |c|
|
15
|
+
c.action do |global, options, arguments|
|
16
|
+
@app.init_directory
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
desc 'Start tracking time'
|
21
|
+
command :start do |c|
|
22
|
+
c.action do |global, options, arguments|
|
23
|
+
@app.start_tracking(arguments[0])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
desc 'Stop tracking time'
|
28
|
+
command :stop do |c|
|
29
|
+
c.action do |global, options, arguments|
|
30
|
+
@app.stop_tracking(arguments[0])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
desc 'Cancel current tracking entry'
|
35
|
+
command :abort do |c|
|
36
|
+
c.action do |global, options, arguments|
|
37
|
+
@app.abort_tracking
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
desc 'Add complete tracking entry'
|
42
|
+
command :commit do |c|
|
43
|
+
c.action do |global, options, arguments|
|
44
|
+
@app.commit_entry(arguments[0], arguments[1])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
desc 'Display tracking status'
|
49
|
+
command :status do |c|
|
50
|
+
c.action do |global, options, arguments|
|
51
|
+
@app.show_status
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
desc 'Display total time on given day'
|
56
|
+
command :total do |c|
|
57
|
+
c.action do |global, options, arguments|
|
58
|
+
@app.show_total(arguments[0])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
desc 'Display time entries'
|
63
|
+
command :log do |c|
|
64
|
+
c.flag [:n, :"max-count"]
|
65
|
+
c.action do |global, options, arguments|
|
66
|
+
limit = options[:n] ? options[:n].to_i : nil
|
67
|
+
@app.show_log(limit)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
desc 'Set or see goal for current month'
|
72
|
+
command :goal do |c|
|
73
|
+
c.action do |global, options, arguments|
|
74
|
+
if arguments.length == 0
|
75
|
+
@app.show_goal
|
76
|
+
elsif arguments.length > 0
|
77
|
+
@app.set_goal(arguments[0])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
desc 'Get or set config options'
|
83
|
+
command :config do |c|
|
84
|
+
c.switch :global
|
85
|
+
c.action do |global, options, arguments|
|
86
|
+
if arguments.length == 1
|
87
|
+
@app.read_config_option(arguments[0], options)
|
88
|
+
elsif arguments.length > 1
|
89
|
+
@app.set_config_option(arguments[0], arguments[1], options)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
desc 'Register project in ~/.muonprojects'
|
95
|
+
command :register do |c|
|
96
|
+
c.action do |global, options, arguments|
|
97
|
+
@app.register_global_project
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
@app = Muon::App.new(Dir.pwd)
|
102
|
+
|
103
|
+
exit GLI.run(@app.dealiasify(ARGV))
|
@@ -0,0 +1 @@
|
|
1
|
+
complete -W "abort commit config goal help init log register start status stop total" muon
|
@@ -0,0 +1,20 @@
|
|
1
|
+
__muon_dir ()
|
2
|
+
{
|
3
|
+
if [ -d ".muon" ]; then
|
4
|
+
echo ".muon"
|
5
|
+
else
|
6
|
+
echo ""
|
7
|
+
fi
|
8
|
+
}
|
9
|
+
|
10
|
+
__muon_ps1 ()
|
11
|
+
{
|
12
|
+
local dir="$(__muon_dir)"
|
13
|
+
if [ -n "$dir" ]; then
|
14
|
+
if [ -f "$dir/current" ]; then
|
15
|
+
echo " [●]"
|
16
|
+
else
|
17
|
+
echo " [ ]"
|
18
|
+
fi
|
19
|
+
fi
|
20
|
+
}
|
data/lib/muon.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'muon/version'
|
data/lib/muon/app.rb
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
require "time"
|
2
|
+
require "chronic_duration"
|
3
|
+
require "muon/config"
|
4
|
+
require "muon/entry"
|
5
|
+
require "muon/format"
|
6
|
+
require "muon/project"
|
7
|
+
|
8
|
+
module Muon
|
9
|
+
class App
|
10
|
+
def initialize(working_dir, home_dir = ENV["HOME"], output = $stdout)
|
11
|
+
@working_dir = working_dir
|
12
|
+
@home_dir = home_dir
|
13
|
+
@output = output
|
14
|
+
@project = Project.new(working_dir)
|
15
|
+
@config = Config.new(config_file, global_config_file)
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :project
|
19
|
+
|
20
|
+
def init_directory
|
21
|
+
project.init_directory
|
22
|
+
end
|
23
|
+
|
24
|
+
def start_tracking(start_time = nil)
|
25
|
+
start_time = Time.parse(start_time) if start_time
|
26
|
+
project.start_tracking(start_time)
|
27
|
+
end
|
28
|
+
|
29
|
+
def stop_tracking(end_time = nil)
|
30
|
+
end_time = Time.parse(end_time) if end_time
|
31
|
+
project.stop_tracking(end_time)
|
32
|
+
end
|
33
|
+
|
34
|
+
def abort_tracking
|
35
|
+
project.abort_tracking
|
36
|
+
end
|
37
|
+
|
38
|
+
def commit_entry(start_time, end_time)
|
39
|
+
start_time = Time.parse(start_time)
|
40
|
+
end_time = Time.parse(end_time)
|
41
|
+
project.commit_entry(start_time, end_time)
|
42
|
+
end
|
43
|
+
|
44
|
+
def show_status
|
45
|
+
if @project.tracking?
|
46
|
+
@output.puts "Time tracking is running since #{Format.duration @project.tracking_duration}."
|
47
|
+
else
|
48
|
+
@output.puts "Time tracking is stopped."
|
49
|
+
end
|
50
|
+
@output.puts "Today's total time is #{Format.duration @project.day_total_time(Time.now)}."
|
51
|
+
end
|
52
|
+
|
53
|
+
def show_total(date = nil)
|
54
|
+
if date.nil?
|
55
|
+
date = Time.now
|
56
|
+
else
|
57
|
+
date = Time.parse(date)
|
58
|
+
end
|
59
|
+
@output.puts "Total time on #{date.strftime('%Y-%m-%d')} is #{Format.duration @project.day_total_time(date)}."
|
60
|
+
end
|
61
|
+
|
62
|
+
def show_log(limit = nil)
|
63
|
+
entries = @project.history_entries
|
64
|
+
entries = entries.take(limit) if limit
|
65
|
+
entries.each do |entry|
|
66
|
+
@output.puts "#{entry.start_time} - #{entry.end_time} (#{Format.duration entry.duration})"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def show_goal
|
71
|
+
if project.has_goal?
|
72
|
+
@output.puts "The goal for this month is #{Format.duration project.goal}."
|
73
|
+
@output.puts "Time left to achieve this goal: #{Format.duration project.goal_remaining_time}."
|
74
|
+
else
|
75
|
+
@output.puts "No goal has been set."
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def set_goal(duration)
|
80
|
+
duration = ChronicDuration.parse(duration)
|
81
|
+
project.goal = duration
|
82
|
+
@output.puts "Setting goal for this month to #{Format.duration duration}."
|
83
|
+
end
|
84
|
+
|
85
|
+
def dealiasify(args)
|
86
|
+
dealiased = @config.get_option("alias.#{args.first}")
|
87
|
+
if dealiased
|
88
|
+
[dealiased] + args.drop(1)
|
89
|
+
else
|
90
|
+
args
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def read_config_option(key, options = {})
|
95
|
+
if options[:global]
|
96
|
+
@output.puts @config.get_global_option(key)
|
97
|
+
else
|
98
|
+
@output.puts @config.get_option(key)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def set_config_option(key, value, options = {})
|
103
|
+
if options[:global]
|
104
|
+
@config.set_global_option(key, value)
|
105
|
+
else
|
106
|
+
@config.set_option(key, value)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def global_projects
|
111
|
+
File.open(global_projects_file, "r") do |f|
|
112
|
+
f.lines.to_a.map(&:strip).map { |path| Project.new(path) }
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def register_global_project
|
117
|
+
File.open(global_projects_file, "a") { |f| f << "#{@working_dir}\n" }
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
# attr_reader :history
|
123
|
+
|
124
|
+
def config_file
|
125
|
+
File.join(project.working_dir, "config")
|
126
|
+
end
|
127
|
+
|
128
|
+
def global_config_file
|
129
|
+
File.join(@home_dir, ".muonconfig") if @home_dir
|
130
|
+
end
|
131
|
+
|
132
|
+
def global_projects_file
|
133
|
+
File.join(@home_dir, ".muonprojects") if @home_dir
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
data/lib/muon/config.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
3
|
+
module Muon
|
4
|
+
class Config
|
5
|
+
attr_reader :local_file, :global_file
|
6
|
+
|
7
|
+
def initialize(local_file, global_file)
|
8
|
+
@local_file, @global_file = local_file, global_file
|
9
|
+
end
|
10
|
+
|
11
|
+
def get_option(key)
|
12
|
+
get_local_option(key) || get_global_option(key)
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_local_option(key)
|
16
|
+
get_option_from_file(local_file, key)
|
17
|
+
end
|
18
|
+
|
19
|
+
def set_local_option(key, value)
|
20
|
+
set_option_in_file(local_file, key, value)
|
21
|
+
end
|
22
|
+
|
23
|
+
alias :set_option :set_local_option
|
24
|
+
|
25
|
+
def get_global_option(key)
|
26
|
+
get_option_from_file(global_file, key)
|
27
|
+
end
|
28
|
+
|
29
|
+
def set_global_option(key, value)
|
30
|
+
set_option_in_file(global_file, key, value)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def get_option_from_file(file, key)
|
36
|
+
config = read_config_file(file) || {}
|
37
|
+
group, name = split_config_key(key)
|
38
|
+
config[group] ||= {}
|
39
|
+
config[group][name]
|
40
|
+
end
|
41
|
+
|
42
|
+
def set_option_in_file(file, key, value)
|
43
|
+
config = read_config_file(file) || {}
|
44
|
+
group, name = *key.split(".", 2)
|
45
|
+
config[group] ||= {}
|
46
|
+
config[group][name] = value
|
47
|
+
write_config_file(file, config)
|
48
|
+
end
|
49
|
+
|
50
|
+
def read_config_file(file)
|
51
|
+
return nil unless File.exists?(file)
|
52
|
+
YAML.load_file(file)
|
53
|
+
end
|
54
|
+
|
55
|
+
def write_config_file(file, config)
|
56
|
+
File.open(file, "w") { |f| f.write YAML.dump(config) }
|
57
|
+
end
|
58
|
+
|
59
|
+
def split_config_key(key)
|
60
|
+
parts = key.split(".")
|
61
|
+
group = parts.slice(0, parts.length - 1).join(".")
|
62
|
+
name = parts.last
|
63
|
+
[group, name]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
data/lib/muon/entry.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require "time"
|
2
|
+
|
3
|
+
module Muon
|
4
|
+
class Entry
|
5
|
+
attr_reader :start_time, :end_time, :parent_hash
|
6
|
+
|
7
|
+
def initialize(start_time, end_time, parent_hash = nil)
|
8
|
+
@start_time, @end_time = start_time, end_time
|
9
|
+
@parent_hash = parent_hash
|
10
|
+
end
|
11
|
+
|
12
|
+
def duration
|
13
|
+
(end_time - start_time).to_i
|
14
|
+
end
|
15
|
+
|
16
|
+
def with_parent_hash(new_parent_hash)
|
17
|
+
raise "Already have parent hash" if parent_hash
|
18
|
+
Entry.new(start_time, end_time, new_parent_hash)
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
"parent #{parent_hash}\n" +
|
23
|
+
"#{start_time},#{end_time}\n"
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.from_s(str)
|
27
|
+
parent_hash = str.lines.to_a[0].sub(/^parent /, "").strip
|
28
|
+
parent_hash = nil if parent_hash == ""
|
29
|
+
start_time, end_time = *str.lines.to_a[1].strip.split(",")
|
30
|
+
new(Time.parse(start_time), Time.parse(end_time), parent_hash)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/lib/muon/format.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Muon
|
2
|
+
class Format
|
3
|
+
def self.duration(seconds)
|
4
|
+
seconds ||= 0
|
5
|
+
seconds = seconds.to_i
|
6
|
+
minutes = seconds / 60
|
7
|
+
if minutes == 0
|
8
|
+
"#{seconds} seconds"
|
9
|
+
else
|
10
|
+
seconds = seconds % 60
|
11
|
+
hours = minutes / 60
|
12
|
+
if hours == 0
|
13
|
+
"#{minutes} minutes, #{seconds} seconds"
|
14
|
+
else
|
15
|
+
minutes = minutes % 60
|
16
|
+
"#{hours} hours, #{minutes} minutes, #{seconds} seconds"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/muon/history.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
require "digest/sha1"
|
2
|
+
|
3
|
+
module Muon
|
4
|
+
class History
|
5
|
+
def initialize(working_dir)
|
6
|
+
@working_dir = working_dir
|
7
|
+
end
|
8
|
+
|
9
|
+
def entries
|
10
|
+
if block_given?
|
11
|
+
entry = load_entry(head)
|
12
|
+
while entry
|
13
|
+
yield entry
|
14
|
+
entry = load_entry(entry.parent_hash)
|
15
|
+
end
|
16
|
+
else
|
17
|
+
enum_for :entries
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def append(entry)
|
22
|
+
self.head = save_entry(entry.with_parent_hash(head))
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def head_file
|
28
|
+
File.join(@working_dir, "HEAD")
|
29
|
+
end
|
30
|
+
|
31
|
+
def head
|
32
|
+
if File.exists?(head_file)
|
33
|
+
File.read(head_file).strip
|
34
|
+
else
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def head=(hash)
|
40
|
+
File.open(head_file, "w") { |f| f.write(hash + "\n") }
|
41
|
+
end
|
42
|
+
|
43
|
+
def load_entry(hash)
|
44
|
+
return nil if hash.nil?
|
45
|
+
File.open(file_for_hash(hash), "r") { |f| Entry.from_s(f.read) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def save_entry(entry)
|
49
|
+
contents = entry.to_s
|
50
|
+
hash = compute_hash(contents)
|
51
|
+
File.open(file_for_hash(hash), "w") { |f| f.write(contents) }
|
52
|
+
return hash
|
53
|
+
end
|
54
|
+
|
55
|
+
def file_for_hash(hash)
|
56
|
+
File.join(@working_dir, "objects", hash)
|
57
|
+
end
|
58
|
+
|
59
|
+
def verify_hash(contents, hash)
|
60
|
+
raise "Hash #{hash} does not match contents!" if compute_hash(compute_hash) != hash
|
61
|
+
end
|
62
|
+
|
63
|
+
def compute_hash(contents)
|
64
|
+
Digest::SHA1.hexdigest(contents)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/muon/project.rb
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
require "muon/history"
|
2
|
+
|
3
|
+
module Muon
|
4
|
+
class Project
|
5
|
+
attr_reader :path
|
6
|
+
|
7
|
+
def initialize(path)
|
8
|
+
@path = path
|
9
|
+
@history = History.new(working_dir)
|
10
|
+
end
|
11
|
+
|
12
|
+
def name
|
13
|
+
File.basename(path)
|
14
|
+
end
|
15
|
+
|
16
|
+
def working_dir
|
17
|
+
File.join(path, ".muon")
|
18
|
+
end
|
19
|
+
|
20
|
+
def init_directory
|
21
|
+
raise "Already initialized!" if Dir.exists?(working_dir)
|
22
|
+
Dir.mkdir(working_dir)
|
23
|
+
Dir.mkdir(File.join working_dir, "objects")
|
24
|
+
end
|
25
|
+
|
26
|
+
def tracking?
|
27
|
+
File.exists?(File.join(working_dir, "current"))
|
28
|
+
end
|
29
|
+
|
30
|
+
def start_tracking(start_time = nil)
|
31
|
+
raise "Already tracking!" if tracking_file_exists?
|
32
|
+
start_time ||= Time.now
|
33
|
+
create_tracking_file(start_time.to_s)
|
34
|
+
end
|
35
|
+
|
36
|
+
def stop_tracking(end_time = nil)
|
37
|
+
raise "Not tracking!" unless tracking_file_exists?
|
38
|
+
start_time = Time.parse(read_tracking_file)
|
39
|
+
end_time ||= Time.now
|
40
|
+
commit_entry(start_time.to_s, end_time.to_s)
|
41
|
+
delete_tracking_file
|
42
|
+
end
|
43
|
+
|
44
|
+
def abort_tracking
|
45
|
+
raise "Not tracking!" unless tracking_file_exists?
|
46
|
+
delete_tracking_file
|
47
|
+
end
|
48
|
+
|
49
|
+
def tracking_duration
|
50
|
+
(Time.now - Time.parse(read_tracking_file)).to_i
|
51
|
+
end
|
52
|
+
|
53
|
+
def commit_entry(start_time, end_time)
|
54
|
+
entry = Entry.new(start_time, end_time)
|
55
|
+
history.append(entry)
|
56
|
+
end
|
57
|
+
|
58
|
+
def history_entries
|
59
|
+
history.entries
|
60
|
+
end
|
61
|
+
|
62
|
+
def day_total_time(date = Time.now)
|
63
|
+
date = date.strftime("%Y%m%d")
|
64
|
+
history.entries.select { |e| e.start_time.strftime("%Y%m%d") == date }.map(&:duration).inject(&:+)
|
65
|
+
end
|
66
|
+
|
67
|
+
def month_total_time(date = Time.now)
|
68
|
+
date = date.strftime("%Y%m")
|
69
|
+
history.entries.select { |e| e.start_time.strftime("%Y%m") == date }.map(&:duration).inject(&:+)
|
70
|
+
end
|
71
|
+
|
72
|
+
def has_goal?
|
73
|
+
goal_file_exists?
|
74
|
+
end
|
75
|
+
|
76
|
+
def goal
|
77
|
+
read_goal_file.to_i
|
78
|
+
end
|
79
|
+
|
80
|
+
def goal=(seconds)
|
81
|
+
write_goal_file(seconds.to_s)
|
82
|
+
end
|
83
|
+
|
84
|
+
def goal_remaining_time
|
85
|
+
goal - month_total_time
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
attr_reader :history
|
91
|
+
|
92
|
+
def tracking_file
|
93
|
+
File.join(working_dir, "current")
|
94
|
+
end
|
95
|
+
|
96
|
+
def tracking_file_exists?
|
97
|
+
File.exists?(tracking_file)
|
98
|
+
end
|
99
|
+
|
100
|
+
def read_tracking_file
|
101
|
+
File.open(tracking_file, "r") { |f| f.read }
|
102
|
+
end
|
103
|
+
|
104
|
+
def create_tracking_file(contents = "")
|
105
|
+
File.open(tracking_file, "w") { |f| f.puts(contents) }
|
106
|
+
end
|
107
|
+
|
108
|
+
def delete_tracking_file
|
109
|
+
File.unlink(tracking_file)
|
110
|
+
end
|
111
|
+
|
112
|
+
def goal_file
|
113
|
+
File.join(working_dir, "goal")
|
114
|
+
end
|
115
|
+
|
116
|
+
def goal_file_exists?
|
117
|
+
File.exists?(goal_file)
|
118
|
+
end
|
119
|
+
|
120
|
+
def read_goal_file
|
121
|
+
File.open(goal_file, "r") { |f| f.read }
|
122
|
+
end
|
123
|
+
|
124
|
+
def write_goal_file(contents = "")
|
125
|
+
File.open(goal_file, "w") { |f| f.puts(contents) }
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
data/lib/muon/version.rb
ADDED
data/muon.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/muon/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["DRUG"]
|
6
|
+
# gem.email = [""]
|
7
|
+
gem.description = %q{Distributed time tracking tool}
|
8
|
+
gem.summary = %q{Distributed time tracking tool}
|
9
|
+
gem.homepage = "http://drug.org.pl"
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "muon"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Muon::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency('gli', "~> 1.0")
|
19
|
+
gem.add_dependency('chronic_duration', "~> 0.9.6")
|
20
|
+
|
21
|
+
gem.add_development_dependency('rake')
|
22
|
+
gem.add_development_dependency('delorean')
|
23
|
+
end
|
data/test/cli_test.rb
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'tmpdir'
|
3
|
+
require 'stringio'
|
4
|
+
require 'delorean'
|
5
|
+
|
6
|
+
class CliTest < Test::Unit::TestCase
|
7
|
+
include Delorean
|
8
|
+
|
9
|
+
def setup
|
10
|
+
@dir = Dir.mktmpdir
|
11
|
+
@home_dir = Dir.mktmpdir
|
12
|
+
@output = StringIO.new
|
13
|
+
@app = Muon::App.new(@dir, @home_dir, @output)
|
14
|
+
@app.init_directory
|
15
|
+
end
|
16
|
+
|
17
|
+
def teardown
|
18
|
+
FileUtils.remove_entry_secure(@dir)
|
19
|
+
FileUtils.remove_entry_secure(@home_dir)
|
20
|
+
end
|
21
|
+
|
22
|
+
def test_init_initializes_only_once
|
23
|
+
assert_raise(RuntimeError) { @app.init_directory }
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_start_creates_tracking_file
|
27
|
+
@app.start_tracking
|
28
|
+
assert File.exists?("#{@dir}/.muon/current")
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_start_fails_if_already_tracking
|
32
|
+
@app.start_tracking
|
33
|
+
assert_raise(RuntimeError) { @app.start_tracking }
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_stop_stops_and_creates_history_entry
|
37
|
+
@app.start_tracking
|
38
|
+
@app.stop_tracking
|
39
|
+
assert ! File.exists?("#{@dir}/.muon/current")
|
40
|
+
assert File.exists?("#{@dir}/.muon/HEAD")
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_stop_fails_if_not_tracking_yet
|
44
|
+
assert_raise(RuntimeError) { @app.stop_tracking }
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_abort_stop_and_does_not_create_entry
|
48
|
+
@app.start_tracking
|
49
|
+
@app.abort_tracking
|
50
|
+
assert ! File.exists?("#{@dir}/.muon/current")
|
51
|
+
assert ! File.exists?("#{@dir}/.muon/HEAD")
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_commit_creates_history_entry
|
55
|
+
@app.commit_entry("00:01", "00:05")
|
56
|
+
assert File.exists?("#{@dir}/.muon/HEAD")
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_status_reports_whether_running
|
60
|
+
@app.show_status
|
61
|
+
assert_equal "Time tracking is stopped.\n", @output.string.lines.to_a[0]
|
62
|
+
|
63
|
+
@app.start_tracking
|
64
|
+
reset_output
|
65
|
+
@app.show_status
|
66
|
+
assert_equal "Time tracking is running since 0 seconds.\n", @output.string.lines.to_a[0]
|
67
|
+
end
|
68
|
+
|
69
|
+
def test_status_reports_todays_total_time
|
70
|
+
time_travel_to("yesterday 12:00") { @app.start_tracking }
|
71
|
+
time_travel_to("yesterday 12:10") { @app.stop_tracking }
|
72
|
+
time_travel_to("today 00:00") { @app.start_tracking }
|
73
|
+
time_travel_to("today 00:05") { @app.stop_tracking }
|
74
|
+
|
75
|
+
@app.show_status
|
76
|
+
assert_equal "Time tracking is stopped.\nToday's total time is 5 minutes, 0 seconds.\n", @output.string
|
77
|
+
end
|
78
|
+
|
79
|
+
def test_total_calculates_total_time_for_a_day
|
80
|
+
time_travel_to("2012-01-01 12:00") { @app.start_tracking }
|
81
|
+
time_travel_to("2012-01-01 12:10") { @app.stop_tracking }
|
82
|
+
time_travel_to("2012-01-02 00:00") { @app.start_tracking }
|
83
|
+
time_travel_to("2012-01-02 00:05") { @app.stop_tracking }
|
84
|
+
|
85
|
+
@app.show_total('2012-01-01')
|
86
|
+
assert_equal "Total time on 2012-01-01 is 10 minutes, 0 seconds.\n", @output.string
|
87
|
+
|
88
|
+
reset_output
|
89
|
+
@app.show_total('2012-01-05')
|
90
|
+
assert_equal "Total time on 2012-01-05 is 0 seconds.\n", @output.string
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_log_lists_entries
|
94
|
+
time_travel_to("2012-08-01") do
|
95
|
+
@app.commit_entry("00:01", "00:05")
|
96
|
+
@app.commit_entry("00:10", "00:20")
|
97
|
+
end
|
98
|
+
|
99
|
+
@app.show_log
|
100
|
+
assert_equal "2012-08-01 00:10:00 +0200 - 2012-08-01 00:20:00 +0200 (10 minutes, 0 seconds)\n", @output.string.lines.to_a[0]
|
101
|
+
assert_equal "2012-08-01 00:01:00 +0200 - 2012-08-01 00:05:00 +0200 (4 minutes, 0 seconds)\n", @output.string.lines.to_a[1]
|
102
|
+
assert_equal 2, @output.string.lines.to_a.length
|
103
|
+
|
104
|
+
reset_output
|
105
|
+
@app.show_log(1)
|
106
|
+
assert_equal "2012-08-01 00:10:00 +0200 - 2012-08-01 00:20:00 +0200 (10 minutes, 0 seconds)\n", @output.string.lines.to_a[0]
|
107
|
+
assert_equal 1, @output.string.lines.to_a.length
|
108
|
+
end
|
109
|
+
|
110
|
+
def test_goal_for_month
|
111
|
+
time_travel_to("2012-08-01") do
|
112
|
+
@app.commit_entry("00:00", "00:05")
|
113
|
+
@app.commit_entry("00:10", "00:20")
|
114
|
+
|
115
|
+
test_command do
|
116
|
+
@app.show_goal
|
117
|
+
assert_equal "No goal has been set.\n", output
|
118
|
+
end
|
119
|
+
|
120
|
+
test_command do
|
121
|
+
@app.set_goal('30:00:00')
|
122
|
+
assert_equal "Setting goal for this month to 30 hours, 0 minutes, 0 seconds.\n", output
|
123
|
+
end
|
124
|
+
|
125
|
+
test_command do
|
126
|
+
@app.show_goal
|
127
|
+
assert_equal "The goal for this month is 30 hours, 0 minutes, 0 seconds.\n", output_lines[0]
|
128
|
+
assert_equal "Time left to achieve this goal: 29 hours, 45 minutes, 0 seconds.\n", output_lines[1]
|
129
|
+
assert_equal 2, output_lines.length
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def test_config_setting_and_reading
|
135
|
+
@app.set_config_option("alias.ci", "commit")
|
136
|
+
|
137
|
+
@app.read_config_option("alias.ci")
|
138
|
+
assert_equal "commit\n", @output.string
|
139
|
+
|
140
|
+
reset_output
|
141
|
+
@app.read_config_option("xxx.y")
|
142
|
+
assert_equal "\n", @output.string
|
143
|
+
end
|
144
|
+
|
145
|
+
def test_dealiasify_replaces_aliases
|
146
|
+
@app.set_config_option("alias.ci", "commit")
|
147
|
+
assert_equal ["commit", "00:01", "00:05"], @app.dealiasify(["ci", "00:01", "00:05"])
|
148
|
+
end
|
149
|
+
|
150
|
+
def test_register_global_project
|
151
|
+
@app.register_global_project
|
152
|
+
assert_equal File.basename(@dir), @app.global_projects[0].name
|
153
|
+
assert_equal @dir, @app.global_projects[0].path
|
154
|
+
end
|
155
|
+
|
156
|
+
private
|
157
|
+
|
158
|
+
def test_command
|
159
|
+
yield
|
160
|
+
reset_output
|
161
|
+
end
|
162
|
+
|
163
|
+
def reset_output
|
164
|
+
@output.string = ""
|
165
|
+
end
|
166
|
+
|
167
|
+
def output
|
168
|
+
@output.string
|
169
|
+
end
|
170
|
+
|
171
|
+
def output_lines
|
172
|
+
@output.string.lines.to_a
|
173
|
+
end
|
174
|
+
end
|
data/test/test_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: muon
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- DRUG
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-01-10 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: gli
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: chronic_duration
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 0.9.6
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 0.9.6
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rake
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: delorean
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
description: Distributed time tracking tool
|
79
|
+
email:
|
80
|
+
executables:
|
81
|
+
- muon
|
82
|
+
extensions: []
|
83
|
+
extra_rdoc_files: []
|
84
|
+
files:
|
85
|
+
- .gitignore
|
86
|
+
- Gemfile
|
87
|
+
- README.md
|
88
|
+
- Rakefile
|
89
|
+
- bin/muon
|
90
|
+
- extras/muon-completion.bash
|
91
|
+
- extras/muon-prompt.sh
|
92
|
+
- lib/muon.rb
|
93
|
+
- lib/muon/app.rb
|
94
|
+
- lib/muon/config.rb
|
95
|
+
- lib/muon/entry.rb
|
96
|
+
- lib/muon/format.rb
|
97
|
+
- lib/muon/history.rb
|
98
|
+
- lib/muon/project.rb
|
99
|
+
- lib/muon/version.rb
|
100
|
+
- muon.gemspec
|
101
|
+
- test/cli_test.rb
|
102
|
+
- test/test_helper.rb
|
103
|
+
homepage: http://drug.org.pl
|
104
|
+
licenses: []
|
105
|
+
post_install_message:
|
106
|
+
rdoc_options: []
|
107
|
+
require_paths:
|
108
|
+
- lib
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
110
|
+
none: false
|
111
|
+
requirements:
|
112
|
+
- - ! '>='
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '0'
|
115
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
116
|
+
none: false
|
117
|
+
requirements:
|
118
|
+
- - ! '>='
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
requirements: []
|
122
|
+
rubyforge_project:
|
123
|
+
rubygems_version: 1.8.23
|
124
|
+
signing_key:
|
125
|
+
specification_version: 3
|
126
|
+
summary: Distributed time tracking tool
|
127
|
+
test_files:
|
128
|
+
- test/cli_test.rb
|
129
|
+
- test/test_helper.rb
|