time_tap 0.2.0 → 0.4.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -37
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +3 -21
- data/README.md +104 -41
- data/Rakefile +1 -30
- data/bin/timetap +25 -26
- data/lib/time_tap.rb +126 -52
- data/lib/time_tap/backend.rb +9 -0
- data/lib/time_tap/backend/file_system.rb +36 -0
- data/lib/time_tap/config.yml.example +32 -0
- data/lib/time_tap/editor.rb +9 -0
- data/lib/time_tap/editor/sublime_text2.rb +15 -0
- data/lib/time_tap/editor/text_mate.rb +22 -0
- data/lib/time_tap/editor/text_mate2.rb +15 -0
- data/lib/time_tap/editor/xcode.rb +24 -0
- data/lib/time_tap/project.rb +136 -116
- data/lib/time_tap/server.rb +20 -20
- data/lib/time_tap/version.rb +3 -0
- data/lib/time_tap/views/project.haml +5 -3
- data/lib/time_tap/views/project_day.haml +1 -0
- data/lib/time_tap/watcher.rb +46 -43
- data/log/.git-keep +1 -0
- data/spec/lib/time_tap/project_spec.rb +12 -0
- data/spec/spec_helper.rb +3 -1
- data/spec/time_tap/backend_spec.rb +12 -0
- data/spec/time_tap/project_spec.rb +13 -0
- data/spec/time_tap/watcher_spec.rb +14 -0
- data/spec/time_tap_spec.rb +6 -4
- data/time_tap.gemspec +26 -108
- data/vendor/SublimeText2/.gitignore +1 -0
- data/vendor/SublimeText2/README.md +27 -0
- data/vendor/SublimeText2/TimeTap.py +10 -0
- data/vendor/TextMate2/TimeTap.tmbundle/Commands/Record current file.tmCommand +33 -0
- data/vendor/TextMate2/TimeTap.tmbundle/info.plist +16 -0
- data/vendor/TimeTap.tmbundle/Commands/Record current file.tmCommand +36 -0
- data/vendor/TimeTap.tmbundle/info.plist +18 -0
- metadata +161 -260
- data/Gemfile.lock +0 -48
- data/VERSION +0 -1
- data/config.yaml +0 -8
- data/lib/time_tap/editors.rb +0 -23
@@ -0,0 +1,36 @@
|
|
1
|
+
class TimeTap::Backend::FileSystem
|
2
|
+
def initialize options = {}
|
3
|
+
raise 'missing :file_name option' unless options[:file_name]
|
4
|
+
@file_name = options[:file_name]
|
5
|
+
end
|
6
|
+
|
7
|
+
def file_name
|
8
|
+
File.expand_path(@file_name)
|
9
|
+
end
|
10
|
+
|
11
|
+
def append_to_file content
|
12
|
+
File.open(file_name, 'a').tap do |f|
|
13
|
+
f.sync = true
|
14
|
+
f << content
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def ruby19?
|
19
|
+
RUBY_VERSION >= '1.9'
|
20
|
+
end
|
21
|
+
|
22
|
+
def each_entry
|
23
|
+
return [] unless File.exists? file_name
|
24
|
+
|
25
|
+
File.open(file_name, 'r', ruby19? ? {:external_encoding => 'utf-8'} : nil) do |file|
|
26
|
+
file.each_line do |line|
|
27
|
+
time, path = line.split(": ")
|
28
|
+
yield time.to_i, path
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def register time, path
|
34
|
+
append_to_file "#{time.to_i}: #{path}\n"
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# This is where the "db" will be saved
|
2
|
+
root: "~"
|
3
|
+
|
4
|
+
# See the README about nested projects
|
5
|
+
nested_project_layers: 1
|
6
|
+
|
7
|
+
# The port on localhost for the web interface
|
8
|
+
port: 1111
|
9
|
+
|
10
|
+
# Set your editor:
|
11
|
+
#
|
12
|
+
# editor: text_mate
|
13
|
+
# editor: sublime_text2
|
14
|
+
editor: text_mate2
|
15
|
+
|
16
|
+
# Use a custom Ruby
|
17
|
+
#
|
18
|
+
# RVM Example:
|
19
|
+
# ruby: /Users/elia/.rvm/bin/ruby-1.9.3-p286
|
20
|
+
|
21
|
+
# Replace the folders timetap will look into:
|
22
|
+
# Example:
|
23
|
+
#
|
24
|
+
# code_folders:
|
25
|
+
# - "/Volumes/Oliver/Code"
|
26
|
+
# - "~/Code/Mikamai"
|
27
|
+
# - "~/Code"
|
28
|
+
# - "~"
|
29
|
+
#
|
30
|
+
|
31
|
+
|
32
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class TimeTap::Editor::SublimeText2
|
2
|
+
def initialize options = {}
|
3
|
+
end
|
4
|
+
|
5
|
+
def current_path
|
6
|
+
container_file = File.expand_path('~/.timetap.sbl2.current_file')
|
7
|
+
file = File.read(container_file).strip if File.exist? container_file
|
8
|
+
|
9
|
+
if File.exist? file
|
10
|
+
file
|
11
|
+
else
|
12
|
+
nil
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'appscript'
|
2
|
+
|
3
|
+
class TimeTap::Editor::TextMate
|
4
|
+
include Appscript
|
5
|
+
|
6
|
+
def initialize options = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def is_running?
|
10
|
+
not(`ps -ax -o comm|grep TextMate`.chomp.strip.empty?)
|
11
|
+
false
|
12
|
+
end
|
13
|
+
|
14
|
+
def current_path
|
15
|
+
if is_running?
|
16
|
+
mate = app('TextMate')
|
17
|
+
document = mate.document.get
|
18
|
+
return nil if document.blank?
|
19
|
+
path = document.first.path.get rescue nil
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class TimeTap::Editor::TextMate2
|
2
|
+
def initialize options = {}
|
3
|
+
end
|
4
|
+
|
5
|
+
def current_path
|
6
|
+
container_file = File.expand_path('~/.timetap.tm2.current_file')
|
7
|
+
file = File.read(container_file).strip if File.exist? container_file
|
8
|
+
|
9
|
+
if File.exist? file
|
10
|
+
file
|
11
|
+
else
|
12
|
+
nil
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'appscript'
|
2
|
+
|
3
|
+
class TimeTap::Editor::Xcode
|
4
|
+
include Appscript
|
5
|
+
|
6
|
+
def initialize options = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def is_running?
|
10
|
+
# Cannot use app('Xcode') because it fails when multiple Xcode versions are installed
|
11
|
+
!app('System Events').processes[its.name.eq('Xcode')].get.empty?
|
12
|
+
end
|
13
|
+
|
14
|
+
def current_path
|
15
|
+
if is_running?
|
16
|
+
# although multiple versions may be installed, tipically they will not be running simultaneously
|
17
|
+
pid = app('System Events').processes[its.name.eq('Xcode')].first.unix_id.get
|
18
|
+
xcode = app.by_pid(pid)
|
19
|
+
document = xcode.document.get
|
20
|
+
return nil if document.blank?
|
21
|
+
path = document.last.path.get rescue nil
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/lib/time_tap/project.rb
CHANGED
@@ -1,149 +1,169 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
attr_reader :pinches
|
4
|
-
class << self
|
5
|
-
attr_accessor :pause_limit
|
6
|
-
def pause_limit
|
7
|
-
@pause_limit ||= 30.minutes
|
8
|
-
end
|
9
|
-
|
10
|
-
def history_file
|
11
|
-
"#{TimeTap.config[:root]}/.tap_history"
|
12
|
-
end
|
1
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
2
|
+
require 'active_support/core_ext/numeric/time'
|
13
3
|
|
14
|
-
|
4
|
+
class TimeTap::Project
|
15
5
|
|
16
|
-
|
17
|
-
file.each_line do |line|
|
18
|
-
time, path = line.split(": ")
|
6
|
+
# CONFIG
|
19
7
|
|
20
|
-
|
21
|
-
|
22
|
-
|
8
|
+
class << self
|
9
|
+
attr_accessor :pause_limit
|
10
|
+
def load_projects
|
11
|
+
@loading ||= Thread.new do
|
12
|
+
backend.each_entry do |time, path|
|
13
|
+
register time, path
|
23
14
|
end
|
24
15
|
end
|
16
|
+
end
|
25
17
|
|
26
|
-
|
27
|
-
|
28
|
-
|
18
|
+
def projects
|
19
|
+
load_projects
|
20
|
+
@projects
|
21
|
+
end
|
22
|
+
attr_reader :backend
|
29
23
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
24
|
+
def reload!
|
25
|
+
@pause_limit = 30.minutes
|
26
|
+
@projects = {}.with_indifferent_access
|
27
|
+
@backend = TimeTap.backend
|
28
|
+
end
|
29
|
+
end
|
30
|
+
reload!
|
34
31
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
end
|
32
|
+
def logger
|
33
|
+
self.class.logger
|
34
|
+
end
|
39
35
|
|
40
|
-
def [] path
|
41
|
-
path = File.expand_path(path)
|
42
|
-
how_nested = 1
|
43
|
-
|
44
|
-
regex_suffix = "([^/]+)"
|
45
|
-
if TimeTap.config["nested_project_layers"] && TimeTap.config["nested_project_layers"].to_i > 0
|
46
|
-
how_nested = TimeTap.config["nested_project_layers"].to_i
|
47
|
-
|
48
|
-
# nested project layers works "how many folders inside your code folder
|
49
|
-
# do you keep projects.
|
50
|
-
#
|
51
|
-
# For example, if your directory structure looks like:
|
52
|
-
# ~/Code/
|
53
|
-
# Clients/
|
54
|
-
# AcmeCorp/
|
55
|
-
# website/
|
56
|
-
# intranet
|
57
|
-
# BetaCorp/
|
58
|
-
# skunkworks/
|
59
|
-
# OpenSource/
|
60
|
-
# project_one/
|
61
|
-
# timetap/
|
62
|
-
#
|
63
|
-
# A nested_project_layers setting of 2 would mean we track "AcmeCorp", "BetaCorp", and everything
|
64
|
-
# under OpenSource, as their own projects
|
65
|
-
regex_suffix = [regex_suffix] * how_nested
|
66
|
-
regex_suffix = regex_suffix.join("/")
|
67
|
-
end
|
68
36
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
name.chomp!
|
75
|
-
key = name.underscore.downcase
|
76
|
-
if projects[key].nil?
|
77
|
-
project = Project.new mid_path, name
|
78
|
-
projects[key] = project
|
79
|
-
end
|
80
|
-
projects[key]
|
81
|
-
else
|
82
|
-
nil
|
83
|
-
end
|
84
|
-
end
|
37
|
+
|
38
|
+
|
39
|
+
class << self
|
40
|
+
def logger
|
41
|
+
TimeTap.logger
|
85
42
|
end
|
86
43
|
|
87
|
-
|
88
|
-
|
89
|
-
@name = name
|
90
|
-
@path = File.expand_path("~/#{mid_path}/#{name}/")
|
91
|
-
@pinches = []
|
44
|
+
def all
|
45
|
+
projects.values
|
92
46
|
end
|
93
47
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
48
|
+
def register time, path
|
49
|
+
project = self[path]
|
50
|
+
if project
|
51
|
+
project << time
|
52
|
+
logger.info "[TimeTap::Project] added #{time} to project #{project.name} (#{project})"
|
53
|
+
else
|
54
|
+
logger.info "[TimeTap::Project] skipping #{time}, no project"
|
98
55
|
end
|
56
|
+
end
|
99
57
|
|
100
|
-
|
101
|
-
|
102
|
-
|
58
|
+
def find name
|
59
|
+
projects[name.underscore.downcase]
|
60
|
+
end
|
61
|
+
|
62
|
+
def [] path
|
63
|
+
return if path.nil? or path.empty?
|
64
|
+
path = File.expand_path(path)
|
65
|
+
how_nested = 1
|
103
66
|
|
104
|
-
|
105
|
-
seconds = duration
|
67
|
+
regex_suffix = "([^/]+)"
|
106
68
|
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
69
|
+
folders = TimeTap.config[:code_folders].map do |folder|
|
70
|
+
folder = File.expand_path(folder)
|
71
|
+
folder = Dir[folder]
|
72
|
+
end.flatten
|
111
73
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
74
|
+
folders_regex = folders.map{|f| Regexp.escape f}.join('|')
|
75
|
+
res = path.scan(%r{(#{folders_regex})/#{regex_suffix}}).flatten
|
76
|
+
|
77
|
+
# res = path.scan(%r{(#{TimeTap.config[:code] || "Code"})/#{regex_suffix}}).flatten
|
78
|
+
mid_path = res[0] # not in a MatchObj group any more, so it's 0 based
|
79
|
+
name = res[how_nested]
|
80
|
+
mid_path, name = path.scan(%r{#{File.expand_path("~")}/([^/]+)/([^/]+)}).flatten if name.nil?
|
81
|
+
|
82
|
+
if name
|
83
|
+
name.chomp!
|
84
|
+
key = name.underscore.downcase
|
85
|
+
if projects[key].nil?
|
86
|
+
project = new mid_path, name
|
87
|
+
projects[key] = project
|
116
88
|
end
|
117
|
-
|
89
|
+
projects[key]
|
90
|
+
else
|
91
|
+
nil
|
118
92
|
end
|
119
93
|
end
|
94
|
+
end
|
120
95
|
|
121
|
-
def << time
|
122
|
-
time = Time.at time
|
123
|
-
last_pinch = pinches.last
|
124
|
-
if pinches.empty?
|
125
|
-
pinches << Pinch.new(time)
|
126
|
-
else
|
127
|
-
last_time = last_pinch.end_time
|
128
|
-
return unless time > last_time
|
129
96
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
97
|
+
|
98
|
+
|
99
|
+
|
100
|
+
def initialize mid_path, name
|
101
|
+
@name = name
|
102
|
+
@path = File.expand_path("~/#{mid_path}/#{name}/")
|
103
|
+
@pinches = []
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
|
108
|
+
|
109
|
+
# ATTRIBUTES
|
110
|
+
|
111
|
+
attr_reader :name, :path, :pinches
|
112
|
+
|
113
|
+
def work_time
|
114
|
+
pinches.sum(&:duration)
|
115
|
+
end
|
116
|
+
|
117
|
+
def days
|
118
|
+
pinches.group_by do |pinch|
|
119
|
+
pinch.start_time.to_date
|
136
120
|
end
|
121
|
+
end
|
137
122
|
|
138
|
-
|
139
|
-
|
123
|
+
|
124
|
+
|
125
|
+
class Pinch
|
126
|
+
attr_accessor :start_time, :end_time
|
127
|
+
def initialize start_time
|
128
|
+
@end_time = @start_time = start_time
|
129
|
+
end
|
130
|
+
|
131
|
+
def duration
|
132
|
+
end_time ? end_time - start_time : 30.seconds
|
140
133
|
end
|
141
134
|
|
135
|
+
def humanized_duration
|
136
|
+
seconds = duration
|
142
137
|
|
143
|
-
|
144
|
-
|
145
|
-
|
138
|
+
hours = mins = 0
|
139
|
+
if seconds >= 60 then
|
140
|
+
mins = (seconds / 60).to_i
|
141
|
+
seconds = (seconds % 60 ).to_i
|
142
|
+
|
143
|
+
if mins >= 60 then
|
144
|
+
hours = (mins / 60).to_i
|
145
|
+
mins = (mins % 60).to_i
|
146
|
+
end
|
146
147
|
end
|
148
|
+
"#{hours}h #{mins}m #{seconds}s"
|
147
149
|
end
|
148
150
|
end
|
151
|
+
|
152
|
+
def << time
|
153
|
+
time = Time.at time
|
154
|
+
last_pinch = pinches.last
|
155
|
+
if pinches.empty?
|
156
|
+
pinches << Pinch.new(time)
|
157
|
+
else
|
158
|
+
last_time = last_pinch.end_time
|
159
|
+
return unless time > last_time
|
160
|
+
|
161
|
+
if (time - last_time) < self.class.pause_limit
|
162
|
+
last_pinch.end_time = time
|
163
|
+
else
|
164
|
+
pinches << Pinch.new(time)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
149
169
|
end
|