time_tap 0.2.0 → 0.4.0.pre
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 +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
|