muon 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|